зеркало из https://github.com/nextcloud/forms.git
chore: Fix code style by running prettier
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de> Signed-off-by: Christian Hartmann <chris-hartmann@gmx.de>
This commit is contained in:
Родитель
5a92200380
Коммит
f3bc09c8be
|
@ -4,8 +4,8 @@ about: Create a report to help us improve
|
|||
title: ''
|
||||
labels: 0. Needs triage, bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Please use the 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to show that you are affected by the same issue. Please don't comment if you have no relevant information to add!**
|
||||
|
||||
**Describe the bug**
|
||||
|
@ -13,6 +13,7 @@ A clear and concise description of what the bug is.
|
|||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
|
@ -25,21 +26,25 @@ A clear and concise description of what you expected to happen.
|
|||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Nextcloud (please complete the following information):**
|
||||
- Nextcloud-Version: [e.g. 19.0.0]
|
||||
- Forms-Version: [e.g. 2.0.0-beta4]
|
||||
|
||||
- Nextcloud-Version: [e.g. 19.0.0]
|
||||
- Forms-Version: [e.g. 2.0.0-beta4]
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Browser log**
|
||||
|
||||
```
|
||||
Open your console, reload your page and/or do the action leading to this issue and copy/paste the log in this thread.
|
||||
```
|
||||
|
@ -48,29 +53,34 @@ Open your console, reload your page and/or do the action leading to this issue a
|
|||
<summary>How to access your browser console (Click to expand)</summary>
|
||||
|
||||
# Chrome
|
||||
- Press either CTRL + SHIFT + J to open the “console” tab of the Developer Tools.
|
||||
- Alternative method:
|
||||
|
||||
- Press either CTRL + SHIFT + J to open the “console” tab of the Developer Tools.
|
||||
- Alternative method:
|
||||
1. Press either CTRL + SHIFT + I or F12 to open the Developer Tools.
|
||||
2. Click the “console” tab.
|
||||
|
||||
# Safari
|
||||
- Press CMD + ALT + I to open the Web Inspector.
|
||||
- See Chrome’s step 2. (Chrome and Safari have pretty much identical dev tools.)
|
||||
|
||||
- Press CMD + ALT + I to open the Web Inspector.
|
||||
- See Chrome’s step 2. (Chrome and Safari have pretty much identical dev tools.)
|
||||
|
||||
# IE9
|
||||
|
||||
1. Press F12 to open the developer tools.
|
||||
2. Click the “console” tab.
|
||||
|
||||
# Firefox
|
||||
- Press CTRL + SHIFT + K to open the Web console (COMMAND + SHIFT + K on Macs).
|
||||
- or, if Firebug is installed (recommended):
|
||||
|
||||
- Press CTRL + SHIFT + K to open the Web console (COMMAND + SHIFT + K on Macs).
|
||||
- or, if Firebug is installed (recommended):
|
||||
1. Press F12 to open Firebug.
|
||||
2. Click on the “console” tab.
|
||||
|
||||
# Opera
|
||||
|
||||
1. Press CTRL + SHIFT + I to open Dragonfly.
|
||||
2. Click on the “console” tab.
|
||||
</details>
|
||||
</details>
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
|
|
@ -4,12 +4,12 @@ about: Suggest an idea for this project
|
|||
title: ''
|
||||
labels: 0. Needs triage, enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Nextcloud (please complete the following information):**
|
||||
- Nextcloud-Version: [e.g. 19.0.0]
|
||||
- Forms-Version: [e.g. 2.0.0-beta4]
|
||||
|
||||
- Nextcloud-Version: [e.g. 19.0.0]
|
||||
- Forms-Version: [e.g. 2.0.0-beta4]
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
|
|
@ -1,106 +1,106 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: composer
|
||||
target-branch: "main"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- 3. to review
|
||||
- dependencies
|
||||
- package-ecosystem: composer
|
||||
target-branch: 'main'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: '03:00'
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- 3. to review
|
||||
- dependencies
|
||||
|
||||
- package-ecosystem: composer
|
||||
target-branch: "main"
|
||||
directory: "/vendor-bin/cs-fixer/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- 3. to review
|
||||
- dependencies
|
||||
- package-ecosystem: composer
|
||||
target-branch: 'main'
|
||||
directory: '/vendor-bin/cs-fixer/'
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: '03:00'
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- 3. to review
|
||||
- dependencies
|
||||
|
||||
- package-ecosystem: npm
|
||||
target-branch: "main"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- 3. to review
|
||||
- dependencies
|
||||
- package-ecosystem: npm
|
||||
target-branch: 'main'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: '03:00'
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- 3. to review
|
||||
- dependencies
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
target-branch: "main"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- 3. to review
|
||||
- dependencies
|
||||
- package-ecosystem: github-actions
|
||||
target-branch: 'main'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: '03:00'
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- 3. to review
|
||||
- dependencies
|
||||
|
||||
# Update stable3 until NC27 is out of support
|
||||
- package-ecosystem: composer
|
||||
target-branch: "stable3"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- 3. to review
|
||||
- dependencies
|
||||
# Update stable3 until NC27 is out of support
|
||||
- package-ecosystem: composer
|
||||
target-branch: 'stable3'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: '03:00'
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- 3. to review
|
||||
- dependencies
|
||||
|
||||
- package-ecosystem: composer
|
||||
target-branch: "stable3"
|
||||
directory: "/vendor-bin/cs-fixer/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- 3. to review
|
||||
- dependencies
|
||||
- package-ecosystem: composer
|
||||
target-branch: 'stable3'
|
||||
directory: '/vendor-bin/cs-fixer/'
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: '03:00'
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- 3. to review
|
||||
- dependencies
|
||||
|
||||
- package-ecosystem: npm
|
||||
target-branch: "stable3"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- 3. to review
|
||||
- dependencies
|
||||
- package-ecosystem: npm
|
||||
target-branch: 'stable3'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: '03:00'
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- 3. to review
|
||||
- dependencies
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
target-branch: "stable3"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- 3. to review
|
||||
- dependencies
|
||||
- package-ecosystem: github-actions
|
||||
target-branch: 'stable3'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: '03:00'
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- 3. to review
|
||||
- dependencies
|
||||
|
|
|
@ -21,6 +21,6 @@ jobs:
|
|||
Your feedback is valuable to us as we continuously strive to improve our community developer experience. Please take a moment to complete our short survey by clicking on the following link: https://cloud.nextcloud.com/apps/forms/s/i9Ago4EQRZ7TWxjfmeEpPkf6
|
||||
Thank you for contributing to Nextcloud and we hope to hear from you soon!
|
||||
days-before-feedback: 14
|
||||
start-date: "2023-07-10"
|
||||
exempt-authors: "${{ steps.scrape.outputs.users }}"
|
||||
start-date: '2023-07-10'
|
||||
exempt-authors: '${{ steps.scrape.outputs.users }}'
|
||||
exempt-bots: true
|
||||
|
|
|
@ -38,7 +38,7 @@ jobs:
|
|||
uses: skjnldsv/xpath-action@7e6a7c379d0e9abc8acaef43df403ab4fc4f770c # master
|
||||
with:
|
||||
filename: ${{ env.APP_NAME }}/appinfo/info.xml
|
||||
expression: "//info//dependencies//nextcloud/@min-version"
|
||||
expression: '//info//dependencies//nextcloud/@min-version'
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
|
@ -80,7 +80,7 @@ jobs:
|
|||
id: check_composer
|
||||
uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v2
|
||||
with:
|
||||
files: "${{ env.APP_NAME }}/composer.json"
|
||||
files: '${{ env.APP_NAME }}/composer.json'
|
||||
|
||||
- name: Install composer dependencies
|
||||
if: steps.check_composer.outputs.files_exists == 'true'
|
||||
|
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
id: check_composer
|
||||
uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v2
|
||||
with:
|
||||
files: "composer.json"
|
||||
files: 'composer.json'
|
||||
|
||||
- name: Install composer dependencies
|
||||
if: steps.check_composer.outputs.files_exists == 'true'
|
||||
|
@ -44,8 +44,8 @@ jobs:
|
|||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
fallbackNode: "^20"
|
||||
fallbackNpm: "^10"
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
|
|
984
CHANGELOG.md
984
CHANGELOG.md
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
27
README.md
27
README.md
|
@ -8,19 +8,21 @@
|
|||
**📝 Simple surveys and questionnaires, self-hosted**
|
||||
|
||||
### Straightforward form creation
|
||||
|
||||
![](screenshots/forms1.png)
|
||||
|
||||
### Simple sharing and responding
|
||||
|
||||
![](screenshots/forms2.png)
|
||||
|
||||
### Response visualization and exporting
|
||||
|
||||
![](screenshots/forms3.png)
|
||||
|
||||
- **📝 Simple design:** No mass of options, only the essentials. Works well on mobile of course.
|
||||
- **📊 View & export results:** Results are visualized and can also be exported as CSV in the same format used by Google Forms.
|
||||
- **🔒 Data under your control!** Unlike in Google Forms, Typeform, Doodle and others, the survey info and responses are kept private on your instance.
|
||||
- **🙋 Get involved!** We have lots of stuff planned like more question types, collaboration on forms, [and much more](https://github.com/nextcloud/forms/milestones)!
|
||||
|
||||
- **📝 Simple design:** No mass of options, only the essentials. Works well on mobile of course.
|
||||
- **📊 View & export results:** Results are visualized and can also be exported as CSV in the same format used by Google Forms.
|
||||
- **🔒 Data under your control!** Unlike in Google Forms, Typeform, Doodle and others, the survey info and responses are kept private on your instance.
|
||||
- **🙋 Get involved!** We have lots of stuff planned like more question types, collaboration on forms, [and much more](https://github.com/nextcloud/forms/milestones)!
|
||||
|
||||
## 🏗 Development setup
|
||||
|
||||
|
@ -29,25 +31,24 @@
|
|||
3. ✅ Enable the app through the app management of your Nextcloud
|
||||
4. 🎉 Partytime! Help fix [some issues](https://github.com/nextcloud/forms/issues) and [review pull requests](https://github.com/nextcloud/forms/pulls) 👍
|
||||
|
||||
|
||||
### 🧙 Advanced development stuff
|
||||
|
||||
To build the Javascript whenever you make changes, you can use `npm run build`. Or `npm run watch` to automatically rebuild on every file save.
|
||||
|
||||
You run several tests by:
|
||||
- `npm run lint` for JavaScript linting
|
||||
- `npm run stylelint` for CSS linting
|
||||
- `composer cs:check` for the Nextcloud php coding standard
|
||||
- `composer lint` for php linting
|
||||
- `composer test:unit` and `composer test:integration` to run the php functionality tests
|
||||
- `composer psalm` for static code analysis
|
||||
|
||||
- `npm run lint` for JavaScript linting
|
||||
- `npm run stylelint` for CSS linting
|
||||
- `composer cs:check` for the Nextcloud php coding standard
|
||||
- `composer lint` for php linting
|
||||
- `composer test:unit` and `composer test:integration` to run the php functionality tests
|
||||
- `composer psalm` for static code analysis
|
||||
|
||||
## ♥ How to create a pull request
|
||||
|
||||
This guide will help you get started:
|
||||
- 💃 [Opening a pull request](https://opensource.guide/how-to-contribute/#opening-a-pull-request)
|
||||
|
||||
- 💃 [Opening a pull request](https://opensource.guide/how-to-contribute/#opening-a-pull-request)
|
||||
|
||||
## ✌ Code of conduct
|
||||
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
comment:
|
||||
require_changes: true
|
||||
layout: "diff"
|
||||
require_changes: true
|
||||
layout: 'diff'
|
||||
|
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -19,7 +19,11 @@
|
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
import { startNextcloud, waitOnNextcloud, configureNextcloud } from '@nextcloud/cypress/docker'
|
||||
import {
|
||||
startNextcloud,
|
||||
waitOnNextcloud,
|
||||
configureNextcloud,
|
||||
} from '@nextcloud/cypress/docker'
|
||||
import { defineConfig } from 'cypress'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import cypressSplit from 'cypress-split'
|
||||
|
@ -45,9 +49,13 @@ export default defineConfig({
|
|||
cypressSplit(on, config)
|
||||
|
||||
const appinfo = readFileSync('appinfo/info.xml').toString()
|
||||
const maxVersion = appinfo.match(/<nextcloud min-version="\d+" max-version="(\d\d+)" \/>/)?.[1]
|
||||
const maxVersion = appinfo.match(
|
||||
/<nextcloud min-version="\d+" max-version="(\d\d+)" \/>/,
|
||||
)?.[1]
|
||||
|
||||
const IP = await startNextcloud(maxVersion ? `stable${maxVersion}` : undefined)
|
||||
const IP = await startNextcloud(
|
||||
maxVersion ? `stable${maxVersion}` : undefined,
|
||||
)
|
||||
await waitOnNextcloud(IP)
|
||||
await configureNextcloud(['forms', 'viewer'])
|
||||
|
||||
|
|
|
@ -2,10 +2,6 @@
|
|||
"env": {
|
||||
"cypress/globals": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:cypress/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"cypress"
|
||||
]
|
||||
"extends": ["plugin:cypress/recommended"],
|
||||
"plugins": ["cypress"]
|
||||
}
|
||||
|
|
|
@ -32,14 +32,11 @@ describe('Create empty form', () => {
|
|||
.should('exist')
|
||||
.and('be.visible')
|
||||
|
||||
cy.contains('button', 'Create a form')
|
||||
.should('be.visible')
|
||||
cy.contains('button', 'Create a form').should('be.visible')
|
||||
})
|
||||
|
||||
it('can use button to create new form', () => {
|
||||
cy.contains('button', 'Create a form')
|
||||
.first()
|
||||
.click()
|
||||
cy.contains('button', 'Create a form').first().click()
|
||||
|
||||
cy.url().should('match', /apps\/forms\/.+/)
|
||||
|
||||
|
@ -48,9 +45,7 @@ describe('Create empty form', () => {
|
|||
})
|
||||
|
||||
it('can use app navigation to create new form', () => {
|
||||
cy.get('nav').contains('button', 'New form')
|
||||
.first()
|
||||
.click()
|
||||
cy.get('nav').contains('button', 'New form').first().click()
|
||||
|
||||
cy.url().should('match', /apps\/forms\/.+/)
|
||||
|
||||
|
@ -59,23 +54,17 @@ describe('Create empty form', () => {
|
|||
})
|
||||
|
||||
it('Updates the form title in the navigation', () => {
|
||||
cy.get('nav').contains('button', 'New form')
|
||||
.first()
|
||||
.click()
|
||||
cy.get('nav').contains('button', 'New form').first().click()
|
||||
|
||||
cy.url().then((url) => {
|
||||
expect(url).to.match(/apps\/forms\/.+/)
|
||||
const formId = url.match(/apps\/forms\/([^/?]+)/)[1]
|
||||
|
||||
cy.get(`nav a[href*="${formId}"]`)
|
||||
.should('contain', 'New form')
|
||||
cy.get(`nav a[href*="${formId}"]`).should('contain', 'New form')
|
||||
|
||||
cy.get('h2 textarea')
|
||||
.should('have.focus')
|
||||
.type('Test form')
|
||||
cy.get('h2 textarea').should('have.focus').type('Test form')
|
||||
|
||||
cy.get(`nav a[href*="${formId}"]`)
|
||||
.should('contain', 'Test form')
|
||||
cy.get(`nav a[href*="${formId}"]`).should('contain', 'Test form')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -22,4 +22,4 @@
|
|||
import './commands'
|
||||
|
||||
// Ignore resize observer errors of Chrome, they are unrelated and save to ignore
|
||||
Cypress.on('uncaught:exception', err => !err.message.includes('ResizeObserver'))
|
||||
Cypress.on('uncaught:exception', (err) => !err.message.includes('ResizeObserver'))
|
||||
|
|
|
@ -2,9 +2,6 @@
|
|||
"extends": "../tsconfig.json",
|
||||
"include": ["./**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"cypress",
|
||||
"node"
|
||||
]
|
||||
"types": ["cypress", "node"]
|
||||
}
|
||||
}
|
||||
|
|
685
docs/API.md
685
docs/API.md
|
@ -1,14 +1,17 @@
|
|||
# Forms Public API
|
||||
|
||||
This file contains the API-Documentation. For more information on the returned Data-Structures, please refer to the [corresponding Documentation](DataStructure.md).
|
||||
|
||||
## Generals
|
||||
- Base URL for all calls to the forms API is `<nextcloud_base_url>/ocs/v2.php/apps/forms`
|
||||
- All Requests need to provide some authentication information.
|
||||
- All Requests to OCS-Endpoints require the Header `OCS-APIRequest: true`
|
||||
- Unless otherwise specified, all parameters are mandatory.
|
||||
|
||||
- By default, the API returns data formatted as _xml_. If formatting as _json_ is desired, the request should contain the header `Accept: application/json`. For simple representation, the output presented in this document is all formatted as _json_.
|
||||
- The OCS-Endpoint *always returns* an object called `ocs`. This contains an object `meta` holding some meta-data, as well as an object `data` holding the actual data. In this document, the response-blocks only show the `data`, if not explicitely stated different.
|
||||
- Base URL for all calls to the forms API is `<nextcloud_base_url>/ocs/v2.php/apps/forms`
|
||||
- All Requests need to provide some authentication information.
|
||||
- All Requests to OCS-Endpoints require the Header `OCS-APIRequest: true`
|
||||
- Unless otherwise specified, all parameters are mandatory.
|
||||
|
||||
- By default, the API returns data formatted as _xml_. If formatting as _json_ is desired, the request should contain the header `Accept: application/json`. For simple representation, the output presented in this document is all formatted as _json_.
|
||||
- The OCS-Endpoint _always returns_ an object called `ocs`. This contains an object `meta` holding some meta-data, as well as an object `data` holding the actual data. In this document, the response-blocks only show the `data`, if not explicitely stated different.
|
||||
|
||||
```
|
||||
"ocs": {
|
||||
"meta": {
|
||||
|
@ -19,35 +22,44 @@ This file contains the API-Documentation. For more information on the returned D
|
|||
"data": <Actual data>
|
||||
}
|
||||
```
|
||||
|
||||
## API changes
|
||||
|
||||
### Deprecation info
|
||||
- Starting with API v2.2 all endpoints that update data will use PATCH/PUT as method. POST is now deprecated and will be removed in API v3
|
||||
|
||||
- Starting with API v2.2 all endpoints that update data will use PATCH/PUT as method. POST is now deprecated and will be removed in API v3
|
||||
|
||||
### Breaking Changes on API v2
|
||||
- The `mandatory` property of questions has been removed. It is replaced by `isRequired`.
|
||||
- Completely new way of handling access & shares.
|
||||
|
||||
- The `mandatory` property of questions has been removed. It is replaced by `isRequired`.
|
||||
- Completely new way of handling access & shares.
|
||||
|
||||
### Other API changes
|
||||
- In API version 2.5 the following endpoints were introduced:
|
||||
- `POST /api/2.5/uploadFiles/{formId}/{questionId}` to upload files to answer before form submitting
|
||||
- In API version 2.4 the following endpoints were introduced:
|
||||
- `POST /api/2.4/form/link/{fileFormat}` to link form to a file
|
||||
- `POST /api/2.4/form/unlink` to unlink form from a file
|
||||
- In API version 2.4 the following endpoints were changed:
|
||||
- `GET /api/v2.4/submissions/export/{hash}` was extended with optional parameter `fileFormat` to export submissions in different formats
|
||||
- `GET /api/v2.4/submissions/export` was extended with optional parameter `fileFormat` to export submissions to cloud in different formats
|
||||
- `GET /api/v2.4/form/{id}` was extended with optional parameters `fileFormat`, `fileId`, `filePath` to link form to a file
|
||||
- In API version 2.3 the endpoint `/api/v2.3/question/clone` was added to clone a question
|
||||
- In API version 2.2 the endpoint `/api/v2.2/form/transfer` was added to transfer ownership of a form
|
||||
- In API version 2.1 the endpoint `/api/v2.1/share/update` was added to update a Share
|
||||
|
||||
- In API version 2.5 the following endpoints were introduced:
|
||||
- `POST /api/2.5/uploadFiles/{formId}/{questionId}` to upload files to answer before form submitting
|
||||
- In API version 2.4 the following endpoints were introduced:
|
||||
- `POST /api/2.4/form/link/{fileFormat}` to link form to a file
|
||||
- `POST /api/2.4/form/unlink` to unlink form from a file
|
||||
- In API version 2.4 the following endpoints were changed:
|
||||
- `GET /api/v2.4/submissions/export/{hash}` was extended with optional parameter `fileFormat` to export submissions in different formats
|
||||
- `GET /api/v2.4/submissions/export` was extended with optional parameter `fileFormat` to export submissions to cloud in different formats
|
||||
- `GET /api/v2.4/form/{id}` was extended with optional parameters `fileFormat`, `fileId`, `filePath` to link form to a file
|
||||
- In API version 2.3 the endpoint `/api/v2.3/question/clone` was added to clone a question
|
||||
- In API version 2.2 the endpoint `/api/v2.2/form/transfer` was added to transfer ownership of a form
|
||||
- In API version 2.1 the endpoint `/api/v2.1/share/update` was added to update a Share
|
||||
|
||||
## Form Endpoints
|
||||
|
||||
### List owned Forms
|
||||
|
||||
Returns condensed objects of all Forms beeing owned by the authenticated user.
|
||||
- Endpoint: `/api/v2.4/forms`
|
||||
- Method: `GET`
|
||||
- Parameters: None
|
||||
- Response: Array of condensed Form Objects, sorted as newest first.
|
||||
|
||||
- Endpoint: `/api/v2.4/forms`
|
||||
- Method: `GET`
|
||||
- Parameters: None
|
||||
- Response: Array of condensed Form Objects, sorted as newest first.
|
||||
|
||||
```
|
||||
"data": [
|
||||
{
|
||||
|
@ -80,24 +92,30 @@ Returns condensed objects of all Forms beeing owned by the authenticated user.
|
|||
```
|
||||
|
||||
### List shared Forms
|
||||
|
||||
Returns condensed objects of all Forms, that are shared & shown to the authenticated user and that have not expired yet.
|
||||
- Endpoint: `/api/v2.4/shared_forms`
|
||||
- Method: `GET`
|
||||
- Parameters: None
|
||||
- Response: Array of condensed Form Objects, sorted as newest first, similar to [List owned Forms](#list-owned-forms).
|
||||
|
||||
- Endpoint: `/api/v2.4/shared_forms`
|
||||
- Method: `GET`
|
||||
- Parameters: None
|
||||
- Response: Array of condensed Form Objects, sorted as newest first, similar to [List owned Forms](#list-owned-forms).
|
||||
|
||||
```
|
||||
See above, 'List owned forms'
|
||||
```
|
||||
|
||||
### Get a partial Form
|
||||
|
||||
Returns a single partial form object, corresponding to owned/shared form-listings.
|
||||
- Endpoint: `/api/v2.4/partial_form/{hash}`
|
||||
- Method: `GET`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _hash_ | String | Hash of the form to request |
|
||||
- Response: Partial form object, similar to form-list elements.
|
||||
|
||||
- Endpoint: `/api/v2.4/partial_form/{hash}`
|
||||
- Method: `GET`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _hash_ | String | Hash of the form to request |
|
||||
- Response: Partial form object, similar to form-list elements.
|
||||
|
||||
```
|
||||
"data": {
|
||||
"id": 6,
|
||||
|
@ -113,23 +131,28 @@ Returns a single partial form object, corresponding to owned/shared form-listing
|
|||
```
|
||||
|
||||
### Create a new Form
|
||||
- Endpoint: `/api/v2.4/form`
|
||||
- Method: `POST`
|
||||
- Parameters: None
|
||||
- Response: The new form object, similar to requesting an existing form.
|
||||
|
||||
- Endpoint: `/api/v2.4/form`
|
||||
- Method: `POST`
|
||||
- Parameters: None
|
||||
- Response: The new form object, similar to requesting an existing form.
|
||||
|
||||
```
|
||||
See next section, 'Request full data of a form'
|
||||
```
|
||||
|
||||
### Request full data of a form
|
||||
|
||||
Returns the full-depth object of the requested form (without submissions).
|
||||
- Endpoint: `/api/v2.4/form/{id}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the form to request |
|
||||
- Method: `GET`
|
||||
- Response: A full object of the form, including access, questions and options in full depth.
|
||||
|
||||
- Endpoint: `/api/v2.4/form/{id}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the form to request |
|
||||
- Method: `GET`
|
||||
- Response: A full object of the form, including access, questions and options in full depth.
|
||||
|
||||
```
|
||||
"data": {
|
||||
"id": 3,
|
||||
|
@ -214,73 +237,87 @@ Returns the full-depth object of the requested form (without submissions).
|
|||
```
|
||||
|
||||
### Clone a form
|
||||
|
||||
Creates a clone of a form (without submissions).
|
||||
- Endpoint: `/api/v2.4/form/clone/{id}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the form to clone |
|
||||
- Method: `POST`
|
||||
- Response: Returns the full object of the new form. See [Request full data of a Form](#request-full-data-of-a-form)
|
||||
|
||||
- Endpoint: `/api/v2.4/form/clone/{id}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the form to clone |
|
||||
- Method: `POST`
|
||||
- Response: Returns the full object of the new form. See [Request full data of a Form](#request-full-data-of-a-form)
|
||||
|
||||
```
|
||||
See section 'Request full data of a form'.
|
||||
```
|
||||
|
||||
### Update form properties
|
||||
|
||||
Update a single or multiple properties of a form-object. Concerns **only** the Form-Object, properties of Questions, Options and Submissions, as well as their creation or deletion, are handled separately.
|
||||
- Endpoint: `/api/v2.4/form/update`
|
||||
- Method: `PATCH`
|
||||
- *Method: `POST` deprecated*
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the form to update |
|
||||
| _keyValuePairs_ | Array | Array of key-value pairs to update |
|
||||
- Restrictions: It is **not allowed** to update one of the following key-value pairs: _id, hash, ownerId, created_
|
||||
- Response: **Status-Code OK**, as well as the id of the updated form.
|
||||
|
||||
- Endpoint: `/api/v2.4/form/update`
|
||||
- Method: `PATCH`
|
||||
- _Method: `POST` deprecated_
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the form to update |
|
||||
| _keyValuePairs_ | Array | Array of key-value pairs to update |
|
||||
- Restrictions: It is **not allowed** to update one of the following key-value pairs: _id, hash, ownerId, created_
|
||||
- Response: **Status-Code OK**, as well as the id of the updated form.
|
||||
|
||||
```
|
||||
"data": 3
|
||||
```
|
||||
|
||||
### Transfer form ownership
|
||||
|
||||
Transfer the ownership of a form to another user
|
||||
- Endpoint: `/api/v2.4/form/transfer`
|
||||
- Method: `POST`
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _formId_ | Integer | ID of the form to tranfer |
|
||||
| _uid_ | Integer | ID of the new form owner |
|
||||
- Restrictions: The initiator must be the current form owner.
|
||||
- Response: **Status-Code OK**, as well as the id of the new owner.
|
||||
|
||||
- Endpoint: `/api/v2.4/form/transfer`
|
||||
- Method: `POST`
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _formId_ | Integer | ID of the form to tranfer |
|
||||
| _uid_ | Integer | ID of the new form owner |
|
||||
- Restrictions: The initiator must be the current form owner.
|
||||
- Response: **Status-Code OK**, as well as the id of the new owner.
|
||||
|
||||
```
|
||||
"data": "user1"
|
||||
```
|
||||
|
||||
### Delete a form
|
||||
- Endpoint: `/api/v2.4/form/{id}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the form to delete |
|
||||
- Method: `DELETE`
|
||||
- Response: **Status-Code OK**, as well as the id of the deleted form.
|
||||
|
||||
- Endpoint: `/api/v2.4/form/{id}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the form to delete |
|
||||
- Method: `DELETE`
|
||||
- Response: **Status-Code OK**, as well as the id of the deleted form.
|
||||
|
||||
```
|
||||
"data": 3
|
||||
```
|
||||
|
||||
### Link a form to a file
|
||||
- Endpoint: `/api/v2.4/form/link/{fileFormat}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|--------------|---------|--------------|
|
||||
| _fileFormat_ | String | csv|ods|xlsx |
|
||||
- Method: `POST`
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|--------------------------------------------|
|
||||
| _hash_ | String | Hash of the form to update |
|
||||
| _path_ | String | Path within User-Dir, to store the file to |
|
||||
- Response: The new question object.
|
||||
|
||||
- Endpoint: `/api/v2.4/form/link/{fileFormat}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|--------------|---------|--------------|
|
||||
| _fileFormat_ | String | csv|ods|xlsx |
|
||||
- Method: `POST`
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|--------------------------------------------|
|
||||
| _hash_ | String | Hash of the form to update |
|
||||
| _path_ | String | Path within User-Dir, to store the file to |
|
||||
- Response: The new question object.
|
||||
|
||||
```
|
||||
"data": {
|
||||
"fileFormat": "csv",
|
||||
|
@ -291,27 +328,31 @@ Transfer the ownership of a form to another user
|
|||
```
|
||||
|
||||
### Unlink file from form
|
||||
- Endpoint: `/api/v2.4/form/unlink`
|
||||
- Method: `POST`
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|----------------------------|
|
||||
| _hash_ | String | Hash of the form to update |
|
||||
- Response: **Status-Code OK**
|
||||
|
||||
- Endpoint: `/api/v2.4/form/unlink`
|
||||
- Method: `POST`
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|----------------------------|
|
||||
| _hash_ | String | Hash of the form to update |
|
||||
- Response: **Status-Code OK**
|
||||
|
||||
## Question Endpoints
|
||||
|
||||
Contains only manipulative question-endpoints. To retrieve questions, request the full form data.
|
||||
|
||||
### Create a new question
|
||||
- Endpoint: `/api/v2.4/question`
|
||||
- Method: `POST`
|
||||
- Parameters:
|
||||
| Parameter | Type | Optional | Description |
|
||||
|-----------|---------|----------|-------------|
|
||||
| _formId_ | Integer | | ID of the form, the new question will belong to |
|
||||
| _type_ | [QuestionType](DataStructure.md#question-types) | | The question-type of the new question |
|
||||
| _text_ | String | yes | *Optional* The text of the new question. |
|
||||
- Response: The new question object.
|
||||
|
||||
- Endpoint: `/api/v2.4/question`
|
||||
- Method: `POST`
|
||||
- Parameters:
|
||||
| Parameter | Type | Optional | Description |
|
||||
|-----------|---------|----------|-------------|
|
||||
| _formId_ | Integer | | ID of the form, the new question will belong to |
|
||||
| _type_ | [QuestionType](DataStructure.md#question-types) | | The question-type of the new question |
|
||||
| _text_ | String | yes | _Optional_ The text of the new question. |
|
||||
- Response: The new question object.
|
||||
|
||||
```
|
||||
"data": {
|
||||
"id": 3,
|
||||
|
@ -327,33 +368,39 @@ Contains only manipulative question-endpoints. To retrieve questions, request th
|
|||
```
|
||||
|
||||
### Update question properties
|
||||
|
||||
Update a single or multiple properties of a question-object.
|
||||
- Endpoint: `/api/v2.4/question/update`
|
||||
- Method: `PATCH`
|
||||
- *Method: `POST` deprecated*
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the question to update |
|
||||
| _keyValuePairs_ | Array | Array of key-value pairs to update |
|
||||
- Restrictions: It is **not allowed** to update one of the following key-value pairs: _id, formId, order_.
|
||||
- Response: **Status-Code OK**, as well as the id of the updated question.
|
||||
|
||||
- Endpoint: `/api/v2.4/question/update`
|
||||
- Method: `PATCH`
|
||||
- _Method: `POST` deprecated_
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the question to update |
|
||||
| _keyValuePairs_ | Array | Array of key-value pairs to update |
|
||||
- Restrictions: It is **not allowed** to update one of the following key-value pairs: _id, formId, order_.
|
||||
- Response: **Status-Code OK**, as well as the id of the updated question.
|
||||
|
||||
```
|
||||
"data": 1
|
||||
```
|
||||
|
||||
### Reorder questions
|
||||
|
||||
Reorders all Questions of a single form
|
||||
- Endpoint: `/api/v2.4/question/reorder`
|
||||
- Method: `PUT`
|
||||
- *Method: `POST` deprecated*
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _formId_ | Integer | ID of the form, the questions belong to |
|
||||
| _newOrder_ | Array | Array of **all** Question-IDs, ordered in the desired order |
|
||||
- Restrictions: The Array **must** contain all Question-IDs corresponding to the specified form and **must not** contain any duplicates.
|
||||
- Response: Array of questionIDs and their corresponding order.
|
||||
|
||||
- Endpoint: `/api/v2.4/question/reorder`
|
||||
- Method: `PUT`
|
||||
- _Method: `POST` deprecated_
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _formId_ | Integer | ID of the form, the questions belong to |
|
||||
| _newOrder_ | Array | Array of **all** Question-IDs, ordered in the desired order |
|
||||
- Restrictions: The Array **must** contain all Question-IDs corresponding to the specified form and **must not** contain any duplicates.
|
||||
- Response: Array of questionIDs and their corresponding order.
|
||||
|
||||
```
|
||||
"data": {
|
||||
"1": {
|
||||
|
@ -369,42 +416,50 @@ Reorders all Questions of a single form
|
|||
```
|
||||
|
||||
### Delete a question
|
||||
- Endpoint: `/api/v2.4/question/{id}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the question to delete |
|
||||
- Method: `DELETE`
|
||||
- Response: **Status-Code OK**, as well as the id of the deleted question.
|
||||
|
||||
- Endpoint: `/api/v2.4/question/{id}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the question to delete |
|
||||
- Method: `DELETE`
|
||||
- Response: **Status-Code OK**, as well as the id of the deleted question.
|
||||
|
||||
```
|
||||
"data": 4
|
||||
```
|
||||
|
||||
### Clone a question
|
||||
|
||||
Creates a clone of a question with all its options.
|
||||
- Endpoint: `/api/v2.4/question/clone/{id}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the question to clone |
|
||||
- Method: `POST`
|
||||
- Response: Returns cloned question object with the new ID set.
|
||||
|
||||
- Endpoint: `/api/v2.4/question/clone/{id}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the question to clone |
|
||||
- Method: `POST`
|
||||
- Response: Returns cloned question object with the new ID set.
|
||||
|
||||
```
|
||||
See section 'Create a new question'.
|
||||
```
|
||||
|
||||
## Option Endpoints
|
||||
|
||||
Contains only manipulative question-endpoints. To retrieve options, request the full form data.
|
||||
|
||||
### Create a new Option
|
||||
- Endpoint: `/api/v2.4/option`
|
||||
- Method: `POST`
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _questionId_ | Integer | ID of the question, the new option will belong to |
|
||||
| _text_ | String | The text of the new option |
|
||||
- Response: The new option object
|
||||
|
||||
- Endpoint: `/api/v2.4/option`
|
||||
- Method: `POST`
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _questionId_ | Integer | ID of the question, the new option will belong to |
|
||||
| _text_ | String | The text of the new option |
|
||||
- Response: The new option object
|
||||
|
||||
```
|
||||
"data": {
|
||||
"id": 7,
|
||||
|
@ -414,45 +469,53 @@ Contains only manipulative question-endpoints. To retrieve options, request the
|
|||
```
|
||||
|
||||
### Update option properties
|
||||
|
||||
Update a single or all properties of an option-object
|
||||
- Endpoint: `/api/v2.4/option/update`
|
||||
- Method: `PATCH`
|
||||
- *Method: `POST` deprecated*
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the option to update |
|
||||
| _keyValuePairs_ | Array | Array of key-value pairs to update |
|
||||
- Restrictions: It is **not allowed** to update one of the following key-value pairs: _id, questionId_.
|
||||
- Response: **Status-Code OK**, as well as the id of the updated option.
|
||||
|
||||
- Endpoint: `/api/v2.4/option/update`
|
||||
- Method: `PATCH`
|
||||
- _Method: `POST` deprecated_
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the option to update |
|
||||
| _keyValuePairs_ | Array | Array of key-value pairs to update |
|
||||
- Restrictions: It is **not allowed** to update one of the following key-value pairs: _id, questionId_.
|
||||
- Response: **Status-Code OK**, as well as the id of the updated option.
|
||||
|
||||
```
|
||||
"data": 7
|
||||
```
|
||||
|
||||
### Delete an option
|
||||
- Endpoint: `/api/v2.4/option/{id}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the option to delete |
|
||||
- Method: `DELETE`
|
||||
- Response: **Status-Code OK**, as well as the id of the deleted option.
|
||||
|
||||
- Endpoint: `/api/v2.4/option/{id}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the option to delete |
|
||||
- Method: `DELETE`
|
||||
- Response: **Status-Code OK**, as well as the id of the deleted option.
|
||||
|
||||
```
|
||||
"data": 7
|
||||
```
|
||||
|
||||
## Sharing Endpoints
|
||||
|
||||
### Add a new Share
|
||||
- Endpoint: `/api/v2.4/share`
|
||||
- Method: `POST`
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|--------------|----------|-------------|
|
||||
| _formId_ | Integer | Id of the form to share |
|
||||
| _shareType_ | String | NC-shareType, out of the used shareTypes. |
|
||||
| _shareWith_ | String | User/Group for the share. Not used for link-shares. |
|
||||
| _permissions_ | String[] | Permissions of the sharees, see [DataStructure](DataStructure.md#Permissions). |
|
||||
- Response: **Status-Code OK**, as well as the new share object.
|
||||
|
||||
- Endpoint: `/api/v2.4/share`
|
||||
- Method: `POST`
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|--------------|----------|-------------|
|
||||
| _formId_ | Integer | Id of the form to share |
|
||||
| _shareType_ | String | NC-shareType, out of the used shareTypes. |
|
||||
| _shareWith_ | String | User/Group for the share. Not used for link-shares. |
|
||||
| _permissions_ | String[] | Permissions of the sharees, see [DataStructure](DataStructure.md#Permissions). |
|
||||
- Response: **Status-Code OK**, as well as the new share object.
|
||||
|
||||
```
|
||||
"data": {
|
||||
"id": 3,
|
||||
|
@ -465,43 +528,52 @@ Update a single or all properties of an option-object
|
|||
```
|
||||
|
||||
### Delete a Share
|
||||
- Endpoint: `/api/v2.4/share/{id}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the share to delete |
|
||||
- Method: `DELETE`
|
||||
- Response: **Status-Code OK**, as well as the id of the deleted share.
|
||||
|
||||
- Endpoint: `/api/v2.4/share/{id}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the share to delete |
|
||||
- Method: `DELETE`
|
||||
- Response: **Status-Code OK**, as well as the id of the deleted share.
|
||||
|
||||
```
|
||||
"data": 5
|
||||
```
|
||||
|
||||
### Update a Share
|
||||
- Endpoint: `/api/v2.4/share/update`
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|------------------|----------|-------------|
|
||||
| _id_ | Integer | ID of the share to update |
|
||||
| *keyValuePairs*¹ | Array | Array of key-value pairs to update |
|
||||
|
||||
¹Currently only the _permissions_ can be updated.
|
||||
- Method: `PATCH`
|
||||
- *Method: `POST` deprecated*
|
||||
- Response: **Status-Code OK**, as well as the id of the share object.
|
||||
- Endpoint: `/api/v2.4/share/update`
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|------------------|----------|-------------|
|
||||
| _id_ | Integer | ID of the share to update |
|
||||
| *keyValuePairs*¹ | Array | Array of key-value pairs to update |
|
||||
|
||||
¹Currently only the _permissions_ can be updated.
|
||||
|
||||
- Method: `PATCH`
|
||||
- _Method: `POST` deprecated_
|
||||
- Response: **Status-Code OK**, as well as the id of the share object.
|
||||
|
||||
```
|
||||
"data": 5
|
||||
```
|
||||
|
||||
## Submission Endpoints
|
||||
|
||||
### Get Form Submissions
|
||||
|
||||
Get all Submissions to a Form
|
||||
- Endpoint: `/api/v2.4/submissions/{hash}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _hash_ | String | Hash of the form to get the submissions for |
|
||||
- Method: `GET`
|
||||
- Response: An Array of all submissions, sorted as newest first, as well as an array of the corresponding questions.
|
||||
|
||||
- Endpoint: `/api/v2.4/submissions/{hash}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _hash_ | String | Hash of the form to get the submissions for |
|
||||
- Method: `GET`
|
||||
- Response: An Array of all submissions, sorted as newest first, as well as an array of the corresponding questions.
|
||||
|
||||
```
|
||||
"data": {
|
||||
"submissions": [
|
||||
|
@ -590,15 +662,18 @@ Get all Submissions to a Form
|
|||
```
|
||||
|
||||
### Get Submissions as csv (Download)
|
||||
|
||||
Returns all submissions to the form in form of a csv-file.
|
||||
- Endpoint: `/api/v2.4/submissions/export/{hash}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|--------------|---------|-------------|
|
||||
| _hash_ | String | Hash of the form to get the submissions for |
|
||||
| _fileFormat_ | String | csv|ods|xlsx |
|
||||
- Method: `GET`
|
||||
- Response: A Data Download Response containing the headers `Content-Disposition: attachment; filename="Form 1 (responses).csv"` and `Content-Type: text/csv;charset=UTF-8`. The actual data contains all submissions to the referred form, formatted as comma separated and escaped csv.
|
||||
|
||||
- Endpoint: `/api/v2.4/submissions/export/{hash}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|--------------|---------|-------------|
|
||||
| _hash_ | String | Hash of the form to get the submissions for |
|
||||
| _fileFormat_ | String | csv|ods|xlsx |
|
||||
- Method: `GET`
|
||||
- Response: A Data Download Response containing the headers `Content-Disposition: attachment; filename="Form 1 (responses).csv"` and `Content-Type: text/csv;charset=UTF-8`. The actual data contains all submissions to the referred form, formatted as comma separated and escaped csv.
|
||||
|
||||
```
|
||||
"User display name","Timestamp","Question 1","Question 2"
|
||||
"jonas","Friday, January 22, 2021 at 12:47:29 AM GMT+0:00","Option 2","Answer"
|
||||
|
@ -606,125 +681,161 @@ Returns all submissions to the form in form of a csv-file.
|
|||
```
|
||||
|
||||
### Export Submissions to Cloud (Files-App)
|
||||
|
||||
Creates a csv file and stores it to the cloud, resp. Files-App.
|
||||
- Endpoint: `/api/v2.4/submissions/export`
|
||||
- Method: `POST`
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|--------------|---------|-------------|
|
||||
| _hash_ | String | Hash of the form to get the submissions for |
|
||||
| _path_ | String | Path within User-Dir, to store the file to |
|
||||
| _fileFormat_ | String | csv|ods|xlsx |
|
||||
- Response: Stores the file to the given path and returns the fileName.
|
||||
|
||||
- Endpoint: `/api/v2.4/submissions/export`
|
||||
- Method: `POST`
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|--------------|---------|-------------|
|
||||
| _hash_ | String | Hash of the form to get the submissions for |
|
||||
| _path_ | String | Path within User-Dir, to store the file to |
|
||||
| _fileFormat_ | String | csv|ods|xlsx |
|
||||
- Response: Stores the file to the given path and returns the fileName.
|
||||
|
||||
```
|
||||
"data": "Form 2 (responses).csv"
|
||||
```
|
||||
|
||||
### Delete Submissions
|
||||
|
||||
Delete all Submissions to a form
|
||||
- Endpoint: `/api/v2.4/submissions/{formId}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _formId_ | Integer | ID of the form to delete the submissions for |
|
||||
- Method: `DELETE`
|
||||
- Response: **Status-Code OK**, as well as the id of the corresponding form.
|
||||
|
||||
- Endpoint: `/api/v2.4/submissions/{formId}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _formId_ | Integer | ID of the form to delete the submissions for |
|
||||
- Method: `DELETE`
|
||||
- Response: **Status-Code OK**, as well as the id of the corresponding form.
|
||||
|
||||
```
|
||||
"data": 3
|
||||
```
|
||||
|
||||
### Upload a file
|
||||
|
||||
Upload a files to answer before form submitting
|
||||
- Endpoint: `/api/2.5/uploadFiles/{formId}/{questionId}`
|
||||
- Method: `POST`
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|--------------|----------------|-------------|
|
||||
| _formId_ | Integer | ID of the form to upload the file to |
|
||||
| _questionId_ | Integer | ID of the question to upload the file to |
|
||||
| _files_ | Array of files | Files to upload |
|
||||
- Response: **Status-Code OK**, as well as the id of the uploaded file and it's name.
|
||||
|
||||
- Endpoint: `/api/2.5/uploadFiles/{formId}/{questionId}`
|
||||
- Method: `POST`
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|--------------|----------------|-------------|
|
||||
| _formId_ | Integer | ID of the form to upload the file to |
|
||||
| _questionId_ | Integer | ID of the question to upload the file to |
|
||||
| _files_ | Array of files | Files to upload |
|
||||
- Response: **Status-Code OK**, as well as the id of the uploaded file and it's name.
|
||||
|
||||
```
|
||||
"data": {"uploadedFileId": integer, "fileName": "string"}
|
||||
```
|
||||
|
||||
### Insert a Submission
|
||||
Store Submission to Database
|
||||
- Endpoint: `/api/v2.4/submission/insert`
|
||||
- Method: `POST`
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _formId_ | Integer | ID of the form to submit into |
|
||||
| _answers_ | Array | Array of Answers |
|
||||
| _shareHash_ | String | optional, only neccessary for submissions to a public share link |
|
||||
|
||||
The Array of Answers has the following structure:
|
||||
- QuestionID as key
|
||||
- An **array** of values as value --> Even for short Text Answers, wrapped into Array.
|
||||
- For Question-Types with pre-defined answers (`multiple`, `multiple_unique`, `dropdown`), the array contains the corresponding option-IDs.
|
||||
- For File-Uploads, the array contains the objects with key `uploadedFileId` (value from Upload a file endpoint).
|
||||
```
|
||||
Store Submission to Database
|
||||
|
||||
- Endpoint: `/api/v2.4/submission/insert`
|
||||
- Method: `POST`
|
||||
- Parameters:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _formId_ | Integer | ID of the form to submit into |
|
||||
| _answers_ | Array | Array of Answers |
|
||||
| _shareHash_ | String | optional, only neccessary for submissions to a public share link |
|
||||
|
||||
The Array of Answers has the following structure:
|
||||
|
||||
- QuestionID as key
|
||||
- An **array** of values as value --> Even for short Text Answers, wrapped into Array.
|
||||
- For Question-Types with pre-defined answers (`multiple`, `multiple_unique`, `dropdown`), the array contains the corresponding option-IDs.
|
||||
|
||||
- For File-Uploads, the array contains the objects with key `uploadedFileId` (value from Upload a file endpoint).
|
||||
|
||||
````
|
||||
{
|
||||
"1":[27,32], // dropdown or multiple
|
||||
"2":["ShortTextAnswer"], // All Text-Based Question-Types
|
||||
"3":[ // File-Upload
|
||||
{"uploadedFileId": integer},
|
||||
{"uploadedFileId": integer}
|
||||
],
|
||||
}
|
||||
{"uploadedFileId": integer},
|
||||
{"uploadedFileId": integer}
|
||||
],
|
||||
}
|
||||
```
|
||||
- Response: **Status-Code OK**.
|
||||
|
||||
- Response: **Status-Code OK**.
|
||||
|
||||
### Delete a single Submission
|
||||
- Endpoint: `/api/v2.4/submission/{id}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the submission to delete |
|
||||
- Method: `DELETE`
|
||||
- Response: **Status-Code OK**, as well as the id of the deleted submission.
|
||||
```
|
||||
"data": 5
|
||||
```
|
||||
|
||||
- Endpoint: `/api/v2.4/submission/{id}`
|
||||
- Url-Parameter:
|
||||
| Parameter | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| _id_ | Integer | ID of the submission to delete |
|
||||
- Method: `DELETE`
|
||||
- Response: **Status-Code OK**, as well as the id of the deleted submission.
|
||||
|
||||
````
|
||||
|
||||
"data": 5
|
||||
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
All Endpoints return one of the following Error-Responses, if the request is not properly raised. This also results in a different `ocs:meta` object.
|
||||
|
||||
### 400 - Bad Request
|
||||
|
||||
This returns in case the Request is not properly set. This can e.g. include:
|
||||
- The corresponding form can not be found
|
||||
- Request Parameters are wrong (including formatting or type of parameters)
|
||||
```
|
||||
"ocs": {
|
||||
"meta": {
|
||||
"status": "failure",
|
||||
"statuscode": 400,
|
||||
"message": ""
|
||||
},
|
||||
"data": []
|
||||
}
|
||||
```
|
||||
### 403 - Forbidden
|
||||
This returns in case the authenticated user is not allowed to access this resource/endpoint. This can e.g. include:
|
||||
- The user has no write access to the form (only form owner is allowed to edit)
|
||||
- The user is not allowed to submit to the form (access-settings, form expired, already submitted)
|
||||
|
||||
- The corresponding form can not be found
|
||||
- Request Parameters are wrong (including formatting or type of parameters)
|
||||
|
||||
```
|
||||
|
||||
"ocs": {
|
||||
"meta": {
|
||||
"status": "failure",
|
||||
"statuscode": 403,
|
||||
"message": ""
|
||||
},
|
||||
"data": []
|
||||
"meta": {
|
||||
"status": "failure",
|
||||
"statuscode": 400,
|
||||
"message": ""
|
||||
},
|
||||
"data": []
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 403 - Forbidden
|
||||
|
||||
This returns in case the authenticated user is not allowed to access this resource/endpoint. This can e.g. include:
|
||||
|
||||
- The user has no write access to the form (only form owner is allowed to edit)
|
||||
- The user is not allowed to submit to the form (access-settings, form expired, already submitted)
|
||||
|
||||
```
|
||||
|
||||
"ocs": {
|
||||
"meta": {
|
||||
"status": "failure",
|
||||
"statuscode": 403,
|
||||
"message": ""
|
||||
},
|
||||
"data": []
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 412 - Precondition Failed
|
||||
|
||||
This Error is not produed by the Forms-API, but comes from Nextclouds OCS API. Typically this is the result when missing the Request-Header `OCS-APIRequest: true`.
|
||||
|
||||
```
|
||||
|
||||
{
|
||||
"message": "CSRF check failed"
|
||||
"message": "CSRF check failed"
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
|
|
|
@ -1,30 +1,33 @@
|
|||
# Forms Data Structure
|
||||
|
||||
**State: Forms v3.3.1 - 08.10.2023**
|
||||
|
||||
This document describes the Object-Structure, that is used within the Forms App and on Forms API v2. It does partially **not** equal the actual database structure behind.
|
||||
|
||||
## Data Structures
|
||||
|
||||
### Form
|
||||
| Property | Type | Restrictions | Description |
|
||||
|-------------|-----------------|---------------|-------------|
|
||||
| id | Integer | unique | An instance-wide unique id of the form |
|
||||
| hash | 16-char String | unique | An instance-wide unique hash |
|
||||
| title | String | max. 256 ch. | The form title |
|
||||
| description | String | max. 8192 ch. | The Form description |
|
||||
| ownerId | String | | The nextcloud userId of the form owner |
|
||||
| submissionMessage | String | max. 2048 ch. | Optional custom message, with Markdown support, to be shown to users when the form is submitted (default is used if set to null) |
|
||||
| created | unix timestamp | | When the form has been created |
|
||||
| access | [Access-Object](#access-object) | | Describing access-settings of the form |
|
||||
| expires | unix-timestamp | | When the form should expire. Timestamp `0` indicates _never_ |
|
||||
| isAnonymous | Boolean | | If Answers will be stored anonymously |
|
||||
| state | Integer | [Form state](#form-state)| The state of the form |
|
||||
| submitMultiple | Boolean | | If users are allowed to submit multiple times to the form |
|
||||
| showExpiration | Boolean | | If the expiration date will be shown on the form |
|
||||
| canSubmit | Boolean | | If the user can Submit to the form, i.e. calculated information out of `submitMultiple` and existing submissions. |
|
||||
| permissions | Array of [Permissions](#permissions) | Array of permissions regarding the form |
|
||||
| questions | Array of [Questions](#question) | | Array of questions belonging to the form |
|
||||
| shares | Array of [Shares](#share) | | Array of shares of the form |
|
||||
| submissions | Array of [Submissions](#submission) | | Array of submissions belonging to the form |
|
||||
|
||||
| Property | Type | Restrictions | Description |
|
||||
| ----------------- | ------------------------------------ | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| id | Integer | unique | An instance-wide unique id of the form |
|
||||
| hash | 16-char String | unique | An instance-wide unique hash |
|
||||
| title | String | max. 256 ch. | The form title |
|
||||
| description | String | max. 8192 ch. | The Form description |
|
||||
| ownerId | String | | The nextcloud userId of the form owner |
|
||||
| submissionMessage | String | max. 2048 ch. | Optional custom message, with Markdown support, to be shown to users when the form is submitted (default is used if set to null) |
|
||||
| created | unix timestamp | | When the form has been created |
|
||||
| access | [Access-Object](#access-object) | | Describing access-settings of the form |
|
||||
| expires | unix-timestamp | | When the form should expire. Timestamp `0` indicates _never_ |
|
||||
| isAnonymous | Boolean | | If Answers will be stored anonymously |
|
||||
| state | Integer | [Form state](#form-state) | The state of the form |
|
||||
| submitMultiple | Boolean | | If users are allowed to submit multiple times to the form |
|
||||
| showExpiration | Boolean | | If the expiration date will be shown on the form |
|
||||
| canSubmit | Boolean | | If the user can Submit to the form, i.e. calculated information out of `submitMultiple` and existing submissions. |
|
||||
| permissions | Array of [Permissions](#permissions) | Array of permissions regarding the form |
|
||||
| questions | Array of [Questions](#question) | | Array of questions belonging to the form |
|
||||
| shares | Array of [Shares](#share) | | Array of shares of the form |
|
||||
| submissions | Array of [Submissions](#submission) | | Array of submissions belonging to the form |
|
||||
|
||||
```
|
||||
{
|
||||
|
@ -53,26 +56,29 @@ This document describes the Object-Structure, that is used within the Forms App
|
|||
```
|
||||
|
||||
#### Form state
|
||||
|
||||
The form state is used for additional states, currently following states are defined:
|
||||
|
||||
| Value | Meaning |
|
||||
|----------------|---------------------------------------------|
|
||||
| 0 | Form is active and open for new submissions |
|
||||
| 1 | Form is closed and does not allow new submissions |
|
||||
| 2 | Form is archived, it does not allow new submissions and can also not be modified anymore |
|
||||
| Value | Meaning |
|
||||
| ----- | ---------------------------------------------------------------------------------------- |
|
||||
| 0 | Form is active and open for new submissions |
|
||||
| 1 | Form is closed and does not allow new submissions |
|
||||
| 2 | Form is archived, it does not allow new submissions and can also not be modified anymore |
|
||||
|
||||
### Question
|
||||
| Property | Type | Restrictions | Description |
|
||||
|----------------|-----------------|--------------|-------------|
|
||||
| id | Integer | unique | An instance-wide unique id of the question |
|
||||
| formId | Integer | | The id of the form, the question belongs to |
|
||||
| order | Integer | unique within form; *not* `0` | The order of the question within that form. Value `0` indicates deleted questions within database (typ. not visible outside) |
|
||||
| type | [Question-Type](#question-types) | | Type of the question |
|
||||
| isRequired | Boolean | | If the question is required to fill the form |
|
||||
| text | String | max. 2048 ch. | The question-text |
|
||||
| name | String | | Technical identifier of the question, e.g. used as HTML name attribute |
|
||||
| options | Array of [Options](#option) | | Array of options belonging to the question. Only relevant for question-type with predefined options. |
|
||||
| extraSettings | [Extra Settings](#extra-settings) | | Additional settings for the question. |
|
||||
|
||||
| Property | Type | Restrictions | Description |
|
||||
| ------------- | --------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| id | Integer | unique | An instance-wide unique id of the question |
|
||||
| formId | Integer | | The id of the form, the question belongs to |
|
||||
| order | Integer | unique within form; _not_ `0` | The order of the question within that form. Value `0` indicates deleted questions within database (typ. not visible outside) |
|
||||
| type | [Question-Type](#question-types) | | Type of the question |
|
||||
| isRequired | Boolean | | If the question is required to fill the form |
|
||||
| text | String | max. 2048 ch. | The question-text |
|
||||
| name | String | | Technical identifier of the question, e.g. used as HTML name attribute |
|
||||
| options | Array of [Options](#option) | | Array of options belonging to the question. Only relevant for question-type with predefined options. |
|
||||
| extraSettings | [Extra Settings](#extra-settings) | | Additional settings for the question. |
|
||||
|
||||
```
|
||||
{
|
||||
"id": 1,
|
||||
|
@ -88,13 +94,15 @@ The form state is used for additional states, currently following states are def
|
|||
```
|
||||
|
||||
### Option
|
||||
|
||||
Options are predefined answer-possibilities corresponding to questions with appropriate question-type.
|
||||
|
||||
| Property | Type | Restrictions | Description |
|
||||
|-------------|-----------------|--------------|-------------|
|
||||
| id | Integer | unique | An instance-wide unique id of the option |
|
||||
| questionId | Integer | | The id of the question, the option belongs to |
|
||||
| text | String | max. 1024 ch.| The option-text |
|
||||
| Property | Type | Restrictions | Description |
|
||||
| ---------- | ------- | ------------- | --------------------------------------------- |
|
||||
| id | Integer | unique | An instance-wide unique id of the option |
|
||||
| questionId | Integer | | The id of the question, the option belongs to |
|
||||
| text | String | max. 1024 ch. | The option-text |
|
||||
|
||||
```
|
||||
{
|
||||
"id": 1,
|
||||
|
@ -104,26 +112,27 @@ Options are predefined answer-possibilities corresponding to questions with appr
|
|||
```
|
||||
|
||||
### Share
|
||||
|
||||
A share-object describes a single share of the form.
|
||||
| Property | Type | Restrictions | Description |
|
||||
| Property | Type | Restrictions | Description |
|
||||
|-------------|-----------------|--------------|-------------|
|
||||
| id | Integer | unique | An instance-wide unique id of the share |
|
||||
| formId | Integer | | The id of the form, the share belongs to |
|
||||
| shareType | NC-IShareType (Int) | `IShare::TYPE_USER = 0`, `IShare::TYPE_GROUP = 1`, `IShare::TYPE_LINK = 3` | Type of the share. Thus also describes how to interpret shareWith. |
|
||||
| shareWith | String | | User/Group/Hash - depending on the shareType |
|
||||
| displayName | String | | Display name of share-target. |
|
||||
| id | Integer | unique | An instance-wide unique id of the share |
|
||||
| formId | Integer | | The id of the form, the share belongs to |
|
||||
| shareType | NC-IShareType (Int) | `IShare::TYPE_USER = 0`, `IShare::TYPE_GROUP = 1`, `IShare::TYPE_LINK = 3` | Type of the share. Thus also describes how to interpret shareWith. |
|
||||
| shareWith | String | | User/Group/Hash - depending on the shareType |
|
||||
| displayName | String | | Display name of share-target. |
|
||||
|
||||
### Submission
|
||||
A submission-object describes a single submission by a user to a form.
|
||||
| Property | Type | Restrictions | Description |
|
||||
|-------------|-----------------|--------------|-------------|
|
||||
| id | Integer | unique | An instance-wide unique id of the submission |
|
||||
| formId | Integer | | The id of the form, the submission belongs to |
|
||||
| userId | String | | The nextcloud userId of the submitting user. If submission is anonymous, this contains `anon-user-<hash>` |
|
||||
| timestamp | unix timestamp | | When the user submitted |
|
||||
| answers | Array of [Answers](#answer) | | Array of the actual user answers, belonging to this submission.
|
||||
| userDisplayName | String | | Display name of the nextcloud-user, derived from `userId`. Contains `Anonymous user` if submitted anonymously. Not stored in DB.
|
||||
|
||||
A submission-object describes a single submission by a user to a form.
|
||||
| Property | Type | Restrictions | Description |
|
||||
|-------------|-----------------|--------------|-------------|
|
||||
| id | Integer | unique | An instance-wide unique id of the submission |
|
||||
| formId | Integer | | The id of the form, the submission belongs to |
|
||||
| userId | String | | The nextcloud userId of the submitting user. If submission is anonymous, this contains `anon-user-<hash>` |
|
||||
| timestamp | unix timestamp | | When the user submitted |
|
||||
| answers | Array of [Answers](#answer) | | Array of the actual user answers, belonging to this submission.
|
||||
| userDisplayName | String | | Display name of the nextcloud-user, derived from `userId`. Contains `Anonymous user` if submitted anonymously. Not stored in DB.
|
||||
|
||||
```
|
||||
{
|
||||
|
@ -137,14 +146,16 @@ A submission-object describes a single submission by a user to a form.
|
|||
```
|
||||
|
||||
### Answer
|
||||
|
||||
The actual answers of users on submission.
|
||||
|
||||
| Property | Type | Restrictions | Description |
|
||||
|-------------|-----------------|--------------|-------------|
|
||||
| id | Integer | unique | An instance-wide unique id of the submission |
|
||||
| submissionId | Integer | | The id of the submission, the answer belongs to |
|
||||
| questionId | Integer | | The id of the question, the answer belongs to |
|
||||
| text | String | max. 4096 ch. | The actual answer text, the user submitted |
|
||||
| Property | Type | Restrictions | Description |
|
||||
| ------------ | ------- | ------------- | ----------------------------------------------- |
|
||||
| id | Integer | unique | An instance-wide unique id of the submission |
|
||||
| submissionId | Integer | | The id of the submission, the answer belongs to |
|
||||
| questionId | Integer | | The id of the question, the answer belongs to |
|
||||
| text | String | max. 4096 ch. | The actual answer text, the user submitted |
|
||||
|
||||
```
|
||||
{
|
||||
"id": 5,
|
||||
|
@ -155,20 +166,22 @@ The actual answers of users on submission.
|
|||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
Array of permissions, the user has on the form. Permissions are named by resp. routes on frontend.
|
||||
| Permission | Description |
|
||||
| Permission | Description |
|
||||
| ---------------|-------------|
|
||||
| edit | User is allowed to edit the form |
|
||||
| results | User is allowed to access the form results |
|
||||
| edit | User is allowed to edit the form |
|
||||
| results | User is allowed to access the form results |
|
||||
| results_delete | User is allowed to delete form submissions |
|
||||
| submit | User is allowed to submit to the form |
|
||||
| submit | User is allowed to submit to the form |
|
||||
|
||||
## Access Object
|
||||
|
||||
Defines some extended options of sharing / access
|
||||
| Property | Type | Description |
|
||||
| Property | Type | Description |
|
||||
|------------------|-----------|-------------|
|
||||
| permitAllUsers | Boolean | All logged in users of this instance are allowed to submit to the form |
|
||||
| showToAllUsers | Boolean | Only active, if permitAllUsers is true - Show the form to all users on appNavigation |
|
||||
| permitAllUsers | Boolean | All logged in users of this instance are allowed to submit to the form |
|
||||
| showToAllUsers | Boolean | Only active, if permitAllUsers is true - Show the form to all users on appNavigation |
|
||||
|
||||
```
|
||||
{
|
||||
|
@ -178,24 +191,26 @@ Defines some extended options of sharing / access
|
|||
```
|
||||
|
||||
## Question Types
|
||||
|
||||
Currently supported Question-Types are:
|
||||
|
||||
| Type-ID | Description |
|
||||
|-----------------|-------------|
|
||||
| `multiple` | Typically known as 'Checkboxes'. Using pre-defined options, the user can select one or multiple from. Needs at least one option available. |
|
||||
| `multiple_unique` | Typically known as 'Radio Buttons'. Using pre-defined options, the user can select exactly one from. Needs at least one option available. |
|
||||
| `dropdown` | Similar to `multiple_unique`, but rendered as dropdown field. |
|
||||
| `short` | A short text answer. Single text line |
|
||||
| `long` | A long text answer. Multi-line supported |
|
||||
| `date` | Showing a dropdown calendar to select a date. |
|
||||
| _`datetime`_ | _deprecated: No longer available for new questions. Showing a dropdown calendar to select a date **and** a time._ |
|
||||
| `time` | Showing a dropdown menu to select a time. |
|
||||
| Type-ID | Description |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `multiple` | Typically known as 'Checkboxes'. Using pre-defined options, the user can select one or multiple from. Needs at least one option available. |
|
||||
| `multiple_unique` | Typically known as 'Radio Buttons'. Using pre-defined options, the user can select exactly one from. Needs at least one option available. |
|
||||
| `dropdown` | Similar to `multiple_unique`, but rendered as dropdown field. |
|
||||
| `short` | A short text answer. Single text line |
|
||||
| `long` | A long text answer. Multi-line supported |
|
||||
| `date` | Showing a dropdown calendar to select a date. |
|
||||
| _`datetime`_ | _deprecated: No longer available for new questions. Showing a dropdown calendar to select a date **and** a time._ |
|
||||
| `time` | Showing a dropdown menu to select a time. |
|
||||
|
||||
## Extra Settings
|
||||
|
||||
Optional extra settings for some [Question Types](#question-types)
|
||||
|
||||
| Extra Setting | Question Type | Type | Values | Description |
|
||||
|-------------------------|---------------------------------------|------------------|---------------------------------------------|-----------------------------------------------------------------------------|
|
||||
| ----------------------- | ------------------------------------- | ---------------- | ------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `allowOtherAnswer` | `multiple, multiple_unique` | Boolean | `true/false` | Allows the user to specify a custom answer |
|
||||
| `shuffleOptions` | `dropdown, multiple, multiple_unique` | Boolean | `true/false` | The list of options should be shuffled |
|
||||
| `optionsLimitMax` | `multiple` | Integer | - | Maximum number of options that can be selected |
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
# Embedding
|
||||
|
||||
Besides sharing and using the [API](./API.md) for custom forms it is possible to embed forms inside external
|
||||
websites.
|
||||
|
||||
## Obtaining the embedding code
|
||||
|
||||
For embedding a form it is **required** to create a *public share link* first and then *convert it to an embeddable link*.\
|
||||
The embedding code can be copied from the *sharing sidebar* or crafted manually by using the public share link:
|
||||
For embedding a form it is **required** to create a _public share link_ first and then _convert it to an embeddable link_.\
|
||||
The embedding code can be copied from the _sharing sidebar_ or crafted manually by using the public share link:
|
||||
|
||||
If the public share link looks like this:\
|
||||
`https://SERVER_DOMAIN/apps/forms/s/SHARE_HASH`
|
||||
|
@ -13,78 +14,92 @@ If the public share link looks like this:\
|
|||
The embeddable URL looks like this:\
|
||||
`https://SERVER_DOMAIN/apps/forms/embed/SHARE_HASH`
|
||||
|
||||
Using the copy-embedding-code button on the *sharing sidebar* will automatically generate ready-to-use HTML code for embedding which looks like this:
|
||||
Using the copy-embedding-code button on the _sharing sidebar_ will automatically generate ready-to-use HTML code for embedding which looks like this:
|
||||
|
||||
```html
|
||||
<iframe src="EMBEDDABLE_URL" width="750" height="900"></iframe>
|
||||
```
|
||||
|
||||
The size parameters are based on our default forms styling.
|
||||
|
||||
## Window message events
|
||||
## Window message events
|
||||
|
||||
The embedded view provides a `MessageEvent` to communicate its size with its parent window.
|
||||
This is done as accessing the document within an `iframe` is not possible if not on the same domain.
|
||||
|
||||
### Auto resizing the `iframe`
|
||||
|
||||
The emitted message on the embedded view looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "resize-iframe",
|
||||
"payload": {
|
||||
"width": 750,
|
||||
"height": 900,
|
||||
},
|
||||
"height": 900
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To receive this information on your parent site:
|
||||
|
||||
```js
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.origin !== "http://your-nextcloud-server.com") {
|
||||
return;
|
||||
}
|
||||
window.addEventListener(
|
||||
'message',
|
||||
(event) => {
|
||||
if (event.origin !== 'http://your-nextcloud-server.com') {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.data.type !== "resize-iframe") {
|
||||
return;
|
||||
}
|
||||
if (event.data.type !== 'resize-iframe') {
|
||||
return
|
||||
}
|
||||
|
||||
const { width, height } = event.data.payload;
|
||||
const { width, height } = event.data.payload
|
||||
|
||||
iframe.width = width;
|
||||
iframe.height = height;
|
||||
}, false);
|
||||
iframe.width = width
|
||||
iframe.height = height
|
||||
},
|
||||
false,
|
||||
)
|
||||
```
|
||||
|
||||
### Form submitted
|
||||
|
||||
When the form is submitted a message event like this is emitted:
|
||||
|
||||
The emitted message on the embedded view looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "form-saved",
|
||||
"payload": {
|
||||
"id": 1234,
|
||||
},
|
||||
"id": 1234
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Custom styling
|
||||
|
||||
To apply custom styles on the embedded forms the [Custom CSS App](https://apps.nextcloud.com/apps/theming_customcss) can be used.
|
||||
|
||||
The embedded form provides the `app-forms-embedded` class, so you can apply your styles.\
|
||||
For example if you want the form to be displayed without margins you can use this:
|
||||
|
||||
```css
|
||||
#content-vue.app-forms-embedded {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
}
|
||||
```
|
||||
|
||||
Or if you want the form to fill the screen:
|
||||
|
||||
```css
|
||||
#content-vue.app-forms-embedded .app-content header,
|
||||
#content-vue.app-forms-embedded .app-content form {
|
||||
max-width: unset;
|
||||
}
|
||||
```
|
||||
```
|
||||
|
|
28
package.json
28
package.json
|
@ -1,31 +1,34 @@
|
|||
{
|
||||
"name": "forms",
|
||||
"description": "Forms app for nextcloud",
|
||||
"version": "4.2.4",
|
||||
"private": true,
|
||||
"description": "Forms app for nextcloud",
|
||||
"homepage": "https://github.com/nextcloud/forms#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/nextcloud/forms/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/nextcloud/forms.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/nextcloud/forms/issues"
|
||||
},
|
||||
"homepage": "https://github.com/nextcloud/forms#readme",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite --mode production build",
|
||||
"dev": "vite --mode development build",
|
||||
"watch": "vite --mode development build --watch",
|
||||
"cypress": "cypress run --e2e",
|
||||
"cypress:gui": "cypress open",
|
||||
"dev": "vite --mode development build",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"lint": "eslint --ext .js,.vue src",
|
||||
"lint:fix": "eslint --ext .js,.vue src --fix",
|
||||
"stylelint": "stylelint css/*.css css/*.scss src/**/*.vue",
|
||||
"stylelint:fix": "stylelint css/*.css css/*.scss src/**/*.vue --fix"
|
||||
"stylelint:fix": "stylelint css/*.css css/*.scss src/**/*.vue --fix",
|
||||
"watch": "vite --mode development build --watch"
|
||||
},
|
||||
"browserslist": [
|
||||
"extends @nextcloud/browserslist-config"
|
||||
],
|
||||
"dependencies": {
|
||||
"@nextcloud/auth": "^2.3.0",
|
||||
"@nextcloud/axios": "^2.5.0",
|
||||
|
@ -49,13 +52,6 @@
|
|||
"vue-router": "^3.6.5",
|
||||
"vuedraggable": "^2.24.3"
|
||||
},
|
||||
"browserslist": [
|
||||
"extends @nextcloud/browserslist-config"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.0.0",
|
||||
"npm": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@mdi/svg": "^7.4.47",
|
||||
|
|
135
src/Forms.vue
135
src/Forms.vue
|
@ -23,9 +23,11 @@
|
|||
|
||||
<template>
|
||||
<NcContent app-name="forms">
|
||||
<NcAppNavigation v-if="canCreateForms || hasForms"
|
||||
<NcAppNavigation
|
||||
v-if="canCreateForms || hasForms"
|
||||
:aria-label="t('forms', 'Forms navigation')">
|
||||
<NcAppNavigationNew v-if="canCreateForms"
|
||||
<NcAppNavigationNew
|
||||
v-if="canCreateForms"
|
||||
:text="t('forms', 'New form')"
|
||||
@click="onNewForm">
|
||||
<template #icon>
|
||||
|
@ -34,8 +36,11 @@
|
|||
</NcAppNavigationNew>
|
||||
<template #list>
|
||||
<!-- Form-Owner-->
|
||||
<NcAppNavigationCaption v-if="ownedForms.length > 0" :name="t('forms', 'Your Forms')" />
|
||||
<AppNavigationForm v-for="form in ownedForms"
|
||||
<NcAppNavigationCaption
|
||||
v-if="ownedForms.length > 0"
|
||||
:name="t('forms', 'Your Forms')" />
|
||||
<AppNavigationForm
|
||||
v-for="form in ownedForms"
|
||||
:key="form.id"
|
||||
:form="form"
|
||||
:read-only="false"
|
||||
|
@ -45,8 +50,11 @@
|
|||
@delete="onDeleteForm" />
|
||||
|
||||
<!-- Shared Forms-->
|
||||
<NcAppNavigationCaption v-if="sharedForms.length > 0" :name="t('forms', 'Shared with you')" />
|
||||
<AppNavigationForm v-for="form in sharedForms"
|
||||
<NcAppNavigationCaption
|
||||
v-if="sharedForms.length > 0"
|
||||
:name="t('forms', 'Shared with you')" />
|
||||
<AppNavigationForm
|
||||
v-for="form in sharedForms"
|
||||
:key="form.id"
|
||||
:form="form"
|
||||
:read-only="true"
|
||||
|
@ -55,7 +63,8 @@
|
|||
</template>
|
||||
<template #footer>
|
||||
<div v-if="archivedForms.length > 0" class="forms-navigation-footer">
|
||||
<NcButton alignment="start"
|
||||
<NcButton
|
||||
alignment="start"
|
||||
class="forms__archived-forms-toggle"
|
||||
type="tertiary"
|
||||
wide
|
||||
|
@ -71,7 +80,8 @@
|
|||
|
||||
<!-- No forms & loading emptycontents -->
|
||||
<NcAppContent v-if="loading || !routeHash || !routeAllowed">
|
||||
<NcEmptyContent v-if="loading"
|
||||
<NcEmptyContent
|
||||
v-if="loading"
|
||||
class="forms-emptycontent"
|
||||
:name="t('forms', 'Loading forms …')">
|
||||
<template #icon>
|
||||
|
@ -79,7 +89,8 @@
|
|||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<NcEmptyContent v-else-if="!hasForms"
|
||||
<NcEmptyContent
|
||||
v-else-if="!hasForms"
|
||||
class="forms-emptycontent"
|
||||
:name="t('forms', 'No forms created yet')">
|
||||
<template #icon>
|
||||
|
@ -92,9 +103,14 @@
|
|||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<NcEmptyContent v-else
|
||||
<NcEmptyContent
|
||||
v-else
|
||||
class="forms-emptycontent"
|
||||
:name="canCreateForms ? t('forms', 'Select a form or create a new one') : t('forms', 'Please select a form')">
|
||||
:name="
|
||||
canCreateForms
|
||||
? t('forms', 'Select a form or create a new one')
|
||||
: t('forms', 'Please select a form')
|
||||
">
|
||||
<template #icon>
|
||||
<FormsIcon :size="64" />
|
||||
</template>
|
||||
|
@ -108,10 +124,12 @@
|
|||
|
||||
<!-- No errors show router content -->
|
||||
<template v-else>
|
||||
<router-view :form.sync="selectedForm"
|
||||
<router-view
|
||||
:form.sync="selectedForm"
|
||||
:sidebar-opened.sync="sidebarOpened"
|
||||
@open-sharing="openSharing" />
|
||||
<router-view v-if="!selectedForm.partial && canEdit"
|
||||
<router-view
|
||||
v-if="!selectedForm.partial && canEdit"
|
||||
:form="selectedForm"
|
||||
:sidebar-opened.sync="sidebarOpened"
|
||||
:active.sync="sidebarActive"
|
||||
|
@ -195,7 +213,9 @@ export default {
|
|||
|
||||
computed: {
|
||||
canEdit() {
|
||||
return this.selectedForm.permissions.includes(this.PERMISSION_TYPES.PERMISSION_EDIT)
|
||||
return this.selectedForm.permissions.includes(
|
||||
this.PERMISSION_TYPES.PERMISSION_EDIT,
|
||||
)
|
||||
},
|
||||
|
||||
hasForms() {
|
||||
|
@ -206,26 +226,25 @@ export default {
|
|||
* All own active forms
|
||||
*/
|
||||
ownedForms() {
|
||||
return this.forms
|
||||
.filter((form) => form.state !== FormState.FormArchived)
|
||||
return this.forms.filter((form) => form.state !== FormState.FormArchived)
|
||||
},
|
||||
|
||||
/**
|
||||
* All active shared forms
|
||||
*/
|
||||
sharedForms() {
|
||||
return this.allSharedForms
|
||||
.filter((form) => form.state !== FormState.FormArchived)
|
||||
return this.allSharedForms.filter(
|
||||
(form) => form.state !== FormState.FormArchived,
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* All forms that have been archived
|
||||
*/
|
||||
archivedForms() {
|
||||
return [
|
||||
...this.forms,
|
||||
...this.allSharedForms,
|
||||
].filter((form) => form.state === FormState.FormArchived)
|
||||
return [...this.forms, ...this.allSharedForms].filter(
|
||||
(form) => form.state === FormState.FormArchived,
|
||||
)
|
||||
},
|
||||
|
||||
routeHash() {
|
||||
|
@ -240,8 +259,9 @@ export default {
|
|||
}
|
||||
|
||||
// Try to find form in owned & shared list
|
||||
const form = [...this.forms, ...this.allSharedForms]
|
||||
.find(form => form.hash === this.routeHash)
|
||||
const form = [...this.forms, ...this.allSharedForms].find(
|
||||
(form) => form.hash === this.routeHash,
|
||||
)
|
||||
|
||||
// If no form found, load it from server. Route will be automatically re-evaluated.
|
||||
if (form === undefined) {
|
||||
|
@ -256,19 +276,25 @@ export default {
|
|||
selectedForm: {
|
||||
get() {
|
||||
if (this.routeAllowed) {
|
||||
return this.forms.concat(this.allSharedForms).find(form => form.hash === this.routeHash)
|
||||
return this.forms
|
||||
.concat(this.allSharedForms)
|
||||
.find((form) => form.hash === this.routeHash)
|
||||
}
|
||||
return {}
|
||||
},
|
||||
set(form) {
|
||||
// If a owned form
|
||||
let index = this.forms.findIndex(search => search.hash === this.routeHash)
|
||||
let index = this.forms.findIndex(
|
||||
(search) => search.hash === this.routeHash,
|
||||
)
|
||||
if (index > -1) {
|
||||
this.$set(this.forms, index, form)
|
||||
return
|
||||
}
|
||||
// Otherwise a shared form
|
||||
index = this.allSharedForms.findIndex(search => search.hash === this.routeHash)
|
||||
index = this.allSharedForms.findIndex(
|
||||
(search) => search.hash === this.routeHash,
|
||||
)
|
||||
if (index > -1) {
|
||||
this.$set(this.allSharedForms, index, form)
|
||||
}
|
||||
|
@ -286,7 +312,9 @@ export default {
|
|||
},
|
||||
|
||||
unmounted() {
|
||||
unsubscribe('forms:last-updated:set', (id) => this.onLastUpdatedByEventBus(id))
|
||||
unsubscribe('forms:last-updated:set', (id) =>
|
||||
this.onLastUpdatedByEventBus(id),
|
||||
)
|
||||
unsubscribe('forms:ownership-transfered', (id) => this.onDeleteForm(id))
|
||||
},
|
||||
|
||||
|
@ -321,20 +349,28 @@ export default {
|
|||
|
||||
// Load Owned forms
|
||||
try {
|
||||
const response = await axios.get(generateOcsUrl('apps/forms/api/v2.4/forms'))
|
||||
const response = await axios.get(
|
||||
generateOcsUrl('apps/forms/api/v2.4/forms'),
|
||||
)
|
||||
this.forms = OcsResponse2Data(response)
|
||||
} catch (error) {
|
||||
logger.error('Error while loading owned forms list', { error })
|
||||
showError(t('forms', 'An error occurred while loading the forms list'))
|
||||
showError(
|
||||
t('forms', 'An error occurred while loading the forms list'),
|
||||
)
|
||||
}
|
||||
|
||||
// Load shared forms
|
||||
try {
|
||||
const response = await axios.get(generateOcsUrl('apps/forms/api/v2.4/shared_forms'))
|
||||
const response = await axios.get(
|
||||
generateOcsUrl('apps/forms/api/v2.4/shared_forms'),
|
||||
)
|
||||
this.allSharedForms = OcsResponse2Data(response)
|
||||
} catch (error) {
|
||||
logger.error('Error while loading shared forms list', { error })
|
||||
showError(t('forms', 'An error occurred while loading the forms list'))
|
||||
showError(
|
||||
t('forms', 'An error occurred while loading the forms list'),
|
||||
)
|
||||
}
|
||||
|
||||
this.loading = false
|
||||
|
@ -358,13 +394,25 @@ export default {
|
|||
})
|
||||
|
||||
this.loading = true
|
||||
if ([...this.forms, ...this.allSharedForms].find((form) => form.hash === hash) === undefined) {
|
||||
if (
|
||||
[...this.forms, ...this.allSharedForms].find(
|
||||
(form) => form.hash === hash,
|
||||
) === undefined
|
||||
) {
|
||||
try {
|
||||
const response = await axios.get(generateOcsUrl('apps/forms/api/v2.4/partial_form/{hash}', { hash }))
|
||||
const response = await axios.get(
|
||||
generateOcsUrl('apps/forms/api/v2.4/partial_form/{hash}', {
|
||||
hash,
|
||||
}),
|
||||
)
|
||||
const form = OcsResponse2Data(response)
|
||||
|
||||
// If the user has (at least) submission-permissions, add it to the shared forms
|
||||
if (form.permissions.includes(this.PERMISSION_TYPES.PERMISSION_SUBMIT)) {
|
||||
if (
|
||||
form.permissions.includes(
|
||||
this.PERMISSION_TYPES.PERMISSION_SUBMIT,
|
||||
)
|
||||
) {
|
||||
this.allSharedForms.push(form)
|
||||
}
|
||||
} catch (error) {
|
||||
|
@ -382,7 +430,9 @@ export default {
|
|||
async onNewForm() {
|
||||
try {
|
||||
// Request a new empty form
|
||||
const response = await axios.post(generateOcsUrl('apps/forms/api/v2.4/form'))
|
||||
const response = await axios.post(
|
||||
generateOcsUrl('apps/forms/api/v2.4/form'),
|
||||
)
|
||||
const newForm = OcsResponse2Data(response)
|
||||
this.forms.unshift(newForm)
|
||||
this.$router.push({ name: 'edit', params: { hash: newForm.hash } })
|
||||
|
@ -400,7 +450,9 @@ export default {
|
|||
*/
|
||||
async onCloneForm(id) {
|
||||
try {
|
||||
const response = await axios.post(generateOcsUrl('apps/forms/api/v2.4/form/clone/{id}', { id }))
|
||||
const response = await axios.post(
|
||||
generateOcsUrl('apps/forms/api/v2.4/form/clone/{id}', { id }),
|
||||
)
|
||||
const newForm = OcsResponse2Data(response)
|
||||
this.forms.unshift(newForm)
|
||||
this.$router.push({ name: 'edit', params: { hash: newForm.hash } })
|
||||
|
@ -417,7 +469,7 @@ export default {
|
|||
* @param {number} id the form id
|
||||
*/
|
||||
async onDeleteForm(id) {
|
||||
const formIndex = this.forms.findIndex(form => form.id === id)
|
||||
const formIndex = this.forms.findIndex((form) => form.id === id)
|
||||
const deletedHash = this.forms[formIndex].hash
|
||||
|
||||
this.forms.splice(formIndex, 1)
|
||||
|
@ -434,12 +486,14 @@ export default {
|
|||
* @param {number} id the form id
|
||||
*/
|
||||
onLastUpdatedByEventBus(id) {
|
||||
const formIndex = this.forms.findIndex(form => form.id === id)
|
||||
const formIndex = this.forms.findIndex((form) => form.id === id)
|
||||
if (formIndex !== -1) {
|
||||
this.forms[formIndex].lastUpdated = moment().unix()
|
||||
this.forms.sort((b, a) => a.lastUpdated - b.lastUpdated)
|
||||
} else {
|
||||
const sharedFormIndex = this.allSharedForms.findIndex(form => form.id === id)
|
||||
const sharedFormIndex = this.allSharedForms.findIndex(
|
||||
(form) => form.id === id,
|
||||
)
|
||||
this.allSharedForms[sharedFormIndex].lastUpdated = moment().unix()
|
||||
this.allSharedForms.sort((b, a) => a.lastUpdated - b.lastUpdated)
|
||||
}
|
||||
|
@ -449,7 +503,6 @@ export default {
|
|||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.forms-navigation-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
@ -23,7 +23,8 @@
|
|||
<template>
|
||||
<NcContent app-name="forms">
|
||||
<NcAppContent class="forms-emptycontent">
|
||||
<NcEmptyContent :name="currentModel.title"
|
||||
<NcEmptyContent
|
||||
:name="currentModel.title"
|
||||
:description="currentModel.description">
|
||||
<template #icon>
|
||||
<Icon :is="currentModel.icon" :size="64" />
|
||||
|
@ -66,7 +67,10 @@ export default {
|
|||
},
|
||||
expired: {
|
||||
title: t('forms', 'Form expired'),
|
||||
description: t('forms', 'This form has expired and is no longer taking answers'),
|
||||
description: t(
|
||||
'forms',
|
||||
'This form has expired and is no longer taking answers',
|
||||
),
|
||||
icon: IconCheck,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -23,14 +23,16 @@
|
|||
<template>
|
||||
<div>
|
||||
<NcSettingsSection :name="t('forms', 'Form creation')">
|
||||
<NcCheckboxRadioSwitch ref="switchRestrictCreation"
|
||||
<NcCheckboxRadioSwitch
|
||||
ref="switchRestrictCreation"
|
||||
:checked.sync="appConfig.restrictCreation"
|
||||
class="forms-settings__creation__switch"
|
||||
type="switch"
|
||||
@update:checked="onRestrictCreationChange">
|
||||
{{ t('forms', 'Restrict form creation to selected groups') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<NcSelect v-model="appConfig.creationAllowedGroups"
|
||||
<NcSelect
|
||||
v-model="appConfig.creationAllowedGroups"
|
||||
:disabled="!appConfig.restrictCreation"
|
||||
:multiple="true"
|
||||
:options="availableGroups"
|
||||
|
@ -40,13 +42,15 @@
|
|||
@input="onCreationAllowedGroupsChange" />
|
||||
</NcSettingsSection>
|
||||
<NcSettingsSection :name="t('forms', 'Form sharing')">
|
||||
<NcCheckboxRadioSwitch ref="switchAllowPublicLink"
|
||||
<NcCheckboxRadioSwitch
|
||||
ref="switchAllowPublicLink"
|
||||
:checked.sync="appConfig.allowPublicLink"
|
||||
type="switch"
|
||||
@update:checked="onAllowPublicLinkChange">
|
||||
{{ t('forms', 'Allow sharing by link') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<NcCheckboxRadioSwitch ref="switchAllowPermitAll"
|
||||
<NcCheckboxRadioSwitch
|
||||
ref="switchAllowPermitAll"
|
||||
:checked.sync="appConfig.allowPermitAll"
|
||||
type="switch"
|
||||
@update:checked="onAllowPermitAllChange">
|
||||
|
@ -102,7 +106,10 @@ export default {
|
|||
async onCreationAllowedGroupsChange(newVal) {
|
||||
const el = this.$refs.switchRestrictCreation
|
||||
el.loading = true
|
||||
await this.saveAppConfig('creationAllowedGroups', newVal.map(group => group.groupId))
|
||||
await this.saveAppConfig(
|
||||
'creationAllowedGroups',
|
||||
newVal.map((group) => group.groupId),
|
||||
)
|
||||
el.loading = false
|
||||
},
|
||||
async onAllowPublicLinkChange(newVal) {
|
||||
|
|
|
@ -21,8 +21,9 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<NcContent app-name="forms" :class="{'app-forms-embedded': isEmbedded}">
|
||||
<Submit :form="form"
|
||||
<NcContent app-name="forms" :class="{ 'app-forms-embedded': isEmbedded }">
|
||||
<Submit
|
||||
:form="form"
|
||||
:public-view="true"
|
||||
:share-hash="shareHash"
|
||||
:is-logged-in="isLoggedIn" />
|
||||
|
@ -61,21 +62,28 @@ export default {
|
|||
subscribe('forms:last-updated:set', this.emitSubmitMessage)
|
||||
|
||||
// Communicate window size to parent window in iframes
|
||||
const resizeObserver = new ResizeObserver(entries => {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
this.emitResizeMessage(entries[0].target)
|
||||
})
|
||||
this.$nextTick(() => resizeObserver.observe(document.querySelector('.app-forms-embedded form')))
|
||||
this.$nextTick(() =>
|
||||
resizeObserver.observe(
|
||||
document.querySelector('.app-forms-embedded form'),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
emitSubmitMessage(id) {
|
||||
window.parent?.postMessage({
|
||||
type: 'form-saved',
|
||||
payload: {
|
||||
id,
|
||||
window.parent?.postMessage(
|
||||
{
|
||||
type: 'form-saved',
|
||||
payload: {
|
||||
id,
|
||||
},
|
||||
},
|
||||
}, '*')
|
||||
'*',
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -88,18 +96,27 @@ export default {
|
|||
|
||||
// When submitted the height and width is 0
|
||||
if (height === 0) {
|
||||
target = document.querySelector('.app-forms-embedded main .empty-content')
|
||||
target = document.querySelector(
|
||||
'.app-forms-embedded main .empty-content',
|
||||
)
|
||||
height = target.getBoundingClientRect().top + target.scrollHeight
|
||||
width = Math.max(target.scrollWidth, document.querySelector('.app-forms-embedded main header').scrollWidth)
|
||||
width = Math.max(
|
||||
target.scrollWidth,
|
||||
document.querySelector('.app-forms-embedded main header')
|
||||
.scrollWidth,
|
||||
)
|
||||
}
|
||||
|
||||
window.parent?.postMessage({
|
||||
type: 'resize-iframe',
|
||||
payload: {
|
||||
width,
|
||||
height,
|
||||
window.parent?.postMessage(
|
||||
{
|
||||
type: 'resize-iframe',
|
||||
payload: {
|
||||
width,
|
||||
height,
|
||||
},
|
||||
},
|
||||
}, '*')
|
||||
'*',
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -21,7 +21,8 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<NcListItem ref="navigationItem"
|
||||
<NcListItem
|
||||
ref="navigationItem"
|
||||
:active="isActive"
|
||||
:actions-aria-label="t('forms', 'Form actions')"
|
||||
:counter-number="form.submissionCount"
|
||||
|
@ -30,7 +31,7 @@
|
|||
:name="formTitle"
|
||||
:to="{
|
||||
name: routerTarget,
|
||||
params: { hash: form.hash }
|
||||
params: { hash: form.hash },
|
||||
}"
|
||||
@click="mobileCloseNavigation">
|
||||
<template #icon>
|
||||
|
@ -42,7 +43,8 @@
|
|||
{{ formSubtitle }}
|
||||
</template>
|
||||
<template v-if="!loading && !readOnly" #actions>
|
||||
<NcActionRouter v-if="!isArchived"
|
||||
<NcActionRouter
|
||||
v-if="!isArchived"
|
||||
:close-after-click="true"
|
||||
:exact="true"
|
||||
:to="{ name: 'edit', params: { hash: form.hash } }"
|
||||
|
@ -52,7 +54,8 @@
|
|||
</template>
|
||||
{{ t('forms', 'Edit form') }}
|
||||
</NcActionRouter>
|
||||
<NcActionButton v-if="!isArchived"
|
||||
<NcActionButton
|
||||
v-if="!isArchived"
|
||||
:close-after-click="true"
|
||||
@click="onShareForm">
|
||||
<template #icon>
|
||||
|
@ -60,7 +63,8 @@
|
|||
</template>
|
||||
{{ t('forms', 'Share form') }}
|
||||
</NcActionButton>
|
||||
<NcActionRouter :close-after-click="true"
|
||||
<NcActionRouter
|
||||
:close-after-click="true"
|
||||
:exact="true"
|
||||
:to="{ name: 'results', params: { hash: form.hash } }"
|
||||
@click="mobileCloseNavigation">
|
||||
|
@ -69,29 +73,47 @@
|
|||
</template>
|
||||
{{ t('forms', 'Results') }}
|
||||
</NcActionRouter>
|
||||
<NcActionButton v-if="canEdit && !isArchived" :close-after-click="true" @click="onCloneForm">
|
||||
<NcActionButton
|
||||
v-if="canEdit && !isArchived"
|
||||
:close-after-click="true"
|
||||
@click="onCloneForm">
|
||||
<template #icon>
|
||||
<IconContentCopy :size="20" />
|
||||
</template>
|
||||
{{ t('forms', 'Copy form') }}
|
||||
</NcActionButton>
|
||||
<NcActionSeparator v-if="canEdit" />
|
||||
<NcActionButton v-if="canEdit" :close-after-click="true" @click="onToggleArchive">
|
||||
<NcActionButton
|
||||
v-if="canEdit"
|
||||
:close-after-click="true"
|
||||
@click="onToggleArchive">
|
||||
<template #icon>
|
||||
<IconArchiveOff v-if="isArchived" :size="20" />
|
||||
<IconArchive v-else :size="20" />
|
||||
</template>
|
||||
{{ isArchived ? t('forms', 'Unarchive form') : t('forms', 'Archive form') }}
|
||||
{{
|
||||
isArchived
|
||||
? t('forms', 'Unarchive form')
|
||||
: t('forms', 'Archive form')
|
||||
}}
|
||||
</NcActionButton>
|
||||
<NcActionButton v-if="canEdit" :close-after-click="true" @click="showDeleteDialog = true">
|
||||
<NcActionButton
|
||||
v-if="canEdit"
|
||||
:close-after-click="true"
|
||||
@click="showDeleteDialog = true">
|
||||
<template #icon>
|
||||
<IconDelete :size="20" />
|
||||
</template>
|
||||
{{ t('forms', 'Delete form') }}
|
||||
</NcActionButton>
|
||||
<NcDialog :open.sync="showDeleteDialog"
|
||||
<NcDialog
|
||||
:open.sync="showDeleteDialog"
|
||||
:name="t('forms', 'Delete form')"
|
||||
:message="t('forms', 'Are you sure you want to delete {title}?', { title: formTitle })"
|
||||
:message="
|
||||
t('forms', 'Are you sure you want to delete {title}?', {
|
||||
title: formTitle,
|
||||
})
|
||||
"
|
||||
:buttons="buttons" />
|
||||
</template>
|
||||
</NcListItem>
|
||||
|
@ -173,7 +195,9 @@ export default {
|
|||
label: t('forms', 'Delete form'),
|
||||
icon: IconDeleteSvg,
|
||||
type: 'error',
|
||||
callback: () => { this.onDeleteForm() },
|
||||
callback: () => {
|
||||
this.onDeleteForm()
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
@ -181,7 +205,9 @@ export default {
|
|||
|
||||
computed: {
|
||||
canEdit() {
|
||||
return this.form.permissions.includes(this.PERMISSION_TYPES.PERMISSION_EDIT)
|
||||
return this.form.permissions.includes(
|
||||
this.PERMISSION_TYPES.PERMISSION_EDIT,
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -274,13 +300,22 @@ export default {
|
|||
async onToggleArchive() {
|
||||
try {
|
||||
// TODO: add loading status feedback ?
|
||||
await axios.patch(generateOcsUrl('apps/forms/api/v2.4/form/update'), {
|
||||
id: this.form.id,
|
||||
keyValuePairs: {
|
||||
state: this.isArchived ? FormState.FormClosed : FormState.FormArchived,
|
||||
await axios.patch(
|
||||
generateOcsUrl('apps/forms/api/v2.4/form/update'),
|
||||
{
|
||||
id: this.form.id,
|
||||
keyValuePairs: {
|
||||
state: this.isArchived
|
||||
? FormState.FormClosed
|
||||
: FormState.FormArchived,
|
||||
},
|
||||
},
|
||||
})
|
||||
this.$set(this.form, 'state', this.isArchived ? FormState.FormClosed : FormState.FormArchived)
|
||||
)
|
||||
this.$set(
|
||||
this.form,
|
||||
'state',
|
||||
this.isArchived ? FormState.FormClosed : FormState.FormArchived,
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Error changing archived state of form', { error })
|
||||
showError(t('forms', 'Error changing archived state of form'))
|
||||
|
@ -290,11 +325,21 @@ export default {
|
|||
async onDeleteForm() {
|
||||
this.loading = true
|
||||
try {
|
||||
await axios.delete(generateOcsUrl('apps/forms/api/v2.4/form/{id}', { id: this.form.id }))
|
||||
await axios.delete(
|
||||
generateOcsUrl('apps/forms/api/v2.4/form/{id}', {
|
||||
id: this.form.id,
|
||||
}),
|
||||
)
|
||||
this.$emit('delete', this.form.id)
|
||||
} catch (error) {
|
||||
logger.error(`Error while deleting ${this.formTitle}`, { error: error.response })
|
||||
showError(t('forms', 'Error while deleting {title}', { title: this.formTitle }))
|
||||
logger.error(`Error while deleting ${this.formTitle}`, {
|
||||
error: error.response,
|
||||
})
|
||||
showError(
|
||||
t('forms', 'Error while deleting {title}', {
|
||||
title: this.formTitle,
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
|
|
|
@ -21,13 +21,15 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<NcDialog content-classes="archived-forms"
|
||||
<NcDialog
|
||||
content-classes="archived-forms"
|
||||
:name="t('forms', 'Archived forms')"
|
||||
:open="open"
|
||||
size="normal"
|
||||
@update:open="$emit('update:open', $event)">
|
||||
<ul :aria-label="t('forms', 'Archived forms')">
|
||||
<AppNavigationForm v-for="form, key in shownForms"
|
||||
<AppNavigationForm
|
||||
v-for="(form, key) in shownForms"
|
||||
:key="key"
|
||||
:form="form"
|
||||
:read-only="false"
|
||||
|
|
|
@ -1,16 +1,19 @@
|
|||
<template>
|
||||
<span :aria-hidden="!title"
|
||||
<span
|
||||
:aria-hidden="!title"
|
||||
:aria-label="title"
|
||||
class="material-design-icon forms-icon"
|
||||
role="img"
|
||||
v-bind="$attrs"
|
||||
@click="$emit('click', $event)">
|
||||
<svg :fill="fillColor"
|
||||
<svg
|
||||
:fill="fillColor"
|
||||
class="material-design-icon__svg"
|
||||
:height="size"
|
||||
:width="size"
|
||||
viewBox="0 0 20 20">
|
||||
<path d="M 2 8.5 c -0.83 0 -1.5 0.67 -1.5 1.5 s 0.67 1.5 1.5 1.5 s 1.5 -0.67 1.5 -1.5 s -0.67 -1.5 -1.5 -1.5 z m 0 -6 c -0.83 0 -1.5 0.67 -1.5 1.5 S 1.17 5.5 2 5.5 S 3.5 4.83 3.5 4 S 2.83 2.5 2 2.5 z m 0 12 c -0.83 0 -1.5 0.68 -1.5 1.5 s 0.68 1.5 1.5 1.5 s 1.5 -0.68 1.5 -1.5 s -0.67 -1.5 -1.5 -1.5 z M 5 17 h 14 v -2 H 5 v 2 z m 0 -6 h 14 v -2 H 5 v 2 z m 0 -8 v 2 h 14 V 3 H 5 z" />
|
||||
<path
|
||||
d="M 2 8.5 c -0.83 0 -1.5 0.67 -1.5 1.5 s 0.67 1.5 1.5 1.5 s 1.5 -0.67 1.5 -1.5 s -0.67 -1.5 -1.5 -1.5 z m 0 -6 c -0.83 0 -1.5 0.67 -1.5 1.5 S 1.17 5.5 2 5.5 S 3.5 4.83 3.5 4 S 2.83 2.5 2 2.5 z m 0 12 c -0.83 0 -1.5 0.68 -1.5 1.5 s 0.68 1.5 1.5 1.5 s 1.5 -0.68 1.5 -1.5 s -0.67 -1.5 -1.5 -1.5 z M 5 17 h 14 v -2 H 5 v 2 z m 0 -6 h 14 v -2 H 5 v 2 z m 0 -8 v 2 h 14 V 3 H 5 z" />
|
||||
<title v-if="title">{{ title }}</title>
|
||||
</svg>
|
||||
</span>
|
||||
|
|
|
@ -1,16 +1,19 @@
|
|||
<template>
|
||||
<span :aria-hidden="!title"
|
||||
<span
|
||||
:aria-hidden="!title"
|
||||
:aria-label="title"
|
||||
class="material-design-icon copy-all-icon"
|
||||
role="img"
|
||||
v-bind="$attrs"
|
||||
@click="$emit('click', $event)">
|
||||
<svg :fill="fillColor"
|
||||
<svg
|
||||
:fill="fillColor"
|
||||
class="material-design-icon__svg"
|
||||
:height="size"
|
||||
:width="size"
|
||||
viewBox="0 0 16 16">
|
||||
<path d="M 13.5 0 h -8 C 4.67 0 4 0.67 4 1.5 v 10 C 4 12.33 4.67 13 5.5 13 h 8 c 0.83 0 1.5 -0.67 1.5 -1.5 v -10 C 15 0.67 14.33 0 13.5 0 z M 13.5 11.5 h -8 v -10 h 8 V 11.5 z M 1 10 v -1.5 h 1.5 V 10 H 1 z M 1 13 v -1.5 h 1.5 V 13 H 1 z M 7 14.5 h 1.5 V 16 H 7 V 14.5 z M 1 5.5 h 1.5 V 7 H 1 V 5.5 z M 5.5 16 H 4 v -1.5 h 1.5 V 16 z M 2.5 16 C 1.67 16 1 15.33 1 14.5 h 1.5 V 16 z M 2.5 4 H 1 c 0 -0.83 0.67 -1.5 1.5 -1.5 V 4 z M 11.49 14.5 c 0 0.83 -0.67 1.5 -1.5 1.5 h 0 v -1.5 L 11.49 14.5 L 11.49 14.5 z" />
|
||||
<path
|
||||
d="M 13.5 0 h -8 C 4.67 0 4 0.67 4 1.5 v 10 C 4 12.33 4.67 13 5.5 13 h 8 c 0.83 0 1.5 -0.67 1.5 -1.5 v -10 C 15 0.67 14.33 0 13.5 0 z M 13.5 11.5 h -8 v -10 h 8 V 11.5 z M 1 10 v -1.5 h 1.5 V 10 H 1 z M 1 13 v -1.5 h 1.5 V 13 H 1 z M 7 14.5 h 1.5 V 16 H 7 V 14.5 z M 1 5.5 h 1.5 V 7 H 1 V 5.5 z M 5.5 16 H 4 v -1.5 h 1.5 V 16 z M 2.5 16 C 1.67 16 1 15.33 1 14.5 h 1.5 V 16 z M 2.5 4 H 1 c 0 -0.83 0.67 -1.5 1.5 -1.5 V 4 z M 11.49 14.5 c 0 0.83 -0.67 1.5 -1.5 1.5 h 0 v -1.5 L 11.49 14.5 L 11.49 14.5 z" />
|
||||
<title v-if="title">{{ title }}</title>
|
||||
</svg>
|
||||
</span>
|
||||
|
|
|
@ -7,8 +7,7 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
<script setup></script>
|
||||
|
||||
<style scoped>
|
||||
.icon-overlay-wrapper {
|
||||
|
|
|
@ -22,7 +22,8 @@
|
|||
|
||||
<template>
|
||||
<div class="pill-menu">
|
||||
<NcCheckboxRadioSwitch v-for="option of options"
|
||||
<NcCheckboxRadioSwitch
|
||||
v-for="option of options"
|
||||
:key="option.id"
|
||||
:aria-label="isMobile ? option.ariaLabel : null"
|
||||
:checked="active.id"
|
||||
|
|
|
@ -1,21 +1,25 @@
|
|||
<template>
|
||||
<li class="question__item" @focusout="handleTabbing">
|
||||
<div :is="pseudoIcon"
|
||||
<div
|
||||
:is="pseudoIcon"
|
||||
v-if="!isDropdown"
|
||||
class="question__item__pseudoInput" />
|
||||
<input ref="input"
|
||||
:aria-label="t('forms', 'An answer for the {index} option', { index: index + 1 })"
|
||||
<input
|
||||
ref="input"
|
||||
:aria-label="
|
||||
t('forms', 'An answer for the {index} option', { index: index + 1 })
|
||||
"
|
||||
:placeholder="t('forms', 'Answer number {index}', { index: index + 1 })"
|
||||
:value="answer.text"
|
||||
class="question__input"
|
||||
:class="{ 'question__input--shifted' : !isDropdown }"
|
||||
:class="{ 'question__input--shifted': !isDropdown }"
|
||||
:maxlength="maxOptionLength"
|
||||
minlength="1"
|
||||
type="text"
|
||||
dir="auto"
|
||||
@input="onInput"
|
||||
@keydown.delete="deleteEntry"
|
||||
@keydown.enter.prevent="focusNextInput">
|
||||
@keydown.enter.prevent="focusNextInput" />
|
||||
|
||||
<!-- Delete answer -->
|
||||
<NcActions>
|
||||
|
@ -85,7 +89,7 @@ export default {
|
|||
queue: new PQueue({ concurrency: 1 }),
|
||||
|
||||
// As data instead of Method, to have a separate debounce per AnswerInput
|
||||
debounceUpdateAnswer: pDebounce(function(answer) {
|
||||
debounceUpdateAnswer: pDebounce(function (answer) {
|
||||
return this.queue.add(() => this.updateAnswer(answer))
|
||||
}, 500),
|
||||
}
|
||||
|
@ -118,7 +122,6 @@ export default {
|
|||
answer.text = this.$refs.input.value
|
||||
|
||||
if (this.answer.local) {
|
||||
|
||||
// Dispatched for creation. Marked as synced
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
this.answer.local = false
|
||||
|
@ -166,10 +169,13 @@ export default {
|
|||
*/
|
||||
async createAnswer(answer) {
|
||||
try {
|
||||
const response = await axios.post(generateOcsUrl('apps/forms/api/v2.4/option'), {
|
||||
questionId: answer.questionId,
|
||||
text: answer.text,
|
||||
})
|
||||
const response = await axios.post(
|
||||
generateOcsUrl('apps/forms/api/v2.4/option'),
|
||||
{
|
||||
questionId: answer.questionId,
|
||||
text: answer.text,
|
||||
},
|
||||
)
|
||||
logger.debug('Created answer', { answer })
|
||||
|
||||
// Was synced once, this is now up to date with the server
|
||||
|
@ -182,7 +188,7 @@ export default {
|
|||
|
||||
return answer
|
||||
},
|
||||
debounceCreateAnswer: pDebounce(function(answer) {
|
||||
debounceCreateAnswer: pDebounce(function (answer) {
|
||||
return this.queue.add(() => this.createAnswer(answer))
|
||||
}, 100),
|
||||
|
||||
|
@ -194,12 +200,15 @@ export default {
|
|||
*/
|
||||
async updateAnswer(answer) {
|
||||
try {
|
||||
await axios.patch(generateOcsUrl('apps/forms/api/v2.4/option/update'), {
|
||||
id: this.answer.id,
|
||||
keyValuePairs: {
|
||||
text: answer.text,
|
||||
await axios.patch(
|
||||
generateOcsUrl('apps/forms/api/v2.4/option/update'),
|
||||
{
|
||||
id: this.answer.id,
|
||||
keyValuePairs: {
|
||||
text: answer.text,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
logger.debug('Updated answer', { answer })
|
||||
} catch (error) {
|
||||
logger.error('Error while saving answer', { answer, error })
|
||||
|
|
|
@ -21,19 +21,22 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<li :class="{
|
||||
'question': true,
|
||||
'question--editable': !readOnly
|
||||
<li
|
||||
:class="{
|
||||
question: true,
|
||||
'question--editable': !readOnly,
|
||||
}"
|
||||
:aria-label="t('forms', 'Question number {index}', {index})">
|
||||
:aria-label="t('forms', 'Question number {index}', { index })">
|
||||
<!-- Drag handle -->
|
||||
<!-- TODO: implement arrow key mapping to reorder question -->
|
||||
<div v-if="!readOnly"
|
||||
<div
|
||||
v-if="!readOnly"
|
||||
:class="{
|
||||
'question__drag-handle': true,
|
||||
'question__drag-handle--shiftup': shiftDragHandle
|
||||
'question__drag-handle--shiftup': shiftDragHandle,
|
||||
}">
|
||||
<NcButton ref="buttonUp"
|
||||
<NcButton
|
||||
ref="buttonUp"
|
||||
:aria-label="t('forms', 'Move question up')"
|
||||
:disabled="!canMoveUp"
|
||||
class="question__drag-handle-button"
|
||||
|
@ -44,7 +47,8 @@
|
|||
</template>
|
||||
</NcButton>
|
||||
<IconDragHorizontalVariant :size="20" />
|
||||
<NcButton ref="buttonDown"
|
||||
<NcButton
|
||||
ref="buttonDown"
|
||||
:aria-label="t('forms', 'Move question down')"
|
||||
:disabled="!canMoveDown"
|
||||
class="question__drag-handle-button"
|
||||
|
@ -59,9 +63,12 @@
|
|||
<!-- Header -->
|
||||
<div class="question__header">
|
||||
<div class="question__header__title">
|
||||
<input v-if="!readOnly"
|
||||
<input
|
||||
v-if="!readOnly"
|
||||
:placeholder="titlePlaceholder"
|
||||
:aria-label="t('forms', 'Title of question number {index}', {index})"
|
||||
:aria-label="
|
||||
t('forms', 'Title of question number {index}', { index })
|
||||
"
|
||||
:value="text"
|
||||
class="question__header__title__text question__header__title__text__input"
|
||||
type="text"
|
||||
|
@ -69,20 +76,23 @@
|
|||
minlength="1"
|
||||
:maxlength="maxStringLengths.questionText"
|
||||
required
|
||||
@input="onTitleChange">
|
||||
<h3 v-else
|
||||
@input="onTitleChange" />
|
||||
<h3
|
||||
v-else
|
||||
:id="titleId"
|
||||
class="question__header__title__text"
|
||||
dir="auto">
|
||||
{{ computedText }}
|
||||
</h3>
|
||||
<div v-if="!readOnly && !questionValid"
|
||||
<div
|
||||
v-if="!readOnly && !questionValid"
|
||||
v-tooltip.auto="warningInvalid"
|
||||
class="question__header__title__warning"
|
||||
tabindex="0">
|
||||
<IconAlertCircleOutline :size="20" />
|
||||
</div>
|
||||
<NcActions v-if="!readOnly"
|
||||
<NcActions
|
||||
v-if="!readOnly"
|
||||
:id="actionsId"
|
||||
:force-menu="true"
|
||||
placement="bottom-end"
|
||||
|
@ -95,13 +105,15 @@
|
|||
<IconDotsHorizontal :size="20" />
|
||||
</IconOverlay>
|
||||
</template>
|
||||
<NcActionCheckbox :checked="isRequired"
|
||||
<NcActionCheckbox
|
||||
:checked="isRequired"
|
||||
@update:checked="onRequiredChange">
|
||||
<!-- TRANSLATORS Making this question necessary to be answered when submitting to a form -->
|
||||
{{ t('forms', 'Required') }}
|
||||
</NcActionCheckbox>
|
||||
<slot name="actions" />
|
||||
<NcActionInput :label="t('forms', 'Technical name of the question')"
|
||||
<NcActionInput
|
||||
:label="t('forms', 'Technical name of the question')"
|
||||
:label-outside="false"
|
||||
:show-trailing-button="false"
|
||||
:value="name"
|
||||
|
@ -111,8 +123,7 @@
|
|||
</template>
|
||||
{{ t('forms', 'Technical name') }}
|
||||
</NcActionInput>
|
||||
<NcActionButton :close-after-click="true"
|
||||
@click="onClone">
|
||||
<NcActionButton :close-after-click="true" @click="onClone">
|
||||
<template #icon>
|
||||
<IconContentCopy :size="20" />
|
||||
</template>
|
||||
|
@ -126,17 +137,29 @@
|
|||
</NcActionButton>
|
||||
</NcActions>
|
||||
</div>
|
||||
<div v-if="hasDescription || !readOnly" class="question__header__description">
|
||||
<textarea v-if="!readOnly"
|
||||
<div
|
||||
v-if="hasDescription || !readOnly"
|
||||
class="question__header__description">
|
||||
<textarea
|
||||
v-if="!readOnly"
|
||||
ref="description"
|
||||
dir="auto"
|
||||
:value="description"
|
||||
:placeholder="t('forms', 'Description (formatting using Markdown is supported)')"
|
||||
:placeholder="
|
||||
t(
|
||||
'forms',
|
||||
'Description (formatting using Markdown is supported)',
|
||||
)
|
||||
"
|
||||
:maxlength="maxStringLengths.questionDescription"
|
||||
class="question__header__description__input"
|
||||
@input="onDescriptionChange" />
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-else class="question__header__description__output" v-html="computedDescription" />
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
v-else
|
||||
class="question__header__description__output"
|
||||
v-html="computedDescription" />
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -373,7 +396,7 @@ export default {
|
|||
gap: 12px;
|
||||
width: 44px;
|
||||
height: 100%;
|
||||
opacity: .5;
|
||||
opacity: 0.5;
|
||||
cursor: grab;
|
||||
|
||||
&-button {
|
||||
|
@ -481,5 +504,4 @@ export default {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -21,12 +21,14 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<Question v-bind="questionProps"
|
||||
<Question
|
||||
v-bind="questionProps"
|
||||
:title-placeholder="answerType.titlePlaceholder"
|
||||
:warning-invalid="answerType.warningInvalid"
|
||||
v-on="commonListeners">
|
||||
<div class="question__content">
|
||||
<NcDateTimePicker v-model="time"
|
||||
<NcDateTimePicker
|
||||
v-model="time"
|
||||
:disabled="!readOnly"
|
||||
:formatter="formatter"
|
||||
:placeholder="datetimePickerPlaceholder"
|
||||
|
@ -109,7 +111,10 @@ export default {
|
|||
* @return {Date}
|
||||
*/
|
||||
parse(dateString) {
|
||||
return moment(dateString, [this.answerType.momentFormat, this.answerType.storageFormat]).toDate()
|
||||
return moment(dateString, [
|
||||
this.answerType.momentFormat,
|
||||
this.answerType.storageFormat,
|
||||
]).toDate()
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -118,7 +123,9 @@ export default {
|
|||
* @param {Date} date The date to store
|
||||
*/
|
||||
onValueChange(date) {
|
||||
this.$emit('update:values', [moment(date).format(this.answerType.storageFormat)])
|
||||
this.$emit('update:values', [
|
||||
moment(date).format(this.answerType.storageFormat),
|
||||
])
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -21,19 +21,22 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<Question v-bind="questionProps"
|
||||
<Question
|
||||
v-bind="questionProps"
|
||||
:title-placeholder="answerType.titlePlaceholder"
|
||||
:warning-invalid="answerType.warningInvalid"
|
||||
:content-valid="contentValid"
|
||||
:shift-drag-handle="shiftDragHandle"
|
||||
v-on="commonListeners">
|
||||
<template #actions>
|
||||
<NcActionCheckbox :checked="extraSettings?.shuffleOptions"
|
||||
<NcActionCheckbox
|
||||
:checked="extraSettings?.shuffleOptions"
|
||||
@update:checked="onShuffleOptionsChange">
|
||||
{{ t('forms', 'Shuffle options') }}
|
||||
</NcActionCheckbox>
|
||||
</template>
|
||||
<NcSelect v-if="readOnly"
|
||||
<NcSelect
|
||||
v-if="readOnly"
|
||||
v-model="selectedOption"
|
||||
:name="name || undefined"
|
||||
:placeholder="selectOptionPlaceholder"
|
||||
|
@ -46,8 +49,11 @@
|
|||
|
||||
<ol v-if="!readOnly" class="question__content">
|
||||
<!-- Answer text input edit -->
|
||||
<AnswerInput v-for="(answer, index) in options"
|
||||
:key="index /* using index to keep the same vnode after new answer creation */"
|
||||
<AnswerInput
|
||||
v-for="(answer, index) in options"
|
||||
:key="
|
||||
index /* using index to keep the same vnode after new answer creation */
|
||||
"
|
||||
ref="input"
|
||||
:answer="answer"
|
||||
:index="index"
|
||||
|
@ -60,7 +66,8 @@
|
|||
@tabbed-out="checkValidOption" />
|
||||
|
||||
<li v-if="!isLastEmpty || hasNoAnswer" class="question__item">
|
||||
<input ref="pseudoInput"
|
||||
<input
|
||||
ref="pseudoInput"
|
||||
v-model="inputValue"
|
||||
:aria-label="t('forms', 'Add a new answer')"
|
||||
:placeholder="t('forms', 'Add a new answer')"
|
||||
|
@ -68,7 +75,7 @@
|
|||
:maxlength="maxStringLengths.optionText"
|
||||
minlength="1"
|
||||
type="text"
|
||||
@input="addNewEntry">
|
||||
@input="addNewEntry" />
|
||||
</li>
|
||||
</ol>
|
||||
</Question>
|
||||
|
@ -143,7 +150,9 @@ export default {
|
|||
mounted() {
|
||||
// Init selected options from values prop
|
||||
if (this.values) {
|
||||
const selected = this.values.map(id => this.options.find(option => option.id === id))
|
||||
const selected = this.values.map((id) =>
|
||||
this.options.find((option) => option.id === id),
|
||||
)
|
||||
this.selectedOption = this.isMultiple ? selected : selected[0]
|
||||
}
|
||||
},
|
||||
|
@ -151,7 +160,9 @@ export default {
|
|||
methods: {
|
||||
onInput(option) {
|
||||
if (Array.isArray(option)) {
|
||||
this.$emit('update:values', [...new Set(option.map((opt) => opt.id))])
|
||||
this.$emit('update:values', [
|
||||
...new Set(option.map((opt) => opt.id)),
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -164,7 +175,7 @@ export default {
|
|||
*/
|
||||
checkValidOption() {
|
||||
// When leaving edit mode, filter and delete empty options
|
||||
this.options.forEach(option => {
|
||||
this.options.forEach((option) => {
|
||||
if (!option.text) {
|
||||
this.deleteOption(option.id)
|
||||
}
|
||||
|
@ -202,7 +213,7 @@ export default {
|
|||
*/
|
||||
updateAnswer(id, answer) {
|
||||
const options = this.options.slice()
|
||||
const answerIndex = options.findIndex(option => option.id === id)
|
||||
const answerIndex = options.findIndex((option) => option.id === id)
|
||||
options[answerIndex] = answer
|
||||
|
||||
this.updateOptions(options)
|
||||
|
@ -255,7 +266,7 @@ export default {
|
|||
*/
|
||||
deleteOption(id) {
|
||||
const options = this.options.slice()
|
||||
const optionIndex = options.findIndex(option => option.id === id)
|
||||
const optionIndex = options.findIndex((option) => option.id === id)
|
||||
|
||||
if (options.length === 1) {
|
||||
// Clear Text, but don't remove. Will be removed, when leaving edit-mode
|
||||
|
@ -286,14 +297,24 @@ export default {
|
|||
* @param {object} option The option to delete
|
||||
*/
|
||||
deleteOptionFromDatabase(option) {
|
||||
const optionIndex = this.options.findIndex(opt => opt.id === option.id)
|
||||
const optionIndex = this.options.findIndex((opt) => opt.id === option.id)
|
||||
|
||||
if (!option.local) {
|
||||
// let's not await, deleting in background
|
||||
axios.delete(generateOcsUrl('apps/forms/api/v2.4/option/{id}', { id: option.id }))
|
||||
.catch(error => {
|
||||
logger.error('Error while deleting an option', { option, error })
|
||||
showError(t('forms', 'There was an issue deleting this option'))
|
||||
axios
|
||||
.delete(
|
||||
generateOcsUrl('apps/forms/api/v2.4/option/{id}', {
|
||||
id: option.id,
|
||||
}),
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error('Error while deleting an option', {
|
||||
option,
|
||||
error,
|
||||
})
|
||||
showError(
|
||||
t('forms', 'There was an issue deleting this option'),
|
||||
)
|
||||
// restore option
|
||||
this.restoreOption(option, optionIndex)
|
||||
})
|
||||
|
|
|
@ -21,7 +21,8 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<Question v-bind="questionProps"
|
||||
<Question
|
||||
v-bind="questionProps"
|
||||
:title-placeholder="answerType.titlePlaceholder"
|
||||
:warning-invalid="answerType.warningInvalid"
|
||||
v-on="commonListeners">
|
||||
|
@ -45,7 +46,8 @@
|
|||
{{ t('forms', 'Allow only specific file types') }}
|
||||
</NcActionButton>
|
||||
|
||||
<NcActionCheckbox v-for="({ label: fileTypeLabel }, fileType) in fileTypes"
|
||||
<NcActionCheckbox
|
||||
v-for="({ label: fileTypeLabel }, fileType) in fileTypes"
|
||||
:key="fileType"
|
||||
:checked="extraSettings?.allowedFileTypes?.includes(fileType)"
|
||||
:value="fileType"
|
||||
|
@ -54,7 +56,8 @@
|
|||
{{ fileTypeLabel }}
|
||||
</NcActionCheckbox>
|
||||
|
||||
<NcActionInput :label="t('forms', 'Custom file extensions')"
|
||||
<NcActionInput
|
||||
:label="t('forms', 'Custom file extensions')"
|
||||
type="multiselect"
|
||||
multiple
|
||||
taggable
|
||||
|
@ -66,21 +69,24 @@
|
|||
</template>
|
||||
|
||||
<template v-if="!allowedFileTypesDialogOpened">
|
||||
<NcActionInput type="number"
|
||||
<NcActionInput
|
||||
type="number"
|
||||
:value="maxAllowedFilesCount"
|
||||
label-outside
|
||||
:label="t('forms', 'Maximum number of files')"
|
||||
:show-trailing-button="false"
|
||||
@input="onMaxAllowedFilesCountInput($event.target.value)" />
|
||||
|
||||
<NcActionInput type="number"
|
||||
<NcActionInput
|
||||
type="number"
|
||||
:value="maxFileSizeValue"
|
||||
label-outside
|
||||
:show-trailing-button="false"
|
||||
:label="t('forms', 'Maximum file size')"
|
||||
@input="onMaxFileSizeValueInput($event.target.value)" />
|
||||
|
||||
<NcActionInput type="multiselect"
|
||||
<NcActionInput
|
||||
type="multiselect"
|
||||
:value="maxFileSizeUnit"
|
||||
:options="availableUnits"
|
||||
required
|
||||
|
@ -91,7 +97,8 @@
|
|||
|
||||
<div class="question__content">
|
||||
<ul>
|
||||
<NcListItem v-for="uploadedFile of values"
|
||||
<NcListItem
|
||||
v-for="uploadedFile of values"
|
||||
:key="uploadedFile.uploadedFileId"
|
||||
:name="uploadedFile.fileName"
|
||||
compact>
|
||||
|
@ -100,8 +107,11 @@
|
|||
</template>
|
||||
|
||||
<template #actions>
|
||||
<NcActionButton class="delete-button-wrapper"
|
||||
@click="onDeleteUploadedFile(uploadedFile.uploadedFileId)">
|
||||
<NcActionButton
|
||||
class="delete-button-wrapper"
|
||||
@click="
|
||||
onDeleteUploadedFile(uploadedFile.uploadedFileId)
|
||||
">
|
||||
<template #icon>
|
||||
<IconDelete :size="20" />
|
||||
</template>
|
||||
|
@ -113,10 +123,13 @@
|
|||
|
||||
<NcLoadingIcon v-show="fileLoading" />
|
||||
|
||||
<input v-show="!fileLoading"
|
||||
<input
|
||||
v-show="!fileLoading"
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:aria-label="t('forms', 'A file answer for the question “{text}”', { text })"
|
||||
:aria-label="
|
||||
t('forms', 'A file answer for the question “{text}”', { text })
|
||||
"
|
||||
:disabled="!readOnly || values.length >= maxAllowedFilesCount"
|
||||
:multiple="maxAllowedFilesCount > 1"
|
||||
:name="name || undefined"
|
||||
|
@ -127,7 +140,6 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import IconChevronLeft from 'vue-material-design-icons/ChevronLeft.vue'
|
||||
import IconDelete from 'vue-material-design-icons/Delete.vue'
|
||||
import IconFile from 'vue-material-design-icons/File.vue'
|
||||
|
@ -192,7 +204,11 @@ export default {
|
|||
allowedFileTypesLabel() {
|
||||
const allowedFileTypes = []
|
||||
if (this.extraSettings?.allowedFileTypes?.length) {
|
||||
allowedFileTypes.push(...this.extraSettings.allowedFileTypes.map(type => fileTypes[type].label))
|
||||
allowedFileTypes.push(
|
||||
...this.extraSettings.allowedFileTypes.map(
|
||||
(type) => fileTypes[type].label,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (this.extraSettings?.allowedFileExtensions?.length) {
|
||||
|
@ -200,7 +216,9 @@ export default {
|
|||
}
|
||||
|
||||
if (allowedFileTypes.length) {
|
||||
return t('forms', 'Allowed file types: {fileTypes}.', { fileTypes: allowedFileTypes.join(', ') })
|
||||
return t('forms', 'Allowed file types: {fileTypes}.', {
|
||||
fileTypes: allowedFileTypes.join(', '),
|
||||
})
|
||||
}
|
||||
|
||||
return t('forms', 'All file types are allowed.')
|
||||
|
@ -209,13 +227,15 @@ export default {
|
|||
|
||||
mounted() {
|
||||
if (this.extraSettings.maxFileSize) {
|
||||
Object.keys(FILE_SIZE_UNITS).forEach(unit => {
|
||||
Object.keys(FILE_SIZE_UNITS).forEach((unit) => {
|
||||
if (this.extraSettings.maxFileSize > FILE_SIZE_UNITS[unit]) {
|
||||
this.maxFileSizeUnit = unit
|
||||
}
|
||||
})
|
||||
|
||||
this.maxFileSizeValue = this.extraSettings.maxFileSize / FILE_SIZE_UNITS[this.maxFileSizeUnit]
|
||||
this.maxFileSizeValue =
|
||||
this.extraSettings.maxFileSize /
|
||||
FILE_SIZE_UNITS[this.maxFileSizeUnit]
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -223,14 +243,27 @@ export default {
|
|||
async onFileInput() {
|
||||
const fileInput = this.$refs.fileInput
|
||||
const formData = new FormData()
|
||||
let fileInvalid = false;
|
||||
let fileInvalid = false
|
||||
|
||||
[...fileInput.files].forEach(file => {
|
||||
;[...fileInput.files].forEach((file) => {
|
||||
formData.append('files[]', file)
|
||||
|
||||
if (this.extraSettings.maxFileSize > 0 && file.size > this.extraSettings.maxFileSize) {
|
||||
showError(t('forms', 'The file {fileName} is too large. The maximum file size is {maxFileSize}.',
|
||||
{ fileName: file.name, maxFileSize: formatFileSize(this.extraSettings.maxFileSize) }))
|
||||
if (
|
||||
this.extraSettings.maxFileSize > 0 &&
|
||||
file.size > this.extraSettings.maxFileSize
|
||||
) {
|
||||
showError(
|
||||
t(
|
||||
'forms',
|
||||
'The file {fileName} is too large. The maximum file size is {maxFileSize}.',
|
||||
{
|
||||
fileName: file.name,
|
||||
maxFileSize: formatFileSize(
|
||||
this.extraSettings.maxFileSize,
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
fileInvalid = true
|
||||
}
|
||||
|
@ -242,19 +275,29 @@ export default {
|
|||
|
||||
formData.append('shareHash', loadState('forms', 'shareHash', null))
|
||||
|
||||
const url = generateOcsUrl('apps/forms/api/v2.5/uploadFiles/{formId}/{questionId}', {
|
||||
formId: this.formId,
|
||||
questionId: this.id,
|
||||
})
|
||||
const url = generateOcsUrl(
|
||||
'apps/forms/api/v2.5/uploadFiles/{formId}/{questionId}',
|
||||
{
|
||||
formId: this.formId,
|
||||
questionId: this.id,
|
||||
},
|
||||
)
|
||||
|
||||
let response
|
||||
try {
|
||||
this.fileLoading = true
|
||||
response = await axios.post(url, formData, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
response = await axios.post(url, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error while submitting the form', { error })
|
||||
showError(t('forms', 'There was an error during submitting the file: {message}.',
|
||||
{ message: error.response.data.ocs.meta.message }))
|
||||
showError(
|
||||
t(
|
||||
'forms',
|
||||
'There was an error during submitting the file: {message}.',
|
||||
{ message: error.response.data.ocs.meta.message },
|
||||
),
|
||||
)
|
||||
|
||||
return
|
||||
} finally {
|
||||
|
@ -262,23 +305,32 @@ export default {
|
|||
fileInput.value = null
|
||||
}
|
||||
|
||||
this.$emit('update:values', [...this.values, ...OcsResponse2Data(response)])
|
||||
this.$emit('update:values', [
|
||||
...this.values,
|
||||
...OcsResponse2Data(response),
|
||||
])
|
||||
},
|
||||
|
||||
onMaxAllowedFilesCountInput(maxAllowedFilesCount) {
|
||||
return this.onExtraSettingsChange({ maxAllowedFilesCount: parseInt(maxAllowedFilesCount) })
|
||||
return this.onExtraSettingsChange({
|
||||
maxAllowedFilesCount: parseInt(maxAllowedFilesCount),
|
||||
})
|
||||
},
|
||||
|
||||
onMaxFileSizeValueInput(maxFileSizeValue) {
|
||||
this.maxFileSizeValue = maxFileSizeValue
|
||||
const maxFileSize = Math.round(maxFileSizeValue * FILE_SIZE_UNITS[this.maxFileSizeUnit])
|
||||
const maxFileSize = Math.round(
|
||||
maxFileSizeValue * FILE_SIZE_UNITS[this.maxFileSizeUnit],
|
||||
)
|
||||
|
||||
return this.onExtraSettingsChange({ maxFileSize })
|
||||
},
|
||||
|
||||
onMaxFileSizeUnitInput(maxFileSizeUnit) {
|
||||
this.maxFileSizeUnit = maxFileSizeUnit
|
||||
const maxFileSize = Math.round(this.maxFileSizeValue * FILE_SIZE_UNITS[maxFileSizeUnit])
|
||||
const maxFileSize = Math.round(
|
||||
this.maxFileSizeValue * FILE_SIZE_UNITS[maxFileSizeUnit],
|
||||
)
|
||||
|
||||
return this.onExtraSettingsChange({ maxFileSize })
|
||||
},
|
||||
|
@ -289,28 +341,36 @@ export default {
|
|||
if (allowed) {
|
||||
allowedFileTypes.push(fileType)
|
||||
} else {
|
||||
allowedFileTypes = allowedFileTypes.filter(type => type !== fileType)
|
||||
allowedFileTypes = allowedFileTypes.filter(
|
||||
(type) => type !== fileType,
|
||||
)
|
||||
}
|
||||
|
||||
return this.onExtraSettingsChange({ allowedFileTypes })
|
||||
},
|
||||
|
||||
onAllowedFileExtensionsAdded(fileExtension) {
|
||||
const allowedFileExtensions = this.extraSettings.allowedFileExtensions || []
|
||||
const allowedFileExtensions =
|
||||
this.extraSettings.allowedFileExtensions || []
|
||||
allowedFileExtensions.push(fileExtension)
|
||||
|
||||
return this.onExtraSettingsChange({ allowedFileExtensions })
|
||||
},
|
||||
|
||||
onAllowedFileExtensionsDeleted(fileExtension) {
|
||||
let allowedFileExtensions = this.extraSettings.allowedFileExtensions || []
|
||||
allowedFileExtensions = allowedFileExtensions.filter(extension => extension !== fileExtension)
|
||||
let allowedFileExtensions =
|
||||
this.extraSettings.allowedFileExtensions || []
|
||||
allowedFileExtensions = allowedFileExtensions.filter(
|
||||
(extension) => extension !== fileExtension,
|
||||
)
|
||||
|
||||
return this.onExtraSettingsChange({ allowedFileExtensions })
|
||||
},
|
||||
|
||||
onDeleteUploadedFile(uploadedFileId) {
|
||||
const values = this.values.filter(value => value.uploadedFileId !== uploadedFileId)
|
||||
const values = this.values.filter(
|
||||
(value) => value.uploadedFileId !== uploadedFileId,
|
||||
)
|
||||
|
||||
this.$emit('update:values', values)
|
||||
},
|
||||
|
|
|
@ -21,13 +21,17 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<Question v-bind="questionProps"
|
||||
<Question
|
||||
v-bind="questionProps"
|
||||
:title-placeholder="answerType.titlePlaceholder"
|
||||
:warning-invalid="answerType.warningInvalid"
|
||||
v-on="commonListeners">
|
||||
<div class="question__content">
|
||||
<textarea ref="textarea"
|
||||
:aria-label="t('forms', 'A long answer for the question “{text}”', { text })"
|
||||
<textarea
|
||||
ref="textarea"
|
||||
:aria-label="
|
||||
t('forms', 'A long answer for the question “{text}”', { text })
|
||||
"
|
||||
:placeholder="submissionInputPlaceholder"
|
||||
:disabled="!readOnly"
|
||||
:required="isRequired"
|
||||
|
@ -102,5 +106,4 @@ export default {
|
|||
margin-inline-start: -12px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -21,18 +21,21 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<Question v-bind="questionProps"
|
||||
<Question
|
||||
v-bind="questionProps"
|
||||
:title-placeholder="answerType.titlePlaceholder"
|
||||
:warning-invalid="answerType.warningInvalid"
|
||||
:content-valid="contentValid"
|
||||
:shift-drag-handle="shiftDragHandle"
|
||||
v-on="commonListeners">
|
||||
<template #actions>
|
||||
<NcActionCheckbox :checked="extraSettings?.shuffleOptions"
|
||||
<NcActionCheckbox
|
||||
:checked="extraSettings?.shuffleOptions"
|
||||
@update:checked="onShuffleOptionsChange">
|
||||
{{ t('forms', 'Shuffle options') }}
|
||||
</NcActionCheckbox>
|
||||
<NcActionCheckbox :checked="allowOtherAnswer"
|
||||
<NcActionCheckbox
|
||||
:checked="allowOtherAnswer"
|
||||
@update:checked="onAllowOtherAnswerChange">
|
||||
{{ t('forms', 'Add "other"') }}
|
||||
</NcActionCheckbox>
|
||||
|
@ -40,11 +43,15 @@
|
|||
<!-- For multiple (checkbox) options allow to limit the answers -->
|
||||
<template v-if="!isUnique">
|
||||
<!-- Allow setting a minimum of options to be checked -->
|
||||
<NcActionCheckbox :checked="!!extraSettings?.optionsLimitMin"
|
||||
@update:checked="(checked) => onLimitOptionsMin(checked ? 1 : null)">
|
||||
<NcActionCheckbox
|
||||
:checked="!!extraSettings?.optionsLimitMin"
|
||||
@update:checked="
|
||||
(checked) => onLimitOptionsMin(checked ? 1 : null)
|
||||
">
|
||||
{{ t('forms', 'Require a minimum of options to be checked') }}
|
||||
</NcActionCheckbox>
|
||||
<NcActionInput v-if="extraSettings?.optionsLimitMin"
|
||||
<NcActionInput
|
||||
v-if="extraSettings?.optionsLimitMin"
|
||||
type="number"
|
||||
:label="t('forms', 'Minimum options to be checked')"
|
||||
:label-outside="false"
|
||||
|
@ -53,11 +60,18 @@
|
|||
@update:value="onLimitOptionsMin" />
|
||||
|
||||
<!-- Allow setting a maximum -->
|
||||
<NcActionCheckbox :checked="!!extraSettings?.optionsLimitMax"
|
||||
@update:checked="(checked) => onLimitOptionsMax(checked ? (sortedOptions.length || 1) : null)">
|
||||
<NcActionCheckbox
|
||||
:checked="!!extraSettings?.optionsLimitMax"
|
||||
@update:checked="
|
||||
(checked) =>
|
||||
onLimitOptionsMax(
|
||||
checked ? sortedOptions.length || 1 : null,
|
||||
)
|
||||
">
|
||||
{{ t('forms', 'Require a maximum of options to be checked') }}
|
||||
</NcActionCheckbox>
|
||||
<NcActionInput v-if="extraSettings?.optionsLimitMax"
|
||||
<NcActionInput
|
||||
v-if="extraSettings?.optionsLimitMax"
|
||||
type="number"
|
||||
:label="t('forms', 'Maximum options to be checked')"
|
||||
:label-outside="false"
|
||||
|
@ -71,7 +85,8 @@
|
|||
<NcNoteCard v-if="hasError" :id="errorId" type="error">
|
||||
{{ errorMessage }}
|
||||
</NcNoteCard>
|
||||
<NcCheckboxRadioSwitch v-for="(answer) in sortedOptions"
|
||||
<NcCheckboxRadioSwitch
|
||||
v-for="answer in sortedOptions"
|
||||
:key="answer.id"
|
||||
:aria-errormessage="hasError ? errorId : undefined"
|
||||
:aria-invalid="hasError ? 'true' : undefined"
|
||||
|
@ -85,7 +100,8 @@
|
|||
{{ answer.text }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<div v-if="allowOtherAnswer" class="question__other-answer">
|
||||
<NcCheckboxRadioSwitch :checked="questionValues"
|
||||
<NcCheckboxRadioSwitch
|
||||
:checked="questionValues"
|
||||
:aria-errormessage="hasError ? errorId : undefined"
|
||||
:aria-invalid="hasError ? 'true' : undefined"
|
||||
:value="otherAnswer ?? QUESTION_EXTRASETTINGS_OTHER_PREFIX"
|
||||
|
@ -97,7 +113,8 @@
|
|||
@keydown.enter.exact.prevent="onKeydownEnter">
|
||||
{{ t('forms', 'Other:') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<NcInputField class="question__input"
|
||||
<NcInputField
|
||||
class="question__input"
|
||||
:label="placeholderOtherAnswer"
|
||||
:required="otherAnswer !== undefined"
|
||||
:value.sync="otherAnswerText" />
|
||||
|
@ -108,8 +125,11 @@
|
|||
<template v-else>
|
||||
<ul class="question__content">
|
||||
<!-- Answer text input edit -->
|
||||
<AnswerInput v-for="(answer, index) in sortedOptions"
|
||||
:key="index /* using index to keep the same vnode after new answer creation */"
|
||||
<AnswerInput
|
||||
v-for="(answer, index) in sortedOptions"
|
||||
:key="
|
||||
index /* using index to keep the same vnode after new answer creation */
|
||||
"
|
||||
ref="input"
|
||||
:answer="answer"
|
||||
:index="index"
|
||||
|
@ -122,23 +142,25 @@
|
|||
@tabbed-out="checkValidOption" />
|
||||
<li v-if="allowOtherAnswer" class="question__item">
|
||||
<div :is="pseudoIcon" class="question__item__pseudoInput" />
|
||||
<input :placeholder="t('forms', 'Other')"
|
||||
<input
|
||||
:placeholder="t('forms', 'Other')"
|
||||
class="question__input"
|
||||
:maxlength="maxStringLengths.optionText"
|
||||
minlength="1"
|
||||
type="text"
|
||||
:readonly="!readOnly">
|
||||
:readonly="!readOnly" />
|
||||
</li>
|
||||
<li v-if="!isLastEmpty || hasNoAnswer" class="question__item">
|
||||
<div :is="pseudoIcon" class="question__item__pseudoInput" />
|
||||
<input ref="pseudoInput"
|
||||
<input
|
||||
ref="pseudoInput"
|
||||
class="question__input"
|
||||
:aria-label="t('forms', 'Add a new answer')"
|
||||
:placeholder="t('forms', 'Add a new answer')"
|
||||
:maxlength="maxStringLengths.optionText"
|
||||
minlength="1"
|
||||
type="text"
|
||||
@input="addNewEntry">
|
||||
@input="addNewEntry" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
@ -258,7 +280,9 @@ export default {
|
|||
* The full "other" answer including prefix, undefined if no "other answer"
|
||||
*/
|
||||
otherAnswer() {
|
||||
return this.values.find((v) => v.startsWith(QUESTION_EXTRASETTINGS_OTHER_PREFIX))
|
||||
return this.values.find((v) =>
|
||||
v.startsWith(QUESTION_EXTRASETTINGS_OTHER_PREFIX),
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -279,7 +303,17 @@ export default {
|
|||
// emit the values and add the "other" answer
|
||||
this.$emit(
|
||||
'update:values',
|
||||
this.isUnique ? [prefixedValue] : [...this.values.filter((v) => !v.startsWith(QUESTION_EXTRASETTINGS_OTHER_PREFIX)), prefixedValue],
|
||||
this.isUnique
|
||||
? [prefixedValue]
|
||||
: [
|
||||
...this.values.filter(
|
||||
(v) =>
|
||||
!v.startsWith(
|
||||
QUESTION_EXTRASETTINGS_OTHER_PREFIX,
|
||||
),
|
||||
),
|
||||
prefixedValue,
|
||||
],
|
||||
)
|
||||
},
|
||||
},
|
||||
|
@ -304,11 +338,21 @@ export default {
|
|||
const max = this.extraSettings.optionsLimitMax ?? 0
|
||||
const min = this.extraSettings.optionsLimitMin ?? 0
|
||||
if (max && this.values.length > max) {
|
||||
this.errorMessage = n('forms', 'You must choose at most one option', 'You must choose a maximum of %n options', max)
|
||||
this.errorMessage = n(
|
||||
'forms',
|
||||
'You must choose at most one option',
|
||||
'You must choose a maximum of %n options',
|
||||
max,
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (min && this.values.length < min) {
|
||||
this.errorMessage = n('forms', 'You must choose at least one option', 'You must choose at least %n options', min)
|
||||
this.errorMessage = n(
|
||||
'forms',
|
||||
'You must choose at least one option',
|
||||
'You must choose at least %n options',
|
||||
min,
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
@ -323,7 +367,10 @@ export default {
|
|||
resetOtherAnswerText() {
|
||||
if (this.otherAnswer) {
|
||||
// make sure to use cached value if empty value is passed
|
||||
this.cachedOtherAnswerText = this.otherAnswer.slice(QUESTION_EXTRASETTINGS_OTHER_PREFIX.length) || this.cachedOtherAnswerText
|
||||
this.cachedOtherAnswerText =
|
||||
this.otherAnswer.slice(
|
||||
QUESTION_EXTRASETTINGS_OTHER_PREFIX.length,
|
||||
) || this.cachedOtherAnswerText
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -337,11 +384,16 @@ export default {
|
|||
*/
|
||||
onChangeOther(value) {
|
||||
value = [value].flat()
|
||||
const pureValue = value.filter((v) => !v.startsWith(QUESTION_EXTRASETTINGS_OTHER_PREFIX))
|
||||
const pureValue = value.filter(
|
||||
(v) => !v.startsWith(QUESTION_EXTRASETTINGS_OTHER_PREFIX),
|
||||
)
|
||||
|
||||
if (value.length > pureValue.length) {
|
||||
// make sure to add the cached test on re-enable
|
||||
this.onChange([...pureValue, `${QUESTION_EXTRASETTINGS_OTHER_PREFIX}${this.cachedOtherAnswerText}`])
|
||||
this.onChange([
|
||||
...pureValue,
|
||||
`${QUESTION_EXTRASETTINGS_OTHER_PREFIX}${this.cachedOtherAnswerText}`,
|
||||
])
|
||||
} else {
|
||||
this.onChange(value)
|
||||
}
|
||||
|
@ -358,7 +410,12 @@ export default {
|
|||
this.onExtraSettingsChange({ optionsLimitMax: undefined })
|
||||
} else if (max) {
|
||||
if ((this.extraSettings.optionsLimitMin ?? 0) > max) {
|
||||
showError(t('forms', 'Upper options limit must be greater than the lower limit'))
|
||||
showError(
|
||||
t(
|
||||
'forms',
|
||||
'Upper options limit must be greater than the lower limit',
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
// If a valid number was passed, update the backend
|
||||
|
@ -375,8 +432,16 @@ export default {
|
|||
if (this.isUnique || min === null) {
|
||||
this.onExtraSettingsChange({ optionsLimitMin: undefined })
|
||||
} else if (min) {
|
||||
if (this.extraSettings.optionsLimitMax && min > this.extraSettings.optionsLimitMax) {
|
||||
showError(t('forms', 'Lower options limit must be smaller than the upper limit'))
|
||||
if (
|
||||
this.extraSettings.optionsLimitMax &&
|
||||
min > this.extraSettings.optionsLimitMax
|
||||
) {
|
||||
showError(
|
||||
t(
|
||||
'forms',
|
||||
'Lower options limit must be smaller than the upper limit',
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
this.onExtraSettingsChange({ optionsLimitMin: min })
|
||||
|
@ -413,7 +478,7 @@ export default {
|
|||
*/
|
||||
checkValidOption() {
|
||||
// When leaving edit mode, filter and delete empty options
|
||||
this.options.forEach(option => {
|
||||
this.options.forEach((option) => {
|
||||
if (!option.text) {
|
||||
this.deleteOption(option.id)
|
||||
}
|
||||
|
@ -452,7 +517,7 @@ export default {
|
|||
*/
|
||||
updateAnswer(id, answer) {
|
||||
const options = [...this.options]
|
||||
const answerIndex = options.findIndex(option => option.id === id)
|
||||
const answerIndex = options.findIndex((option) => option.id === id)
|
||||
options[answerIndex] = answer
|
||||
|
||||
this.updateOptions(options)
|
||||
|
@ -512,7 +577,7 @@ export default {
|
|||
*/
|
||||
deleteOption(id) {
|
||||
const options = this.options.slice()
|
||||
const optionIndex = options.findIndex(option => option.id === id)
|
||||
const optionIndex = options.findIndex((option) => option.id === id)
|
||||
|
||||
if (options.length === 1) {
|
||||
// Clear Text, but don't remove. Will be removed, when leaving edit-mode
|
||||
|
@ -543,14 +608,24 @@ export default {
|
|||
* @param {object} option The option to delete
|
||||
*/
|
||||
deleteOptionFromDatabase(option) {
|
||||
const optionIndex = this.options.findIndex(opt => opt.id === option.id)
|
||||
const optionIndex = this.options.findIndex((opt) => opt.id === option.id)
|
||||
|
||||
if (!option.local) {
|
||||
// let's not await, deleting in background
|
||||
axios.delete(generateOcsUrl('apps/forms/api/v2.4/option/{id}', { id: option.id }))
|
||||
.catch(error => {
|
||||
logger.error('Error while deleting an option', { error, option })
|
||||
showError(t('forms', 'There was an issue deleting this option'))
|
||||
axios
|
||||
.delete(
|
||||
generateOcsUrl('apps/forms/api/v2.4/option/{id}', {
|
||||
id: option.id,
|
||||
}),
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error('Error while deleting an option', {
|
||||
error,
|
||||
option,
|
||||
})
|
||||
showError(
|
||||
t('forms', 'There was an issue deleting this option'),
|
||||
)
|
||||
// restore option
|
||||
this.restoreOption(option, optionIndex)
|
||||
})
|
||||
|
@ -655,5 +730,4 @@ export default {
|
|||
.question__other-answer:deep() .input-field__input {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -21,13 +21,17 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<Question v-bind="questionProps"
|
||||
<Question
|
||||
v-bind="questionProps"
|
||||
:title-placeholder="answerType.titlePlaceholder"
|
||||
:warning-invalid="answerType.warningInvalid"
|
||||
v-on="commonListeners">
|
||||
<div class="question__content">
|
||||
<input ref="input"
|
||||
:aria-label="t('forms', 'A short answer for the question “{text}”', { text })"
|
||||
<input
|
||||
ref="input"
|
||||
:aria-label="
|
||||
t('forms', 'A short answer for the question “{text}”', { text })
|
||||
"
|
||||
:placeholder="submissionInputPlaceholder"
|
||||
:disabled="!readOnly"
|
||||
:name="name || undefined"
|
||||
|
@ -40,10 +44,15 @@
|
|||
:type="validationObject.inputType"
|
||||
:step="validationObject.inputType === 'number' ? 'any' : undefined"
|
||||
@input="onInput"
|
||||
@keydown.enter.exact.prevent="onKeydownEnter">
|
||||
<NcActions v-if="!readOnly"
|
||||
@keydown.enter.exact.prevent="onKeydownEnter" />
|
||||
<NcActions
|
||||
v-if="!readOnly"
|
||||
:id="validationTypeMenuId"
|
||||
:aria-label="t('forms', 'Input types (currently: {type})', { type: validationObject.label })"
|
||||
:aria-label="
|
||||
t('forms', 'Input types (currently: {type})', {
|
||||
type: validationObject.label,
|
||||
})
|
||||
"
|
||||
:container="`#${validationTypeMenuId}`"
|
||||
:open.sync="isValidationTypeMenuOpen"
|
||||
class="validation-type-menu__toggle"
|
||||
|
@ -51,14 +60,18 @@
|
|||
<template #icon>
|
||||
<component :is="validationObject.icon" :size="20" />
|
||||
</template>
|
||||
<NcActionRadio v-for="(validationTypeObject, validationTypeName) in validationTypes"
|
||||
<NcActionRadio
|
||||
v-for="(
|
||||
validationTypeObject, validationTypeName
|
||||
) in validationTypes"
|
||||
:key="validationTypeName"
|
||||
:checked="validationType === validationTypeName"
|
||||
:name="validationTypeName"
|
||||
@update:checked="onChangeValidationType(validationTypeName)">
|
||||
{{ validationTypeObject.label }}
|
||||
</NcActionRadio>
|
||||
<NcActionInput v-if="validationType === 'regex'"
|
||||
<NcActionInput
|
||||
v-if="validationType === 'regex'"
|
||||
ref="regexInput"
|
||||
:label="t('forms', 'Regular expression for input validation')"
|
||||
:value="validationRegex"
|
||||
|
@ -107,9 +120,15 @@ export default {
|
|||
computed: {
|
||||
submissionInputPlaceholder() {
|
||||
if (!this.readOnly) {
|
||||
return this.validationObject.createPlaceholder || this.answerType.createPlaceholder
|
||||
return (
|
||||
this.validationObject.createPlaceholder ||
|
||||
this.answerType.createPlaceholder
|
||||
)
|
||||
}
|
||||
return this.validationObject.submitPlaceholder || this.answerType.submitPlaceholder
|
||||
return (
|
||||
this.validationObject.submitPlaceholder ||
|
||||
this.answerType.submitPlaceholder
|
||||
)
|
||||
},
|
||||
/**
|
||||
* Current user input validation type
|
||||
|
@ -151,7 +170,13 @@ export default {
|
|||
// Then check native browser validation (might be better then our)
|
||||
// If the browsers validation succeeds either the browser does not implement a validation
|
||||
// or it is valid, so we double check by running our custom validation.
|
||||
if (!input.checkValidity() || !this.validationObject.validate(value, splitRegex(this.validationRegex))) {
|
||||
if (
|
||||
!input.checkValidity() ||
|
||||
!this.validationObject.validate(
|
||||
value,
|
||||
splitRegex(this.validationRegex),
|
||||
)
|
||||
) {
|
||||
input.setCustomValidity(this.validationObject.errorMessage)
|
||||
}
|
||||
}
|
||||
|
@ -167,11 +192,17 @@ export default {
|
|||
onChangeValidationType(validationType) {
|
||||
if (validationType === 'regex') {
|
||||
// Make sure to also submit a regex (even if empty)
|
||||
this.onExtraSettingsChange({ validationType, validationRegex: this.validationRegex })
|
||||
this.onExtraSettingsChange({
|
||||
validationType,
|
||||
validationRegex: this.validationRegex,
|
||||
})
|
||||
} else {
|
||||
// For all other types except regex we close the menu (for regex we keep it open to allow entering a regex)
|
||||
this.isValidationTypeMenuOpen = false
|
||||
this.onExtraSettingsChange({ validationType: validationType === 'text' ? undefined : validationType })
|
||||
this.onExtraSettingsChange({
|
||||
validationType:
|
||||
validationType === 'text' ? undefined : validationType,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -237,6 +268,6 @@ export default {
|
|||
|
||||
:deep(input:invalid) {
|
||||
// nextcloud/server#36548
|
||||
border-color: var(--color-error)!important;
|
||||
border-color: var(--color-error) !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -28,11 +28,15 @@
|
|||
<!-- Do not wrap the following line between tags! `white-space:pre-line` respects `\n` but would produce additional empty first line -->
|
||||
<!-- eslint-disable-next-line -->
|
||||
<template v-if="answers.length">
|
||||
<p v-for="answer of answers"
|
||||
<p
|
||||
v-for="answer of answers"
|
||||
:key="answer.id"
|
||||
class="answer__text"
|
||||
dir="auto">
|
||||
<a :href="answer.url" target="_blank"><IconFile :size="20" class="answer__text-icon" /> {{ answer.text }}</a>
|
||||
<a :href="answer.url" target="_blank"
|
||||
><IconFile :size="20" class="answer__text-icon" />
|
||||
{{ answer.text }}</a
|
||||
>
|
||||
</p>
|
||||
</template>
|
||||
<p v-else class="answer__text" dir="auto">
|
||||
|
@ -88,5 +92,4 @@ export default {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -30,20 +30,24 @@
|
|||
</p>
|
||||
|
||||
<!-- Answers with countable results for visualization -->
|
||||
<ol v-if="answerTypes[question.type].predefined"
|
||||
<ol
|
||||
v-if="answerTypes[question.type].predefined"
|
||||
class="question-summary__statistic">
|
||||
<li v-for="option in questionOptions"
|
||||
:key="option.id">
|
||||
<li v-for="option in questionOptions" :key="option.id">
|
||||
<label :for="`option-${option.questionId}-${option.id}`">
|
||||
{{ option.count }}
|
||||
<span class="question-summary__statistic-percentage">
|
||||
({{ option.percentage }}%):
|
||||
</span>
|
||||
<span :class="{'question-summary__statistic-text--best':option.best}">
|
||||
<span
|
||||
:class="{
|
||||
'question-summary__statistic-text--best': option.best,
|
||||
}">
|
||||
{{ option.text }}
|
||||
</span>
|
||||
</label>
|
||||
<meter :id="`option-${option.questionId}-${option.id}`"
|
||||
<meter
|
||||
:id="`option-${option.questionId}-${option.id}`"
|
||||
min="0"
|
||||
:max="submissions.length"
|
||||
:value="option.count" />
|
||||
|
@ -56,7 +60,10 @@
|
|||
<!-- eslint-disable-next-line -->
|
||||
<li v-for="answer in answers" :key="answer.id" dir="auto">
|
||||
<template v-if="answer.url">
|
||||
<a :href="answer.url" target="_blank"><IconFile :size="20" class="question-summary__text-icon" /> {{ answer.text }}</a>
|
||||
<a :href="answer.url" target="_blank"
|
||||
><IconFile :size="20" class="question-summary__text-icon" />
|
||||
{{ answer.text }}</a
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ answer.text }}
|
||||
|
@ -99,7 +106,7 @@ export default {
|
|||
// For countable questions like multiple choice and checkboxes
|
||||
questionOptions() {
|
||||
// Build list of question options
|
||||
const questionOptionsStats = this.question.options.map(option => ({
|
||||
const questionOptionsStats = this.question.options.map((option) => ({
|
||||
...option,
|
||||
count: 0,
|
||||
percentage: 0,
|
||||
|
@ -123,16 +130,20 @@ export default {
|
|||
})
|
||||
|
||||
// Go through submissions to check which options have how many responses
|
||||
this.submissions.forEach(submission => {
|
||||
const answers = submission.answers.filter(answer => answer.questionId === this.question.id)
|
||||
this.submissions.forEach((submission) => {
|
||||
const answers = submission.answers.filter(
|
||||
(answer) => answer.questionId === this.question.id,
|
||||
)
|
||||
if (!answers.length) {
|
||||
// Record 'No response'
|
||||
questionOptionsStats[0].count++
|
||||
}
|
||||
|
||||
// Check question options to find which needs to be increased
|
||||
answers.forEach(answer => {
|
||||
const optionsStatIndex = questionOptionsStats.findIndex(option => option.text === answer.text)
|
||||
answers.forEach((answer) => {
|
||||
const optionsStatIndex = questionOptionsStats.findIndex(
|
||||
(option) => option.text === answer.text,
|
||||
)
|
||||
if (optionsStatIndex < 0) {
|
||||
if (this.question.extraSettings?.allowOtherAnswer) {
|
||||
questionOptionsStats[1].count++
|
||||
|
@ -154,11 +165,14 @@ export default {
|
|||
return object2.count - object1.count
|
||||
})
|
||||
|
||||
questionOptionsStats.forEach(questionOptionsStat => {
|
||||
questionOptionsStats.forEach((questionOptionsStat) => {
|
||||
// Fill percentage values
|
||||
questionOptionsStat.percentage = Math.round((100 * questionOptionsStat.count) / this.submissions.length)
|
||||
questionOptionsStat.percentage = Math.round(
|
||||
(100 * questionOptionsStat.count) / this.submissions.length,
|
||||
)
|
||||
// Mark all best results. First one is best for sure due to sorting
|
||||
questionOptionsStat.best = (questionOptionsStat.count === questionOptionsStats[0].count)
|
||||
questionOptionsStat.best =
|
||||
questionOptionsStat.count === questionOptionsStats[0].count
|
||||
})
|
||||
|
||||
return questionOptionsStats
|
||||
|
@ -172,20 +186,24 @@ export default {
|
|||
let noResponseCount = 0
|
||||
|
||||
// Go through submissions to check which options have how many responses
|
||||
this.submissions.forEach(submission => {
|
||||
const answers = submission.answers.filter(answer => answer.questionId === this.question.id)
|
||||
this.submissions.forEach((submission) => {
|
||||
const answers = submission.answers.filter(
|
||||
(answer) => answer.questionId === this.question.id,
|
||||
)
|
||||
if (!answers.length) {
|
||||
// Record 'No response'
|
||||
noResponseCount++
|
||||
}
|
||||
|
||||
// Add text answers
|
||||
answers.forEach(answer => {
|
||||
answers.forEach((answer) => {
|
||||
if (answer.fileId) {
|
||||
answersModels.push({
|
||||
id: answer.id,
|
||||
text: answer.text,
|
||||
url: generateUrl('/f/{fileId}', { fileId: answer.fileId }),
|
||||
url: generateUrl('/f/{fileId}', {
|
||||
fileId: answer.fileId,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
answersModels.push({
|
||||
|
@ -197,8 +215,18 @@ export default {
|
|||
})
|
||||
|
||||
// Calculate no response percentage
|
||||
const noResponsePercentage = Math.round((100 * noResponseCount) / this.submissions.length)
|
||||
answersModels.unshift({ id: 0, text: noResponseCount + ' (' + noResponsePercentage + '%): ' + t('forms', 'No response') })
|
||||
const noResponsePercentage = Math.round(
|
||||
(100 * noResponseCount) / this.submissions.length,
|
||||
)
|
||||
answersModels.unshift({
|
||||
id: 0,
|
||||
text:
|
||||
noResponseCount +
|
||||
' (' +
|
||||
noResponsePercentage +
|
||||
'%): ' +
|
||||
t('forms', 'No response'),
|
||||
})
|
||||
|
||||
return answersModels
|
||||
},
|
||||
|
|
|
@ -39,7 +39,8 @@
|
|||
{{ submissionDateTime }}
|
||||
</p>
|
||||
|
||||
<Answer v-for="question in answeredQuestions"
|
||||
<Answer
|
||||
v-for="question in answeredQuestions"
|
||||
:key="question.id"
|
||||
:answer-text="question.squashedAnswers"
|
||||
:answers="question.answers"
|
||||
|
@ -96,8 +97,10 @@ export default {
|
|||
answeredQuestions() {
|
||||
const answeredQuestionsArray = []
|
||||
|
||||
this.questions.forEach(question => {
|
||||
const answers = this.submission.answers.filter(answer => answer.questionId === question.id)
|
||||
this.questions.forEach((question) => {
|
||||
const answers = this.submission.answers.filter(
|
||||
(answer) => answer.questionId === question.id,
|
||||
)
|
||||
if (!answers.length) {
|
||||
return // no answers, go to next question
|
||||
}
|
||||
|
@ -106,16 +109,20 @@ export default {
|
|||
answeredQuestionsArray.push({
|
||||
id: question.id,
|
||||
text: question.text,
|
||||
answers: answers.map(answer => {
|
||||
answers: answers.map((answer) => {
|
||||
return {
|
||||
id: answer.id,
|
||||
text: answer.text,
|
||||
url: generateUrl('/f/{fileId}', { fileId: answer.fileId }),
|
||||
url: generateUrl('/f/{fileId}', {
|
||||
fileId: answer.fileId,
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
const squashedAnswers = answers.map(answer => answer.text).join('; ')
|
||||
const squashedAnswers = answers
|
||||
.map((answer) => answer.text)
|
||||
.join('; ')
|
||||
|
||||
answeredQuestionsArray.push({
|
||||
id: question.id,
|
||||
|
@ -123,7 +130,6 @@ export default {
|
|||
squashedAnswers,
|
||||
})
|
||||
}
|
||||
|
||||
})
|
||||
return answeredQuestionsArray
|
||||
},
|
||||
|
|
|
@ -24,28 +24,32 @@
|
|||
|
||||
<template>
|
||||
<div class="sidebar-tabs__content">
|
||||
<NcCheckboxRadioSwitch :checked="form.isAnonymous"
|
||||
<NcCheckboxRadioSwitch
|
||||
:checked="form.isAnonymous"
|
||||
:disabled="formArchived"
|
||||
type="switch"
|
||||
@update:checked="onAnonChange">
|
||||
<!-- TRANSLATORS Checkbox to select whether responses will be stored anonymously or not -->
|
||||
{{ t('forms', 'Store responses anonymously') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<NcCheckboxRadioSwitch v-tooltip="disableSubmitMultipleExplanation"
|
||||
<NcCheckboxRadioSwitch
|
||||
v-tooltip="disableSubmitMultipleExplanation"
|
||||
:checked="submitMultiple"
|
||||
:disabled="disableSubmitMultiple || formArchived"
|
||||
type="switch"
|
||||
@update:checked="onSubmitMultipleChange">
|
||||
{{ t('forms', 'Allow multiple responses per person') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<NcCheckboxRadioSwitch :checked="formExpires"
|
||||
<NcCheckboxRadioSwitch
|
||||
:checked="formExpires"
|
||||
:disabled="formArchived"
|
||||
type="switch"
|
||||
@update:checked="onFormExpiresChange">
|
||||
{{ t('forms', 'Set expiration date') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<div v-show="formExpires && !formArchived" class="settings-div--indent">
|
||||
<NcDateTimePicker id="expiresDatetimePicker"
|
||||
<NcDateTimePicker
|
||||
id="expiresDatetimePicker"
|
||||
:clearable="false"
|
||||
:disabled-date="notBeforeToday"
|
||||
:disabled-time="notBeforeNow"
|
||||
|
@ -56,13 +60,15 @@
|
|||
:value="expirationDate"
|
||||
type="datetime"
|
||||
@change="onExpirationDateChange" />
|
||||
<NcCheckboxRadioSwitch :checked="form.showExpiration"
|
||||
<NcCheckboxRadioSwitch
|
||||
:checked="form.showExpiration"
|
||||
type="switch"
|
||||
@update:checked="onShowExpirationChange">
|
||||
{{ t('forms', 'Show expiration date on form') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
<NcCheckboxRadioSwitch :checked="formClosed"
|
||||
<NcCheckboxRadioSwitch
|
||||
:checked="formClosed"
|
||||
:disabled="formArchived"
|
||||
aria-describedby="forms-settings__close-form"
|
||||
type="switch"
|
||||
|
@ -72,43 +78,69 @@
|
|||
<p id="forms-settings__close-form" class="settings-hint">
|
||||
{{ t('forms', 'Closed forms do not accept new submissions.') }}
|
||||
</p>
|
||||
<NcCheckboxRadioSwitch :checked="formArchived"
|
||||
<NcCheckboxRadioSwitch
|
||||
:checked="formArchived"
|
||||
aria-describedby="forms-settings__archive-form"
|
||||
type="switch"
|
||||
@update:checked="onFormArchivedChange">
|
||||
{{ t('forms', 'Archive form') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<p id="forms-settings__archive-form" class="settings-hint">
|
||||
{{ t('forms', 'Archived forms do not accept new submissions and can not be modified.') }}
|
||||
{{
|
||||
t(
|
||||
'forms',
|
||||
'Archived forms do not accept new submissions and can not be modified.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<NcCheckboxRadioSwitch :checked="hasCustomSubmissionMessage"
|
||||
<NcCheckboxRadioSwitch
|
||||
:checked="hasCustomSubmissionMessage"
|
||||
:disabled="formArchived"
|
||||
type="switch"
|
||||
@update:checked="onUpdateHasCustomSubmissionMessage">
|
||||
{{ t('forms', 'Custom submission message') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<div v-show="hasCustomSubmissionMessage"
|
||||
<div
|
||||
v-show="hasCustomSubmissionMessage"
|
||||
class="settings-div--indent submission-message"
|
||||
:tabindex="editMessage ? undefined : '0'"
|
||||
@focus="editMessage = true">
|
||||
<textarea v-if="!formArchived && (editMessage || !form.submissionMessage)"
|
||||
v-click-outside="() => { editMessage = false }"
|
||||
<textarea
|
||||
v-if="!formArchived && (editMessage || !form.submissionMessage)"
|
||||
v-click-outside="
|
||||
() => {
|
||||
editMessage = false
|
||||
}
|
||||
"
|
||||
aria-describedby="forms-submission-message-description"
|
||||
:aria-label="t('forms', 'Custom submission message')"
|
||||
:value="form.submissionMessage"
|
||||
:maxlength="maxStringLengths.submissionMessage"
|
||||
:placeholder="t('forms', 'Message to show after a user submitted the form (formatting using Markdown is supported)')"
|
||||
:placeholder="
|
||||
t(
|
||||
'forms',
|
||||
'Message to show after a user submitted the form (formatting using Markdown is supported)',
|
||||
)
|
||||
"
|
||||
class="submission-message__input"
|
||||
@blur="editMessage = false"
|
||||
@change="onSubmissionMessageChange" />
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div v-else
|
||||
<div
|
||||
v-else
|
||||
:aria-label="t('forms', 'Custom submission message')"
|
||||
class="submission-message__output"
|
||||
v-html="submissionMessageHTML" />
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<div id="forms-submission-message-description" class="submission-message__description">
|
||||
{{ t('forms', 'Message to show after a user submitted the form. Please note that the message will not be translated!') }}
|
||||
<div
|
||||
id="forms-submission-message-description"
|
||||
class="submission-message__description">
|
||||
{{
|
||||
t(
|
||||
'forms',
|
||||
'Message to show after a user submitted the form. Please note that the message will not be translated!',
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -166,25 +198,37 @@ export default {
|
|||
* If the form has a custom submission message or the user wants to add one (settings switch)
|
||||
*/
|
||||
hasCustomSubmissionMessage() {
|
||||
return this.form?.submissionMessage !== undefined && this.form?.submissionMessage !== null
|
||||
return (
|
||||
this.form?.submissionMessage !== undefined &&
|
||||
this.form?.submissionMessage !== null
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Submit Multiple is disabled, if it cannot be controlled.
|
||||
*/
|
||||
disableSubmitMultiple() {
|
||||
return this.hasPublicLink || this.form.access.legacyLink || this.form.isAnonymous
|
||||
return (
|
||||
this.hasPublicLink ||
|
||||
this.form.access.legacyLink ||
|
||||
this.form.isAnonymous
|
||||
)
|
||||
},
|
||||
disableSubmitMultipleExplanation() {
|
||||
if (this.disableSubmitMultiple) {
|
||||
return t('forms', 'This can not be controlled, if the form has a public link or stores responses anonymously.')
|
||||
return t(
|
||||
'forms',
|
||||
'This can not be controlled, if the form has a public link or stores responses anonymously.',
|
||||
)
|
||||
}
|
||||
return ''
|
||||
},
|
||||
hasPublicLink() {
|
||||
return this.form.shares
|
||||
.filter(share => share.shareType === this.SHARE_TYPES.SHARE_TYPE_LINK)
|
||||
.length !== 0
|
||||
return (
|
||||
this.form.shares.filter(
|
||||
(share) => share.shareType === this.SHARE_TYPES.SHARE_TYPE_LINK,
|
||||
).length !== 0
|
||||
)
|
||||
},
|
||||
|
||||
// If disabled, submitMultiple will be casted to true
|
||||
|
@ -232,7 +276,11 @@ export default {
|
|||
},
|
||||
onFormExpiresChange(checked) {
|
||||
if (checked) {
|
||||
this.$emit('update:formProp', 'expires', moment().add(1, 'hour').unix()) // Expires in one hour.
|
||||
this.$emit(
|
||||
'update:formProp',
|
||||
'expires',
|
||||
moment().add(1, 'hour').unix(),
|
||||
) // Expires in one hour.
|
||||
} else {
|
||||
this.$emit('update:formProp', 'expires', 0)
|
||||
}
|
||||
|
@ -247,15 +295,27 @@ export default {
|
|||
* @param {Date} datetime the expiration Date
|
||||
*/
|
||||
onExpirationDateChange(datetime) {
|
||||
this.$emit('update:formProp', 'expires', parseInt(moment(datetime).format('X')))
|
||||
this.$emit(
|
||||
'update:formProp',
|
||||
'expires',
|
||||
parseInt(moment(datetime).format('X')),
|
||||
)
|
||||
},
|
||||
|
||||
onFormClosedChange(isClosed) {
|
||||
this.$emit('update:formProp', 'state', isClosed ? FormState.FormClosed : FormState.FormActive)
|
||||
this.$emit(
|
||||
'update:formProp',
|
||||
'state',
|
||||
isClosed ? FormState.FormClosed : FormState.FormActive,
|
||||
)
|
||||
},
|
||||
|
||||
onFormArchivedChange(isArchived) {
|
||||
this.$emit('update:formProp', 'state', isArchived ? FormState.FormArchived : FormState.FormClosed)
|
||||
this.$emit(
|
||||
'update:formProp',
|
||||
'state',
|
||||
isArchived ? FormState.FormArchived : FormState.FormClosed,
|
||||
)
|
||||
},
|
||||
|
||||
onSubmissionMessageChange({ target }) {
|
||||
|
@ -347,7 +407,8 @@ export default {
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__input, &__output {
|
||||
&__input,
|
||||
&__output {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
line-height: 24px;
|
||||
|
|
|
@ -23,7 +23,8 @@
|
|||
|
||||
<template>
|
||||
<div>
|
||||
<NcSelect :clear-search-on-select="false"
|
||||
<NcSelect
|
||||
:clear-search-on-select="false"
|
||||
:close-on-select="false"
|
||||
:loading="showLoadingCircle"
|
||||
:get-option-key="(option) => option.key"
|
||||
|
@ -56,7 +57,7 @@ export default {
|
|||
props: {
|
||||
currentShares: {
|
||||
type: Array,
|
||||
default: () => ([]),
|
||||
default: () => [],
|
||||
},
|
||||
showLoading: {
|
||||
type: Boolean,
|
||||
|
@ -74,10 +75,24 @@ export default {
|
|||
options() {
|
||||
if (this.isValidQuery) {
|
||||
// Suggestions without existing shares
|
||||
return this.suggestions.filter(item => !this.currentShares.find(share => share.shareWith === item.shareWith && share.shareType === item.shareType))
|
||||
return this.suggestions.filter(
|
||||
(item) =>
|
||||
!this.currentShares.find(
|
||||
(share) =>
|
||||
share.shareWith === item.shareWith &&
|
||||
share.shareType === item.shareType,
|
||||
),
|
||||
)
|
||||
}
|
||||
// Recommendations without existing shares
|
||||
return this.recommendations.filter(item => !this.currentShares.find(share => share.shareWith === item.shareWith && share.shareType === item.shareType))
|
||||
return this.recommendations.filter(
|
||||
(item) =>
|
||||
!this.currentShares.find(
|
||||
(share) =>
|
||||
share.shareWith === item.shareWith &&
|
||||
share.shareType === item.shareType,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -112,8 +127,8 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.select {
|
||||
margin-block-end: 8px !important;
|
||||
width: 100%;
|
||||
}
|
||||
.select {
|
||||
margin-block-end: 8px !important;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -22,7 +22,8 @@
|
|||
|
||||
<template>
|
||||
<li class="share-div">
|
||||
<NcAvatar :user="share.shareWith"
|
||||
<NcAvatar
|
||||
:user="share.shareWith"
|
||||
:disable-menu="true"
|
||||
:display-name="displayName"
|
||||
:is-no-user="isNoUser" />
|
||||
|
@ -32,10 +33,15 @@
|
|||
</div>
|
||||
<NcActions class="share-div__actions">
|
||||
<NcActionCaption :name="t('forms', 'Permissions')" />
|
||||
<NcActionCheckbox :checked="canAccessResults" @update:checked="updatePermissionResults">
|
||||
<NcActionCheckbox
|
||||
:checked="canAccessResults"
|
||||
@update:checked="updatePermissionResults">
|
||||
{{ t('forms', 'View responses') }}
|
||||
</NcActionCheckbox>
|
||||
<NcActionCheckbox :checked="canDeleteResults" :disabled="!canAccessResults" @update:checked="updatePermissionDeleteResults">
|
||||
<NcActionCheckbox
|
||||
:checked="canDeleteResults"
|
||||
:disabled="!canAccessResults"
|
||||
@update:checked="updatePermissionDeleteResults">
|
||||
{{ t('forms', 'Delete responses') }}
|
||||
</NcActionCheckbox>
|
||||
<NcActionSeparator />
|
||||
|
@ -83,25 +89,31 @@ export default {
|
|||
|
||||
computed: {
|
||||
canAccessResults() {
|
||||
return this.share.permissions.includes(this.PERMISSION_TYPES.PERMISSION_RESULTS)
|
||||
return this.share.permissions.includes(
|
||||
this.PERMISSION_TYPES.PERMISSION_RESULTS,
|
||||
)
|
||||
},
|
||||
canDeleteResults() {
|
||||
return this.share.permissions.includes(this.PERMISSION_TYPES.PERMISSION_RESULTS_DELETE)
|
||||
return this.share.permissions.includes(
|
||||
this.PERMISSION_TYPES.PERMISSION_RESULTS_DELETE,
|
||||
)
|
||||
},
|
||||
isNoUser() {
|
||||
return this.share.shareType !== this.SHARE_TYPES.SHARE_TYPE_USER
|
||||
},
|
||||
displayName() {
|
||||
return !this.share.displayName ? this.share.shareWith : this.share.displayName
|
||||
return !this.share.displayName
|
||||
? this.share.shareWith
|
||||
: this.share.displayName
|
||||
},
|
||||
displayNameAppendix() {
|
||||
switch (this.share.shareType) {
|
||||
case this.SHARE_TYPES.SHARE_TYPE_GROUP:
|
||||
return `(${t('forms', 'Group')})`
|
||||
case this.SHARE_TYPES.SHARE_TYPE_CIRCLE:
|
||||
return `(${t('forms', 'Team')})`
|
||||
default:
|
||||
return ''
|
||||
case this.SHARE_TYPES.SHARE_TYPE_GROUP:
|
||||
return `(${t('forms', 'Group')})`
|
||||
case this.SHARE_TYPES.SHARE_TYPE_CIRCLE:
|
||||
return `(${t('forms', 'Team')})`
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
},
|
||||
},
|
||||
|
@ -117,16 +129,25 @@ export default {
|
|||
updatePermissionResults(hasPermission) {
|
||||
if (hasPermission === false) {
|
||||
// ensure to remove the delete permission if results permission is dropped
|
||||
this.updatePermission(this.PERMISSION_TYPES.PERMISSION_RESULTS_DELETE, false)
|
||||
this.updatePermission(
|
||||
this.PERMISSION_TYPES.PERMISSION_RESULTS_DELETE,
|
||||
false,
|
||||
)
|
||||
}
|
||||
return this.updatePermission(this.PERMISSION_TYPES.PERMISSION_RESULTS, hasPermission)
|
||||
return this.updatePermission(
|
||||
this.PERMISSION_TYPES.PERMISSION_RESULTS,
|
||||
hasPermission,
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {boolean} hasPermission If the results_delete permission should be granted
|
||||
*/
|
||||
updatePermissionDeleteResults(hasPermission) {
|
||||
return this.updatePermission(this.PERMISSION_TYPES.PERMISSION_RESULTS_DELETE, hasPermission)
|
||||
return this.updatePermission(
|
||||
this.PERMISSION_TYPES.PERMISSION_RESULTS_DELETE,
|
||||
hasPermission,
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -140,12 +161,13 @@ export default {
|
|||
if (hasPermission) {
|
||||
share.permissions = [...new Set([...share.permissions, permission])]
|
||||
} else {
|
||||
share.permissions = share.permissions.filter(perm => perm !== permission)
|
||||
share.permissions = share.permissions.filter(
|
||||
(perm) => perm !== permission,
|
||||
)
|
||||
}
|
||||
this.$emit('update:share', share)
|
||||
},
|
||||
},
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -23,12 +23,15 @@
|
|||
|
||||
<template>
|
||||
<div class="sidebar-tabs__content">
|
||||
<SharingSearchDiv :current-shares="form.shares"
|
||||
<SharingSearchDiv
|
||||
:current-shares="form.shares"
|
||||
:show-loading="isLoading"
|
||||
@add-share="addShare" />
|
||||
|
||||
<!-- Public Link -->
|
||||
<div v-if="!hasPublicLink && appConfig.allowPublicLink" class="share-div share-div--link">
|
||||
<div
|
||||
v-if="!hasPublicLink && appConfig.allowPublicLink"
|
||||
class="share-div share-div--link">
|
||||
<div class="share-div__avatar">
|
||||
<IconLinkVariant :size="20" />
|
||||
</div>
|
||||
|
@ -43,24 +46,33 @@
|
|||
</NcActions>
|
||||
</div>
|
||||
<TransitionGroup v-else tag="div">
|
||||
<div v-for="share in publicLinkShares"
|
||||
<div
|
||||
v-for="share in publicLinkShares"
|
||||
:key="'share-' + share.shareType + '-' + share.shareWith"
|
||||
:set="void(isEmbeddable = isEmbeddingAllowed(share))"
|
||||
:set="void (isEmbeddable = isEmbeddingAllowed(share))"
|
||||
class="share-div share-div--link"
|
||||
:class="{ 'share-div--embeddable': isEmbeddingAllowed(share) }">
|
||||
<div class="share-div__avatar">
|
||||
<IconLinkBoxVariantOutline v-if="isEmbeddable" :size="20" />
|
||||
<IconLinkVariant v-else :size="20" />
|
||||
</div>
|
||||
<span class="share-div__desc">{{ isEmbeddable ? t('forms', 'Embeddable link') : t('forms', 'Share link') }}</span>
|
||||
<span class="share-div__desc">{{
|
||||
isEmbeddable
|
||||
? t('forms', 'Embeddable link')
|
||||
: t('forms', 'Share link')
|
||||
}}</span>
|
||||
<NcActions :inline="1">
|
||||
<NcActionLink :href="getPublicShareLink(share)" @click.prevent="copyLink($event, getPublicShareLink(share))">
|
||||
<NcActionLink
|
||||
:href="getPublicShareLink(share)"
|
||||
@click.prevent="copyLink($event, getPublicShareLink(share))">
|
||||
<template #icon>
|
||||
<IconCopyAll :size="20" />
|
||||
</template>
|
||||
{{ t('forms', 'Copy to clipboard') }}
|
||||
</NcActionLink>
|
||||
<NcActionButton v-if="isEmbeddable" @click="copyEmbeddingCode(share)">
|
||||
<NcActionButton
|
||||
v-if="isEmbeddable"
|
||||
@click="copyEmbeddingCode(share)">
|
||||
<template #icon>
|
||||
<IconCodeBrackets :size="20" />
|
||||
</template>
|
||||
|
@ -79,7 +91,8 @@
|
|||
</template>
|
||||
{{ t('forms', 'Remove link') }}
|
||||
</NcActionButton>
|
||||
<NcActionButton v-if="appConfig.allowPublicLink"
|
||||
<NcActionButton
|
||||
v-if="appConfig.allowPublicLink"
|
||||
:close-after-click="true"
|
||||
@click="addPublicLink">
|
||||
<template #icon>
|
||||
|
@ -98,9 +111,17 @@
|
|||
</div>
|
||||
<div class="share-div__desc share-div__desc--twoline">
|
||||
<span>{{ t('forms', 'Legacy Link') }}</span>
|
||||
<span>{{ t('forms', 'Form still supports old sharing-link.') }}</span>
|
||||
<span>{{
|
||||
t('forms', 'Form still supports old sharing-link.')
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-tooltip="t('forms', 'For compatibility with the old Sharing, the internal link is still usable as Share link. We recommend replacing the link with a new Share link.')"
|
||||
<div
|
||||
v-tooltip="
|
||||
t(
|
||||
'forms',
|
||||
'For compatibility with the old Sharing, the internal link is still usable as Share link. We recommend replacing the link with a new Share link.',
|
||||
)
|
||||
"
|
||||
class="share-div__legacy-warning">
|
||||
<IconAlertCircleOutline :size="20" />
|
||||
</div>
|
||||
|
@ -121,10 +142,19 @@
|
|||
</div>
|
||||
<div class="share-div__desc share-div__desc--twoline">
|
||||
<span>{{ t('forms', 'Internal link') }}</span>
|
||||
<span>{{ t('forms', 'Only works for logged in accounts with access rights') }}</span>
|
||||
<span>{{
|
||||
t(
|
||||
'forms',
|
||||
'Only works for logged in accounts with access rights',
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<NcActions>
|
||||
<NcActionLink :href="getInternalShareLink(form.hash)" @click.prevent="copyLink($event, getInternalShareLink(form.hash))">
|
||||
<NcActionLink
|
||||
:href="getInternalShareLink(form.hash)"
|
||||
@click.prevent="
|
||||
copyLink($event, getInternalShareLink(form.hash))
|
||||
">
|
||||
<template #icon>
|
||||
<IconCopyAll :size="20" />
|
||||
</template>
|
||||
|
@ -142,19 +172,23 @@
|
|||
<label for="share-switch__permit-all" class="share-div__desc">
|
||||
{{ t('forms', 'Permit access to all logged in accounts') }}
|
||||
</label>
|
||||
<NcCheckboxRadioSwitch id="share-switch__permit-all"
|
||||
<NcCheckboxRadioSwitch
|
||||
id="share-switch__permit-all"
|
||||
:checked="form.access.permitAllUsers"
|
||||
type="switch"
|
||||
@update:checked="onPermitAllUsersChange" />
|
||||
</div>
|
||||
<div v-if="form.access.permitAllUsers" class="share-div share-div--indent">
|
||||
<div
|
||||
v-if="form.access.permitAllUsers"
|
||||
class="share-div share-div--indent">
|
||||
<div class="share-div__avatar">
|
||||
<FormsIcon :size="16" />
|
||||
</div>
|
||||
<label for="share-switch__show-to-all" class="share-div__desc">
|
||||
{{ t('forms', 'Show to all accounts on sidebar') }}
|
||||
</label>
|
||||
<NcCheckboxRadioSwitch id="share-switch__show-to-all"
|
||||
<NcCheckboxRadioSwitch
|
||||
id="share-switch__show-to-all"
|
||||
:checked="form.access.showToAllUsers"
|
||||
type="switch"
|
||||
@update:checked="onShowToAllUsersChange" />
|
||||
|
@ -163,7 +197,8 @@
|
|||
|
||||
<!-- Single shares -->
|
||||
<TransitionGroup tag="ul">
|
||||
<SharingShareDiv v-for="share in sortedShares"
|
||||
<SharingShareDiv
|
||||
v-for="share in sortedShares"
|
||||
:key="'share-' + share.shareType + '-' + share.shareWith"
|
||||
:share="share"
|
||||
@remove-share="removeShare"
|
||||
|
@ -239,15 +274,21 @@ export default {
|
|||
sortedShares() {
|
||||
// Remove Link-Shares, which are handled separately, then sort
|
||||
return this.form.shares
|
||||
.filter(share => share.shareType !== this.SHARE_TYPES.SHARE_TYPE_LINK)
|
||||
.filter(
|
||||
(share) => share.shareType !== this.SHARE_TYPES.SHARE_TYPE_LINK,
|
||||
)
|
||||
.sort(this.sortByTypeAndDisplayname)
|
||||
},
|
||||
hasPublicLink() {
|
||||
return this.publicLinkShares.length !== 0
|
||||
},
|
||||
publicLinkShares() {
|
||||
const shares = this.form.shares.filter(share => share.shareType === this.SHARE_TYPES.SHARE_TYPE_LINK)
|
||||
shares.sort((a, b) => this.isEmbeddingAllowed(a) ? 1 : (this.isEmbeddingAllowed(b) ? -1 : 0))
|
||||
const shares = this.form.shares.filter(
|
||||
(share) => share.shareType === this.SHARE_TYPES.SHARE_TYPE_LINK,
|
||||
)
|
||||
shares.sort((a, b) =>
|
||||
this.isEmbeddingAllowed(a) ? 1 : this.isEmbeddingAllowed(b) ? -1 : 0,
|
||||
)
|
||||
return shares
|
||||
},
|
||||
},
|
||||
|
@ -262,18 +303,23 @@ export default {
|
|||
this.isLoading = true
|
||||
|
||||
try {
|
||||
const response = await axios.post(generateOcsUrl('apps/forms/api/v2.4/share'), {
|
||||
formId: this.form.id,
|
||||
shareType: newShare.shareType,
|
||||
shareWith: newShare.shareWith,
|
||||
})
|
||||
const response = await axios.post(
|
||||
generateOcsUrl('apps/forms/api/v2.4/share'),
|
||||
{
|
||||
formId: this.form.id,
|
||||
shareType: newShare.shareType,
|
||||
shareWith: newShare.shareWith,
|
||||
},
|
||||
)
|
||||
const share = OcsResponse2Data(response)
|
||||
|
||||
// Add new share
|
||||
this.$emit('add-share', share)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error while adding new share', { error, share: newShare })
|
||||
logger.error('Error while adding new share', {
|
||||
error,
|
||||
share: newShare,
|
||||
})
|
||||
showError(t('forms', 'There was an error while adding the share'))
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
|
@ -284,15 +330,17 @@ export default {
|
|||
this.isLoading = true
|
||||
|
||||
try {
|
||||
const response = await axios.post(generateOcsUrl('apps/forms/api/v2.4/share'), {
|
||||
formId: this.form.id,
|
||||
shareType: this.SHARE_TYPES.SHARE_TYPE_LINK,
|
||||
})
|
||||
const response = await axios.post(
|
||||
generateOcsUrl('apps/forms/api/v2.4/share'),
|
||||
{
|
||||
formId: this.form.id,
|
||||
shareType: this.SHARE_TYPES.SHARE_TYPE_LINK,
|
||||
},
|
||||
)
|
||||
const share = OcsResponse2Data(response)
|
||||
|
||||
// Add new share
|
||||
this.$emit('add-share', share)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error adding public link', { error })
|
||||
showError(t('forms', 'There was an error while adding the link'))
|
||||
|
@ -306,7 +354,13 @@ export default {
|
|||
* @param {{ permissions: string[] }} share The public link share to make embeddable
|
||||
*/
|
||||
makeEmbeddable(share) {
|
||||
this.updateShare({ ...share, permissions: [...share.permissions, this.PERMISSION_TYPES.PERMISSION_EMBED] })
|
||||
this.updateShare({
|
||||
...share,
|
||||
permissions: [
|
||||
...share.permissions,
|
||||
this.PERMISSION_TYPES.PERMISSION_EMBED,
|
||||
],
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -318,19 +372,26 @@ export default {
|
|||
this.isLoading = true
|
||||
|
||||
try {
|
||||
const response = await axios.patch(generateOcsUrl('apps/forms/api/v2.4/share/update'), {
|
||||
id: updatedShare.id,
|
||||
keyValuePairs: {
|
||||
permissions: updatedShare.permissions,
|
||||
const response = await axios.patch(
|
||||
generateOcsUrl('apps/forms/api/v2.4/share/update'),
|
||||
{
|
||||
id: updatedShare.id,
|
||||
keyValuePairs: {
|
||||
permissions: updatedShare.permissions,
|
||||
},
|
||||
},
|
||||
)
|
||||
const share = Object.assign(updatedShare, {
|
||||
id: OcsResponse2Data(response),
|
||||
})
|
||||
const share = Object.assign(updatedShare, { id: OcsResponse2Data(response) })
|
||||
|
||||
// Add new share
|
||||
this.$emit('update-share', share)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error while updating share', { error, share: updatedShare })
|
||||
logger.error('Error while updating share', {
|
||||
error,
|
||||
share: updatedShare,
|
||||
})
|
||||
showError(t('forms', 'There was an error while updating the share'))
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
|
@ -346,9 +407,11 @@ export default {
|
|||
this.isLoading = true
|
||||
|
||||
try {
|
||||
await axios.delete(generateOcsUrl('apps/forms/api/v2.4/share/{id}', {
|
||||
id: share.id,
|
||||
}))
|
||||
await axios.delete(
|
||||
generateOcsUrl('apps/forms/api/v2.4/share/{id}', {
|
||||
id: share.id,
|
||||
}),
|
||||
)
|
||||
this.$emit('remove-share', share)
|
||||
} catch (error) {
|
||||
logger.error('Error while removing share', { error, share })
|
||||
|
@ -451,7 +514,7 @@ export default {
|
|||
&__legacy-warning {
|
||||
background-size: 18px;
|
||||
margin-inline-end: 4px;
|
||||
color: var(--color-error)
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -22,23 +22,38 @@
|
|||
|
||||
<template>
|
||||
<div>
|
||||
<NcButton class="transfer-button"
|
||||
<NcButton
|
||||
class="transfer-button"
|
||||
alignment="start"
|
||||
type="tertiary"
|
||||
:wide="true"
|
||||
@click="openModal">
|
||||
<span class="transfer-button__text">{{ t('forms', 'Transfer ownership') }}</span>
|
||||
<span class="transfer-button__text">{{
|
||||
t('forms', 'Transfer ownership')
|
||||
}}</span>
|
||||
</NcButton>
|
||||
|
||||
<NcDialog :open.sync="showModal"
|
||||
<NcDialog
|
||||
:open.sync="showModal"
|
||||
content-classes="modal-content"
|
||||
:name="t('forms', 'Transfer ownership')"
|
||||
:out-transition="true"
|
||||
@close="closeModal">
|
||||
<template #default>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<p v-html="t('forms', 'You\'re going to transfer the ownership of {name} to another account. Please select the account to which you want to transfer ownership.', { name: `<strong>${form.title}</strong>` }, undefined, { escape: false })" />
|
||||
<NcSelect v-model="selected"
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<p
|
||||
v-html="
|
||||
t(
|
||||
'forms',
|
||||
'You\'re going to transfer the ownership of {name} to another account. Please select the account to which you want to transfer ownership.',
|
||||
{ name: `<strong>${form.title}</strong>` },
|
||||
undefined,
|
||||
{ escape: false },
|
||||
)
|
||||
" />
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<NcSelect
|
||||
v-model="selected"
|
||||
class="modal-content__select"
|
||||
:reset-focus-on-options-change="false"
|
||||
:clear-search-on-select="true"
|
||||
|
@ -49,26 +64,42 @@
|
|||
:placeholder="t('forms', 'Search for a user')"
|
||||
:user-select="true"
|
||||
label="displayName"
|
||||
@search="(query)=>asyncSearch(query,true)">
|
||||
@search="(query) => asyncSearch(query, true)">
|
||||
<template #no-options>
|
||||
{{ noResultText }}
|
||||
</template>
|
||||
</NcSelect>
|
||||
|
||||
<br>
|
||||
<br />
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<p v-html="t('forms', 'Type {text} to confirm.', { text: `<strong>${confirmationString}</strong>` }, undefined, { escape: false })" />
|
||||
<NcTextField :label="t('forms', 'Confirmation text')"
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<p
|
||||
v-html="
|
||||
t(
|
||||
'forms',
|
||||
'Type {text} to confirm.',
|
||||
{ text: `<strong>${confirmationString}</strong>` },
|
||||
undefined,
|
||||
{ escape: false },
|
||||
)
|
||||
" />
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<NcTextField
|
||||
:label="t('forms', 'Confirmation text')"
|
||||
:value.sync="confirmationInput"
|
||||
:success="confirmationInput === confirmationString" />
|
||||
|
||||
<br>
|
||||
<br />
|
||||
|
||||
<p><strong>{{ t('forms','This can not be undone.') }}</strong></p>
|
||||
<p>
|
||||
<strong>{{ t('forms', 'This can not be undone.') }}</strong>
|
||||
</p>
|
||||
</template>
|
||||
<template #actions>
|
||||
<NcButton :disabled="!canTransfer" type="error" @click="onOwnershipTransfer">
|
||||
<NcButton
|
||||
:disabled="!canTransfer"
|
||||
type="error"
|
||||
@click="onOwnershipTransfer">
|
||||
{{ t('forms', 'I understand, transfer this form') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
|
@ -114,7 +145,9 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
canTransfer() {
|
||||
return this.confirmationInput === this.confirmationString && !!this.selected
|
||||
return (
|
||||
this.confirmationInput === this.confirmationString && !!this.selected
|
||||
)
|
||||
},
|
||||
confirmationString() {
|
||||
return `${this.form.ownerId}/${this.form.title}`
|
||||
|
@ -143,26 +176,35 @@ export default {
|
|||
if (this.form.id && this.selected.shareWith) {
|
||||
try {
|
||||
emit('forms:last-updated:set', this.form.id)
|
||||
await axios.post(generateOcsUrl('apps/forms/api/v2.4/form/transfer'), {
|
||||
formId: this.form.id,
|
||||
uid: this.selected.shareWith,
|
||||
})
|
||||
showSuccess(`${t('forms', 'This form is now owned by')} ${this.selected.displayName}`)
|
||||
await axios.post(
|
||||
generateOcsUrl('apps/forms/api/v2.4/form/transfer'),
|
||||
{
|
||||
formId: this.form.id,
|
||||
uid: this.selected.shareWith,
|
||||
},
|
||||
)
|
||||
showSuccess(
|
||||
`${t('forms', 'This form is now owned by')} ${this.selected.displayName}`,
|
||||
)
|
||||
emit('forms:ownership-transfered', this.form.id)
|
||||
} catch (error) {
|
||||
logger.error('Error while transfering form ownership', { error })
|
||||
showError(t('forms', 'An error occurred while transfering ownership'))
|
||||
showError(
|
||||
t('forms', 'An error occurred while transfering ownership'),
|
||||
)
|
||||
}
|
||||
|
||||
} else {
|
||||
logger.error('Null parameters while transfering form ownership', { selectedUser: this.selected })
|
||||
showError(t('forms', 'An error occurred while transfering ownership'))
|
||||
logger.error('Null parameters while transfering form ownership', {
|
||||
selectedUser: this.selected,
|
||||
})
|
||||
showError(
|
||||
t('forms', 'An error occurred while transfering ownership'),
|
||||
)
|
||||
}
|
||||
},
|
||||
clearSelected() {
|
||||
this.selected = null
|
||||
},
|
||||
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -20,16 +20,19 @@
|
|||
-
|
||||
-->
|
||||
<template>
|
||||
<div class="top-bar"
|
||||
<div
|
||||
class="top-bar"
|
||||
:class="{
|
||||
'top-bar--has-sidebar': sidebarOpened,
|
||||
}"
|
||||
role="toolbar">
|
||||
<PillMenu v-if="!canOnlySubmit"
|
||||
<PillMenu
|
||||
v-if="!canOnlySubmit"
|
||||
:active="currentView"
|
||||
:options="availableViews"
|
||||
@update:active="onChangeView" />
|
||||
<NcButton v-if="canShare && !sidebarOpened"
|
||||
<NcButton
|
||||
v-if="canShare && !sidebarOpened"
|
||||
:aria-label="isMobile ? t('forms', 'Share form') : null"
|
||||
type="tertiary"
|
||||
@click="onShareForm">
|
||||
|
@ -40,6 +43,18 @@
|
|||
{{ t('forms', 'Share') }}
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcButton
|
||||
v-if="showSidebarToggle"
|
||||
:aria-label="t('forms', 'Toggle settings')"
|
||||
:title="t('forms', 'Toggle settings')"
|
||||
type="tertiary"
|
||||
@click="toggleSidebar">
|
||||
<template #icon>
|
||||
<IconMenuOpen
|
||||
:size="24"
|
||||
:class="{ 'icon--flipped': sidebarOpened }" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -109,7 +124,7 @@ export default {
|
|||
|
||||
computed: {
|
||||
currentView() {
|
||||
return this.availableViews.filter(v => v.id === this.$route.name)[0]
|
||||
return this.availableViews.filter((v) => v.id === this.$route.name)[0]
|
||||
},
|
||||
availableViews() {
|
||||
const views = []
|
||||
|
@ -128,17 +143,25 @@ export default {
|
|||
return this.permissions.includes(this.PERMISSION_TYPES.PERMISSION_SUBMIT)
|
||||
},
|
||||
canEdit() {
|
||||
return this.permissions.includes(this.PERMISSION_TYPES.PERMISSION_EDIT) && !this.archived
|
||||
return (
|
||||
this.permissions.includes(this.PERMISSION_TYPES.PERMISSION_EDIT) &&
|
||||
!this.archived
|
||||
)
|
||||
},
|
||||
canSeeResults() {
|
||||
return this.permissions.includes(this.PERMISSION_TYPES.PERMISSION_RESULTS)
|
||||
return this.permissions.includes(
|
||||
this.PERMISSION_TYPES.PERMISSION_RESULTS,
|
||||
)
|
||||
},
|
||||
canShare() {
|
||||
// This probably can get a permission of itself
|
||||
return this.canEdit
|
||||
},
|
||||
canOnlySubmit() {
|
||||
return this.permissions.length === 1 && this.permissions.includes(this.PERMISSION_TYPES.PERMISSION_SUBMIT)
|
||||
return (
|
||||
this.permissions.length === 1 &&
|
||||
this.permissions.includes(this.PERMISSION_TYPES.PERMISSION_SUBMIT)
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
@ -31,8 +31,8 @@ Vue.prototype.t = translate
|
|||
Vue.prototype.n = translatePlural
|
||||
|
||||
export default new Vue({
|
||||
el: '#content',
|
||||
// eslint-disable-next-line vue/match-component-file-name
|
||||
name: 'FormsEmptyContent',
|
||||
render: h => h(FormsEmptyContent),
|
||||
el: '#content',
|
||||
// eslint-disable-next-line vue/match-component-file-name
|
||||
name: 'FormsEmptyContent',
|
||||
render: (h) => h(FormsEmptyContent),
|
||||
})
|
||||
|
|
|
@ -43,5 +43,5 @@ export default new Vue({
|
|||
// eslint-disable-next-line vue/match-component-file-name
|
||||
name: 'FormsRoot',
|
||||
router,
|
||||
render: h => h(Forms),
|
||||
render: (h) => h(Forms),
|
||||
})
|
||||
|
|
|
@ -33,7 +33,12 @@ export default {
|
|||
PERMISSION_SUBMIT: 'submit',
|
||||
/** Internal permission to mark public link shares as embeddable */
|
||||
PERMISSION_EMBED: 'embed',
|
||||
PERMISSION_ALL: [this.PERMISSION_EDIT, this.PERMISSION_RESULTS, this.PERMISSION_RESULTS_DELETE, this.PERMISSION_SUBMIT],
|
||||
PERMISSION_ALL: [
|
||||
this.PERMISSION_EDIT,
|
||||
this.PERMISSION_RESULTS,
|
||||
this.PERMISSION_RESULTS_DELETE,
|
||||
this.PERMISSION_SUBMIT,
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
|
|
|
@ -31,7 +31,6 @@ import Question from '../components/Questions/Question.vue'
|
|||
export default {
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
|
||||
/**
|
||||
* Question-Id
|
||||
*/
|
||||
|
@ -236,7 +235,7 @@ export default {
|
|||
*
|
||||
* @param {string} text the title
|
||||
*/
|
||||
onTitleChange: debounce(function(text) {
|
||||
onTitleChange: debounce(function (text) {
|
||||
this.$emit('update:text', text)
|
||||
this.saveQuestionProperty('text', text)
|
||||
}, 200),
|
||||
|
@ -245,7 +244,7 @@ export default {
|
|||
*
|
||||
* @param {string} description the description
|
||||
*/
|
||||
onDescriptionChange: debounce(function(description) {
|
||||
onDescriptionChange: debounce(function (description) {
|
||||
this.$emit('update:description', description)
|
||||
this.saveQuestionProperty('description', description)
|
||||
}, 200),
|
||||
|
@ -255,7 +254,7 @@ export default {
|
|||
*
|
||||
* @param {boolean} isRequiredValue new isRequired Value
|
||||
*/
|
||||
onRequiredChange: debounce(function(isRequiredValue) {
|
||||
onRequiredChange: debounce(function (isRequiredValue) {
|
||||
this.$emit('update:isRequired', isRequiredValue)
|
||||
this.saveQuestionProperty('isRequired', isRequiredValue)
|
||||
}, 200),
|
||||
|
@ -267,7 +266,7 @@ export default {
|
|||
*
|
||||
* @param {object} newSettings changed settings
|
||||
*/
|
||||
onExtraSettingsChange: debounce(function(newSettings) {
|
||||
onExtraSettingsChange: debounce(function (newSettings) {
|
||||
const newExtraSettings = { ...this.extraSettings, ...newSettings }
|
||||
this.$emit('update:extraSettings', newExtraSettings)
|
||||
this.saveQuestionProperty('extraSettings', newExtraSettings)
|
||||
|
@ -278,7 +277,7 @@ export default {
|
|||
*
|
||||
* @param {string} name The new technical name of the input
|
||||
*/
|
||||
onNameChange: debounce(function(name) {
|
||||
onNameChange: debounce(function (name) {
|
||||
this.$emit('update:name', name)
|
||||
this.saveQuestionProperty('name', name)
|
||||
}, 200),
|
||||
|
@ -331,7 +330,9 @@ export default {
|
|||
focus() {
|
||||
this.$el.scrollIntoView({ behavior: 'smooth' })
|
||||
this.$nextTick(() => {
|
||||
const title = this.$el.querySelector('.question__header__title__text__input')
|
||||
const title = this.$el.querySelector(
|
||||
'.question__header__title__text__input',
|
||||
)
|
||||
if (title) {
|
||||
title.focus()
|
||||
}
|
||||
|
@ -348,8 +349,11 @@ export default {
|
|||
const shuffled = [...input]
|
||||
let idx = shuffled.length
|
||||
while (--idx > 0) {
|
||||
const rndIdx = Math.floor(Math.random() * (idx + 1));
|
||||
[shuffled[rndIdx], shuffled[idx]] = [shuffled[idx], shuffled[rndIdx]]
|
||||
const rndIdx = Math.floor(Math.random() * (idx + 1))
|
||||
;[shuffled[rndIdx], shuffled[idx]] = [
|
||||
shuffled[idx],
|
||||
shuffled[rndIdx],
|
||||
]
|
||||
}
|
||||
return shuffled
|
||||
},
|
||||
|
@ -357,12 +361,15 @@ export default {
|
|||
async saveQuestionProperty(key, value) {
|
||||
try {
|
||||
// TODO: add loading status feedback ?
|
||||
await axios.patch(generateOcsUrl('apps/forms/api/v2.4/question/update'), {
|
||||
id: this.id,
|
||||
keyValuePairs: {
|
||||
[key]: value,
|
||||
await axios.patch(
|
||||
generateOcsUrl('apps/forms/api/v2.4/question/update'),
|
||||
{
|
||||
id: this.id,
|
||||
keyValuePairs: {
|
||||
[key]: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
emit('forms:last-updated:set', this.formId)
|
||||
} catch (error) {
|
||||
logger.error('Error while saving question', { error })
|
||||
|
|
|
@ -32,7 +32,12 @@ export default {
|
|||
* @return {string} link
|
||||
*/
|
||||
getInternalShareLink(formHash) {
|
||||
return window.location.protocol + '//' + window.location.host + generateUrl(`/apps/forms/${this.form.hash}`)
|
||||
return (
|
||||
window.location.protocol +
|
||||
'//' +
|
||||
window.location.host +
|
||||
generateUrl(`/apps/forms/${this.form.hash}`)
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -48,7 +53,7 @@ export default {
|
|||
} else {
|
||||
url = generateUrl(`/apps/forms/s/${share.shareWith}`)
|
||||
}
|
||||
return (new URL(url, window.location)).href
|
||||
return new URL(url, window.location).href
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -56,7 +61,10 @@ export default {
|
|||
* @param {{ shareType: number, permissions: string[] }} share The share to check
|
||||
*/
|
||||
isEmbeddingAllowed(share) {
|
||||
return share.shareType === this.SHARE_TYPES.SHARE_TYPE_LINK && share.permissions?.includes(this.PERMISSION_TYPES.PERMISSION_EMBED)
|
||||
return (
|
||||
share.shareType === this.SHARE_TYPES.SHARE_TYPE_LINK &&
|
||||
share.permissions?.includes(this.PERMISSION_TYPES.PERMISSION_EMBED)
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -83,7 +91,7 @@ export default {
|
|||
*
|
||||
* @param {object} share Public link-share
|
||||
*/
|
||||
async copyEmbeddingCode(share) {
|
||||
async copyEmbeddingCode(share) {
|
||||
const code = `<iframe src="${this.getPublicShareLink(share)}" width="750" height="900"></iframe>`
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
|
|
|
@ -64,22 +64,22 @@ export default {
|
|||
*/
|
||||
shareTypeToIcon(type) {
|
||||
switch (type) {
|
||||
case this.SHARE_TYPES.SHARE_TYPE_GUEST:
|
||||
// case this.SHARE_TYPES.SHARE_TYPE_REMOTE:
|
||||
// case this.SHARE_TYPES.SHARE_TYPE_USER:
|
||||
return IconUserSvg
|
||||
case this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP:
|
||||
case this.SHARE_TYPES.SHARE_TYPE_GROUP:
|
||||
return IconGroupSvg
|
||||
case this.SHARE_TYPES.SHARE_TYPE_EMAIL:
|
||||
return IconMailSvg
|
||||
case this.SHARE_TYPES.SHARE_TYPE_CIRCLE:
|
||||
return IconCircleSvg
|
||||
case this.SHARE_TYPES.SHARE_TYPE_ROOM:
|
||||
return IconChatSvg
|
||||
case this.SHARE_TYPES.SHARE_TYPE_GUEST:
|
||||
// case this.SHARE_TYPES.SHARE_TYPE_REMOTE:
|
||||
// case this.SHARE_TYPES.SHARE_TYPE_USER:
|
||||
return IconUserSvg
|
||||
case this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP:
|
||||
case this.SHARE_TYPES.SHARE_TYPE_GROUP:
|
||||
return IconGroupSvg
|
||||
case this.SHARE_TYPES.SHARE_TYPE_EMAIL:
|
||||
return IconMailSvg
|
||||
case this.SHARE_TYPES.SHARE_TYPE_CIRCLE:
|
||||
return IconCircleSvg
|
||||
case this.SHARE_TYPES.SHARE_TYPE_ROOM:
|
||||
return IconChatSvg
|
||||
|
||||
default:
|
||||
return ''
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
@ -37,8 +37,10 @@ export default {
|
|||
|
||||
query: '',
|
||||
// TODO: have a global mixin for this, shared with server?
|
||||
maxAutocompleteResults: parseInt(OC.config['sharing.maxAutocompleteResults'], 10) || 200,
|
||||
minSearchStringLength: parseInt(OC.config['sharing.minSearchStringLength'], 10) || 0,
|
||||
maxAutocompleteResults:
|
||||
parseInt(OC.config['sharing.maxAutocompleteResults'], 10) || 200,
|
||||
minSearchStringLength:
|
||||
parseInt(OC.config['sharing.minSearchStringLength'], 10) || 0,
|
||||
// Search Results
|
||||
recommendations: [],
|
||||
suggestions: [],
|
||||
|
@ -51,7 +53,11 @@ export default {
|
|||
* @return {boolean}
|
||||
*/
|
||||
isValidQuery() {
|
||||
return this.query && this.query.trim() !== '' && this.query.length > this.minSearchStringLength
|
||||
return (
|
||||
this.query &&
|
||||
this.query.trim() !== '' &&
|
||||
this.query.length > this.minSearchStringLength
|
||||
)
|
||||
},
|
||||
/**
|
||||
* Text when there is no Results to be shown
|
||||
|
@ -66,16 +72,16 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Search for suggestions
|
||||
*
|
||||
* @param {string} query The search query to search for
|
||||
*/
|
||||
/**
|
||||
* Search for suggestions
|
||||
*
|
||||
* @param {string} query The search query to search for
|
||||
*/
|
||||
async asyncSearch(query) {
|
||||
// save query to check if valid
|
||||
// save query to check if valid
|
||||
this.query = query.trim()
|
||||
if (this.isValidQuery) {
|
||||
// already set loading to have proper ux feedback during debounce
|
||||
// already set loading to have proper ux feedback during debounce
|
||||
this.loading = true
|
||||
await this.debounceGetSuggestions(query)
|
||||
}
|
||||
|
@ -86,7 +92,7 @@ export default {
|
|||
*
|
||||
* @param {...*} args arguments to pass
|
||||
*/
|
||||
debounceGetSuggestions: debounce(function(...args) {
|
||||
debounceGetSuggestions: debounce(function (...args) {
|
||||
this.getSuggestions(...args)
|
||||
}, 300),
|
||||
|
||||
|
@ -99,18 +105,23 @@ export default {
|
|||
this.loading = true
|
||||
|
||||
// Search for all used share-types, except public link.
|
||||
const shareType = this.SHARE_TYPES_USED.filter(type => type !== this.SHARE_TYPES.SHARE_TYPE_LINK)
|
||||
const shareType = this.SHARE_TYPES_USED.filter(
|
||||
(type) => type !== this.SHARE_TYPES.SHARE_TYPE_LINK,
|
||||
)
|
||||
|
||||
try {
|
||||
const request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/sharees'), {
|
||||
params: {
|
||||
format: 'json',
|
||||
itemType: 'file',
|
||||
perPage: this.maxAutocompleteResults,
|
||||
search: query,
|
||||
shareType,
|
||||
const request = await axios.get(
|
||||
generateOcsUrl('apps/files_sharing/api/v1/sharees'),
|
||||
{
|
||||
params: {
|
||||
format: 'json',
|
||||
itemType: 'file',
|
||||
perPage: this.maxAutocompleteResults,
|
||||
search: query,
|
||||
shareType,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const data = OcsResponse2Data(request)
|
||||
const exact = data.exact
|
||||
|
@ -134,14 +145,19 @@ export default {
|
|||
this.loading = true
|
||||
|
||||
try {
|
||||
const request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/sharees_recommended'), {
|
||||
params: {
|
||||
format: 'json',
|
||||
itemType: 'file',
|
||||
const request = await axios.get(
|
||||
generateOcsUrl('apps/files_sharing/api/v1/sharees_recommended'),
|
||||
{
|
||||
params: {
|
||||
format: 'json',
|
||||
itemType: 'file',
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
this.recommendations = this.formatSearchResults(OcsResponse2Data(request).exact)
|
||||
this.recommendations = this.formatSearchResults(
|
||||
OcsResponse2Data(request).exact,
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Fetching recommendations failed.', { error })
|
||||
} finally {
|
||||
|
@ -163,10 +179,12 @@ export default {
|
|||
// flatten array of arrays
|
||||
const flatResults = Object.values(results).flat()
|
||||
|
||||
return this.filterUnwantedShares(flatResults)
|
||||
.map(share => this.formatForMultiselect(share))
|
||||
// sort by type so we can get user&groups first...
|
||||
.sort((a, b) => a.shareType - b.shareType)
|
||||
return (
|
||||
this.filterUnwantedShares(flatResults)
|
||||
.map((share) => this.formatForMultiselect(share))
|
||||
// sort by type so we can get user&groups first...
|
||||
.sort((a, b) => a.shareType - b.shareType)
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -185,8 +203,10 @@ export default {
|
|||
|
||||
try {
|
||||
// filter out current user
|
||||
if (share.value.shareType === this.SHARE_TYPES.SHARE_TYPE_USER
|
||||
&& share.value.shareWith === getCurrentUser().uid) {
|
||||
if (
|
||||
share.value.shareType === this.SHARE_TYPES.SHARE_TYPE_USER &&
|
||||
share.value.shareWith === getCurrentUser().uid
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
|
@ -83,7 +83,10 @@ export default {
|
|||
},
|
||||
|
||||
formDescription() {
|
||||
return this.markdownit.render(this.form.description) || this.form.description
|
||||
return (
|
||||
this.markdownit.render(this.form.description) ||
|
||||
this.form.description
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -120,19 +123,25 @@ export default {
|
|||
logger.debug(`Loading form ${id}`)
|
||||
|
||||
// Create new cancelable get request
|
||||
const { request, cancel } = CancelableRequest(async function(url, requestOptions) {
|
||||
return axios.get(url, requestOptions)
|
||||
})
|
||||
const { request, cancel } = CancelableRequest(
|
||||
async function (url, requestOptions) {
|
||||
return axios.get(url, requestOptions)
|
||||
},
|
||||
)
|
||||
// Store cancel-function
|
||||
this.cancelFetchFullForm = cancel
|
||||
|
||||
try {
|
||||
const response = await request(generateOcsUrl('apps/forms/api/v2.4/form/{id}', { id }))
|
||||
const response = await request(
|
||||
generateOcsUrl('apps/forms/api/v2.4/form/{id}', { id }),
|
||||
)
|
||||
this.$emit('update:form', OcsResponse2Data(response))
|
||||
this.isLoadingForm = false
|
||||
} catch (error) {
|
||||
if (axios.isCancel(error)) {
|
||||
logger.debug(`The request for form ${id} has been canceled`, { error })
|
||||
logger.debug(`The request for form ${id} has been canceled`, {
|
||||
error,
|
||||
})
|
||||
} else {
|
||||
logger.error(`Unexpected error fetching form ${id}`, { error })
|
||||
this.isLoadingForm = false
|
||||
|
@ -147,12 +156,15 @@ export default {
|
|||
async saveFormProperty(key) {
|
||||
try {
|
||||
// TODO: add loading status feedback ?
|
||||
await axios.patch(generateOcsUrl('apps/forms/api/v2.4/form/update'), {
|
||||
id: this.form.id,
|
||||
keyValuePairs: {
|
||||
[key]: this.form[key],
|
||||
await axios.patch(
|
||||
generateOcsUrl('apps/forms/api/v2.4/form/update'),
|
||||
{
|
||||
id: this.form.id,
|
||||
keyValuePairs: {
|
||||
[key]: this.form[key],
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
emit('forms:last-updated:set', this.form.id)
|
||||
} catch (error) {
|
||||
logger.error('Error saving form property', { error })
|
||||
|
|
|
@ -69,12 +69,15 @@ export default {
|
|||
icon: IconCheckboxOutline,
|
||||
label: t('forms', 'Checkboxes'),
|
||||
predefined: true,
|
||||
validate: question => question.options.length > 0,
|
||||
validate: (question) => question.options.length > 0,
|
||||
|
||||
titlePlaceholder: t('forms', 'Checkbox question title'),
|
||||
createPlaceholder: t('forms', 'People can submit a different answer'),
|
||||
submitPlaceholder: t('forms', 'Enter your answer'),
|
||||
warningInvalid: t('forms', 'This question needs a title and at least one answer!'),
|
||||
warningInvalid: t(
|
||||
'forms',
|
||||
'This question needs a title and at least one answer!',
|
||||
),
|
||||
},
|
||||
|
||||
multiple_unique: {
|
||||
|
@ -82,12 +85,15 @@ export default {
|
|||
icon: IconRadioboxMarked,
|
||||
label: t('forms', 'Radio buttons'),
|
||||
predefined: true,
|
||||
validate: question => question.options.length > 0,
|
||||
validate: (question) => question.options.length > 0,
|
||||
|
||||
titlePlaceholder: t('forms', 'Radio buttons question title'),
|
||||
createPlaceholder: t('forms', 'People can submit a different answer'),
|
||||
submitPlaceholder: t('forms', 'Enter your answer'),
|
||||
warningInvalid: t('forms', 'This question needs a title and at least one answer!'),
|
||||
warningInvalid: t(
|
||||
'forms',
|
||||
'This question needs a title and at least one answer!',
|
||||
),
|
||||
|
||||
// Using the same vue-component as multiple, this specifies that the component renders as multiple_unique.
|
||||
unique: true,
|
||||
|
@ -98,12 +104,15 @@ export default {
|
|||
icon: IconArrowDownDropCircleOutline,
|
||||
label: t('forms', 'Dropdown'),
|
||||
predefined: true,
|
||||
validate: question => question.options.length > 0,
|
||||
validate: (question) => question.options.length > 0,
|
||||
|
||||
titlePlaceholder: t('forms', 'Dropdown question title'),
|
||||
createPlaceholder: t('forms', 'People can pick one option'),
|
||||
submitPlaceholder: t('forms', 'Pick an option'),
|
||||
warningInvalid: t('forms', 'This question needs a title and at least one answer!'),
|
||||
warningInvalid: t(
|
||||
'forms',
|
||||
'This question needs a title and at least one answer!',
|
||||
),
|
||||
},
|
||||
|
||||
file: {
|
||||
|
|
|
@ -70,7 +70,8 @@ export default {
|
|||
inputType: 'tel',
|
||||
label: t('forms', 'Phone number'),
|
||||
// Remove common separator symbols, like space or braces, and validate rest are pure numbers
|
||||
validate: (input) => /^\+?[0-9]{3,}$/.test(input.replace(/[\s()-/x.]/ig, '')),
|
||||
validate: (input) =>
|
||||
/^\+?[0-9]{3,}$/.test(input.replace(/[\s()-/x.]/gi, '')),
|
||||
errorMessage: t('forms', 'The input is not a valid phone number'),
|
||||
createPlaceholder: t('forms', 'People can enter a telephone number'),
|
||||
submitPlaceholder: t('forms', 'Enter a telephone number'),
|
||||
|
@ -116,7 +117,8 @@ export default {
|
|||
icon: IconRegex,
|
||||
inputType: 'text',
|
||||
label: t('forms', 'Custom regular expression'),
|
||||
validate: (input, { pattern, modifiers }) => (new RegExp(pattern, modifiers)).test(input),
|
||||
validate: (input, { pattern, modifiers }) =>
|
||||
new RegExp(pattern, modifiers).test(input),
|
||||
errorMessage: t('forms', 'The input does not match the required pattern'),
|
||||
},
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
overflow-wrap: break-word;
|
||||
|
||||
:deep() {
|
||||
>:not(:first-child) {
|
||||
> :not(:first-child) {
|
||||
margin-block-start: 1.5em;
|
||||
}
|
||||
|
||||
|
@ -59,11 +59,12 @@
|
|||
p code {
|
||||
background-color: var(--color-background-dark);
|
||||
border-radius: var(--border-radius);
|
||||
padding-block: .1em;
|
||||
padding-inline: .3em;
|
||||
padding-block: 0.1em;
|
||||
padding-inline: 0.3em;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
ul,
|
||||
ol {
|
||||
padding-inline-start: 10px;
|
||||
margin-inline-start: 10px;
|
||||
}
|
||||
|
|
|
@ -32,8 +32,8 @@ Vue.prototype.t = translate
|
|||
Vue.prototype.n = translatePlural
|
||||
|
||||
export default new Vue({
|
||||
el: '#forms-settings',
|
||||
// eslint-disable-next-line vue/match-component-file-name
|
||||
name: 'FormsSettings',
|
||||
render: h => h(FormsSettings),
|
||||
el: '#forms-settings',
|
||||
// eslint-disable-next-line vue/match-component-file-name
|
||||
name: 'FormsSettings',
|
||||
render: (h) => h(FormsSettings),
|
||||
})
|
||||
|
|
|
@ -35,5 +35,5 @@ export default new Vue({
|
|||
el: '#content',
|
||||
// eslint-disable-next-line vue/match-component-file-name
|
||||
name: 'FormsSubmitRoot',
|
||||
render: h => h(FormsSubmitRoot),
|
||||
render: (h) => h(FormsSubmitRoot),
|
||||
})
|
||||
|
|
|
@ -29,7 +29,7 @@ import axios from '@nextcloud/axios'
|
|||
* @param {Function} request the axios promise request
|
||||
* @return {object}
|
||||
*/
|
||||
const CancelableRequest = function(request) {
|
||||
const CancelableRequest = function (request) {
|
||||
/**
|
||||
* Generate an axios cancel token
|
||||
*/
|
||||
|
@ -42,7 +42,7 @@ const CancelableRequest = function(request) {
|
|||
* @param {string} url the url to send the request to
|
||||
* @param {object} [options] optional config for the request
|
||||
*/
|
||||
const fetch = async function(url, options) {
|
||||
const fetch = async function (url, options) {
|
||||
return request(
|
||||
url,
|
||||
Object.assign({ cancelToken: source.token }, { options }),
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
import { getLoggerBuilder } from '@nextcloud/logger'
|
||||
|
||||
const logger = getLoggerBuilder()
|
||||
.setApp('forms')
|
||||
.detectUser()
|
||||
.build()
|
||||
const logger = getLoggerBuilder().setApp('forms').detectUser().build()
|
||||
|
||||
export default logger
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
* @param {object} response response returned by axios
|
||||
* @return {object} The actual data out of the ocs response
|
||||
*/
|
||||
const OcsResponse2Data = function(response) {
|
||||
const OcsResponse2Data = function (response) {
|
||||
return response.data.ocs.data
|
||||
}
|
||||
|
||||
|
|
|
@ -54,7 +54,7 @@ export function validateExpression(input) {
|
|||
|
||||
// Check if regular expression can be compiled
|
||||
try {
|
||||
(() => new RegExp(pattern, modifiers))()
|
||||
;(() => new RegExp(pattern, modifiers))()
|
||||
return true
|
||||
} catch (e) {
|
||||
return false
|
||||
|
|
|
@ -25,11 +25,12 @@
|
|||
*
|
||||
* @param {string} formTitle Title of current form to set on window.
|
||||
*/
|
||||
const SetWindowTitle = function(formTitle) {
|
||||
const SetWindowTitle = function (formTitle) {
|
||||
if (formTitle === '') {
|
||||
window.document.title = t('forms', 'Forms') + ' - ' + OC.theme.title
|
||||
} else {
|
||||
window.document.title = formTitle + ' - ' + t('forms', 'Forms') + ' - ' + OC.theme.title
|
||||
window.document.title =
|
||||
formTitle + ' - ' + t('forms', 'Forms') + ' - ' + OC.theme.title
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -24,14 +24,19 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<NcAppContent :page-heading="form.title ? t('forms', 'Edit form') : t('forms', 'Create form')">
|
||||
<NcAppContent
|
||||
:page-heading="
|
||||
form.title ? t('forms', 'Edit form') : t('forms', 'Create form')
|
||||
">
|
||||
<!-- Show results & sidebar button -->
|
||||
<TopBar :archived="isFormArchived"
|
||||
<TopBar
|
||||
:archived="isFormArchived"
|
||||
:permissions="form?.permissions"
|
||||
:sidebar-opened="sidebarOpened"
|
||||
@share-form="onShareForm" />
|
||||
|
||||
<NcEmptyContent v-if="isLoadingForm"
|
||||
<NcEmptyContent
|
||||
v-if="isLoadingForm"
|
||||
class="emtpycontent"
|
||||
:name="t('forms', 'Loading {title} …', { title: form.title })">
|
||||
<template #icon>
|
||||
|
@ -39,10 +44,15 @@
|
|||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<NcEmptyContent v-else-if="isFormArchived"
|
||||
<NcEmptyContent
|
||||
v-else-if="isFormArchived"
|
||||
class="emtpycontent"
|
||||
:name="t('forms', 'Form is archived')"
|
||||
:description="t('forms', 'Form \'{title}\' is archived and cannot be modified.', { title: form.title })">
|
||||
:description="
|
||||
t('forms', 'Form \'{title}\' is archived and cannot be modified.', {
|
||||
title: form.title,
|
||||
})
|
||||
">
|
||||
<template #icon>
|
||||
<IconLock :size="64" />
|
||||
</template>
|
||||
|
@ -52,8 +62,11 @@
|
|||
<!-- Forms title & description-->
|
||||
<header>
|
||||
<h2>
|
||||
<label class="hidden-visually" for="form-title">{{ t('forms', 'Form title') }}</label>
|
||||
<textarea id="form-title"
|
||||
<label class="hidden-visually" for="form-title">{{
|
||||
t('forms', 'Form title')
|
||||
}}</label>
|
||||
<textarea
|
||||
id="form-title"
|
||||
ref="title"
|
||||
v-model="form.title"
|
||||
class="form-title"
|
||||
|
@ -68,13 +81,19 @@
|
|||
<label class="hidden-visually" for="form-desc">
|
||||
{{ t('forms', 'Description') }}
|
||||
</label>
|
||||
<textarea id="form-desc"
|
||||
<textarea
|
||||
id="form-desc"
|
||||
ref="description"
|
||||
class="form-desc"
|
||||
rows="1"
|
||||
dir="auto"
|
||||
:value="form.description"
|
||||
:placeholder="t('forms', 'Description (formatting using Markdown is supported)')"
|
||||
:placeholder="
|
||||
t(
|
||||
'forms',
|
||||
'Description (formatting using Markdown is supported)',
|
||||
)
|
||||
"
|
||||
:maxlength="maxStringLengths.formDescription"
|
||||
@input="updateDescription" />
|
||||
<!-- Show expiration message-->
|
||||
|
@ -89,19 +108,26 @@
|
|||
|
||||
<section>
|
||||
<!-- Questions list -->
|
||||
<Draggable v-model="form.questions"
|
||||
<Draggable
|
||||
v-model="form.questions"
|
||||
:animation="200"
|
||||
tag="ul"
|
||||
handle=".question__drag-handle"
|
||||
@change="onQuestionOrderChange"
|
||||
@start="isDragging = true"
|
||||
@end="isDragging = false">
|
||||
<transition-group :name="isDragging ? 'no-external-transition-on-drag' : 'question-list'">
|
||||
<component :is="answerTypes[question.type].component"
|
||||
<transition-group
|
||||
:name="
|
||||
isDragging
|
||||
? 'no-external-transition-on-drag'
|
||||
: 'question-list'
|
||||
">
|
||||
<component
|
||||
:is="answerTypes[question.type].component"
|
||||
v-for="(question, index) in form.questions"
|
||||
ref="questions"
|
||||
:key="question.id"
|
||||
:can-move-down="index < (form.questions.length - 1)"
|
||||
:can-move-down="index < form.questions.length - 1"
|
||||
:can-move-up="index > 0"
|
||||
:answer-type="answerTypes[question.type]"
|
||||
:index="index + 1"
|
||||
|
@ -116,7 +142,8 @@
|
|||
|
||||
<!-- Add new questions menu -->
|
||||
<div class="question-menu">
|
||||
<NcActions ref="questionMenu"
|
||||
<NcActions
|
||||
ref="questionMenu"
|
||||
:open.sync="questionMenuOpened"
|
||||
:menu-name="t('forms', 'Add a question')"
|
||||
:aria-label="t('forms', 'Add a question')"
|
||||
|
@ -125,7 +152,8 @@
|
|||
<NcLoadingIcon v-if="isLoadingQuestions" :size="20" />
|
||||
<IconPlus v-else :size="20" />
|
||||
</template>
|
||||
<NcActionButton v-for="(answer, type) in answerTypesFilter"
|
||||
<NcActionButton
|
||||
v-for="(answer, type) in answerTypesFilter"
|
||||
:key="answer.label"
|
||||
:close-after-click="true"
|
||||
:disabled="isLoadingQuestions"
|
||||
|
@ -214,7 +242,10 @@ export default {
|
|||
},
|
||||
|
||||
isRequiredUsed() {
|
||||
return this.form.questions.reduce((isUsed, question) => isUsed || question.isRequired, false)
|
||||
return this.form.questions.reduce(
|
||||
(isUsed, question) => isUsed || question.isRequired,
|
||||
false,
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -243,7 +274,9 @@ export default {
|
|||
}
|
||||
|
||||
if (this.isRequiredUsed) {
|
||||
message += ' ' + t('forms', 'An asterisk (*) indicates mandatory questions.')
|
||||
message +=
|
||||
' ' +
|
||||
t('forms', 'An asterisk (*) indicates mandatory questions.')
|
||||
}
|
||||
|
||||
return message
|
||||
|
@ -293,13 +326,16 @@ export default {
|
|||
methods: {
|
||||
onMoveUp(index) {
|
||||
if (index > 0) {
|
||||
[this.form.questions[index - 1], this.form.questions[index]] = [this.form.questions[index], this.form.questions[index - 1]]
|
||||
;[this.form.questions[index - 1], this.form.questions[index]] = [
|
||||
this.form.questions[index],
|
||||
this.form.questions[index - 1],
|
||||
]
|
||||
this.onQuestionOrderChange()
|
||||
}
|
||||
},
|
||||
onMoveDown(index) {
|
||||
// only if not the last one
|
||||
if (index < (this.form.questions.length - 1)) {
|
||||
if (index < this.form.questions.length - 1) {
|
||||
this.onMoveUp(index + 1)
|
||||
}
|
||||
},
|
||||
|
@ -347,10 +383,10 @@ export default {
|
|||
/**
|
||||
* Title & description save methods
|
||||
*/
|
||||
saveTitle: debounce(async function() {
|
||||
saveTitle: debounce(async function () {
|
||||
this.saveFormProperty('title')
|
||||
}, 200),
|
||||
saveDescription: debounce(async function() {
|
||||
saveDescription: debounce(async function () {
|
||||
this.saveFormProperty('description')
|
||||
}, 200),
|
||||
|
||||
|
@ -364,31 +400,41 @@ export default {
|
|||
this.isLoadingQuestions = true
|
||||
|
||||
try {
|
||||
const response = await axios.post(generateOcsUrl('apps/forms/api/v2.4/question'), {
|
||||
formId: this.form.id,
|
||||
type,
|
||||
text,
|
||||
})
|
||||
const response = await axios.post(
|
||||
generateOcsUrl('apps/forms/api/v2.4/question'),
|
||||
{
|
||||
formId: this.form.id,
|
||||
type,
|
||||
text,
|
||||
},
|
||||
)
|
||||
const question = OcsResponse2Data(response)
|
||||
|
||||
// Add newly created question
|
||||
this.form.questions.push(Object.assign({
|
||||
text,
|
||||
type,
|
||||
answers: [],
|
||||
}, question))
|
||||
this.form.questions.push(
|
||||
Object.assign(
|
||||
{
|
||||
text,
|
||||
type,
|
||||
answers: [],
|
||||
},
|
||||
question,
|
||||
),
|
||||
)
|
||||
|
||||
// Focus newly added question
|
||||
this.$nextTick(() => {
|
||||
const lastQuestion = this.$refs.questions[this.$refs.questions.length - 1]
|
||||
const lastQuestion =
|
||||
this.$refs.questions[this.$refs.questions.length - 1]
|
||||
lastQuestion.focus()
|
||||
})
|
||||
|
||||
emit('forms:last-updated:set', this.form.id)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error while adding new question', { error })
|
||||
showError(t('forms', 'There was an error while adding the new question'))
|
||||
showError(
|
||||
t('forms', 'There was an error while adding the new question'),
|
||||
)
|
||||
} finally {
|
||||
this.isLoadingQuestions = false
|
||||
}
|
||||
|
@ -404,13 +450,19 @@ export default {
|
|||
this.isLoadingQuestions = true
|
||||
|
||||
try {
|
||||
await axios.delete(generateOcsUrl('apps/forms/api/v2.4/question/{id}', { id }))
|
||||
const index = this.form.questions.findIndex(search => search.id === id)
|
||||
await axios.delete(
|
||||
generateOcsUrl('apps/forms/api/v2.4/question/{id}', { id }),
|
||||
)
|
||||
const index = this.form.questions.findIndex(
|
||||
(search) => search.id === id,
|
||||
)
|
||||
this.form.questions.splice(index, 1)
|
||||
emit('forms:last-updated:set', this.form.id)
|
||||
} catch (error) {
|
||||
logger.error(`Error while removing question ${id}`, { error })
|
||||
showError(t('forms', 'There was an error while removing the question'))
|
||||
showError(
|
||||
t('forms', 'There was an error while removing the question'),
|
||||
)
|
||||
} finally {
|
||||
this.isLoadingQuestions = false
|
||||
}
|
||||
|
@ -425,18 +477,27 @@ export default {
|
|||
this.isLoadingQuestions = true
|
||||
|
||||
try {
|
||||
const response = await axios.post(generateOcsUrl('apps/forms/api/v2.4/question/clone/{id}', { id }))
|
||||
const response = await axios.post(
|
||||
generateOcsUrl('apps/forms/api/v2.4/question/clone/{id}', {
|
||||
id,
|
||||
}),
|
||||
)
|
||||
const question = OcsResponse2Data(response)
|
||||
|
||||
this.form.questions.push(Object.assign({
|
||||
answers: [],
|
||||
}, question))
|
||||
this.form.questions.push(
|
||||
Object.assign(
|
||||
{
|
||||
answers: [],
|
||||
},
|
||||
question,
|
||||
),
|
||||
)
|
||||
|
||||
this.$nextTick(() => {
|
||||
const lastQuestion = this.$refs.questions[this.$refs.questions.length - 1]
|
||||
const lastQuestion =
|
||||
this.$refs.questions[this.$refs.questions.length - 1]
|
||||
lastQuestion.focus()
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Error while duplicating question ${id}`, { error })
|
||||
showError('There was an error while duplicating the question')
|
||||
|
@ -450,13 +511,16 @@ export default {
|
|||
*/
|
||||
async onQuestionOrderChange() {
|
||||
this.isLoadingQuestions = true
|
||||
const newOrder = this.form.questions.map(question => question.id)
|
||||
const newOrder = this.form.questions.map((question) => question.id)
|
||||
|
||||
try {
|
||||
await axios.put(generateOcsUrl('apps/forms/api/v2.4/question/reorder'), {
|
||||
formId: this.form.id,
|
||||
newOrder,
|
||||
})
|
||||
await axios.put(
|
||||
generateOcsUrl('apps/forms/api/v2.4/question/reorder'),
|
||||
{
|
||||
formId: this.form.id,
|
||||
newOrder,
|
||||
},
|
||||
)
|
||||
emit('forms:last-updated:set', this.form.id)
|
||||
} catch (error) {
|
||||
logger.error('Error while saving form', { error })
|
||||
|
@ -514,7 +578,9 @@ export default {
|
|||
padding-inline: 10px;
|
||||
margin-block: 22px 14px;
|
||||
margin-inline: 0;
|
||||
width: calc(100% - 56px); // margin of header, needed if screen is < 806px (max-width + margin-left)
|
||||
width: calc(
|
||||
100% - 56px
|
||||
); // margin of header, needed if screen is < 806px (max-width + margin-left)
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
resize: none;
|
||||
|
|
|
@ -24,20 +24,28 @@
|
|||
|
||||
<template>
|
||||
<NcAppContent :page-heading="t('forms', 'Results')">
|
||||
<NcDialog :open.sync="showLinkedFileNotAvailableDialog"
|
||||
<NcDialog
|
||||
:open.sync="showLinkedFileNotAvailableDialog"
|
||||
:name="t('forms', 'Linked file not available')"
|
||||
:message="t('forms', 'Linked file is not available, would you like to link a new file?')"
|
||||
:message="
|
||||
t(
|
||||
'forms',
|
||||
'Linked file is not available, would you like to link a new file?',
|
||||
)
|
||||
"
|
||||
:buttons="linkedFileNotAvailableButtons"
|
||||
size="normal"
|
||||
:can-close="false" />
|
||||
|
||||
<TopBar :archived="isFormArchived"
|
||||
<TopBar
|
||||
:archived="isFormArchived"
|
||||
:permissions="form?.permissions"
|
||||
:sidebar-opened="sidebarOpened"
|
||||
@share-form="onShareForm" />
|
||||
|
||||
<!-- Loading submissions -->
|
||||
<NcEmptyContent v-if="loadingResults"
|
||||
<NcEmptyContent
|
||||
v-if="loadingResults"
|
||||
class="forms-emptycontent"
|
||||
:name="t('forms', 'Loading responses …')">
|
||||
<template #icon>
|
||||
|
@ -46,10 +54,13 @@
|
|||
</NcEmptyContent>
|
||||
|
||||
<!-- No submissions -->
|
||||
<NcEmptyContent v-else-if="noSubmissions"
|
||||
<NcEmptyContent
|
||||
v-else-if="noSubmissions"
|
||||
:name="t('forms', 'No responses yet')"
|
||||
class="forms-emptycontent"
|
||||
:description="t('forms', 'Results of submitted forms will show up here')">
|
||||
:description="
|
||||
t('forms', 'Results of submitted forms will show up here')
|
||||
">
|
||||
<template #icon>
|
||||
<IconPoll :size="64" />
|
||||
</template>
|
||||
|
@ -62,14 +73,20 @@
|
|||
{{ t('forms', 'Share form') }}
|
||||
</NcButton>
|
||||
|
||||
<NcButton v-if="canEditForm && !form.fileId" type="tertiary-no-background" @click="onLinkFile">
|
||||
<NcButton
|
||||
v-if="canEditForm && !form.fileId"
|
||||
type="tertiary-no-background"
|
||||
@click="onLinkFile">
|
||||
<template #icon>
|
||||
<IconLink :size="20" />
|
||||
</template>
|
||||
{{ t('forms', 'Create spreadsheet') }}
|
||||
</NcButton>
|
||||
|
||||
<NcButton v-if="form.fileId" :href="fileUrl" type="tertiary-no-background">
|
||||
<NcButton
|
||||
v-if="form.fileId"
|
||||
:href="fileUrl"
|
||||
type="tertiary-no-background">
|
||||
<template #icon>
|
||||
<IconTable :size="20" />
|
||||
</template>
|
||||
|
@ -85,33 +102,49 @@
|
|||
<h2 dir="auto">
|
||||
{{ formTitle }}
|
||||
</h2>
|
||||
<p>{{ t('forms', '{amount} responses', { amount: form.submissions.length }) }}</p>
|
||||
<p>
|
||||
{{
|
||||
t('forms', '{amount} responses', {
|
||||
amount: form.submissions.length,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
<!-- View switcher between Summary and Responses -->
|
||||
<div class="response-actions">
|
||||
<PillMenu :options="responseViews" :active.sync="activeResponseView" class="response-actions__toggle" />
|
||||
<PillMenu
|
||||
:options="responseViews"
|
||||
:active.sync="activeResponseView"
|
||||
class="response-actions__toggle" />
|
||||
|
||||
<!-- Action menu for cloud export and deletion -->
|
||||
<NcActions :aria-label="t('forms', 'Options')"
|
||||
<NcActions
|
||||
:aria-label="t('forms', 'Options')"
|
||||
force-name
|
||||
:inline="isMobile ? 0 : 1"
|
||||
@blur="isDownloadActionOpened = false"
|
||||
@close="isDownloadActionOpened = false">
|
||||
<template v-if="!isDownloadActionOpened">
|
||||
<template v-if="canEditForm && form.fileId">
|
||||
<NcActionButton :href="fileUrl" type="tertiary-no-background">
|
||||
<NcActionButton
|
||||
:href="fileUrl"
|
||||
type="tertiary-no-background">
|
||||
<template #icon>
|
||||
<IconTable :size="20" />
|
||||
</template>
|
||||
{{ t('forms', 'Open spreadsheet') }}
|
||||
</NcActionButton>
|
||||
<NcActionButton close-after-click @click="onReExport">
|
||||
<NcActionButton
|
||||
close-after-click
|
||||
@click="onReExport">
|
||||
<template #icon>
|
||||
<IconRefresh :size="20" />
|
||||
</template>
|
||||
{{ t('forms', 'Re-export spreadsheet') }}
|
||||
</NcActionButton>
|
||||
<NcActionButton close-after-click @click="onUnlinkFile">
|
||||
<NcActionButton
|
||||
close-after-click
|
||||
@click="onUnlinkFile">
|
||||
<template #icon>
|
||||
<IconLinkVariantOff :size="20" />
|
||||
</template>
|
||||
|
@ -119,19 +152,24 @@
|
|||
</NcActionButton>
|
||||
<NcActionSeparator />
|
||||
</template>
|
||||
<NcActionButton v-else-if="canEditForm" @click="onLinkFile">
|
||||
<NcActionButton
|
||||
v-else-if="canEditForm"
|
||||
@click="onLinkFile">
|
||||
<template #icon>
|
||||
<IconLink :size="20" />
|
||||
</template>
|
||||
{{ t('forms', 'Create spreadsheet') }}
|
||||
</NcActionButton>
|
||||
<NcActionButton close-after-click @click="onStoreToFiles">
|
||||
<NcActionButton
|
||||
close-after-click
|
||||
@click="onStoreToFiles">
|
||||
<template #icon>
|
||||
<IconFolder :size="20" />
|
||||
</template>
|
||||
{{ t('forms', 'Save copy to Files') }}
|
||||
</NcActionButton>
|
||||
<NcActionButton :close-after-click="false"
|
||||
<NcActionButton
|
||||
:close-after-click="false"
|
||||
:is-menu="true"
|
||||
@click="isDownloadActionOpened = true">
|
||||
<template #icon>
|
||||
|
@ -140,7 +178,8 @@
|
|||
{{ t('forms', 'Download') }}
|
||||
</NcActionButton>
|
||||
|
||||
<NcActionButton v-if="canDeleteSubmissions"
|
||||
<NcActionButton
|
||||
v-if="canDeleteSubmissions"
|
||||
close-after-click
|
||||
@click="deleteAllSubmissions">
|
||||
<template #icon>
|
||||
|
@ -159,19 +198,25 @@
|
|||
{{ t('forms', 'Download') }}
|
||||
</NcActionButton>
|
||||
<NcActionSeparator />
|
||||
<NcActionButton close-after-click @click="onDownloadFile('csv')">
|
||||
<NcActionButton
|
||||
close-after-click
|
||||
@click="onDownloadFile('csv')">
|
||||
<template #icon>
|
||||
<IconFileDelimited :size="20" />
|
||||
</template>
|
||||
CSV
|
||||
</NcActionButton>
|
||||
<NcActionButton close-after-click @click="onDownloadFile('ods')">
|
||||
<NcActionButton
|
||||
close-after-click
|
||||
@click="onDownloadFile('ods')">
|
||||
<template #icon>
|
||||
<IconTable :size="20" />
|
||||
</template>
|
||||
ODS
|
||||
</NcActionButton>
|
||||
<NcActionButton close-after-click @click="onDownloadFile('xlsx')">
|
||||
<NcActionButton
|
||||
close-after-click
|
||||
@click="onDownloadFile('xlsx')">
|
||||
<template #icon>
|
||||
<IconFileExcel :size="20" />
|
||||
</template>
|
||||
|
@ -184,7 +229,8 @@
|
|||
|
||||
<!-- Summary view for visualization -->
|
||||
<section v-if="activeResponseView.id === 'summary'">
|
||||
<ResultsSummary v-for="question in form.questions"
|
||||
<ResultsSummary
|
||||
v-for="question in form.questions"
|
||||
:key="question.id"
|
||||
:question="question"
|
||||
:submissions="form.submissions" />
|
||||
|
@ -192,7 +238,8 @@
|
|||
|
||||
<!-- Responses view for individual responses -->
|
||||
<section v-else>
|
||||
<Submission v-for="submission in form.submissions"
|
||||
<Submission
|
||||
v-for="submission in form.submissions"
|
||||
:key="submission.id"
|
||||
:submission="submission"
|
||||
:questions="form.questions"
|
||||
|
@ -202,9 +249,16 @@
|
|||
</template>
|
||||
|
||||
<!-- Confirmation dialog for deleting all submissions -->
|
||||
<NcDialog :open.sync="showConfirmDeleteDialog"
|
||||
<NcDialog
|
||||
:open.sync="showConfirmDeleteDialog"
|
||||
:name="t('forms', 'Delete submissions')"
|
||||
:message="t('forms', 'Are you sure you want to delete all responses of {title}?', { title: formTitle })"
|
||||
:message="
|
||||
t(
|
||||
'forms',
|
||||
'Are you sure you want to delete all responses of {title}?',
|
||||
{ title: formTitle },
|
||||
)
|
||||
"
|
||||
:buttons="confirmDeleteButtons" />
|
||||
</NcAppContent>
|
||||
</template>
|
||||
|
@ -259,7 +313,11 @@ import OcsResponse2Data from '../utils/OcsResponse2Data.js'
|
|||
import PermissionTypes from '../mixins/PermissionTypes.js'
|
||||
import PillMenu from '../components/PillMenu.vue'
|
||||
|
||||
const SUPPORTED_FILE_FORMATS = { ods: IconTableSvg, csv: IconFileDelimitedSvg, xlsx: IconFileExcelSvg }
|
||||
const SUPPORTED_FILE_FORMATS = {
|
||||
ods: IconTableSvg,
|
||||
csv: IconFileDelimitedSvg,
|
||||
xlsx: IconFileExcelSvg,
|
||||
}
|
||||
let fileFormat = 'csv'
|
||||
|
||||
const responseViews = [
|
||||
|
@ -330,13 +388,17 @@ export default {
|
|||
label: t('forms', 'Unlink spreadsheet'),
|
||||
icon: IconLinkVariantOffSvg,
|
||||
type: 'error',
|
||||
callback: () => { this.onUnlinkFile() },
|
||||
callback: () => {
|
||||
this.onUnlinkFile()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('forms', 'Create spreadsheet'),
|
||||
icon: IconLinkSvg,
|
||||
type: 'primary',
|
||||
callback: () => { this.onLinkFile() },
|
||||
callback: () => {
|
||||
this.onLinkFile()
|
||||
},
|
||||
},
|
||||
],
|
||||
confirmDeleteButtons: [
|
||||
|
@ -344,13 +406,17 @@ export default {
|
|||
label: t('forms', 'Cancel'),
|
||||
icon: IconCancelSvg,
|
||||
type: 'tertiary',
|
||||
callback: () => { this.showConfirmDeleteDialog = false },
|
||||
callback: () => {
|
||||
this.showConfirmDeleteDialog = false
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('forms', 'Delete submissions'),
|
||||
icon: IconDeleteSvg,
|
||||
type: 'error',
|
||||
callback: () => { this.deleteAllSubmissionsConfirmed() },
|
||||
callback: () => {
|
||||
this.deleteAllSubmissionsConfirmed()
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
@ -362,11 +428,17 @@ export default {
|
|||
},
|
||||
|
||||
canDeleteSubmissions() {
|
||||
return this.form.permissions.includes(this.PERMISSION_TYPES.PERMISSION_RESULTS_DELETE) && !this.isFormArchived
|
||||
return (
|
||||
this.form.permissions.includes(
|
||||
this.PERMISSION_TYPES.PERMISSION_RESULTS_DELETE,
|
||||
) && !this.isFormArchived
|
||||
)
|
||||
},
|
||||
|
||||
canEditForm() {
|
||||
return this.form.permissions.includes(this.PERMISSION_TYPES.PERMISSION_EDIT)
|
||||
return this.form.permissions.includes(
|
||||
this.PERMISSION_TYPES.PERMISSION_EDIT,
|
||||
)
|
||||
},
|
||||
|
||||
noSubmissions() {
|
||||
|
@ -416,12 +488,19 @@ export default {
|
|||
logger.debug(`Loading results for form ${this.form.hash}`)
|
||||
|
||||
try {
|
||||
const response = await axios.get(generateOcsUrl('apps/forms/api/v2.4/submissions/{hash}', { hash: this.form.hash }))
|
||||
const response = await axios.get(
|
||||
generateOcsUrl('apps/forms/api/v2.4/submissions/{hash}', {
|
||||
hash: this.form.hash,
|
||||
}),
|
||||
)
|
||||
|
||||
let loadedSubmissions = OcsResponse2Data(response).submissions
|
||||
const loadedQuestions = OcsResponse2Data(response).questions
|
||||
|
||||
loadedSubmissions = this.formatDateAnswers(loadedSubmissions, loadedQuestions)
|
||||
loadedSubmissions = this.formatDateAnswers(
|
||||
loadedSubmissions,
|
||||
loadedQuestions,
|
||||
)
|
||||
|
||||
// Append questions & submissions
|
||||
this.$set(this.form, 'submissions', loadedSubmissions)
|
||||
|
@ -435,32 +514,56 @@ export default {
|
|||
},
|
||||
|
||||
async onDownloadFile(fileFormat) {
|
||||
const exportUrl = generateOcsUrl('apps/forms/api/v2.4/submissions/export/{hash}', { hash: this.form.hash })
|
||||
+ '?requesttoken=' + encodeURIComponent(getRequestToken())
|
||||
+ '&fileFormat=' + fileFormat
|
||||
const exportUrl =
|
||||
generateOcsUrl('apps/forms/api/v2.4/submissions/export/{hash}', {
|
||||
hash: this.form.hash,
|
||||
}) +
|
||||
'?requesttoken=' +
|
||||
encodeURIComponent(getRequestToken()) +
|
||||
'&fileFormat=' +
|
||||
fileFormat
|
||||
window.open(exportUrl, '_self')
|
||||
},
|
||||
|
||||
async onLinkFile() {
|
||||
try {
|
||||
await this.getPicker().pick()
|
||||
await this.getPicker()
|
||||
.pick()
|
||||
.then(async (path) => {
|
||||
try {
|
||||
const response = await axios.post(generateOcsUrl('apps/forms/api/v2.4/form/link/{fileFormat}', { fileFormat }), {
|
||||
hash: this.form.hash,
|
||||
path,
|
||||
})
|
||||
const response = await axios.post(
|
||||
generateOcsUrl(
|
||||
'apps/forms/api/v2.4/form/link/{fileFormat}',
|
||||
{ fileFormat },
|
||||
),
|
||||
{
|
||||
hash: this.form.hash,
|
||||
path,
|
||||
},
|
||||
)
|
||||
const responseData = OcsResponse2Data(response)
|
||||
|
||||
this.form.fileFormat = responseData.fileFormat
|
||||
this.form.fileId = responseData.fileId
|
||||
this.form.filePath = responseData.filePath
|
||||
|
||||
showSuccess(t('forms', 'File {file} successfully linked', { file: responseData.fileName }))
|
||||
showSuccess(
|
||||
t('forms', 'File {file} successfully linked', {
|
||||
file: responseData.fileName,
|
||||
}),
|
||||
)
|
||||
emit('forms:last-updated:set', this.form.id)
|
||||
} catch (error) {
|
||||
logger.error('Error while exporting to Files and linking', { error })
|
||||
showError(t('forms', 'There was an error while linking the file'))
|
||||
logger.error(
|
||||
'Error while exporting to Files and linking',
|
||||
{ error },
|
||||
)
|
||||
showError(
|
||||
t(
|
||||
'forms',
|
||||
'There was an error while linking the file',
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
|
@ -472,14 +575,29 @@ export default {
|
|||
// Show Filepicker, then call API to store
|
||||
async onStoreToFiles() {
|
||||
try {
|
||||
await this.getPicker().pick()
|
||||
await this.getPicker()
|
||||
.pick()
|
||||
.then(async (path) => {
|
||||
try {
|
||||
const response = await axios.post(generateOcsUrl('apps/forms/api/v2.4/submissions/export'), { hash: this.form.hash, path, fileFormat })
|
||||
showSuccess(t('forms', 'Export successful to {file}', { file: OcsResponse2Data(response) }))
|
||||
const response = await axios.post(
|
||||
generateOcsUrl(
|
||||
'apps/forms/api/v2.4/submissions/export',
|
||||
),
|
||||
{ hash: this.form.hash, path, fileFormat },
|
||||
)
|
||||
showSuccess(
|
||||
t('forms', 'Export successful to {file}', {
|
||||
file: OcsResponse2Data(response),
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Error while exporting to Files', { error })
|
||||
showError(t('forms', 'There was an error while exporting to Files'))
|
||||
showError(
|
||||
t(
|
||||
'forms',
|
||||
'There was an error while exporting to Files',
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
|
@ -489,12 +607,17 @@ export default {
|
|||
},
|
||||
|
||||
async fetchLinkedFileInfo() {
|
||||
const response = await axios.get(generateOcsUrl('apps/forms/api/v2.4/form/{id}', { id: this.form.id }))
|
||||
const response = await axios.get(
|
||||
generateOcsUrl('apps/forms/api/v2.4/form/{id}', {
|
||||
id: this.form.id,
|
||||
}),
|
||||
)
|
||||
const form = OcsResponse2Data(response)
|
||||
this.$set(this.form, 'fileFormat', form.fileFormat)
|
||||
this.$set(this.form, 'fileId', form.fileId)
|
||||
this.$set(this.form, 'filePath', form.filePath)
|
||||
this.showLinkedFileNotAvailableDialog = this.canEditForm && form.fileId && !form.filePath
|
||||
this.showLinkedFileNotAvailableDialog =
|
||||
this.canEditForm && form.fileId && !form.filePath
|
||||
},
|
||||
|
||||
async onReExport() {
|
||||
|
@ -504,10 +627,19 @@ export default {
|
|||
return
|
||||
}
|
||||
try {
|
||||
const response = await axios.post(generateOcsUrl('apps/forms/api/v2.4/submissions/export'),
|
||||
{ hash: this.form.hash, path: this.form.filePath, fileFormat: this.form.fileFormat },
|
||||
const response = await axios.post(
|
||||
generateOcsUrl('apps/forms/api/v2.4/submissions/export'),
|
||||
{
|
||||
hash: this.form.hash,
|
||||
path: this.form.filePath,
|
||||
fileFormat: this.form.fileFormat,
|
||||
},
|
||||
)
|
||||
showSuccess(
|
||||
t('forms', 'Export successful to {file}', {
|
||||
file: OcsResponse2Data(response),
|
||||
}),
|
||||
)
|
||||
showSuccess(t('forms', 'Export successful to {file}', { file: OcsResponse2Data(response) }))
|
||||
} catch (error) {
|
||||
logger.error('Error while exporting to Files', { error })
|
||||
showError(t('forms', 'There was an error, while exporting to Files'))
|
||||
|
@ -518,14 +650,20 @@ export default {
|
|||
this.loadingResults = true
|
||||
|
||||
try {
|
||||
await axios.delete(generateOcsUrl('apps/forms/api/v2.4/submission/{id}', { id }))
|
||||
await axios.delete(
|
||||
generateOcsUrl('apps/forms/api/v2.4/submission/{id}', { id }),
|
||||
)
|
||||
showSuccess(t('forms', 'Submission deleted'))
|
||||
const index = this.form.submissions.findIndex(search => search.id === id)
|
||||
const index = this.form.submissions.findIndex(
|
||||
(search) => search.id === id,
|
||||
)
|
||||
this.form.submissions.splice(index, 1)
|
||||
emit('forms:last-updated:set', this.form.id)
|
||||
} catch (error) {
|
||||
logger.error(`Error while removing response ${id}`, { error })
|
||||
showError(t('forms', 'There was an error while removing this response'))
|
||||
showError(
|
||||
t('forms', 'There was an error while removing this response'),
|
||||
)
|
||||
} finally {
|
||||
this.loadingResults = false
|
||||
}
|
||||
|
@ -539,7 +677,11 @@ export default {
|
|||
this.showConfirmDeleteDialog = false
|
||||
this.loadingResults = true
|
||||
try {
|
||||
await axios.delete(generateOcsUrl('apps/forms/api/v2.4/submissions/{formId}', { formId: this.form.id }))
|
||||
await axios.delete(
|
||||
generateOcsUrl('apps/forms/api/v2.4/submissions/{formId}', {
|
||||
formId: this.form.id,
|
||||
}),
|
||||
)
|
||||
this.form.submissions = []
|
||||
emit('forms:last-updated:set', this.form.id)
|
||||
} catch (error) {
|
||||
|
@ -554,17 +696,30 @@ export default {
|
|||
// Filter questions that are date/datetime/time
|
||||
const dateQuestions = Object.fromEntries(
|
||||
questions
|
||||
.filter(question => question.type === 'date' | question.type === 'datetime' | question.type === 'time')
|
||||
.map(question => [question.id, question.type]),
|
||||
.filter(
|
||||
(question) =>
|
||||
(question.type === 'date') |
|
||||
(question.type === 'datetime') |
|
||||
(question.type === 'time'),
|
||||
)
|
||||
.map((question) => [question.id, question.type]),
|
||||
)
|
||||
|
||||
// Go through submissions and reformat answers to date/time questions
|
||||
submissions.forEach(submission => {
|
||||
submission.answers.filter(answer => answer.questionId in dateQuestions)
|
||||
.forEach(answer => {
|
||||
const date = moment(answer.text, answerTypes[dateQuestions[answer.questionId]].storageFormat)
|
||||
submissions.forEach((submission) => {
|
||||
submission.answers
|
||||
.filter((answer) => answer.questionId in dateQuestions)
|
||||
.forEach((answer) => {
|
||||
const date = moment(
|
||||
answer.text,
|
||||
answerTypes[dateQuestions[answer.questionId]]
|
||||
.storageFormat,
|
||||
)
|
||||
if (date.isValid()) {
|
||||
answer.text = date.format(answerTypes[dateQuestions[answer.questionId]].momentFormat)
|
||||
answer.text = date.format(
|
||||
answerTypes[dateQuestions[answer.questionId]]
|
||||
.momentFormat,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
@ -577,21 +732,29 @@ export default {
|
|||
return this.picker
|
||||
}
|
||||
|
||||
this.picker = getFilePickerBuilder(t('forms', 'Choose spreadsheet location'))
|
||||
this.picker = getFilePickerBuilder(
|
||||
t('forms', 'Choose spreadsheet location'),
|
||||
)
|
||||
.setMultiSelect(false)
|
||||
.allowDirectories()
|
||||
.setButtonFactory((selectedNodes, currentPath, currentView) => {
|
||||
if (selectedNodes.length === 1) {
|
||||
const extension = selectedNodes[0].extension.slice(1).toLowerCase()
|
||||
const extension = selectedNodes[0].extension
|
||||
.slice(1)
|
||||
.toLowerCase()
|
||||
if (SUPPORTED_FILE_FORMATS[extension]) {
|
||||
return [{
|
||||
label: t('forms', 'Select {file}', { file: selectedNodes[0].basename }),
|
||||
icon: SUPPORTED_FILE_FORMATS[extension],
|
||||
callback() {
|
||||
fileFormat = extension
|
||||
return [
|
||||
{
|
||||
label: t('forms', 'Select {file}', {
|
||||
file: selectedNodes[0].basename,
|
||||
}),
|
||||
icon: SUPPORTED_FILE_FORMATS[extension],
|
||||
callback() {
|
||||
fileFormat = extension
|
||||
},
|
||||
type: 'primary',
|
||||
},
|
||||
type: 'primary',
|
||||
}]
|
||||
]
|
||||
}
|
||||
|
||||
return []
|
||||
|
|
|
@ -21,32 +21,32 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<NcAppSidebar :open="sidebarOpened"
|
||||
<NcAppSidebar
|
||||
:open="sidebarOpened"
|
||||
:active="active"
|
||||
:name="t('forms', 'Form settings')"
|
||||
@update:active="onUpdateActive"
|
||||
@update:open="$emit('update:sidebarOpened', $event)">
|
||||
<NcAppSidebarTab id="forms-sharing"
|
||||
:order="0"
|
||||
:name="t('forms', 'Sharing')">
|
||||
<NcAppSidebarTab id="forms-sharing" :order="0" :name="t('forms', 'Sharing')">
|
||||
<template #icon>
|
||||
<IconShareVariant :size="20" />
|
||||
</template>
|
||||
<SharingSidebarTab :form="form"
|
||||
<SharingSidebarTab
|
||||
:form="form"
|
||||
@update:formProp="onPropertyChange"
|
||||
@add-share="onAddShare"
|
||||
@remove-share="onRemoveShare"
|
||||
@update-share="onUpdateShare" />
|
||||
</NcAppSidebarTab>
|
||||
|
||||
<NcAppSidebarTab id="forms-settings"
|
||||
<NcAppSidebarTab
|
||||
id="forms-settings"
|
||||
:order="1"
|
||||
:name="t('forms', 'Settings')">
|
||||
<template #icon>
|
||||
<IconSettings :size="20" />
|
||||
</template>
|
||||
<SettingsSidebarTab :form="form"
|
||||
@update:formProp="onPropertyChange" />
|
||||
<SettingsSidebarTab :form="form" @update:formProp="onPropertyChange" />
|
||||
</NcAppSidebarTab>
|
||||
</NcAppSidebar>
|
||||
</template>
|
||||
|
@ -109,12 +109,16 @@ export default {
|
|||
emit('forms:last-updated:set', this.form.id)
|
||||
},
|
||||
onRemoveShare(share) {
|
||||
const index = this.form.shares.findIndex(search => search.id === share.id)
|
||||
const index = this.form.shares.findIndex(
|
||||
(search) => search.id === share.id,
|
||||
)
|
||||
this.form.shares.splice(index, 1)
|
||||
emit('forms:last-updated:set', this.form.id)
|
||||
},
|
||||
onUpdateShare(share) {
|
||||
const index = this.form.shares.findIndex(search => search.id === share.id)
|
||||
const index = this.form.shares.findIndex(
|
||||
(search) => search.id === share.id,
|
||||
)
|
||||
this.form.shares.splice(index, 1, share)
|
||||
emit('forms:last-updated:set', this.form.id)
|
||||
},
|
||||
|
|
|
@ -23,15 +23,19 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<NcAppContent :class="{'app-content--public': publicView}" :page-heading="t('forms', 'Submit form')">
|
||||
<TopBar v-if="!publicView"
|
||||
<NcAppContent
|
||||
:class="{ 'app-content--public': publicView }"
|
||||
:page-heading="t('forms', 'Submit form')">
|
||||
<TopBar
|
||||
v-if="!publicView"
|
||||
:archived="isArchived"
|
||||
:permissions="form?.permissions"
|
||||
:sidebar-opened="sidebarOpened"
|
||||
@share-form="onShareForm" />
|
||||
|
||||
<!-- Form is loading -->
|
||||
<NcEmptyContent v-if="isLoadingForm"
|
||||
<NcEmptyContent
|
||||
v-if="isLoadingForm"
|
||||
class="forms-emptycontent"
|
||||
:name="t('forms', 'Loading {title} …', { title: form.title })">
|
||||
<template #icon>
|
||||
|
@ -46,7 +50,8 @@
|
|||
{{ formTitle }}
|
||||
</h2>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div v-if="!loading && !success && !!formDescription"
|
||||
<div
|
||||
v-if="!loading && !success && !!formDescription"
|
||||
class="form-desc"
|
||||
dir="auto"
|
||||
v-html="formDescription" />
|
||||
|
@ -59,14 +64,16 @@
|
|||
</p>
|
||||
</header>
|
||||
|
||||
<NcEmptyContent v-if="loading"
|
||||
<NcEmptyContent
|
||||
v-if="loading"
|
||||
class="forms-emptycontent"
|
||||
:name="t('forms', 'Submitting form …')">
|
||||
<template #icon>
|
||||
<NcLoadingIcon :size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
<NcEmptyContent v-else-if="success || !form.canSubmit"
|
||||
<NcEmptyContent
|
||||
v-else-if="success || !form.canSubmit"
|
||||
class="forms-emptycontent"
|
||||
:name="t('forms', 'Thank you for completing the form!')"
|
||||
:description="form.submissionMessage">
|
||||
|
@ -78,29 +85,40 @@
|
|||
<p class="submission-message" v-html="submissionMessageHTML" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
<NcEmptyContent v-else-if="isExpired"
|
||||
<NcEmptyContent
|
||||
v-else-if="isExpired"
|
||||
class="forms-emptycontent"
|
||||
:name="t('forms', 'Form expired')"
|
||||
:description="t('forms', 'This form has expired and is no longer taking answers')">
|
||||
:description="
|
||||
t(
|
||||
'forms',
|
||||
'This form has expired and is no longer taking answers',
|
||||
)
|
||||
">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :svg="IconCheckSvg" size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
<NcEmptyContent v-else-if="isClosed || isArchived"
|
||||
<NcEmptyContent
|
||||
v-else-if="isClosed || isArchived"
|
||||
class="forms-emptycontent"
|
||||
:name="t('forms', 'Form closed')"
|
||||
:description="t('forms', 'This form was closed and is no longer taking answers')">
|
||||
:description="
|
||||
t(
|
||||
'forms',
|
||||
'This form was closed and is no longer taking answers',
|
||||
)
|
||||
">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :svg="IconCheckSvg" size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- Questions list -->
|
||||
<form v-else
|
||||
ref="form"
|
||||
@submit.prevent="onSubmit">
|
||||
<form v-else ref="form" @submit.prevent="onSubmit">
|
||||
<ul>
|
||||
<Questions :is="answerTypes[question.type].component"
|
||||
<Questions
|
||||
:is="answerTypes[question.type].component"
|
||||
v-for="(question, index) in validQuestions"
|
||||
ref="questions"
|
||||
:key="question.id"
|
||||
|
@ -114,23 +132,33 @@
|
|||
@keydown.ctrl.enter="onKeydownCtrlEnter"
|
||||
@update:values="(values) => onUpdate(question, values)" />
|
||||
</ul>
|
||||
<input ref="submitButton"
|
||||
<input
|
||||
ref="submitButton"
|
||||
class="primary"
|
||||
type="submit"
|
||||
:value="t('forms', 'Submit')"
|
||||
:disabled="loading"
|
||||
:aria-label="t('forms', 'Submit form')">
|
||||
:aria-label="t('forms', 'Submit form')" />
|
||||
</form>
|
||||
|
||||
<!-- Confirmation dialog if form is empty submitted -->
|
||||
<NcDialog :open.sync="showConfirmEmptyModal"
|
||||
<NcDialog
|
||||
:open.sync="showConfirmEmptyModal"
|
||||
:name="t('forms', 'Confirm submit')"
|
||||
:message="t('forms', 'Are you sure you want to submit an empty form?')"
|
||||
:message="
|
||||
t('forms', 'Are you sure you want to submit an empty form?')
|
||||
"
|
||||
:buttons="confirmEmptyModalButtons" />
|
||||
<!-- Confirmation dialog if form is left unsubmitted -->
|
||||
<NcDialog :open.sync="showConfirmLeaveDialog"
|
||||
<NcDialog
|
||||
:open.sync="showConfirmLeaveDialog"
|
||||
:name="t('forms', 'Leave form')"
|
||||
:message="t('forms', 'You have unsaved changes! Do you still want to leave?')"
|
||||
:message="
|
||||
t(
|
||||
'forms',
|
||||
'You have unsaved changes! Do you still want to leave?',
|
||||
)
|
||||
"
|
||||
:buttons="confirmLeaveFormButtons"
|
||||
:can-close="false"
|
||||
:close-on-click-outside="false" />
|
||||
|
@ -247,7 +275,7 @@ export default {
|
|||
},
|
||||
|
||||
validQuestions() {
|
||||
return this.form.questions.filter(question => {
|
||||
return this.form.questions.filter((question) => {
|
||||
// All questions must have a valid title
|
||||
if (question.text?.trim() === '') {
|
||||
return false
|
||||
|
@ -262,7 +290,10 @@ export default {
|
|||
},
|
||||
|
||||
isRequiredUsed() {
|
||||
return this.form.questions.reduce((isUsed, question) => isUsed || question.isRequired, false)
|
||||
return this.form.questions.reduce(
|
||||
(isUsed, question) => isUsed || question.isRequired,
|
||||
false,
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -289,7 +320,9 @@ export default {
|
|||
message += t('forms', 'Responses are connected to your account.')
|
||||
}
|
||||
if (this.isRequiredUsed) {
|
||||
message += ' ' + t('forms', 'An asterisk (*) indicates mandatory questions.')
|
||||
message +=
|
||||
' ' +
|
||||
t('forms', 'An asterisk (*) indicates mandatory questions.')
|
||||
}
|
||||
|
||||
return message
|
||||
|
@ -299,7 +332,10 @@ export default {
|
|||
* Rendered HTML of the custom submission message
|
||||
*/
|
||||
submissionMessageHTML() {
|
||||
if (this.form.submissionMessage && (this.success || !this.form.canSubmit)) {
|
||||
if (
|
||||
this.form.submissionMessage &&
|
||||
(this.success || !this.form.canSubmit)
|
||||
) {
|
||||
return this.markdownit.render(this.form.submissionMessage)
|
||||
}
|
||||
return ''
|
||||
|
@ -317,32 +353,38 @@ export default {
|
|||
* Buttons for the "confirm submit empty form" dialog
|
||||
*/
|
||||
confirmEmptyModalButtons() {
|
||||
return [{
|
||||
label: t('forms', 'Abort'),
|
||||
icon: IconCancelSvg,
|
||||
callback: () => {},
|
||||
}, {
|
||||
label: t('forms', 'Submit'),
|
||||
icon: IconCheckSvg,
|
||||
type: 'primary',
|
||||
callback: () => this.onConfirmedSubmit(),
|
||||
}]
|
||||
return [
|
||||
{
|
||||
label: t('forms', 'Abort'),
|
||||
icon: IconCancelSvg,
|
||||
callback: () => {},
|
||||
},
|
||||
{
|
||||
label: t('forms', 'Submit'),
|
||||
icon: IconCheckSvg,
|
||||
type: 'primary',
|
||||
callback: () => this.onConfirmedSubmit(),
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
/**
|
||||
* Buttons for the "confirm leave unsubmitted form" dialog
|
||||
*/
|
||||
confirmLeaveFormButtons() {
|
||||
return [{
|
||||
label: t('forms', 'Abort'),
|
||||
icon: IconCancelSvg,
|
||||
callback: () => this.confirmButtonCallback(false),
|
||||
}, {
|
||||
label: t('forms', 'Leave'),
|
||||
icon: IconCheckSvg,
|
||||
type: 'primary',
|
||||
callback: () => this.confirmButtonCallback(true),
|
||||
}]
|
||||
return [
|
||||
{
|
||||
label: t('forms', 'Abort'),
|
||||
icon: IconCancelSvg,
|
||||
callback: () => this.confirmButtonCallback(false),
|
||||
},
|
||||
{
|
||||
label: t('forms', 'Leave'),
|
||||
icon: IconCheckSvg,
|
||||
type: 'primary',
|
||||
callback: () => this.confirmButtonCallback(true),
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -387,7 +429,9 @@ export default {
|
|||
* @return {Record<string,any>}
|
||||
*/
|
||||
getFormValuesFromLocalStorage() {
|
||||
const fromLocalStorage = localStorage.getItem(`nextcloud_forms_${this.publicView ? this.shareHash : this.hash}`)
|
||||
const fromLocalStorage = localStorage.getItem(
|
||||
`nextcloud_forms_${this.publicView ? this.shareHash : this.hash}`,
|
||||
)
|
||||
if (fromLocalStorage) {
|
||||
return JSON.parse(fromLocalStorage)
|
||||
}
|
||||
|
@ -403,15 +447,15 @@ export default {
|
|||
for (const [key, answer] of Object.entries(savedState)) {
|
||||
const answers = []
|
||||
switch (answer?.type) {
|
||||
case 'QuestionMultiple':
|
||||
answer.value.forEach(num => {
|
||||
answers.push(num.toString())
|
||||
})
|
||||
this.answers[key] = answers
|
||||
break
|
||||
default:
|
||||
this.answers[key] = answer.value
|
||||
break
|
||||
case 'QuestionMultiple':
|
||||
answer.value.forEach((num) => {
|
||||
answers.push(num.toString())
|
||||
})
|
||||
this.answers[key] = answers
|
||||
break
|
||||
default:
|
||||
this.answers[key] = answer.value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -434,14 +478,19 @@ export default {
|
|||
},
|
||||
}
|
||||
const stringified = JSON.stringify(state)
|
||||
localStorage.setItem(`nextcloud_forms_${this.publicView ? this.shareHash : this.hash}`, stringified)
|
||||
localStorage.setItem(
|
||||
`nextcloud_forms_${this.publicView ? this.shareHash : this.hash}`,
|
||||
stringified,
|
||||
)
|
||||
},
|
||||
|
||||
deleteFormFieldFromLocalStorage() {
|
||||
if (!this.isLoggedIn) {
|
||||
return
|
||||
}
|
||||
localStorage.removeItem(`nextcloud_forms_${this.publicView ? this.shareHash : this.hash}`)
|
||||
localStorage.removeItem(
|
||||
`nextcloud_forms_${this.publicView ? this.shareHash : this.hash}`,
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -449,7 +498,7 @@ export default {
|
|||
* @param {{id: number}} question The question to answer
|
||||
* @param {unknown[]} values The new values
|
||||
*/
|
||||
onUpdate(question, values) {
|
||||
onUpdate(question, values) {
|
||||
this.answers = { ...this.answers, [question.id]: values }
|
||||
this.addFormFieldToLocalStorage(question)
|
||||
},
|
||||
|
@ -462,7 +511,9 @@ export default {
|
|||
*/
|
||||
onKeydownEnter(event) {
|
||||
const formInputs = Array.from(this.$refs.form)
|
||||
const sourceInputIndex = formInputs.findIndex(input => input === event.originalTarget)
|
||||
const sourceInputIndex = formInputs.findIndex(
|
||||
(input) => input === event.originalTarget,
|
||||
)
|
||||
|
||||
// Focus next form element
|
||||
formInputs[sourceInputIndex + 1].focus()
|
||||
|
@ -509,7 +560,9 @@ export default {
|
|||
* Submit the form after the browser validated it 🚀 or show confirmation modal if empty
|
||||
*/
|
||||
async onSubmit() {
|
||||
const validation = (this.$refs.questions ?? []).map(async (question) => await question.validate())
|
||||
const validation = (this.$refs.questions ?? []).map(
|
||||
async (question) => await question.validate(),
|
||||
)
|
||||
|
||||
try {
|
||||
// wait for all to be validated
|
||||
|
@ -519,7 +572,12 @@ export default {
|
|||
}
|
||||
|
||||
// in case no answer is set or all are empty show the confirmation dialog
|
||||
if (Object.keys(this.answers).length === 0 || Object.values(this.answers).every((answers) => answers.length === 0)) {
|
||||
if (
|
||||
Object.keys(this.answers).length === 0 ||
|
||||
Object.values(this.answers).every(
|
||||
(answers) => answers.length === 0,
|
||||
)
|
||||
) {
|
||||
this.showConfirmEmptyModal = true
|
||||
} else {
|
||||
// otherwise do the real submit
|
||||
|
@ -539,19 +597,25 @@ export default {
|
|||
this.loading = true
|
||||
|
||||
try {
|
||||
await axios.post(generateOcsUrl('apps/forms/api/v2.4/submission/insert'), {
|
||||
formId: this.form.id,
|
||||
answers: this.answers,
|
||||
shareHash: this.shareHash,
|
||||
})
|
||||
await axios.post(
|
||||
generateOcsUrl('apps/forms/api/v2.4/submission/insert'),
|
||||
{
|
||||
formId: this.form.id,
|
||||
answers: this.answers,
|
||||
shareHash: this.shareHash,
|
||||
},
|
||||
)
|
||||
this.submitForm = true
|
||||
this.success = true
|
||||
this.deleteFormFieldFromLocalStorage()
|
||||
emit('forms:last-updated:set', this.form.id)
|
||||
} catch (error) {
|
||||
logger.error('Error while submitting the form', { error })
|
||||
showError(t('forms', 'There was an error submitting the form: {message}',
|
||||
{ message: error.response.data.ocs.meta.message }))
|
||||
showError(
|
||||
t('forms', 'There was an error submitting the form: {message}', {
|
||||
message: error.response.data.ocs.meta.message,
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
|
@ -568,7 +632,6 @@ export default {
|
|||
this.submitForm = false
|
||||
},
|
||||
},
|
||||
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
|
@ -603,7 +666,9 @@ export default {
|
|||
.form-title,
|
||||
.form-desc,
|
||||
.info-message {
|
||||
width: calc(100% - 56px); // margin of header, needed if screen is < 806px (max-width + margin-left)
|
||||
width: calc(
|
||||
100% - 56px
|
||||
); // margin of header, needed if screen is < 806px (max-width + margin-left)
|
||||
font-size: 100%;
|
||||
padding-block: 0px;
|
||||
padding-inline: 16px;
|
||||
|
@ -650,7 +715,7 @@ export default {
|
|||
padding-inline-start: 44px;
|
||||
}
|
||||
|
||||
input[type=submit] {
|
||||
input[type='submit'] {
|
||||
align-self: flex-end;
|
||||
margin: 5px;
|
||||
margin-block-end: 160px;
|
||||
|
|
|
@ -3,6 +3,6 @@
|
|||
"allowJs": true,
|
||||
"target": "ESNext",
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "Bundler",
|
||||
"moduleResolution": "Bundler"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,115 +1,108 @@
|
|||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "f2391d39f330ebebb8b0d663f5b60927",
|
||||
"packages": [
|
||||
{
|
||||
"name": "nextcloud/coding-standard",
|
||||
"version": "v1.2.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nextcloud/coding-standard.git",
|
||||
"reference": "cf5f18d989ec62fb4cdc7fc92a36baf34b3d829e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nextcloud/coding-standard/zipball/cf5f18d989ec62fb4cdc7fc92a36baf34b3d829e",
|
||||
"reference": "cf5f18d989ec62fb4cdc7fc92a36baf34b3d829e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.3|^8.0",
|
||||
"php-cs-fixer/shim": "^3.17"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Nextcloud\\CodingStandard\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christoph Wurst",
|
||||
"email": "christoph@winzerhof-wurst.at"
|
||||
}
|
||||
],
|
||||
"description": "Nextcloud coding standards for the php cs fixer",
|
||||
"support": {
|
||||
"issues": "https://github.com/nextcloud/coding-standard/issues",
|
||||
"source": "https://github.com/nextcloud/coding-standard/tree/v1.2.1"
|
||||
},
|
||||
"time": "2024-02-01T14:54:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "php-cs-fixer/shim",
|
||||
"version": "v3.51.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHP-CS-Fixer/shim.git",
|
||||
"reference": "a792394f7f3934f75a4df9dca544796c6f503027"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/a792394f7f3934f75a4df9dca544796c6f503027",
|
||||
"reference": "a792394f7f3934f75a4df9dca544796c6f503027",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"ext-tokenizer": "*",
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"replace": {
|
||||
"friendsofphp/php-cs-fixer": "self.version"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-dom": "For handling output formats in XML",
|
||||
"ext-mbstring": "For handling non-UTF8 characters."
|
||||
},
|
||||
"bin": [
|
||||
"php-cs-fixer",
|
||||
"php-cs-fixer.phar"
|
||||
],
|
||||
"type": "application",
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Dariusz Rumiński",
|
||||
"email": "dariusz.ruminski@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A tool to automatically fix PHP code style",
|
||||
"support": {
|
||||
"issues": "https://github.com/PHP-CS-Fixer/shim/issues",
|
||||
"source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.51.0"
|
||||
},
|
||||
"time": "2024-02-28T19:51:07+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": [],
|
||||
"platform-dev": [],
|
||||
"platform-overrides": {
|
||||
"php": "8.0"
|
||||
},
|
||||
"plugin-api-version": "2.6.0"
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "f2391d39f330ebebb8b0d663f5b60927",
|
||||
"packages": [
|
||||
{
|
||||
"name": "nextcloud/coding-standard",
|
||||
"version": "v1.2.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nextcloud/coding-standard.git",
|
||||
"reference": "cf5f18d989ec62fb4cdc7fc92a36baf34b3d829e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nextcloud/coding-standard/zipball/cf5f18d989ec62fb4cdc7fc92a36baf34b3d829e",
|
||||
"reference": "cf5f18d989ec62fb4cdc7fc92a36baf34b3d829e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.3|^8.0",
|
||||
"php-cs-fixer/shim": "^3.17"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Nextcloud\\CodingStandard\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": ["MIT"],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christoph Wurst",
|
||||
"email": "christoph@winzerhof-wurst.at"
|
||||
}
|
||||
],
|
||||
"description": "Nextcloud coding standards for the php cs fixer",
|
||||
"support": {
|
||||
"issues": "https://github.com/nextcloud/coding-standard/issues",
|
||||
"source": "https://github.com/nextcloud/coding-standard/tree/v1.2.1"
|
||||
},
|
||||
"time": "2024-02-01T14:54:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "php-cs-fixer/shim",
|
||||
"version": "v3.51.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHP-CS-Fixer/shim.git",
|
||||
"reference": "a792394f7f3934f75a4df9dca544796c6f503027"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/a792394f7f3934f75a4df9dca544796c6f503027",
|
||||
"reference": "a792394f7f3934f75a4df9dca544796c6f503027",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"ext-tokenizer": "*",
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"replace": {
|
||||
"friendsofphp/php-cs-fixer": "self.version"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-dom": "For handling output formats in XML",
|
||||
"ext-mbstring": "For handling non-UTF8 characters."
|
||||
},
|
||||
"bin": ["php-cs-fixer", "php-cs-fixer.phar"],
|
||||
"type": "application",
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": ["MIT"],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Dariusz Rumiński",
|
||||
"email": "dariusz.ruminski@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A tool to automatically fix PHP code style",
|
||||
"support": {
|
||||
"issues": "https://github.com/PHP-CS-Fixer/shim/issues",
|
||||
"source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.51.0"
|
||||
},
|
||||
"time": "2024-02-28T19:51:07+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": [],
|
||||
"platform-dev": [],
|
||||
"platform-overrides": {
|
||||
"php": "8.0"
|
||||
},
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
|
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -1,22 +1,25 @@
|
|||
import { createAppConfig } from '@nextcloud/vite-config'
|
||||
import { join, resolve } from 'path'
|
||||
|
||||
export default createAppConfig({
|
||||
emptyContent: resolve(join('src', 'emptyContent.js')),
|
||||
main: resolve(join('src', 'main.js')),
|
||||
submit: resolve(join('src', 'submit.js')),
|
||||
settings: resolve(join('src', 'settings.js')),
|
||||
}, {
|
||||
config: {
|
||||
build: {
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['vue', 'vue-router'],
|
||||
export default createAppConfig(
|
||||
{
|
||||
emptyContent: resolve(join('src', 'emptyContent.js')),
|
||||
main: resolve(join('src', 'main.js')),
|
||||
submit: resolve(join('src', 'submit.js')),
|
||||
settings: resolve(join('src', 'settings.js')),
|
||||
},
|
||||
{
|
||||
config: {
|
||||
build: {
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['vue', 'vue-router'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
|
Загрузка…
Ссылка в новой задаче