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:
Ferdinand Thiessen 2024-05-06 14:02:11 +02:00 коммит произвёл Christian Hartmann
Родитель 5a92200380
Коммит f3bc09c8be
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 01CF79F7199D2C63
72 изменённых файлов: 8608 добавлений и 7300 удалений

44
.github/ISSUE_TEMPLATE/bug_report.md поставляемый
Просмотреть файл

@ -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 Chromes step 2. (Chrome and Safari have pretty much identical dev tools.)
- Press CMD + ALT + I to open the Web Inspector.
- See Chromes 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.

6
.github/ISSUE_TEMPLATE/feature_request.md поставляемый
Просмотреть файл

@ -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 [...]

194
.github/dependabot.yml поставляемый
Просмотреть файл

@ -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

4
.github/pr-feedback.yml поставляемый
Просмотреть файл

@ -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'

6
.github/workflows/cypress.yml поставляемый
Просмотреть файл

@ -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

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -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'

5622
composer.lock сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -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"]
}
}

Просмотреть файл

@ -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;
}
```
```

Просмотреть файл

@ -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",

Просмотреть файл

@ -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"
}
}

219
vendor-bin/cs-fixer/composer.lock сгенерированный
Просмотреть файл

@ -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"
}

5328
vendor-bin/psalm/composer.lock сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -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'],
},
},
},
},
},
},
})
)