├── test ├── lib │ ├── upload_tests │ │ └── upload-test.txt │ └── file-utils.js ├── package.json └── test.js ├── .eslintrc.json ├── .stylelintrc.json ├── modules └── @apostrophecms │ ├── form-widget │ ├── views │ │ ├── widget.html │ │ └── widgetBase.html │ ├── ui │ │ └── src │ │ │ ├── index.scss │ │ │ ├── recaptcha.js │ │ │ ├── errors.js │ │ │ ├── fields.js │ │ │ └── index.js │ └── index.js │ ├── form-base-field-widget │ ├── views │ │ ├── widget.html │ │ └── fragments │ │ │ └── utility.html │ └── index.js │ ├── form-divider-widget │ ├── index.js │ └── views │ │ └── widget.html │ ├── form-group-widget │ ├── views │ │ └── widget.html │ └── index.js │ ├── form-conditional-widget │ ├── views │ │ └── widget.html │ ├── ui │ │ └── src │ │ │ └── index.js │ └── index.js │ ├── form-text-field-widget │ ├── ui │ │ └── src │ │ │ └── index.js │ ├── views │ │ └── widget.html │ └── index.js │ ├── form-textarea-field-widget │ ├── ui │ │ └── src │ │ │ └── index.js │ ├── index.js │ └── views │ │ └── widget.html │ ├── form-boolean-field-widget │ ├── index.js │ ├── views │ │ └── widget.html │ └── ui │ │ └── src │ │ └── index.js │ ├── form-radio-field-widget │ ├── index.js │ ├── views │ │ └── widget.html │ └── ui │ │ └── src │ │ └── index.js │ ├── form-checkboxes-field-widget │ ├── views │ │ └── widget.html │ ├── index.js │ └── ui │ │ └── src │ │ └── index.js │ ├── form-file-field-widget │ ├── views │ │ └── widget.html │ ├── ui │ │ └── src │ │ │ └── index.js │ └── index.js │ ├── form-select-field-widget │ ├── views │ │ └── widget.html │ ├── ui │ │ └── src │ │ │ └── index.js │ └── index.js │ └── form-global-settings │ └── index.js ├── ui └── src │ ├── index.js │ └── index.scss ├── views ├── emailConfirmation.html └── emailSubmission.html ├── .gitignore ├── LICENSE.md ├── package.json ├── lib ├── recaptcha.js ├── fields.js └── processor.js ├── CHANGELOG.md ├── i18n ├── en.json ├── sk.json ├── pt-BR.json ├── it.json ├── fr.json ├── de.json └── es.json ├── index.js └── README.md /test/lib/upload_tests/upload-test.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ "apostrophe" ] 3 | } 4 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-apostrophe" 3 | } 4 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-widget/views/widget.html: -------------------------------------------------------------------------------- 1 | {% extends "widgetBase.html" %} -------------------------------------------------------------------------------- /ui/src/index.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | apos.aposForm = apos.aposForm || {}; 3 | apos.aposForm.collectors = {}; 4 | }; 5 | -------------------------------------------------------------------------------- /views/emailConfirmation.html: -------------------------------------------------------------------------------- 1 |

Thank you

2 | 3 |

Thank you for your submission to the form, {{ data.form.title }}.

4 | -------------------------------------------------------------------------------- /views/emailSubmission.html: -------------------------------------------------------------------------------- 1 |

New submission to form: {{ data.form.title }}

2 | 3 | 8 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-base-field-widget/views/widget.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore MacOS X metadata forks (fusefs) 2 | ._* 3 | package-lock.json 4 | *.DS_Store 5 | node_modules 6 | .vscode 7 | 8 | # Never commit a CSS map file, anywhere 9 | *.css.map 10 | 11 | # vim swp files 12 | .*.sw* 13 | 14 | # Test files 15 | test/public/uploads -------------------------------------------------------------------------------- /modules/@apostrophecms/form-divider-widget/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extend: '@apostrophecms/widget-type', 3 | options: { 4 | initialModal: false, 5 | label: 'aposForm:divider', 6 | className: 'apos-form-divider', 7 | icon: 'minus-icon' 8 | }, 9 | icons: { 10 | 'minus-icon': 'Minus' 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-group-widget/views/widget.html: -------------------------------------------------------------------------------- 1 | {% set widget = data.widget %} 2 | {% set prependIfPrefix = apos.modules['@apostrophecms/form'].prependIfPrefix %} 3 | 4 |
7 | {{ widget.label }} 8 | {% area widget, 'contents' %} 9 |
10 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "//": "This package.json file is not actually installed.", 3 | "//": "Apostrophe requires that all npm modules to be loaded by moog", 4 | "//": "exist in package.json at project level, which for a test is here", 5 | "dependencies": { 6 | "apostrophe": "^3.4.0", 7 | "@apostrophecms/form": "git://github.com/apostrophecms/form.git" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-conditional-widget/views/widget.html: -------------------------------------------------------------------------------- 1 | {% set prependIfPrefix = apos.modules['@apostrophecms/form'].prependIfPrefix %} 2 |
5 | {% area data.widget, 'contents' %} 6 |
-------------------------------------------------------------------------------- /modules/@apostrophecms/form-text-field-widget/ui/src/index.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | apos.aposForm.collectors['@apostrophecms/form-text-field'] = { 3 | selector: '[data-apos-form-text]', 4 | collector (el) { 5 | const input = el.querySelector('input'); 6 | 7 | return { 8 | field: input.getAttribute('name'), 9 | value: input.value 10 | }; 11 | } 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-textarea-field-widget/ui/src/index.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | apos.aposForm.collectors['@apostrophecms/form-textarea-field'] = { 3 | selector: '[data-apos-form-textarea]', 4 | collector (el) { 5 | const input = el.querySelector('textarea'); 6 | 7 | return { 8 | field: input.getAttribute('name'), 9 | value: input.value 10 | }; 11 | } 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-divider-widget/views/widget.html: -------------------------------------------------------------------------------- 1 | {% set prependIfPrefix = apos.modules['@apostrophecms/form'].prependIfPrefix %} 2 | 3 | {% set className = '' %} 4 | {% if data.options.className %} 5 | {% set className = data.options.className %} 6 | {% elif data.manager.options.className %} 7 | {% set className = data.manager.options.className %} 8 | {% endif %} 9 | 10 |
11 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-textarea-field-widget/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extend: '@apostrophecms/form-base-field-widget', 3 | options: { 4 | label: 'aposForm:textArea', 5 | icon: 'form-textarea-icon' 6 | }, 7 | icons: { 8 | 'form-textarea-icon': 'FormTextarea' 9 | }, 10 | fields: { 11 | add: { 12 | placeholder: { 13 | label: 'aposForm:textPlaceholder', 14 | type: 'string', 15 | help: 'aposForm:textPlaceholderHelp' 16 | } 17 | } 18 | }, 19 | methods (self) { 20 | return { 21 | sanitizeFormField (widget, input, output) { 22 | output[widget.fieldName] = self.apos.launder.string(input[widget.fieldName]); 23 | } 24 | }; 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-boolean-field-widget/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extend: '@apostrophecms/form-base-field-widget', 3 | options: { 4 | label: 'aposForm:boolean', 5 | icon: 'toggle-switch-outline-icon' 6 | }, 7 | icons: { 8 | 'toggle-switch-outline-icon': 'ToggleSwitchOutline' 9 | }, 10 | fields (self, options) { 11 | return { 12 | add: { 13 | checked: { 14 | label: 'aposForm:booleanChecked', 15 | help: 'aposForm:booleanCheckedHelp', 16 | type: 'boolean' 17 | } 18 | } 19 | }; 20 | }, 21 | methods (self) { 22 | return { 23 | sanitizeFormField (widget, input, output) { 24 | output[widget.fieldName] = self.apos.launder.boolean(input[widget.fieldName]); 25 | } 26 | }; 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-widget/ui/src/index.scss: -------------------------------------------------------------------------------- 1 | // Colors 2 | $apos-form-red: #ea433a; 3 | 4 | // Spacing 5 | $apos-form-spacing: 20px; 6 | 7 | .apos-form-field-required { 8 | color: $apos-form-red; 9 | } 10 | 11 | .apos-form-label { 12 | display: block; 13 | 14 | &-message { 15 | padding-left: $apos-form-spacing; 16 | } 17 | } 18 | 19 | .apos-form-input, 20 | .apos-form-group, 21 | .apos-form-fieldset { 22 | margin-bottom: $apos-form-spacing; 23 | } 24 | 25 | .apos-form-input-error { 26 | outline: 1px solid $apos-form-red; 27 | } 28 | 29 | .apos-form-error { 30 | color: $apos-form-red; 31 | } 32 | 33 | .apos-form-conditional[disabled] { 34 | display: none; 35 | 36 | // Display the fieldset when in an editor modal. 37 | .apos-modal & { 38 | display: block; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-conditional-widget/ui/src/index.js: -------------------------------------------------------------------------------- 1 | export default () => { 2 | apos.aposForm.checkConditional = function (groups, input) { 3 | if (!groups) { 4 | groups = []; 5 | } 6 | 7 | if (!input) { 8 | return; 9 | } 10 | 11 | Array.prototype.slice.call(groups).forEach(function (fieldSet) { 12 | const conditionValue = fieldSet.getAttribute('data-apos-form-condition-value'); 13 | let activate = true; 14 | 15 | if (input.type === 'checkbox' && input.value !== conditionValue) { 16 | return; 17 | } 18 | 19 | if (input.type === 'checkbox' && !input.checked) { 20 | activate = false; 21 | } 22 | 23 | if (input.value === conditionValue && activate) { 24 | fieldSet.removeAttribute('disabled'); 25 | } else { 26 | fieldSet.setAttribute('disabled', true); 27 | } 28 | }); 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-base-field-widget/views/fragments/utility.html: -------------------------------------------------------------------------------- 1 | {% macro optional(required) %} 2 | {% set disable = apos.modules['@apostrophecms/form'].options.disableOptionalLabel %} 3 | {% if not required and not disable %} 4 | {{ __t('aposForm:templateOptional') }} 5 | {% endif %} 6 | {% endmacro %} 7 | 8 | {% macro required(required) %} 9 | {% set disable = apos.modules['@apostrophecms/form'].options.disableOptionalLabel %} 10 | {% if required %} 11 | * 12 | {% endif %} 13 | {% endmacro %} 14 | 15 | {% macro checkInput(widget, choice, id) %} 16 | {% set prefixer = apos.modules['@apostrophecms/form'].prependIfPrefix %} 17 | 21 | {% endmacro %} 22 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-boolean-field-widget/views/widget.html: -------------------------------------------------------------------------------- 1 | {# ids must be unique doc-wide #} 2 | {% set id = apos.util.generateId() %} 3 | {% set widget = data.widget %} 4 | {% set prependIfPrefix = apos.modules['@apostrophecms/form'].prependIfPrefix %} 5 |
6 | 16 | 22 |
23 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-widget/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extend: '@apostrophecms/widget-type', 3 | options: { 4 | label: 'aposForm:widgetForm', 5 | icon: 'form-select-icon' 6 | }, 7 | icons: { 8 | 'form-select-icon': 'FormSelect' 9 | }, 10 | fields: { 11 | add: { 12 | _form: { 13 | label: 'aposForm:widgetFormSelect', 14 | type: 'relationship', 15 | withType: '@apostrophecms/form', 16 | required: true, 17 | max: 1 18 | } 19 | } 20 | }, 21 | extendMethods (self) { 22 | return { 23 | load(_super, req, widgets) { 24 | const formModule = self.apos.modules['@apostrophecms/form']; 25 | const classPrefix = formModule.options.classPrefix; 26 | 27 | if (classPrefix) { 28 | widgets.forEach(widget => { 29 | widget.classPrefix = classPrefix; 30 | }); 31 | } 32 | 33 | return _super(req, widgets); 34 | } 35 | }; 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-textarea-field-widget/views/widget.html: -------------------------------------------------------------------------------- 1 | {# ids must be unique doc-wide #} 2 | {% set id = apos.util.generateId() %} 3 | {% set widget = data.widget %} 4 | {% set prependIfPrefix = apos.modules['@apostrophecms/form'].prependIfPrefix %} 5 | {% import "fragments/utility.html" as utils with context %} 6 | 7 |
8 | 18 | 22 |
-------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Apostrophe Technologies 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /test/lib/file-utils.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const uploadSource = `${__dirname}/upload_tests/`; 3 | 4 | module.exports = { 5 | wipeIt: async function (uploadTarget, apos) { 6 | deleteFolderRecursive(uploadTarget); 7 | 8 | function deleteFolderRecursive(path) { 9 | let files = []; 10 | if (fs.existsSync(path)) { 11 | files = fs.readdirSync(path); 12 | files.forEach(function (file, index) { 13 | const curPath = path + '/' + file; 14 | if (fs.lstatSync(curPath).isDirectory()) { // recurse 15 | deleteFolderRecursive(curPath); 16 | } else { // delete file 17 | fs.unlinkSync(curPath); 18 | } 19 | }); 20 | fs.rmdirSync(path); 21 | } 22 | } 23 | 24 | const db = await apos.db.collection('aposAttachments'); 25 | 26 | db.deleteMany({}); 27 | }, 28 | insert: async function (filename, apos) { 29 | return apos.attachment.insert(apos.task.getReq(), { 30 | name: filename, 31 | path: `${uploadSource}${filename}` 32 | }); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-group-widget/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extend: '@apostrophecms/widget-type', 3 | options: { 4 | label: 'aposForm:group', 5 | className: 'apos-form-group', 6 | icon: 'file-multiple-outline-icon' 7 | }, 8 | icons: { 9 | 'file-multiple-outline-icon': 'FileMultipleOutline' 10 | }, 11 | fields (self) { 12 | // Prevent nested groups 13 | const form = self.options.apos.modules['@apostrophecms/form']; 14 | const { 15 | '@apostrophecms/form-group': groupWidget, 16 | ...formWidgets 17 | } = form.fields.contents.options.widgets; 18 | 19 | return { 20 | add: { 21 | label: { 22 | label: 'aposForm:groupLabel', 23 | type: 'string', 24 | required: true 25 | }, 26 | contents: { 27 | label: 'aposForm:groupContents', 28 | help: 'aposForm:groupContentsHelp', 29 | type: 'area', 30 | contextual: false, 31 | options: { 32 | widgets: formWidgets 33 | } 34 | } 35 | } 36 | }; 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-radio-field-widget/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extend: '@apostrophecms/form-select-field-widget', 3 | options: { 4 | label: 'aposForm:radio', 5 | icon: 'radiobox-marked-icon' 6 | }, 7 | icons: { 8 | 'radiobox-marked-icon': 'RadioboxMarked' 9 | }, 10 | fields: { 11 | add: { 12 | choices: { 13 | label: 'aposForm:radioChoice', 14 | type: 'array', 15 | titleField: 'label', 16 | required: true, 17 | min: 1, // Two would be better, but this is primarily to avoid errors. 18 | fields: { 19 | add: { 20 | label: { 21 | type: 'string', 22 | required: true, 23 | label: 'aposForm:checkboxChoicesLabel', 24 | help: 'aposForm:checkboxChoicesLabelHelp' 25 | }, 26 | value: { 27 | type: 'string', 28 | label: 'aposForm:checkboxChoicesValue', 29 | help: 'aposForm:checkboxChoicesValueHelp' 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-checkboxes-field-widget/views/widget.html: -------------------------------------------------------------------------------- 1 | {# ids must be unique doc-wide #} 2 | {% set id = apos.util.generateId() %} 3 | {% set widget = data.widget %} 4 | {% set prependIfPrefix = apos.modules['@apostrophecms/form'].prependIfPrefix %} 5 | {% import "fragments/utility.html" as utils with context %} 6 | 7 |
12 | 13 | {{ widget.fieldLabel}} 14 | 15 | {{ utils.optional(widget.required) }} 16 | {{ utils.required(widget.required) }} 17 | {% for choice in widget.choices %} 18 | {% set choiceId = id + apos.util.slugify(choice.value) %} 19 |
20 | {{ utils.checkInput(widget, choice, choiceId, prependIfPrefix) }} 21 | 24 |
25 | {% endfor %} 26 | 27 |
28 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-radio-field-widget/views/widget.html: -------------------------------------------------------------------------------- 1 | {# ids must be unique doc-wide #} 2 | {% set id = apos.util.generateId() %} 3 | {% set widget = data.widget %} 4 | {% set prependIfPrefix = apos.modules['@apostrophecms/form'].prependIfPrefix %} 5 | {% import "fragments/utility.html" as utils with context %} 6 | 7 |
11 | 12 | {{ widget.fieldLabel}} 13 | 14 | {{ utils.optional(widget.required) }} 15 | {{ utils.required(widget.required) }} 16 | {% for choice in widget.choices %} 17 | {% set choiceId = id + apos.util.slugify(choice.value) %} 18 |
19 | 23 | 26 |
27 | {% endfor %} 28 | 29 |
30 | -------------------------------------------------------------------------------- /ui/src/index.scss: -------------------------------------------------------------------------------- 1 | $apos-form-spacing: 35px; 2 | 3 | .apos-form-hidden { 4 | display: none; 5 | } 6 | // TODO: refactor to remove apos-form-visible. We can't depend on the parent 7 | // having the right display property. `initial` could work better, but simply 8 | // hiding and unhiding is safer. The `hidden` HTML attribute could work as well. 9 | .apos-form-visible { 10 | display: inherit; 11 | } 12 | 13 | .apos-form-input, 14 | .apos-form-group, 15 | .apos-form-fieldset { 16 | .apos-modal & { 17 | margin-bottom: $apos-form-spacing; 18 | } 19 | } 20 | 21 | .apos-form-checkboxes--dropdown { 22 | display: inline-block; 23 | } 24 | 25 | .apos-form-checkboxes-toggle { 26 | width: auto; 27 | } 28 | 29 | .apos-form-checkboxes-toggle::after { 30 | padding-left: 24px; 31 | content: '▶'; 32 | 33 | .apos-form-checkboxes--dropdown.is-active & { 34 | padding-left: 24px; 35 | content: '▲'; 36 | } 37 | } 38 | 39 | .apos-form-checkboxes-dropdown-choices { 40 | overflow: hidden; 41 | width: auto; 42 | height: 0; 43 | 44 | label { 45 | display: block; 46 | width: auto; 47 | line-height: 1.5; 48 | } 49 | 50 | .apos-form-checkboxes--dropdown.is-active & { 51 | overflow: auto; 52 | height: auto; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-file-field-widget/views/widget.html: -------------------------------------------------------------------------------- 1 | {# ids must be unique doc-wide #} 2 | {% set id = apos.util.generateId() %} 3 | {% set widget = data.widget %} 4 | {% set prependIfPrefix = apos.modules['@apostrophecms/form'].prependIfPrefix %} 5 | {% import "fragments/utility.html" as utils with context %} 6 | 7 |
8 | 16 | 26 |
27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apostrophecms/form", 3 | "version": "1.5.2", 4 | "description": "Build forms for ApostropheCMS in a simple user interface.", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "npm run eslint && npm run stylelint", 8 | "eslint": "eslint .", 9 | "stylelint": "stylelint --custom-syntax postcss-scss **/*.scss", 10 | "test": "npm run lint && npx mocha" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/apostrophecms/form.git" 15 | }, 16 | "homepage": "https://github.com/apostrophecms/form#readme", 17 | "author": "Apostrophe Technologies", 18 | "license": "MIT", 19 | "keywords": [ 20 | "apostrophe", 21 | "apostrophecms", 22 | "forms", 23 | "form builder" 24 | ], 25 | "devDependencies": { 26 | "apostrophe": "^4.0.0", 27 | "eslint": "^8.50.0", 28 | "eslint-config-apostrophe": "^4.1.0", 29 | "eslint-config-standard": "^17.1.0", 30 | "eslint-plugin-import": "^2.22.0", 31 | "eslint-plugin-node": "^11.1.0", 32 | "eslint-plugin-promise": "^6.1.1", 33 | "eslint-plugin-standard": "^5.0.0", 34 | "mocha": "^10.2.0", 35 | "stylelint": "^15.10.3", 36 | "stylelint-config-apostrophe": "^3.0.0" 37 | }, 38 | "dependencies": { 39 | "multer": "^2.0.2" 40 | } 41 | } -------------------------------------------------------------------------------- /modules/@apostrophecms/form-select-field-widget/views/widget.html: -------------------------------------------------------------------------------- 1 | {# ids must be unique doc-wide #} 2 | {% set id = apos.util.generateId() %} 3 | {% set widget = data.widget %} 4 | {% set prependIfPrefix = apos.modules['@apostrophecms/form'].prependIfPrefix %} 5 | {% import "fragments/utility.html" as utils with context %} 6 | 7 |
8 | 18 | 28 |
29 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-select-field-widget/ui/src/index.js: -------------------------------------------------------------------------------- 1 | const WIDGET_NAME = '@apostrophecms/form-select-field'; 2 | const WIDGET_SELECTOR = '[data-apos-form-select]'; 3 | 4 | export default () => { 5 | apos.util.widgetPlayers[WIDGET_NAME] = { 6 | selector: WIDGET_SELECTOR, 7 | player (el) { 8 | const formWidget = apos.util.closest(el, '[data-apos-form-form]'); 9 | if (!formWidget) { 10 | // Editing the form in the piece modal, it is not active for submissions 11 | return; 12 | } 13 | 14 | const input = el.querySelector('select'); 15 | const inputName = input.getAttribute('name'); 16 | 17 | const conditionalGroups = formWidget.querySelectorAll('[data-apos-form-condition="' + inputName + '"]'); 18 | 19 | if (conditionalGroups.length > 0) { 20 | const check = apos.aposForm.checkConditional; 21 | check(conditionalGroups, input); 22 | 23 | input.addEventListener('change', function () { 24 | check(conditionalGroups, input); 25 | }); 26 | } 27 | } 28 | }; 29 | 30 | apos.aposForm.collectors[WIDGET_NAME] = { 31 | selector: WIDGET_SELECTOR, 32 | collector (el) { 33 | const input = el.querySelector('select'); 34 | 35 | return { 36 | field: input.getAttribute('name'), 37 | value: input.value 38 | }; 39 | } 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-text-field-widget/views/widget.html: -------------------------------------------------------------------------------- 1 | {# ids must be unique doc-wide #} 2 | {% set id = apos.util.generateId() %} 3 | {% set widget = data.widget %} 4 | {% set prependIfPrefix = apos.modules['@apostrophecms/form'].prependIfPrefix %} 5 | {% import "fragments/utility.html" as utils with context %} 6 | 7 |
8 | 18 | {% if widget.inputType == 'date' %} 19 |

20 | {{ __t("(YYYY-MM-DD)") }} 21 |

22 | {% endif %} 23 | 33 |
34 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-boolean-field-widget/ui/src/index.js: -------------------------------------------------------------------------------- 1 | const WIDGET_NAME = '@apostrophecms/form-boolean-field'; 2 | const WIDGET_SELECTOR = '[data-apos-form-boolean]'; 3 | 4 | export default () => { 5 | apos.util.widgetPlayers[WIDGET_NAME] = { 6 | selector: WIDGET_SELECTOR, 7 | player (el) { 8 | const formWidget = apos.util.closest(el, '[data-apos-form-form]'); 9 | if (!formWidget) { 10 | // Editing the form in the piece modal, it is not active for submissions 11 | return; 12 | } 13 | 14 | const input = el.querySelector('input[type="checkbox"]'); 15 | const inputName = input.getAttribute('name'); 16 | 17 | const conditionalGroups = formWidget.querySelectorAll('[data-apos-form-condition="' + inputName + '"]'); 18 | 19 | if (conditionalGroups.length > 0) { 20 | const check = apos.aposForm.checkConditional; 21 | 22 | if (input.checked) { 23 | check(conditionalGroups, input); 24 | } 25 | 26 | input.addEventListener('change', function (e) { 27 | check(conditionalGroups, e.target); 28 | }); 29 | } 30 | } 31 | }; 32 | 33 | apos.aposForm.collectors[WIDGET_NAME] = { 34 | selector: WIDGET_SELECTOR, 35 | collector (el) { 36 | const input = el.querySelector('input[type="checkbox"]'); 37 | 38 | return { 39 | field: input.getAttribute('name'), 40 | value: input.checked 41 | }; 42 | } 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-widget/ui/src/recaptcha.js: -------------------------------------------------------------------------------- 1 | /* global grecaptcha */ 2 | let siteKey; 3 | 4 | export default function (widgetEl) { 5 | if (!widgetEl.querySelector('[data-apos-recaptcha-sitekey]')) { 6 | return; 7 | } 8 | 9 | siteKey = widgetEl.querySelector('[data-apos-recaptcha-sitekey]').dataset.aposRecaptchaSitekey; 10 | 11 | if (!document.querySelector('[data-apos-recaptcha-script]')) { 12 | 13 | window.enableSubmissions = function () { 14 | grecaptcha.ready(function() { 15 | const buttons = document.querySelectorAll('[data-apos-form-submit]'); 16 | 17 | [ ...buttons ].forEach(btn => { 18 | btn.disabled = false; 19 | }); 20 | }); 21 | }; 22 | addRecaptchaScript(siteKey); 23 | } 24 | 25 | return { 26 | getToken 27 | }; 28 | } 29 | 30 | function addRecaptchaScript (siteKey) { 31 | const container = document.querySelector('[data-apos-refreshable]') || document.body; 32 | const recaptchaScript = document.createElement('script'); 33 | 34 | recaptchaScript.src = apos.http.addQueryToUrl('https://www.google.com/recaptcha/api.js', { 35 | render: siteKey, 36 | onload: 'enableSubmissions' 37 | }); 38 | 39 | recaptchaScript.setAttribute('data-apos-recaptcha-script', ''); 40 | recaptchaScript.setAttribute('async', ''); 41 | recaptchaScript.setAttribute('defer', ''); 42 | container.appendChild(recaptchaScript); 43 | } 44 | 45 | async function getToken (widgetEl) { 46 | return grecaptcha.execute(siteKey, { action: 'submit' }); 47 | } 48 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-text-field-widget/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extend: '@apostrophecms/form-base-field-widget', 3 | options: { 4 | label: 'aposForm:text', 5 | icon: 'form-textbox-icon' 6 | }, 7 | icons: { 8 | 'form-textbox-icon': 'FormTextbox' 9 | }, 10 | fields: { 11 | add: { 12 | inputType: { 13 | label: 'aposForm:textType', 14 | type: 'select', 15 | help: 'aposForm:textTypeHelp', 16 | choices: [ 17 | { 18 | label: 'aposForm:textTypeText', 19 | value: 'text' 20 | }, 21 | { 22 | label: 'aposForm:textTypeEmail', 23 | value: 'email' 24 | }, 25 | { 26 | label: 'aposForm:textTypePhone', 27 | value: 'tel' 28 | }, 29 | { 30 | label: 'aposForm:textTypeUrl', 31 | value: 'url' 32 | }, 33 | { 34 | label: 'aposForm:textTypeDate', 35 | value: 'date' 36 | }, 37 | { 38 | label: 'aposForm:textTypePassword', 39 | value: 'password' 40 | } 41 | ], 42 | def: 'text' 43 | }, 44 | placeholder: { 45 | label: 'aposForm:textPlaceholder', 46 | type: 'string', 47 | help: 'aposForm:textPlaceholderHelp' 48 | } 49 | } 50 | }, 51 | methods (self) { 52 | return { 53 | sanitizeFormField (widget, input, output) { 54 | output[widget.fieldName] = self.apos.launder.string(input[widget.fieldName]); 55 | } 56 | }; 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-radio-field-widget/ui/src/index.js: -------------------------------------------------------------------------------- 1 | const WIDGET_NAME = '@apostrophecms/form-radio-field'; 2 | const WIDGET_SELECTOR = '[data-apos-form-radio]'; 3 | 4 | export default () => { 5 | apos.util.widgetPlayers[WIDGET_NAME] = { 6 | selector: WIDGET_SELECTOR, 7 | player (el) { 8 | const formWidget = apos.util.closest(el, '[data-apos-form-form]'); 9 | const inputs = el.querySelectorAll('input[type="radio"]'); 10 | 11 | if (!formWidget || inputs.length === 0) { 12 | // Editing the form in the piece modal, it is not active for submissions 13 | return; 14 | } 15 | 16 | const inputName = inputs[0].getAttribute('name'); 17 | 18 | const conditionalGroups = formWidget.querySelectorAll('[data-apos-form-condition="' + inputName + '"]'); 19 | 20 | if (conditionalGroups.length > 0) { 21 | const input = el.querySelector('input[type="radio"]:checked'); 22 | 23 | const check = apos.aposForm.checkConditional; 24 | check(conditionalGroups, input); 25 | 26 | Array.prototype.forEach.call(inputs, function (radio) { 27 | radio.addEventListener('change', function (e) { 28 | check(conditionalGroups, e.target); 29 | }); 30 | }); 31 | } 32 | } 33 | }; 34 | 35 | apos.aposForm.collectors[WIDGET_NAME] = { 36 | selector: WIDGET_SELECTOR, 37 | collector (el) { 38 | const inputs = el.querySelectorAll('input[type="radio"]'); 39 | const checked = el.querySelector('input[type="radio"]:checked'); 40 | 41 | return { 42 | field: inputs[0].getAttribute('name'), 43 | value: checked ? checked.value : undefined 44 | }; 45 | } 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-widget/ui/src/errors.js: -------------------------------------------------------------------------------- 1 | function processErrors (errors, el) { 2 | const form = el.querySelector('[data-apos-form-form]'); 3 | const errorMsg = el.querySelector('[data-apos-form-submit-error]'); 4 | 5 | apos.util.emit(document.body, '@apostrophecms/form:submission-failed', { 6 | form, 7 | errors 8 | }); 9 | apos.util.addClass(errorMsg, 'apos-form-visible'); 10 | 11 | highlight(el, errors); 12 | } 13 | 14 | function highlight(el, errors) { 15 | if (!Array.isArray(errors)) { 16 | return; 17 | } 18 | 19 | const form = el.querySelector('[data-apos-form-form]'); 20 | const globalError = el.querySelector('[data-apos-form-global-error]'); 21 | 22 | globalError.innerText = ''; 23 | 24 | errors.forEach(function (error) { 25 | if (!validateError(error)) { 26 | return; 27 | } 28 | 29 | if (error.global) { 30 | globalError.innerText = globalError.innerText + ' ' + 31 | error.message; 32 | 33 | return; 34 | } 35 | let fields = form.querySelectorAll(`[name=${error.field}]`); 36 | fields = Array.prototype.slice.call(fields); 37 | 38 | const labelMessage = form.querySelector(`[data-apos-input-message=${error.field}]`); 39 | 40 | fields.forEach(function (field) { 41 | apos.util.addClass(field, 'apos-form-input-error'); 42 | }); 43 | 44 | apos.util.addClass(labelMessage, 'apos-form-error'); 45 | labelMessage.innerText = error.message; 46 | labelMessage.hidden = false; 47 | }); 48 | } 49 | 50 | function validateError (error) { 51 | if ((!error.global && !error.field) || !error.message) { 52 | return false; 53 | } 54 | 55 | return true; 56 | } 57 | 58 | export { 59 | processErrors 60 | }; 61 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-conditional-widget/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extend: '@apostrophecms/widget-type', 3 | options: { 4 | label: 'aposForm:conditional', 5 | icon: 'arrow-decision-icon' 6 | }, 7 | icons: { 8 | 'arrow-decision-icon': 'ArrowDecision' 9 | }, 10 | fields (self) { 11 | // Get the form widgets from the form piece module and add them in the 12 | // conditional contents area, removing the conditional field itself. 13 | const forms = self.options.apos.modules['@apostrophecms/form']; 14 | const formWidgets = Object.assign({}, forms.fields.contents.options.widgets); 15 | delete formWidgets['@apostrophecms/form-conditional']; 16 | 17 | return { 18 | add: { 19 | conditionName: { 20 | label: 'aposForm:conditionalName', 21 | htmlHelp: 'aposForm:conditionalNameHelp', 22 | required: true, 23 | type: 'string' 24 | }, 25 | conditionValue: { 26 | label: 'aposForm:conditionalValue', 27 | htmlHelp: 'aposForm:conditionalValueHtmlHelp', 28 | required: true, 29 | type: 'string' 30 | }, 31 | contents: { 32 | label: 'aposForm:formContents', 33 | help: 'aposForm:conditionalContentsHelp', 34 | type: 'area', 35 | contextual: false, 36 | options: { 37 | widgets: formWidgets 38 | } 39 | } 40 | } 41 | }; 42 | }, 43 | extendMethods (self) { 44 | return { 45 | load (_super, req, widgets) { 46 | const forms = self.apos.modules['@apostrophecms/form']; 47 | const classPrefix = forms.options.classPrefix; 48 | 49 | widgets.forEach(widget => { 50 | widget.classPrefix = classPrefix; 51 | }); 52 | 53 | return _super(req, widgets); 54 | } 55 | }; 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-file-field-widget/ui/src/index.js: -------------------------------------------------------------------------------- 1 | const WIDGET_NAME = '@apostrophecms/form-file-field'; 2 | const WIDGET_SELECTOR = '[data-apos-form-file]'; 3 | 4 | export default () => { 5 | const readableSize = ({ units, size }) => { 6 | return size < 1000 7 | ? `${size} ${units.B}` 8 | : size < 1000 * 1000 9 | ? `${(size / 1000).toFixed(2)} ${units.KB}` 10 | : size < 1000 * 1000 * 1000 11 | ? `${(size / (1000 * 1000)).toFixed(2)} ${units.MB}` 12 | : `${(size / (1000 * 1000 * 1000)).toFixed(2)} ${units.GB}`; 13 | }; 14 | 15 | const sizeLimiter = (input) => { 16 | if (!input.dataset.maxSize) { 17 | return; 18 | } 19 | 20 | const { files } = input; 21 | const totalSize = Array.from(files || []).reduce((sum, { size }) => sum + size, 0); 22 | 23 | const units = JSON.parse(input.dataset.fileSizeUnits || '{}'); 24 | const maxSize = input.dataset.maxSize; 25 | const maxSizeError = (input.dataset.maxSizeError || '').replace( 26 | '%2$s', 27 | readableSize({ 28 | size: maxSize, 29 | units 30 | }) 31 | ); 32 | if (maxSize && totalSize > maxSize) { 33 | const error = new Error( 34 | maxSizeError.replace( 35 | '%1$s', 36 | readableSize({ 37 | size: totalSize, 38 | units 39 | }) 40 | ) 41 | ); 42 | error.field = input.getAttribute('name'); 43 | 44 | throw error; 45 | } 46 | }; 47 | 48 | apos.aposForm.collectors[WIDGET_NAME] = { 49 | selector: WIDGET_SELECTOR, 50 | collector (el) { 51 | const input = el.querySelector('input[type="file"]'); 52 | sizeLimiter(input); 53 | 54 | return { 55 | field: input.getAttribute('name'), 56 | value: 'pending', 57 | files: input.files 58 | }; 59 | } 60 | }; 61 | }; 62 | -------------------------------------------------------------------------------- /lib/recaptcha.js: -------------------------------------------------------------------------------- 1 | module.exports = function(self) { 2 | return { 3 | async getRecaptchaSecret (req, self) { 4 | const globalDoc = await self.apos.global.find(req, {}).toObject(); 5 | 6 | return self.options.recaptchaSecret 7 | ? self.options.recaptchaSecret 8 | : globalDoc.useRecaptcha 9 | ? globalDoc.recaptchaSecret 10 | : null; 11 | }, 12 | cleanOptions (options) { 13 | if (!options.recaptchaSecret || !options.recaptchaSite) { 14 | // No fooling around. If they are not *both* included, both are invalid. 15 | // Deleting here to avoid having to repeatedly check for both's existence. 16 | delete options.recaptchaSite; 17 | delete options.recaptchaSecret; 18 | } 19 | }, 20 | async checkRecaptcha (req, input, formErrors) { 21 | const recaptchaSecret = await self.getRecaptchaSecret(req, self); 22 | 23 | if (!recaptchaSecret) { 24 | return; 25 | } 26 | 27 | if (!input.recaptcha) { 28 | formErrors.push({ 29 | global: true, 30 | error: 'recaptcha', 31 | message: req.t('aposForm:recaptchaSubmitError') 32 | }); 33 | 34 | return; 35 | } 36 | 37 | try { 38 | const url = 'https://www.google.com/recaptcha/api/siteverify'; 39 | const recaptchaUri = `${url}?secret=${recaptchaSecret}&response=${input.recaptcha}`; 40 | 41 | const response = await self.apos.http.post(recaptchaUri); 42 | 43 | if (!response.success) { 44 | formErrors.push({ 45 | global: true, 46 | error: 'recaptcha', 47 | message: req.t('aposForm:recaptchaValidationError') 48 | }); 49 | } 50 | } catch (e) { 51 | self.apos.util.error(e); 52 | 53 | formErrors.push({ 54 | global: true, 55 | error: 'recaptcha', 56 | message: req.t('aposForm:recaptchaConfigError') 57 | }); 58 | } 59 | } 60 | }; 61 | }; 62 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-checkboxes-field-widget/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extend: '@apostrophecms/form-base-field-widget', 3 | options: { 4 | label: 'aposForm:checkbox', 5 | icon: 'checkbox-marked-outline-icon' 6 | }, 7 | icons: { 8 | 'checkbox-marked-outline-icon': 'CheckboxMarkedOutline' 9 | }, 10 | fields: { 11 | add: { 12 | choices: { 13 | label: 'aposForm:checkboxChoices', 14 | type: 'array', 15 | titleField: 'label', 16 | required: true, 17 | min: 1, // Two would be better, but this is primarily to avoid errors. 18 | fields: { 19 | add: { 20 | label: { 21 | label: 'aposForm:checkboxChoicesLabel', 22 | type: 'string', 23 | required: true, 24 | help: 'aposForm:checkboxChoicesLabelHelp' 25 | }, 26 | value: { 27 | label: 'aposForm:checkboxChoicesValue', 28 | type: 'string', 29 | help: 'aposForm:checkboxChoicesValueHelp' 30 | } 31 | } 32 | } 33 | } 34 | } 35 | }, 36 | methods (self) { 37 | return { 38 | sanitizeFormField (widget, input, output) { 39 | // Get the options from that form for the widget 40 | const choices = self.getChoicesValues(widget); 41 | 42 | if (!input[widget.fieldName]) { 43 | output[widget.fieldName] = null; 44 | return; 45 | } 46 | 47 | input[widget.fieldName] = Array.isArray(input[widget.fieldName]) 48 | ? input[widget.fieldName] 49 | : []; 50 | 51 | // Return an array of selected choices as the output. 52 | output[widget.fieldName] = input[widget.fieldName] 53 | .map(choice => { 54 | return self.apos.launder.select(choice, choices); 55 | }) 56 | .filter(choice => { 57 | // Filter out the undefined, laundered out values. 58 | return choice; 59 | }); 60 | } 61 | }; 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-widget/ui/src/fields.js: -------------------------------------------------------------------------------- 1 | // Async field validation, in case this needs to hit an API route. 2 | async function collectValues (form) { 3 | if (!apos.aposForm.collectors || apos.aposForm.collectors.length === 0) { 4 | return; 5 | } 6 | 7 | const formErrors = []; 8 | const input = {}; 9 | 10 | for (const type in apos.aposForm.collectors) { 11 | const selector = apos.aposForm.collectors[type].selector; 12 | const collector = apos.aposForm.collectors[type].collector; 13 | const fields = form.querySelectorAll(selector); 14 | 15 | for (const field of fields) { 16 | try { 17 | const response = await collector(field); 18 | if (typeof response !== 'object' || !response.field) { 19 | // Log this. Not useful information for an end user. 20 | // eslint-disable-next-line 21 | console.error(`${type} field widget type is returning an invalid collector response.`); 22 | } 23 | 24 | // If there are files to upload, return an object with the files. 25 | input[response.field] = response.files 26 | ? { 27 | value: response.value, 28 | files: response.files 29 | } 30 | : response.value; 31 | } catch (error) { 32 | // Add error to formErrors 33 | const fieldError = error.field ? error : error?.data?.fieldError; 34 | 35 | if (fieldError?.field) { 36 | const e = fieldError; 37 | 38 | formErrors.push({ 39 | field: e.field, 40 | message: e.message || 'Error' 41 | }); 42 | } else { 43 | formErrors.push({ 44 | global: true, 45 | message: 'Unknown form field error' 46 | }); 47 | } 48 | } 49 | } 50 | } 51 | 52 | if (formErrors.length > 0) { 53 | const error = new Error('invalid'); 54 | error.data = { 55 | formErrors 56 | }; 57 | 58 | throw error; 59 | } 60 | 61 | return input; 62 | } 63 | 64 | export { 65 | collectValues 66 | }; 67 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-file-field-widget/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extend: '@apostrophecms/form-base-field-widget', 3 | options: { 4 | label: 'aposForm:file', 5 | icon: 'file-upload-outline-icon' 6 | }, 7 | icons: { 8 | 'file-upload-outline-icon': 'FileUploadOutline' 9 | }, 10 | fields: { 11 | add: { 12 | allowMultiple: { 13 | label: 'aposForm:fileAllowMultiple', 14 | type: 'boolean', 15 | def: true 16 | }, 17 | limitSize: { 18 | label: 'aposForm:fileLimitSize', 19 | type: 'boolean', 20 | def: false 21 | }, 22 | maxSize: { 23 | label: 'aposForm:fileMaxSize', 24 | help: 'aposForm:fileMaxSizeHelp', 25 | type: 'integer', 26 | if: { 27 | limitSize: true 28 | } 29 | } 30 | } 31 | }, 32 | methods (self) { 33 | return { 34 | async sanitizeFormField (widget, input, output) { 35 | const fileIds = self.apos.launder.ids(input[widget.fieldName]); 36 | 37 | // File IDs are stored in an array to allow multiple-file uploads. 38 | output[widget.fieldName] = []; 39 | 40 | for (const id of fileIds) { 41 | const info = await self.apos.attachment.db.findOne({ 42 | _id: id 43 | }); 44 | 45 | if (info) { 46 | output[widget.fieldName].push(self.apos.attachment.url(info, { 47 | size: 'original' 48 | })); 49 | } 50 | } 51 | } 52 | }; 53 | }, 54 | extendMethods (self) { 55 | return { 56 | async output(_super, req, widget, options, _with) { 57 | return _super( 58 | req, 59 | { 60 | ...widget, 61 | allowMultiple: widget.allowMultiple ?? true, 62 | fileSizeUnits: { 63 | B: req.t('aposForm:fileSizeUnitB'), 64 | KB: req.t('aposForm:fileSizeUnitKB'), 65 | MB: req.t('aposForm:fileSizeUnitMB'), 66 | GB: req.t('aposForm:fileSizeUnitGB') 67 | } 68 | }, 69 | options, 70 | _with 71 | ); 72 | } 73 | }; 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-select-field-widget/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extend: '@apostrophecms/form-base-field-widget', 3 | options: { 4 | label: 'aposForm:select', 5 | icon: 'form-select-icon', 6 | allowMultiple: false 7 | }, 8 | fields(self) { 9 | const optionalFields = self.options.allowMultiple 10 | ? { 11 | allowMultiple: { 12 | label: 'aposForm:selectAllowMultiple', 13 | type: 'boolean', 14 | def: false 15 | }, 16 | size: { 17 | label: 'aposForm:selectSize', 18 | type: 'integer', 19 | def: 0, 20 | min: 0, 21 | if: { 22 | allowMultiple: true 23 | } 24 | } 25 | } 26 | : {}; 27 | 28 | return { 29 | add: { 30 | choices: { 31 | label: 'aposForm:selectChoice', 32 | type: 'array', 33 | titleField: 'label', 34 | required: true, 35 | fields: { 36 | add: { 37 | label: { 38 | type: 'string', 39 | required: true, 40 | label: 'aposForm:checkboxChoicesLabel', 41 | help: 'aposForm:checkboxChoicesLabelHelp' 42 | }, 43 | value: { 44 | type: 'string', 45 | label: 'aposForm:checkboxChoicesValue', 46 | help: 'aposForm:checkboxChoicesValueHelp' 47 | } 48 | } 49 | } 50 | }, 51 | ...optionalFields 52 | } 53 | }; 54 | }, 55 | methods (self) { 56 | return { 57 | sanitizeFormField (widget, input, output) { 58 | // Get the options from that form for the widget 59 | const choices = self.getChoicesValues(widget); 60 | 61 | output[widget.fieldName] = self.apos.launder.select(input[widget.fieldName], choices); 62 | } 63 | }; 64 | }, 65 | extendMethods (self) { 66 | return { 67 | async output(_super, req, widget, options, _with) { 68 | return _super( 69 | req, 70 | { 71 | ...widget, 72 | allowMultiple: (self.options.allowMultiple && widget.allowMultiple) ?? false, 73 | size: widget.size ?? 0 74 | }, 75 | options, 76 | _with 77 | ); 78 | } 79 | }; 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-base-field-widget/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extend: '@apostrophecms/widget-type', 3 | options: { 4 | label: 'aposForm:baseWidget' 5 | }, 6 | fields: { 7 | add: { 8 | fieldLabel: { 9 | label: 'aposForm:fieldLabel', 10 | type: 'string', 11 | required: true 12 | }, 13 | required: { 14 | label: 'aposForm:fieldRequired', 15 | type: 'boolean' 16 | }, 17 | fieldName: { 18 | label: 'aposForm:fieldName', 19 | type: 'slug', 20 | following: [ 'fieldLabel' ], 21 | help: 'aposForm:fieldNameHelp' 22 | } 23 | } 24 | }, 25 | methods (self) { 26 | return { 27 | checkRequired (req, widget, input) { 28 | if (widget.required && !input[widget.fieldName]) { 29 | throw self.apos.error('invalid', { 30 | fieldError: { 31 | field: widget.fieldName, 32 | error: 'required', 33 | message: req.t('aposForm:requiredError') 34 | } 35 | }); 36 | } 37 | }, 38 | getChoicesValues (widget) { 39 | if (!widget || !widget.choices) { 40 | return []; 41 | } 42 | 43 | return widget.choices.map(choice => { 44 | return choice.value; 45 | }); 46 | } 47 | }; 48 | }, 49 | extendMethods (self) { 50 | return { 51 | sanitize (_super, req, input, options) { 52 | if (!input.fieldName) { 53 | input.fieldName = self.apos.util.slugify(input.fieldLabel); 54 | } 55 | 56 | // If no option value entered, use the option label for the value. 57 | if (Array.isArray(input.choices)) { 58 | input.choices.forEach(choice => { 59 | if (!choice.value) { 60 | choice.value = choice.label; 61 | } 62 | }); 63 | } 64 | 65 | return _super(req, input, options); 66 | }, 67 | load (_super, req, widgets) { 68 | const formModule = self.apos.modules['@apostrophecms/form']; 69 | const classPrefix = formModule.options.classPrefix; 70 | 71 | if (classPrefix) { 72 | widgets.forEach(widget => { 73 | widget.classPrefix = classPrefix; 74 | }); 75 | } 76 | 77 | return _super(req, widgets); 78 | } 79 | }; 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-checkboxes-field-widget/ui/src/index.js: -------------------------------------------------------------------------------- 1 | const WIDGET_NAME = '@apostrophecms/form-checkboxes-field'; 2 | const WIDGET_SELECTOR = '[data-apos-form-checkboxes]'; 3 | 4 | export default () => { 5 | apos.util.widgetPlayers[WIDGET_NAME] = { 6 | selector: WIDGET_SELECTOR, 7 | player (el) { 8 | const formWidget = apos.util.closest(el, '[data-apos-form-form]'); 9 | 10 | if (!formWidget) { 11 | // Editing the form in the piece modal, it is not active for submissions 12 | return; 13 | } 14 | 15 | const inputs = el.querySelectorAll('input[type="checkbox"]'); 16 | const inputName = inputs[0].getAttribute('name'); 17 | const conditionalGroups = formWidget.querySelectorAll('[data-apos-form-condition="' + inputName + '"]'); 18 | // TODO: Remove this logic or update the collectToSkip function to support 19 | // arrays. 20 | if (conditionalGroups.length > 0) { 21 | const check = apos.aposForm.checkConditional; 22 | 23 | Array.prototype.forEach.call(inputs, function (checkbox) { 24 | checkbox.addEventListener('change', function (e) { 25 | check(conditionalGroups, e.target); 26 | }); 27 | }); 28 | } 29 | 30 | const toggle = el.querySelector('[data-apos-form-toggle]'); 31 | if (toggle) { 32 | toggle.onclick = function(e) { 33 | e.stopPropagation(); 34 | e.preventDefault(); 35 | 36 | const active = 'is-active'; 37 | if (el.classList.contains(active)) { 38 | el.classList.remove(active); 39 | } else { 40 | el.classList.add(active); 41 | } 42 | }; 43 | } 44 | } 45 | }; 46 | 47 | apos.aposForm.collectors[WIDGET_NAME] = { 48 | selector: WIDGET_SELECTOR, 49 | collector (el) { 50 | const inputs = el.querySelectorAll('input[type="checkbox"]:checked'); 51 | const inputsArray = Array.prototype.slice.call(inputs); 52 | 53 | if (inputsArray.length === 0) { 54 | const unchecked = el.querySelector('input[type="checkbox"]'); 55 | return { 56 | field: unchecked.getAttribute('name'), 57 | value: undefined 58 | }; 59 | } 60 | 61 | return { 62 | field: inputs[0].getAttribute('name'), 63 | value: inputsArray.map(input => input.value) 64 | }; 65 | } 66 | }; 67 | }; 68 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-global-settings/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | improve: '@apostrophecms/global', 3 | handlers (self) { 4 | return { 5 | 'apostrophe:modulesRegistered': { 6 | addFormRecaptchaFields () { 7 | const formOptions = self.apos.modules['@apostrophecms/form'].options; 8 | 9 | if (!formOptions.recaptchaSecret && !formOptions.recaptchaSite) { 10 | const fieldGroup = { 11 | name: 'form', 12 | label: 'aposForm:globalGroup' 13 | }; 14 | const recaptchaFields = [ 15 | { 16 | name: 'useRecaptcha', 17 | label: 'aposForm:useRecaptcha', 18 | type: 'boolean', 19 | htmlHelp: 'aposForm:useRecaptchaHtmlHelp', 20 | group: fieldGroup 21 | }, 22 | { 23 | name: 'recaptchaSite', 24 | label: 'aposForm:recaptchaSite', 25 | help: 'aposForm:recaptchaSiteHelp', 26 | type: 'string', 27 | required: true, 28 | group: fieldGroup, 29 | if: { 30 | useRecaptcha: true 31 | } 32 | }, 33 | { 34 | name: 'recaptchaSecret', 35 | label: 'aposForm:recaptchaSecret', 36 | help: 'aposForm:recaptchaSecretHelp', 37 | type: 'string', 38 | required: true, 39 | group: fieldGroup, 40 | if: { 41 | useRecaptcha: true 42 | } 43 | } 44 | ]; 45 | self.schema = self.schema.concat(recaptchaFields); 46 | // Reorder to support `last` group ordering. 47 | self.schema.sort((first, second) => { 48 | if ( 49 | (first && first.group && first.group.last) && 50 | !(second && second.group && second.group.last) 51 | ) { 52 | return 1; 53 | } else if ( 54 | !(first && first.group && first.group.last) && 55 | (second && second.group && second.group.last) 56 | ) { 57 | return -1; 58 | } else { 59 | return 0; 60 | } 61 | }); 62 | } 63 | } 64 | } 65 | }; 66 | } 67 | }; 68 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-widget/views/widgetBase.html: -------------------------------------------------------------------------------- 1 | {% set widget = data.widget %} 2 | {% set form = data.widget._form[0] %} 3 | {% set classPrefix = data.widget.classPrefix %} 4 | {% set prependIfPrefix = apos.modules['@apostrophecms/form'].prependIfPrefix %} 5 | {% set recaptchaSite = apos.modules['@apostrophecms/form'].options.recaptchaSite or (data.global.useRecaptcha and data.global.recaptchaSite) %} 6 | {% set recaptchaReady = form.enableRecaptcha and recaptchaSite %} 7 | 8 |
9 | {% if form %} 10 | {% set params = false %} 11 | {% if form.queryParamList %} 12 | {% set params = '' %} 13 | {% for param in form.queryParamList %} 14 | {% if loop.last %} 15 | {% set params = params + param.key %} 16 | {% else %} 17 | {% set params = params + param.key + ',' %} 18 | {% endif %} 19 | {% endfor %} 20 | {% endif %} 21 |
29 | {% area form, 'contents' %} 30 | 31 | {% if recaptchaReady %} 32 | 35 | {% endif %} 36 | {% block submitBlock %} 37 | 43 | {% endblock %} 44 |
45 | 51 | 52 | {% if recaptchaSite %} 53 | 58 | {% endif %} 59 |

63 | {{ __t('aposForm:widgetSubmitting') }} 64 |

65 | 73 | {% endif %} 74 |
75 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.5.2 (2025-10-30) 4 | 5 | ### Changes 6 | 7 | * Updates README 8 | 9 | ### Security 10 | 11 | * Clear an npm audit warning by replacing `connect-multiparty` with `multer`. 12 | * To be clear, this was never an actual security vulnerability. The CVE in question is disputed, and for good reasons. However, since `connect-multiparty` is no longer maintained, it makes sense to move to `multer`. 13 | 14 | ## 1.5.1 (2025-07-09) 15 | 16 | ### Fixes 17 | 18 | * Updates conditional logic for conditional addition of fields to emails to correctly handle boolean values. 19 | 20 | ## 1.5.0 (2025-05-27) 21 | 22 | ### Adds 23 | * Adds visual asterisk for required fields 24 | * Adds `disableOptionalLabel` flag to options for disabling the display of the "(Optional)" label for optional fields 25 | * Move the current widget.html code from form-widget to widgetBase.html and extend it in widget.html. Add blocks in so you can overwrite them from widget.html. Thanks to [hennan929](https://github.com/hennan929) for this contribution. 26 | 27 | ## 1.4.2 (2024-10-31) 28 | 29 | * Adds AI-generated and community-reviewed missing translations 30 | 31 | ## 1.4.1 (2024-08-08) 32 | 33 | ### Fixes 34 | 35 | * Fixes file upload without `limitSize`. Previously it was returning `Unknown form field error`. 36 | 37 | ## 1.4.0 (2024-06-12) 38 | 39 | ### Changes 40 | 41 | * Set keyboard shortcut to `G` then `O`. 42 | 43 | ## 1.3.1 (2024-04-18) 44 | 45 | ### Changes 46 | 47 | * Updates the documentation. 48 | 49 | ### Fixes 50 | * Changes the value collection for the `form-boolean-field-widget` to the `checked` status instead of the `value` directly. 51 | 52 | ## 1.3.0 (2023-11-03) 53 | 54 | ### Adds 55 | 56 | * Add group widget which is a fieldset container for other form widgets. 57 | * Add multiple and size fields to select widget. 58 | 59 | ### Fixes 60 | 61 | * Fix missing select widget icon. 62 | 63 | ## 1.2.0 (2023-10-12) 64 | 65 | ### Adds 66 | 67 | * File upload can now be limited in size (frontend only) by setting the max file size per file upload field. 68 | On the server, there are many factors that can influence this rule (for example proxies and servers used 69 | between Apostrophe and the end-user). That is why this rule is not enforced on the server side. 70 | We use the default express connect-multiparty size limits. The rule is checked before submit. 71 | * Allow to configure file field `multiple` attribute. By default, file field allow the user to select multiple files. 72 | This can now be disabled. 73 | * Add divider widget (`
` tag) to form widgets. 74 | 75 | ### Fixes 76 | 77 | * To avoid confusion, we can now select only one form when editing the form widget relationship to form field ('Form to display'). Note that selecting more than one form never had any useful effect. 78 | 79 | ## 1.1.1 (2023-02-17) 80 | 81 | ### Fixes 82 | 83 | * Remove `apostrophe` as a peer dependency. 84 | 85 | ## 1.1.0 (2023-01-18) 86 | 87 | ### Adds 88 | 89 | * Emit new event `beforeSaveSubmission`. The event receives `req, { form, data, submission }` allowing an opportunity to modify `submission` just before it is saved to the MongoDB collection. For most purposes the `submission` event is more useful. 90 | 91 | ### Fixes 92 | 93 | * Fixes missing root widget class when `classPrefix` option is set. 94 | 95 | ## 1.0.1 (2022-08-03) 96 | 97 | ### Fixes 98 | 99 | * Fixes typos in the `emailsConditionsField`, 'emailsConditionsFieldHelp', `emailsConsitionsValue`, and `emailsConditionsValueHtmlHelp` l10n keys. 100 | * Changes the `htmlHelp` key value for the `value` object to maintain consistency in l10n key format. 101 | 102 | ## 1.0.0 (2022-02-04) 103 | 104 | ### Fixes 105 | 106 | * Sets the dev dependency of Apostrophe to a published version. 107 | * Fixes a typo in the recaptchaValidationError l10n key. 108 | * Fixes an incorrect error localization key usage. 109 | 110 | ### Adds 111 | 112 | * Adds full support for file field widgets, including a new dedicated upload route. 113 | 114 | ## 1.0.0-beta.1 (2021-11-15) 115 | 116 | ### Changes 117 | 118 | * Changes to reCAPTCHA v3 for form user verification. The browser events are removed since the reCAPTCHA retrieves its token automatically. 119 | * Removes the dropdown style for checkbox fields. 120 | 121 | ## 1.0.0-beta (2021-10-14) 122 | 123 | * Initial release for Apostrophe 3. 124 | -------------------------------------------------------------------------------- /lib/fields.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | initial(options) { 3 | return { 4 | title: { 5 | label: 'aposForm:formName', 6 | type: 'string', 7 | sortify: true, 8 | required: true 9 | }, 10 | contents: { 11 | label: 'aposForm:formContents', 12 | type: 'area', 13 | options: { 14 | widgets: options.formWidgets || { 15 | '@apostrophecms/form-text-field': {}, 16 | '@apostrophecms/form-textarea-field': {}, 17 | // TODO: Enable the file field when anonymous uploading is available 18 | // '@apostrophecms/form-file-field': {}, 19 | '@apostrophecms/form-boolean-field': {}, 20 | '@apostrophecms/form-select-field': {}, 21 | '@apostrophecms/form-radio-field': {}, 22 | '@apostrophecms/form-checkboxes-field': {}, 23 | '@apostrophecms/form-conditional': {}, 24 | '@apostrophecms/form-divider': {}, 25 | // '@apostrophecms/form-group': {} 26 | '@apostrophecms/rich-text': { 27 | toolbar: [ 28 | 'styles', 'bold', 'italic', 'link', 29 | 'orderedList', 'bulletList' 30 | ] 31 | } 32 | } 33 | } 34 | }, 35 | submitLabel: { 36 | label: 'aposForm:submitLabel', 37 | type: 'string' 38 | }, 39 | thankYouHeading: { 40 | label: 'aposForm:thankYouTitle', 41 | type: 'string' 42 | }, 43 | thankYouBody: { 44 | label: 'aposForm:thankYouBody', 45 | type: 'area', 46 | options: { 47 | widgets: options.thankYouWidgets || { 48 | '@apostrophecms/rich-text': { 49 | toolbar: [ 50 | 'styles', 'bold', 'italic', 'link', 51 | 'orderedList', 'bulletList' 52 | ] 53 | } 54 | } 55 | } 56 | }, 57 | sendConfirmationEmail: { 58 | label: 'aposForm:confEmailEnable', 59 | // NOTE: The confirmation email is in `views/emailConfirmation.html`. 60 | // Edit the message there, adding any dynamic content as needed. 61 | help: 'aposForm:confEmailEnableHelp', 62 | type: 'boolean' 63 | }, 64 | emailConfirmationField: { 65 | label: 'aposForm:confEmailField', 66 | help: 'aposForm:confEmailFieldHelp', 67 | type: 'string', 68 | required: true, 69 | if: { 70 | sendConfirmationEmail: true 71 | } 72 | }, 73 | enableQueryParams: { 74 | label: 'aposForm:enableQueryParams', 75 | type: 'boolean', 76 | htmlHelp: 'aposForm:enableQueryParamsHtmlHelp' 77 | }, 78 | queryParamList: { 79 | label: 'aposForm:queryParamList', 80 | type: 'array', 81 | titleField: 'key', 82 | required: true, 83 | help: 'aposForm:queryParamListHelp', 84 | fields: { 85 | add: { 86 | key: { 87 | type: 'string', 88 | label: 'aposForm:queryParamKey', 89 | required: true 90 | }, 91 | lengthLimit: { 92 | type: 'integer', 93 | label: 'aposForm:queryParamLimit', 94 | help: 'aposForm:queryParamLimitHelp', 95 | min: 1 96 | } 97 | } 98 | }, 99 | if: { 100 | enableQueryParams: true 101 | } 102 | }, 103 | enableRecaptcha: { 104 | label: 'aposForm:recaptchaEnable', 105 | help: 'aposForm:recaptchaEnableHelp', 106 | type: 'boolean' 107 | } 108 | }; 109 | }, 110 | emailFields: { 111 | emails: { 112 | label: 'aposForm:emails', 113 | type: 'array', 114 | titleField: 'email', 115 | fields: { 116 | add: { 117 | email: { 118 | type: 'email', 119 | required: true, 120 | label: 'aposForm:emailsAddress' 121 | }, 122 | conditions: { 123 | label: 'aposForm:emailsConditions', 124 | help: 'aposForm:emailsConditionsHelp', 125 | type: 'array', 126 | titleField: 'value', 127 | fields: { 128 | add: { 129 | field: { 130 | label: 'aposForm:emailsConditionsField', 131 | type: 'string', 132 | help: 'aposForm:emailsConditionsFieldHelp' 133 | }, 134 | value: { 135 | type: 'string', 136 | label: 'aposForm:emailsConditionsValue', 137 | htmlHelp: 'aposForm:emailsConditionsValueHtmlHelp' 138 | } 139 | } 140 | } 141 | } 142 | } 143 | } 144 | }, 145 | email: { 146 | label: 'aposForm:emailField', 147 | type: 'string', 148 | required: true, 149 | help: 'aposForm:emailFieldHelp' 150 | } 151 | } 152 | }; 153 | -------------------------------------------------------------------------------- /lib/processor.js: -------------------------------------------------------------------------------- 1 | module.exports = function (self) { 2 | return { 3 | async submitForm (req) { 4 | const input = JSON.parse(req.body.data); 5 | const output = {}; 6 | const formErrors = []; 7 | const formId = self.inferIdLocaleAndMode(req, input._id); 8 | 9 | const form = await self.find(req, { 10 | _id: self.apos.launder.id(formId) 11 | }).toObject(); 12 | 13 | if (!form) { 14 | throw self.apos.error('notfound'); 15 | } 16 | 17 | if (form.enableRecaptcha) { 18 | try { 19 | // Process reCAPTCHA input if needed. 20 | await self.checkRecaptcha(req, input, formErrors); 21 | } catch (e) { 22 | self.apos.util.error('reCAPTCHA submission error', e); 23 | throw self.apos.error('invalid'); 24 | } 25 | } 26 | 27 | // Find any file field submissions and insert the files as attachments 28 | for (const [ field, value ] of Object.entries(input)) { 29 | if (value === 'files-pending') { 30 | try { 31 | input[field] = await self.insertFieldFiles(req, field, req.files); 32 | } catch (error) { 33 | self.apos.util.error(error); 34 | formErrors.push({ 35 | field, 36 | error: 'invalid', 37 | message: req.t('aposForm:fileUploadError') 38 | }); 39 | } 40 | } 41 | } 42 | 43 | // Recursively walk the area and its sub-areas so we find 44 | // fields nested in two-column widgets and the like 45 | 46 | // walk is not an async function so build an array of them to start 47 | const areas = []; 48 | 49 | self.apos.area.walk({ 50 | contents: form.contents 51 | }, function(area) { 52 | areas.push(area); 53 | }); 54 | 55 | const fieldNames = []; 56 | const conditionals = {}; 57 | const skipFields = []; 58 | 59 | // Populate the conditionals object fully to clear disabled values 60 | // before starting sanitization. 61 | for (const area of areas) { 62 | const widgets = area.items || []; 63 | for (const widget of widgets) { 64 | // Capture field names for the params check list. 65 | fieldNames.push(widget.fieldName); 66 | 67 | if (widget.type === '@apostrophecms/form-conditional') { 68 | self.trackConditionals(conditionals, widget); 69 | } 70 | } 71 | } 72 | 73 | self.collectToSkip(input, conditionals, skipFields); 74 | 75 | for (const area of areas) { 76 | const widgets = area.items || []; 77 | for (const widget of widgets) { 78 | const manager = self.apos.area.getWidgetManager(widget.type); 79 | if ( 80 | manager && manager.sanitizeFormField && 81 | !skipFields.includes(widget.fieldName) 82 | ) { 83 | try { 84 | manager.checkRequired(req, widget, input); 85 | await manager.sanitizeFormField(widget, input, output); 86 | } catch (err) { 87 | if (err.data && err.data.fieldError) { 88 | formErrors.push(err.data.fieldError); 89 | } else { 90 | throw err; 91 | } 92 | } 93 | } 94 | } 95 | } 96 | 97 | if (formErrors.length > 0) { 98 | throw self.apos.error('invalid', { 99 | formErrors 100 | }); 101 | } 102 | 103 | if (form.enableQueryParams && form.queryParamList.length > 0) { 104 | self.processQueryParams(form, input, output, fieldNames); 105 | } 106 | 107 | await self.emit('submission', req, form, output); 108 | 109 | return {}; 110 | }, 111 | async insertFieldFiles (req, name, files) { 112 | 113 | // Upload each file for the field, then join IDs. 114 | const ids = []; 115 | 116 | for (const entry in files) { 117 | if (!self.matchesName(entry, name)) { 118 | continue; 119 | } 120 | 121 | const attachment = await self.apos.attachment.insert(req, files[entry], { 122 | permissions: false 123 | }); 124 | 125 | ids.push(attachment._id); 126 | } 127 | 128 | return ids; 129 | 130 | }, 131 | matchesName(str, name) { 132 | return str.startsWith(name) && str.match(/.+-\d+$/); 133 | }, 134 | trackConditionals(conditionals = {}, widget) { 135 | const conditionName = widget.conditionName; 136 | const conditionValue = widget.conditionValue; 137 | 138 | if (!widget || !widget.contents || !widget.contents.items) { 139 | return; 140 | } 141 | 142 | conditionals[conditionName] = conditionals[conditionName] || {}; 143 | 144 | conditionals[conditionName][conditionValue] = conditionals[conditionName][conditionValue] || []; 145 | 146 | widget.contents.items.forEach(item => { 147 | conditionals[conditionName][conditionValue].push(item.fieldName); 148 | }); 149 | 150 | // If there aren't any fields in the conditional group, don't bother 151 | // tracking it. 152 | if (conditionals[conditionName][conditionValue].length === 0) { 153 | delete conditionals[conditionName][conditionValue]; 154 | } 155 | }, 156 | collectToSkip(input, conditionals, skipFields) { 157 | const normalize = (val) => { 158 | if (typeof val === 'string') { 159 | const cleaned = val.replace(/^["']|["']$/g, ''); 160 | // Use exact matches to avoid substring issues 161 | if (cleaned === 'on' || cleaned === 'true') return true; 162 | if (cleaned === 'off' || cleaned === 'false') return false; 163 | return cleaned; 164 | } 165 | return val; 166 | }; 167 | 168 | for (const name in conditionals) { 169 | for (const value in conditionals[name]) { 170 | if (normalize(input[name]) !== normalize(value)) { 171 | conditionals[name][value].forEach(field => skipFields.push(field)); 172 | } 173 | } 174 | } 175 | } 176 | }; 177 | }; 178 | -------------------------------------------------------------------------------- /modules/@apostrophecms/form-widget/ui/src/index.js: -------------------------------------------------------------------------------- 1 | import { processErrors } from './errors'; 2 | import { collectValues } from './fields'; 3 | import enableRecaptcha from './recaptcha'; 4 | 5 | export default () => { 6 | apos.util.widgetPlayers['@apostrophecms/form'] = { 7 | selector: '[data-apos-form-wrapper]', 8 | player: function (el) { 9 | const form = el.querySelector('[data-apos-form-form]'); 10 | 11 | if (!form) { 12 | return; 13 | } 14 | 15 | form.addEventListener('submit', submit); 16 | 17 | const recaptcha = enableRecaptcha(el); 18 | 19 | // If there are specified query parameters to capture, see if fields 20 | // can be populated. 21 | if (form.hasAttribute('data-apos-form-params')) { 22 | setParameterValues(); 23 | } 24 | 25 | async function submit(event) { 26 | event.preventDefault(); 27 | 28 | if (apos?.adminBar?.editMode) { 29 | await apos.notify('aposForm:disabledInEditMode', { 30 | type: 'info' 31 | }); 32 | return; 33 | } 34 | 35 | if (el.querySelector('[data-apos-form-busy]')) { 36 | return setTimeout(async function() { 37 | await submit(event); 38 | }, 100); 39 | } 40 | 41 | form.setAttribute('data-apos-form-busy', '1'); 42 | 43 | let input; 44 | const formData = new window.FormData(); 45 | 46 | try { 47 | // Collect field values on the event 48 | input = await collectValues(form); 49 | 50 | // Upload each set of field files separately 51 | for (const field in input) { 52 | // Upload file field files if input has files. 53 | if (typeof input[field] === 'object' && input[field].files) { 54 | await appendFiles(field, input[field], formData); 55 | 56 | input[field] = 'files-pending'; 57 | } 58 | } 59 | 60 | } catch (error) { 61 | processErrors(error?.data?.formErrors, el); 62 | 63 | form.removeAttribute('data-apos-form-busy'); 64 | 65 | return; 66 | } 67 | 68 | input._id = form.getAttribute('data-apos-form-form'); 69 | 70 | if (recaptcha) { 71 | const recaptchaError = el.querySelector('[data-apos-form-recaptcha-error]'); 72 | 73 | try { 74 | input.recaptcha = await recaptcha.getToken(el); 75 | } catch (error) { 76 | // eslint-disable-next-line 77 | console.error('reCAPTCHA execution error:', error); 78 | apos.util.addClass(recaptchaError, 'apos-form-visible'); 79 | return null; 80 | } 81 | } 82 | 83 | // For resubmissions 84 | const errorMsg = el.querySelector('[data-apos-form-submit-error]'); 85 | const spinner = el.querySelector('[data-apos-form-spinner]'); 86 | const thankYou = el.querySelector('[data-apos-form-thank-you]'); 87 | apos.util.removeClass(errorMsg, 'apos-form-visible'); 88 | apos.util.addClass(spinner, 'apos-form-visible'); 89 | 90 | // Convert to arrays old school for IE. 91 | const existingErrorInputs = Array.prototype.slice.call(el.querySelectorAll('.apos-form-input-error')); 92 | const existingErrorMessages = Array.prototype.slice.call(el.querySelectorAll('[data-apos-input-message].apos-form-error')); 93 | 94 | existingErrorInputs.forEach(function (input) { 95 | apos.util.removeClass(input, 'apos-form-input-error'); 96 | }); 97 | 98 | existingErrorMessages.forEach(function (message) { 99 | apos.util.removeClass(message, 'apos-form-error'); 100 | message.hidden = true; 101 | }); 102 | 103 | // Capture query parameters. 104 | if (form.hasAttribute('data-apos-form-params')) { 105 | captureParameters(input); 106 | } 107 | 108 | let formErrors = null; 109 | 110 | formData.append('data', JSON.stringify(input)); 111 | 112 | try { 113 | await apos.http.post('/api/v1/@apostrophecms/form/submit', { 114 | body: formData 115 | }); 116 | } catch (error) { 117 | formErrors = error.body?.data?.formErrors; 118 | } 119 | 120 | form.removeAttribute('data-apos-form-busy'); 121 | apos.util.removeClass(spinner, 'apos-form-visible'); 122 | 123 | if (formErrors) { 124 | processErrors(formErrors, el); 125 | 126 | } else { 127 | apos.util.emit(document.body, '@apostrophecms/form:submission-form', { 128 | form, 129 | formError: null 130 | }); 131 | apos.util.addClass(thankYou, 'apos-form-visible'); 132 | apos.util.addClass(form, 'apos-form-hidden'); 133 | } 134 | } 135 | 136 | function setParameterValues () { 137 | const paramList = form.getAttribute('data-apos-form-params').split(','); 138 | const params = apos.http.parseQuery(window.location.search); 139 | 140 | paramList.forEach(function (param) { 141 | const paramInput = form.querySelector('[name="' + param + '"]'); 142 | 143 | if (!params[param]) { 144 | return; 145 | } 146 | 147 | // If the input is a checkbox, check all in the comma-separated query 148 | // parameter value. 149 | if (paramInput && paramInput.type === 'checkbox') { 150 | params[param].split(',').forEach(function (value) { 151 | const checkbox = form.querySelector('[name="' + param + '"][value="' + value + '"]'); 152 | 153 | if (checkbox) { 154 | checkbox.checked = true; 155 | } 156 | }); 157 | // If the input is a radio, check the matching input. 158 | } else if (paramInput && paramInput.type === 'radio') { 159 | form.querySelector('[name="' + param + '"][value="' + params[param] + '"]').checked = true; 160 | // If the input is a select field, make sure the value is an option. 161 | } else if (paramInput && paramInput.type === 'select') { 162 | if (paramInput.querySelector('option[value="' + params[param] + '"')) { 163 | paramInput.value = params[param]; 164 | } 165 | // Otherwise set the input value to the parameter value. 166 | } else if (paramInput) { 167 | paramInput.value = params[param]; 168 | } 169 | }); 170 | } 171 | 172 | function captureParameters (input) { 173 | input.queryParams = apos.http.parseQuery(window.location.search); 174 | } 175 | 176 | async function appendFiles(field, fieldInput, data) { 177 | let i = 0; 178 | for (const file of fieldInput.files) { 179 | data.append(`${field}-${i}`, file); 180 | i++; 181 | } 182 | } 183 | } 184 | }; 185 | }; 186 | -------------------------------------------------------------------------------- /i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseWidget": "Form base widget", 3 | "boolean": "Boolean/opt-in input", 4 | "booleanChecked": "Default to pre-checked", 5 | "booleanCheckedHelp": "If \"yes,\" the checkbox will start in the checked state.", 6 | "checkbox": "Checkbox input", 7 | "checkboxChoices": "Checkbox input options", 8 | "checkboxChoicesLabel": "Option label", 9 | "checkboxChoicesLabelHelp": "The readable label displayed to users.", 10 | "checkboxChoicesValue": "Option value", 11 | "checkboxChoicesValueHelp": "The value saved (as text) in the database. If not entered, the label will be used.", 12 | "conditional": "Conditional field group", 13 | "conditionalContentsHelp": "If the condition above is met these fields will activate.", 14 | "conditionalName": "Form field to check", 15 | "conditionalNameHelp": "Enter the \"Field Name\" value for a select, radio, or boolean form field.", 16 | "conditionalValue": "Value that activates this group", 17 | "conditionalValueHtmlHelp": "If using a boolean/opt-in field, set this to \"true\".", 18 | "confEmailEnable": "Send a confirmation email", 19 | "confEmailEnableHelp": "Enable this to send a message to the person who submits this form.", 20 | "confEmailField": "Which is your confirmation email field?", 21 | "confEmailFieldHelp": "Enter the \"name\" value of the field where people with enter their email address. For best results, make sure this field only accepts email addresses.", 22 | "defaultThankYou": "Thank you", 23 | "disabledInEditMode": "Switch to Preview or Published mode to test the form.", 24 | "divider": "Divider", 25 | "emailField": "Primary internal email address", 26 | "emailFieldHelp": "You may enter one from the previous list. This is the address that will be used as the \"from\" address on any generated email messages.", 27 | "emails": "Email Address(es) for Results", 28 | "emailsAddress": "Email Address for Results", 29 | "emailsConditions": "Set Conditions for this Notification", 30 | "emailsConditionsField":"Enter a field to use as your condition.", 31 | "emailsConditionsFieldHelp":"Only select (drop-down) and checkbox fields can be used for this condition.", 32 | "emailsConditionsHelp":"For example, if you only notify this email address if the \"country\" field is set to \"Austria\". All conditions must be met. Add the email again with another conditional set if needed.", 33 | "emailsConditionsValue":"Enter the value an end-user will enter to meet this conditional.", 34 | "emailsConditionsValueHtmlHelp":"Use comma-separated values to check multiple values on this field (an OR relationship). Values that actually contain commas should be entered in double-quotation marks (e.g., Proud Mary, The Best, \"River Deep, Mountain High\").", 35 | "enableQueryParams": "Enable query parameter capture", 36 | "enableQueryParamsHtmlHelp": "If enabled, all query parameters (the key/value pairs in a query string) will be collected when the form is submitted. You may also set list of specific parameter keys that you wish to collect.", 37 | "errorEmailConfirm": "The form field {{ field }} is configured to be used for the confirmation email address, but it allows non-email formats.", 38 | "fieldLabel": "Field label", 39 | "fieldName": "Field name", 40 | "fieldNameHelp": "No spaces or punctuation other than dashes. If left blank, the form will populate this with a simplified form of the label. Changing this field after a form is in use may cause problems with any integrations.", 41 | "fieldRequired": "Is this field required?", 42 | "file": "File attachment", 43 | "fileAllowMultiple": "Allow multiple file attachments", 44 | "fileLimitSize": "Limit file size?", 45 | "fileMaxSize": "Max file attachment size", 46 | "fileMaxSizeError": "File is too large '%1$s' (max size: %2$s).", 47 | "fileMaxSizeHelp": "In Bytes", 48 | "fileMissingEarly": "Uploaded temporary file {{ path }} was already removed, this should have been the responsibility of the upload route", 49 | "fileSizeUnitB": "B", 50 | "fileSizeUnitGB": "GB", 51 | "fileSizeUnitKB": "KB", 52 | "fileSizeUnitMB": "MB", 53 | "fileUploadError": "An error occurred uploading the file. It may be too large or of an inappropriate type.", 54 | "fileUploading": "Uploading...", 55 | "form": "Form", 56 | "formContents": "Form contents", 57 | "formErrors": "Errors found in the form submission", 58 | "formName": "Form name", 59 | "forms": "Forms", 60 | "globalGroup": "Form Settings", 61 | "group": "Group", 62 | "groupAdvanced": "Advanced", 63 | "groupAfterSubmission": "After submission", 64 | "groupContents": "Group Contents", 65 | "groupContentsHelp": "Contains all form widgets except groups", 66 | "groupForm": "Form", 67 | "groupLabel": "Group Label", 68 | "notFoundForm": "No matching form was found", 69 | "queryParamKey": "Key", 70 | "queryParamLimit": "Limit Saved Parameter Value Length (characters)", 71 | "queryParamLimitHelp": "Enter a whole number to limit the length of the value saved.", 72 | "queryParamList": "Query parameter keys", 73 | "queryParamListHelp": "Create an array item for each query parameter value you wish to capture.", 74 | "radio": "Radio input", 75 | "radioChoice": "Radio input options", 76 | "recaptchaConfigError": "The reCAPTCHA verification system may be down or incorrectly configured. Please try again or notify the site owner.", 77 | "recaptchaEnable": "Enable reCAPTCHA on the form (spam prevention)", 78 | "recaptchaEnableHelp": "To use, reCAPTCHA a site ID and secret key must be configured in website code or the website global settings.", 79 | "recaptchaSecret": "reCAPTCHA secret key", 80 | "recaptchaSecretHelp": "Enter the secret key from a reCAPTCHA account", 81 | "recaptchaSite": "reCAPTCHA site key", 82 | "recaptchaSiteHelp": "Enter the site key from a reCAPTCHA account", 83 | "recaptchaSubmitError": "There was a problem submitting your reCAPTCHA verification.", 84 | "recaptchaValidationError": "There was a problem validating your reCAPTCHA verification submission.", 85 | "requiredError": "This field is required", 86 | "select": "Select input", 87 | "selectAllowMultiple": "Allow multiple options to be selected", 88 | "selectBlank": " ", 89 | "selectChoice": "Select input options", 90 | "selectSize": "Number of options in the list that should be visible", 91 | "submitLabel": "Submit button label", 92 | "templateOptional": "(Optional)", 93 | "text": "Text input", 94 | "textArea": "Text area input", 95 | "textPlaceholder": "Placeholder", 96 | "textPlaceholderHelp": "Text to display in the field before someone uses it (e.g., to provide additional directions).", 97 | "textType": "Input type", 98 | "textTypeDate": "Date", 99 | "textTypeEmail": "Email", 100 | "textTypeHelp": "If you are requesting certain formatted information (e.g., email, url, phone number), select the relevant input type here. If not, use \"Text\".", 101 | "textTypePassword": "Password", 102 | "textTypePhone": "Telephone", 103 | "textTypeText": "Text", 104 | "textTypeUrl": "URL", 105 | "thankYouBody": "Thank you message content", 106 | "thankYouTitle": "Thank you message title", 107 | "useRecaptcha": "Use Google reCAPTCHA on forms", 108 | "useRecaptchaHtmlHelp": "reCAPTCHA helps avoid spam submissions on forms. You will need a secret key and site key.", 109 | "widgetCaptchaError": "There was an error connecting to the reCAPTCHA validation service. Please reload the page.", 110 | "widgetForm": "Form", 111 | "widgetFormSelect": "Form to display", 112 | "widgetNoScript": "NOTE: The form above requires JavaScript enabled in the browser for submission.", 113 | "widgetSubmit": "Submit", 114 | "widgetSubmitError": "An error occurred submitting the form. Please try again.", 115 | "widgetSubmitting": "Submitting..." 116 | } 117 | -------------------------------------------------------------------------------- /i18n/sk.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseWidget": "Základný widget formulára", 3 | "boolean": "Boolean/opt-in vstup", 4 | "booleanChecked": "Predvolene začiarknuté", 5 | "booleanCheckedHelp": "Ak \"áno,\" začiarkavacie políčko sa východne zobrazí začiarknuté.", 6 | "checkbox": "Vstup začiarkavacieho políčka", 7 | "checkboxChoices": "Možnosti vstupu začiarkavacieho políčka", 8 | "checkboxChoicesLabel": "Názov možnosti", 9 | "checkboxChoicesLabelHelp": "Čitateľský názov zobrazený používateľom.", 10 | "checkboxChoicesValue": "Hodnota možnosti", 11 | "checkboxChoicesValueHelp": "Hodnota, ktorá sa uloží (ako text) do databázy. Ak nie je zadaná, použije sa názov.", 12 | "conditional": "Podmienená skupina polí", 13 | "conditionalContentsHelp": "Ak je podmienka splnená, tieto polia budú aktivované.", 14 | "conditionalName": "Názov poľa formulára, ktoré sa má skontrolovať", 15 | "conditionalNameHelp": "Zadajte hodnotu \"Názov poľa\" pre výber, rádio alebo boolean pole.", 16 | "conditionalValue": "Hodnota, ktorá aktivuje túto skupinu", 17 | "conditionalValueHtmlHelp": "Ak používate boolean/opt-in pole, nastavte toto na \"true\".", 18 | "confEmailEnable": "Odoslať potvrdzovací e-mail", 19 | "confEmailEnableHelp": "Aktivujte to, aby ste odoslali správu osobe, ktorá odosiela tento formulár.", 20 | "confEmailField": "Ktoré je vaše pole pre potvrdzovací e-mail?", 21 | "confEmailFieldHelp": "Zadajte hodnotu \"názov\" poľa, kde ľudia zadajú svoju e-mailovú adresu. Pre najlepšie výsledky sa uistite, že toto pole prijíma iba e-mailové adresy.", 22 | "defaultThankYou": "Ďakujeme", 23 | "disabledInEditMode": "Prepnite na režim náhľadu alebo publikovaného, aby ste otestovali formulár.", 24 | "divider": "Čiarový oddelovač", 25 | "emailField": "Hlavná interná e-mailová adresa", 26 | "emailFieldHelp": "Môžete zadať jednu z predchádzajúceho zoznamu. Toto je adresa, ktorá sa použije ako \"od\" adresa na akýchkoľvek generovaných e-mailových správach.", 27 | "emails": "E-mailová adresa(e) pre výsledky", 28 | "emailsAddress": "E-mailová adresa pre výsledky", 29 | "emailsConditions": "Nastavte podmienky pre toto upozornenie", 30 | "emailsConditionsField": "Zadajte pole, ktoré sa má použiť ako vaša podmienka.", 31 | "emailsConditionsFieldHelp": "Použiť sa môžu iba vyberateľné (rozbaľovacie) a políčka začiarkavacieho políčka pre túto podmienku.", 32 | "emailsConditionsHelp": "Napríklad, ak chcete upozorňovať túto e-mailovú adresu, iba ak je pole \"krajina\" nastavené na \"Rakúsko\". Všetky podmienky musia byť splnené. Ak je to potrebné, pridajte e-mail znovu s iným súborom podmienok.", 33 | "emailsConditionsValue": "Zadajte hodnotu, ktorú koncový používateľ zadá na splnenie tejto podmienky.", 34 | "emailsConditionsValueHtmlHelp": "Použite hodnoty oddelené čiarkou, aby ste skontrolovali viacero hodnôt v tomto poli (vzťah OR). Hodnoty, ktoré skutočne obsahujú čiarky, by mali byť zadané v úvodzovkách (napr., Proud Mary, The Best, \"River Deep, Mountain High\").", 35 | "enableQueryParams": "Povoliť zachytávanie parametrov dotazu", 36 | "enableQueryParamsHtmlHelp": "Ak je povolené, všetky parametre dotazu (kľúčové/hodnotové páry v dotazovom reťazci) budú zozbierané pri odoslaní formulára. Môžete tiež nastaviť zoznam konkrétnych kľúčov parametrov, ktoré chcete zbierať.", 37 | "errorEmailConfirm": "Pole formulára {{ field }} je nakonfigurované na použitie pre potvrdzovaciu e-mailovú adresu, ale umožňuje ne-e-mailové formáty.", 38 | "fieldLabel": "Názov poľa", 39 | "fieldName": "Názov poľa", 40 | "fieldNameHelp": "Žiadne medzery ani interpunkcia okrem pomlčiek. Ak je prázdne, formulár ho automaticky naplní zjednodušenou formou názvu. Zmena tohto poľa po použití formulára môže spôsobiť problémy s akýmikoľvek integráciami.", 41 | "fieldRequired": "Je toto pole povinné?", 42 | "file": "Príloha súboru", 43 | "fileAllowMultiple": "Povoliť viacero príloh súboru", 44 | "fileLimitSize": "Obmedziť veľkosť súboru?", 45 | "fileMaxSize": "Maximálna veľkosť prílohy súboru", 46 | "fileMaxSizeError": "Súbor je príliš veľký '%1$s' (max. veľkosť: %2$s).", 47 | "fileMaxSizeHelp": "V Bitoch", 48 | "fileMissingEarly": "Nahratý dočasný súbor {{ path }} bol už odstránený, toto by malo byť zodpovednosťou cesty nahrávania.", 49 | "fileSizeUnitB": "B", 50 | "fileSizeUnitGB": "GB", 51 | "fileSizeUnitKB": "KB", 52 | "fileSizeUnitMB": "MB", 53 | "fileUploadError": "Pri nahrávaní súboru došlo k chybe. Môže byť príliš veľký alebo nevhodného typu.", 54 | "fileUploading": "Nahrávanie...", 55 | "form": "Formulár", 56 | "formContents": "Obsah formulára", 57 | "formErrors": "Nájdené chyby vo formulári", 58 | "formName": "Názov formulára", 59 | "forms": "Formuláre", 60 | "globalGroup": "Nastavenia formulára", 61 | "group": "Skupina", 62 | "groupAdvanced": "Rozšírené", 63 | "groupAfterSubmission": "Po odoslaní", 64 | "groupContents": "Obsah skupiny", 65 | "groupContentsHelp": "Obsahuje všetky widgety formulára okrem skupín", 66 | "groupForm": "Formulár", 67 | "groupLabel": "Názov skupiny", 68 | "notFoundForm": "Neboli nájdené zodpovedajúce formuláre", 69 | "queryParamKey": "Kľúč", 70 | "queryParamLimit": "Obmedziť dĺžku uložených hodnôt parametrov (znaky)", 71 | "queryParamLimitHelp": "Zadajte celé číslo na obmedzenie dĺžky uložených hodnôt.", 72 | "queryParamList": "Kľúče parametrov dotazu", 73 | "queryParamListHelp": "Vytvorte položku poľa pre každú hodnotu parametra dotazu, ktorú chcete zachytiť.", 74 | "radio": "Rádiový vstup", 75 | "radioChoice": "Možnosti rádiového vstupu", 76 | "recaptchaConfigError": "Systém overovania reCAPTCHA môže byť vypnutý alebo nesprávne nakonfigurovaný. Skúste to znova alebo informujte vlastníka stránky.", 77 | "recaptchaEnable": "Povoliť reCAPTCHA vo formulári (prevencia spamu)", 78 | "recaptchaEnableHelp": "Na použitie musí byť ID a tajný kľúč reCAPTCHA nakonfigurované v kóde webovej stránky alebo v globálnych nastaveniach webových stránok.", 79 | "recaptchaSecret": "Tajný kľúč reCAPTCHA", 80 | "recaptchaSecretHelp": "Zadajte tajný kľúč z účtu reCAPTCHA", 81 | "recaptchaSite": "Kľúč na stránku reCAPTCHA", 82 | "recaptchaSiteHelp": "Zadajte kľúč stránky z účtu reCAPTCHA", 83 | "recaptchaSubmitError": "Pri odosielaní sa vyskytol problém s overením reCAPTCHA.", 84 | "recaptchaValidationError": "Pri overení odoslania vášho overenia reCAPTCHA sa vyskytol problém.", 85 | "requiredError": "Toto pole je povinné", 86 | "select": "Vstup výberu", 87 | "selectAllowMultiple": "Povoliť výber viacerých možností", 88 | "selectBlank": " ", 89 | "selectChoice": "Možnosti vstupu výberu", 90 | "selectSize": "Počet možností v zozname, ktoré by mali byť viditeľné", 91 | "submitLabel": "Názov tlačidla odoslania", 92 | "templateOptional": "(Voliteľné)", 93 | "text": "Textový vstup", 94 | "textArea": "Vstup textovej oblasti", 95 | "textPlaceholder": "Zástupný text", 96 | "textPlaceholderHelp": "Text, ktorý sa má zobraziť v poli predtým, než ho niekto použije (napr. na poskytnutie dodatočných pokynov).", 97 | "textType": "Typ vstupu", 98 | "textTypeDate": "Dátum", 99 | "textTypeEmail": "E-mail", 100 | "textTypeHelp": "Ak požadujete určité formátované informácie (napr. e-mail, url, telefónne číslo), vyberte si relevantný typ vstupu tu. Ak nie, použite \"Text\".", 101 | "textTypePassword": "Heslo", 102 | "textTypePhone": "Telefón", 103 | "textTypeText": "Text", 104 | "textTypeUrl": "URL", 105 | "thankYouBody": "Obsah správy poďakovania", 106 | "thankYouTitle": "Názov správy poďakovania", 107 | "useRecaptcha": "Použiť Google reCAPTCHA na formulároch", 108 | "useRecaptchaHtmlHelp": "reCAPTCHA pomáha zabrániť spamovým odoslaním na formulároch. Budete potrebovať tajný kľúč a kľúč stránky.", 109 | "widgetCaptchaError": "Pri pripojení k službe overovania reCAPTCHA sa vyskytla chyba. Skúste znovu načítať stránku.", 110 | "widgetForm": "Formulár", 111 | "widgetFormSelect": "Formulár na zobrazenie", 112 | "widgetNoScript": "POZNÁMKA: Formulár vyššie vyžaduje JavaScript aktivovaný v prehliadači na odoslanie.", 113 | "widgetSubmit": "Odoslať", 114 | "widgetSubmitError": "Pri odosielaní formulára sa vyskytla chyba. Skúste to znova.", 115 | "widgetSubmitting": "Odosielanie..." 116 | } 117 | -------------------------------------------------------------------------------- /i18n/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseWidget": "Widget de formulário base", 3 | "boolean": "Entrada booleana/opção", 4 | "booleanChecked": "Definir como pré-selecionado", 5 | "booleanCheckedHelp": "Se \"sim\", a caixa de seleção começará no estado selecionado.", 6 | "checkbox": "Entrada de caixa de seleção", 7 | "checkboxChoices": "Opções de entrada de caixa de seleção", 8 | "checkboxChoicesLabel": "Rótulo da opção", 9 | "checkboxChoicesLabelHelp": "O rótulo legível exibido para os usuários.", 10 | "checkboxChoicesValue": "Valor da opção", 11 | "checkboxChoicesValueHelp": "O valor salvo (como texto) no banco de dados. Se não inserido, o rótulo será usado.", 12 | "conditional": "Grupo de campo condicional", 13 | "conditionalContentsHelp": "Se a condição acima for atendida, esses campos serão ativados.", 14 | "conditionalName": "Campo do formulário a verificar", 15 | "conditionalNameHelp": "Insira o valor \"Nome do Campo\" para um campo de seleção, rádio ou booleano.", 16 | "conditionalValue": "Valor que ativa este grupo", 17 | "conditionalValueHtmlHelp": "Se usar um campo booleano/opção, defina isso como \"true\".", 18 | "confEmailEnable": "Enviar um e-mail de confirmação", 19 | "confEmailEnableHelp": "Ative isso para enviar uma mensagem para a pessoa que envia este formulário.", 20 | "confEmailField": "Qual é o seu campo de e-mail de confirmação?", 21 | "confEmailFieldHelp": "Insira o valor \"nome\" do campo onde as pessoas deverão inserir seu endereço de e-mail. Para melhores resultados, certifique-se de que este campo aceite apenas endereços de e-mail.", 22 | "defaultThankYou": "Obrigado", 23 | "disabledInEditMode": "Mude para o modo de Pré-visualização ou Publicado para testar o formulário.", 24 | "divider": "Divisor", 25 | "emailField": "Endereço de e-mail interno principal", 26 | "emailFieldHelp": "Você pode inserir um da lista anterior. Este é o endereço que será usado como o endereço \"de\" em qualquer mensagem de e-mail gerada.", 27 | "emails": "Endereço(s) de e-mail para Resultados", 28 | "emailsAddress": "Endereço de e-mail para Resultados", 29 | "emailsConditions": "Defina Condições para esta Notificação", 30 | "emailsConditionsField": "Insira um campo para usar como sua condição.", 31 | "emailsConditionsFieldHelp": "Somente campos de seleção (drop-down) e caixas de verificação podem ser usados para essa condição.", 32 | "emailsConditionsHelp": "Por exemplo, se você só notificar este endereço de e-mail se o campo \"país\" estiver definido como \"Áustria\". Todas as condições devem ser atendidas. Adicione o e-mail novamente com outro conjunto condicional, se necessário.", 33 | "emailsConditionsValue": "Insira o valor que um usuário final irá inserir para atender a esta condição.", 34 | "emailsConditionsValueHtmlHelp": "Use valores separados por vírgula para verificar múltiplos valores neste campo (uma relação OR). Valores que realmente contêm vírgulas devem ser inseridos entre aspas duplas (por exemplo, Proud Mary, The Best, \"River Deep, Mountain High\").", 35 | "enableQueryParams": "Ativar captura de parâmetros de consulta", 36 | "enableQueryParamsHtmlHelp": "Se ativado, todos os parâmetros de consulta (os pares chave/valor em uma string de consulta) serão coletados quando o formulário for enviado. Você também pode definir uma lista de chaves de parâmetros específicas que deseja coletar.", 37 | "errorEmailConfirm": "O campo do formulário {{ field }} está configurado para ser usado para o endereço de e-mail de confirmação, mas permite formatos não e-mail.", 38 | "fieldLabel": "Rótulo do campo", 39 | "fieldName": "Nome do campo", 40 | "fieldNameHelp": "Sem espaços ou pontuação além de traços. Se deixado em branco, o formulário preencherá isso com uma forma simplificada do rótulo. Alterar este campo após um formulário estar em uso pode causar problemas com integrações.", 41 | "fieldRequired": "Este campo é obrigatório?", 42 | "file": "Anexo de arquivo", 43 | "fileAllowMultiple": "Permitir vários anexos de arquivo", 44 | "fileLimitSize": "Limitar o tamanho do arquivo?", 45 | "fileMaxSize": "Tamanho máximo do anexo de arquivo", 46 | "fileMaxSizeError": "O arquivo é muito grande '%1$s' (tamanho máximo: %2$s).", 47 | "fileMaxSizeHelp": "Em Bytes", 48 | "fileMissingEarly": "O arquivo temporário enviado {{ path }} já foi removido, isso deveria ter sido responsabilidade da rota de upload", 49 | "fileSizeUnitB": "B", 50 | "fileSizeUnitGB": "GB", 51 | "fileSizeUnitKB": "KB", 52 | "fileSizeUnitMB": "MB", 53 | "fileUploadError": "Ocorreu um erro ao carregar o arquivo. Pode ser muito grande ou de um tipo inadequado.", 54 | "fileUploading": "Carregando...", 55 | "form": "Formulário", 56 | "formContents": "Conteúdo do formulário", 57 | "formErrors": "Erros encontrados na submissão do formulário", 58 | "formName": "Nome do formulário", 59 | "forms": "Formulários", 60 | "globalGroup": "Configurações do Formulário", 61 | "group": "Grupo", 62 | "groupAdvanced": "Avançado", 63 | "groupAfterSubmission": "Após a submissão", 64 | "groupContents": "Conteúdos do Grupo", 65 | "groupContentsHelp": "Contém todos os widgets de formulário, exceto grupos", 66 | "groupForm": "Formulário", 67 | "groupLabel": "Rótulo do Grupo", 68 | "notFoundForm": "Nenhum formulário correspondente foi encontrado", 69 | "queryParamKey": "Chave", 70 | "queryParamLimit": "Limitar o comprimento do valor do parâmetro salvo (caracteres)", 71 | "queryParamLimitHelp": "Insira um número inteiro para limitar o comprimento do valor salvo.", 72 | "queryParamList": "Chaves de parâmetros de consulta", 73 | "queryParamListHelp": "Crie um item de array para cada valor de parâmetro de consulta que você deseja capturar.", 74 | "radio": "Entrada de rádio", 75 | "radioChoice": "Opções de entrada de rádio", 76 | "recaptchaConfigError": "O sistema de verificação reCAPTCHA pode estar fora do ar ou configurado incorretamente. Tente novamente ou notifique o proprietário do site.", 77 | "recaptchaEnable": "Ativar reCAPTCHA no formulário (prevenção de spam)", 78 | "recaptchaEnableHelp": "Para usar, um ID do site reCAPTCHA e uma chave secreta devem ser configurados no código do site ou nas configurações globais do site.", 79 | "recaptchaSecret": "Chave secreta do reCAPTCHA", 80 | "recaptchaSecretHelp": "Insira a chave secreta de uma conta reCAPTCHA", 81 | "recaptchaSite": "Chave do site do reCAPTCHA", 82 | "recaptchaSiteHelp": "Insira a chave do site de uma conta reCAPTCHA", 83 | "recaptchaSubmitError": "Houve um problema ao enviar sua verificação reCAPTCHA.", 84 | "recaptchaValidationError": "Houve um problema ao validar sua submissão de verificação reCAPTCHA.", 85 | "requiredError": "Este campo é obrigatório", 86 | "select": "Entrada de seleção", 87 | "selectAllowMultiple": "Permitir que múltiplas opções sejam selecionadas", 88 | "selectBlank": " ", 89 | "selectChoice": "Opções de entrada de seleção", 90 | "selectSize": "Número de opções na lista que devem ser visíveis", 91 | "submitLabel": "Rótulo do botão de envio", 92 | "templateOptional": "(Opcional)", 93 | "text": "Entrada de texto", 94 | "textArea": "Entrada de área de texto", 95 | "textPlaceholder": "Marcador", 96 | "textPlaceholderHelp": "Texto a ser exibido no campo antes que alguém o use (por exemplo, para fornecer direções adicionais).", 97 | "textType": "Tipo de entrada", 98 | "textTypeDate": "Data", 99 | "textTypeEmail": "E-mail", 100 | "textTypeHelp": "Se você estiver solicitando certas informações formatadas (por exemplo, e-mail, URL, número de telefone), selecione o tipo de entrada relevante aqui. Se não, use \"Texto\".", 101 | "textTypePassword": "Senha", 102 | "textTypePhone": "Telefone", 103 | "textTypeText": "Texto", 104 | "textTypeUrl": "URL", 105 | "thankYouBody": "Conteúdo da mensagem de agradecimento", 106 | "thankYouTitle": "Título da mensagem de agradecimento", 107 | "useRecaptcha": "Usar Google reCAPTCHA em formulários", 108 | "useRecaptchaHtmlHelp": "reCAPTCHA ajuda a evitar submissões de spam em formulários. Você precisará de uma chave secreta e chave do site.", 109 | "widgetCaptchaError": "Houve um erro ao conectar ao serviço de validação reCAPTCHA. Por favor, recarregue a página.", 110 | "widgetForm": "Formulário", 111 | "widgetFormSelect": "Formulário a exibir", 112 | "widgetNoScript": "NOTA: O formulário acima requer JavaScript ativado no navegador para envio.", 113 | "widgetSubmit": "Enviar", 114 | "widgetSubmitError": "Ocorreu um erro ao enviar o formulário. Por favor, tente novamente.", 115 | "widgetSubmitting": "Enviando..." 116 | } 117 | -------------------------------------------------------------------------------- /i18n/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseWidget": "Widget di base per il modulo", 3 | "boolean": "Input booleano/opt-in", 4 | "booleanChecked": "Predefinito su selezionato", 5 | "booleanCheckedHelp": "Se \"sì,\" la casella di controllo sarà attivata di default.", 6 | "checkbox": "Input casella di controllo", 7 | "checkboxChoices": "Opzioni input casella di controllo", 8 | "checkboxChoicesLabel": "Etichetta dell'opzione", 9 | "checkboxChoicesLabelHelp": "L'etichetta leggibile visualizzata agli utenti.", 10 | "checkboxChoicesValue": "Valore dell'opzione", 11 | "checkboxChoicesValueHelp": "Il valore salvato (come testo) nel database. Se non inserito, verrà utilizzata l'etichetta.", 12 | "conditional": "Gruppo di campi condizionali", 13 | "conditionalContentsHelp": "Se la condizione sopra indicata è soddisfatta, questi campi saranno attivati.", 14 | "conditionalName": "Campo del modulo da controllare", 15 | "conditionalNameHelp": "Inserisci il valore \"Nome del Campo\" per un campo di modulo select, radio o booleano.", 16 | "conditionalValue": "Valore che attiva questo gruppo", 17 | "conditionalValueHtmlHelp": "Se si utilizza un campo booleano/opt-in, impostare su \"true\".", 18 | "confEmailEnable": "Invia un'email di conferma", 19 | "confEmailEnableHelp": "Abilita questa opzione per inviare un messaggio alla persona che invia questo modulo.", 20 | "confEmailField": "Qual è il campo email di conferma?", 21 | "confEmailFieldHelp": "Inserisci il valore \"nome\" del campo dove le persone entreranno il loro indirizzo email. Per i migliori risultati, assicurati che questo campo accetti solo indirizzi email.", 22 | "defaultThankYou": "Grazie", 23 | "disabledInEditMode": "Passa alla modalità Anteprima o Pubblicata per testare il modulo.", 24 | "divider": "Divisione", 25 | "emailField": "Indirizzo email interno principale", 26 | "emailFieldHelp": "Puoi inserire uno dall'elenco precedente. Questo sarà l'indirizzo utilizzato come indirizzo \"da\" per eventuali messaggi email generati.", 27 | "emails": "Indirizzo/e email per i risultati", 28 | "emailsAddress": "Indirizzo email per i risultati", 29 | "emailsConditions": "Imposta condizioni per questa notifica", 30 | "emailsConditionsField": "Inserisci un campo da utilizzare come tua condizione.", 31 | "emailsConditionsFieldHelp": "Solo i campi selezione (a discesa) e casella di controllo possono essere utilizzati per questa condizione.", 32 | "emailsConditionsHelp": "Ad esempio, se notifichi solo questo indirizzo email se il campo \"paese\" è impostato su \"Austria\". Tutte le condizioni devono essere soddisfatte. Aggiungi nuovamente l'email con un altro set condizionale se necessario.", 33 | "emailsConditionsValue": "Inserisci il valore che un utente finale entrerà per soddisfare questa condizione.", 34 | "emailsConditionsValueHtmlHelp": "Utilizza valori separati da virgola per controllare più valori su questo campo (una relazione OR). I valori che contengono effettivamente virgole devono essere inseriti tra virgolette doppie (ad es., Proud Mary, The Best, \"River Deep, Mountain High\").", 35 | "enableQueryParams": "Abilita la cattura dei parametri di query", 36 | "enableQueryParamsHtmlHelp": "Se abilitato, tutti i parametri di query (le coppie chiave/valore in una stringa di query) saranno raccolti quando il modulo viene inviato. Puoi anche impostare un elenco di chiavi di parametro specifiche che desideri raccogliere.", 37 | "errorEmailConfirm": "Il campo del modulo {{ field }} è configurato per essere utilizzato per l'indirizzo email di conferma, ma consente formati non email.", 38 | "fieldLabel": "Etichetta del campo", 39 | "fieldName": "Nome del campo", 40 | "fieldNameHelp": "Nessuno spazio o punteggiatura diversa dai trattini. Se lasciato vuoto, il modulo popolerà questo con una forma semplificata dell'etichetta. Modificare questo campo dopo che un modulo è in uso potrebbe causare problemi con eventuali integrazioni.", 41 | "fieldRequired": "Questo campo è richiesto?", 42 | "file": "Allegato file", 43 | "fileAllowMultiple": "Consenti più allegati file", 44 | "fileLimitSize": "Limitare la dimensione del file?", 45 | "fileMaxSize": "Dimensione massima dell'allegato file", 46 | "fileMaxSizeError": "Il file è troppo grande '%1$s' (dimensione massima: %2$s).", 47 | "fileMaxSizeHelp": "In Byte", 48 | "fileMissingEarly": "Il file caricato temporaneamente {{ path }} è già stato rimosso; questa dovrebbe essere stata la responsabilità della route di upload.", 49 | "fileSizeUnitB": "B", 50 | "fileSizeUnitGB": "GB", 51 | "fileSizeUnitKB": "KB", 52 | "fileSizeUnitMB": "MB", 53 | "fileUploadError": "Si è verificato un errore durante il caricamento del file. Potrebbe essere troppo grande o di un tipo inappropriato.", 54 | "fileUploading": "Caricamento in corso...", 55 | "form": "Modulo", 56 | "formContents": "Contenuti del modulo", 57 | "formErrors": "Errori trovati nell'invio del modulo", 58 | "formName": "Nome del modulo", 59 | "forms": "Moduli", 60 | "globalGroup": "Impostazioni del modulo", 61 | "group": "Gruppo", 62 | "groupAdvanced": "Avanzato", 63 | "groupAfterSubmission": "Dopo l'invio", 64 | "groupContents": "Contenuti del gruppo", 65 | "groupContentsHelp": "Contiene tutti i widget del modulo tranne i gruppi", 66 | "groupForm": "Modulo", 67 | "groupLabel": "Etichetta del gruppo", 68 | "notFoundForm": "Nessun modulo corrispondente trovato", 69 | "queryParamKey": "Chiave", 70 | "queryParamLimit": "Limita la lunghezza del valore del parametro salvato (caratteri)", 71 | "queryParamLimitHelp": "Inserisci un numero intero per limitare la lunghezza del valore salvato.", 72 | "queryParamList": "Chiavi dei parametri di query", 73 | "queryParamListHelp": "Crea un elemento dell'array per ciascun valore di parametro di query che desideri catturare.", 74 | "radio": "Input radio", 75 | "radioChoice": "Opzioni input radio", 76 | "recaptchaConfigError": "Il sistema di verifica reCAPTCHA potrebbe essere inattivo o configurato in modo errato. Riprova o notificherai il proprietario del sito.", 77 | "recaptchaEnable": "Abilita reCAPTCHA sul modulo (prevenzione spam)", 78 | "recaptchaEnableHelp": "Per utilizzare reCAPTCHA, un ID sito e una chiave segreta devono essere configurati nel codice del sito web o nelle impostazioni globali del sito.", 79 | "recaptchaSecret": "Chiave segreta reCAPTCHA", 80 | "recaptchaSecretHelp": "Inserisci la chiave segreta da un account reCAPTCHA", 81 | "recaptchaSite": "Chiave del sito reCAPTCHA", 82 | "recaptchaSiteHelp": "Inserisci la chiave del sito da un account reCAPTCHA", 83 | "recaptchaSubmitError": "Si è verificato un problema durante l'invio della verifica reCAPTCHA.", 84 | "recaptchaValidationError": "Si è verificato un problema nella validazione dell'invio della verifica reCAPTCHA.", 85 | "requiredError": "Questo campo è richiesto", 86 | "select": "Input selezione", 87 | "selectAllowMultiple": "Consenti la selezione di più opzioni", 88 | "selectBlank": " ", 89 | "selectChoice": "Opzioni input selezione", 90 | "selectSize": "Numero di opzioni nella lista che devono essere visibili", 91 | "submitLabel": "Etichetta del pulsante di invio", 92 | "templateOptional": "(Facoltativo)", 93 | "text": "Input testo", 94 | "textArea": "Input area di testo", 95 | "textPlaceholder": "Segnaposto", 96 | "textPlaceholderHelp": "Testo da visualizzare nel campo prima che qualcuno lo utilizzi (ad esempio, per fornire indicazioni aggiuntive).", 97 | "textType": "Tipo di input", 98 | "textTypeDate": "Data", 99 | "textTypeEmail": "Email", 100 | "textTypeHelp": "Se stai richiedendo informazioni formattate specifiche (ad esempio, email, url, numero di telefono), seleziona il tipo di input pertinente qui. In caso contrario, usa \"Testo\".", 101 | "textTypePassword": "Password", 102 | "textTypePhone": "Telefono", 103 | "textTypeText": "Testo", 104 | "textTypeUrl": "URL", 105 | "thankYouBody": "Contenuto del messaggio di ringraziamento", 106 | "thankYouTitle": "Titolo del messaggio di ringraziamento", 107 | "useRecaptcha": "Utilizza Google reCAPTCHA sui moduli", 108 | "useRecaptchaHtmlHelp": "reCAPTCHA aiuta a evitare invii di spam sui moduli. Avrai bisogno di una chiave segreta e chiave del sito.", 109 | "widgetCaptchaError": "Si è verificato un errore durante la connessione al servizio di verifica reCAPTCHA. Ricarica la pagina.", 110 | "widgetForm": "Modulo", 111 | "widgetFormSelect": "Modulo da visualizzare", 112 | "widgetNoScript": "NOTA: Il modulo sopra richiede JavaScript abilitato nel browser per l'invio.", 113 | "widgetSubmit": "Invia", 114 | "widgetSubmitError": "Si è verificato un errore durante l'invio del modulo. Riprova.", 115 | "widgetSubmitting": "Invio in corso..." 116 | } 117 | -------------------------------------------------------------------------------- /i18n/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseWidget": "Widget de formulaire de base", 3 | "boolean": "Champ booléen/opt-in", 4 | "booleanChecked": "Par défaut, pré-coché", 5 | "booleanCheckedHelp": "Si \"oui,\" la case à cocher commencera dans l'état coché.", 6 | "checkbox": "Champ de case à cocher", 7 | "checkboxChoices": "Options de champ de case à cocher", 8 | "checkboxChoicesLabel": "Étiquette de l'option", 9 | "checkboxChoicesLabelHelp": "L'étiquette lisible affichée aux utilisateurs.", 10 | "checkboxChoicesValue": "Valeur de l'option", 11 | "checkboxChoicesValueHelp": "La valeur enregistrée (au format texte) dans la base de données. Si non saisie, l'étiquette sera utilisée.", 12 | "conditional": "Groupe de champs conditionnels", 13 | "conditionalContentsHelp": "Si la condition ci-dessus est remplie, ces champs seront activés.", 14 | "conditionalName": "Champ de formulaire à vérifier", 15 | "conditionalNameHelp": "Entrez la valeur \"Nom du champ\" pour un champ de formulaire sélectionner, radio ou booléen.", 16 | "conditionalValue": "Valeur qui active ce groupe", 17 | "conditionalValueHtmlHelp": "Si vous utilisez un champ booléen/opt-in, définissez cela à \"true\".", 18 | "confEmailEnable": "Envoyer un e-mail de confirmation", 19 | "confEmailEnableHelp": "Activez cela pour envoyer un message à la personne qui soumet ce formulaire.", 20 | "confEmailField": "Quel est votre champ d'e-mail de confirmation ?", 21 | "confEmailFieldHelp": "Entrez la valeur \"nom\" du champ où les gens entreront leur adresse e-mail. Pour de meilleurs résultats, assurez-vous que ce champ n'accepte que des adresses e-mails.", 22 | "defaultThankYou": "Merci", 23 | "disabledInEditMode": "Passez en mode Aperçu ou Publié pour tester le formulaire.", 24 | "divider": "Diviseur", 25 | "emailField": "Adresse e-mail interne principale", 26 | "emailFieldHelp": "Vous pouvez entrer une adresse de la liste précédente. C'est l'adresse qui sera utilisée comme adresse \"de\" dans tous les messages e-mail générés.", 27 | "emails": "Adresse(s) e-mail pour les résultats", 28 | "emailsAddress": "Adresse e-mail pour les résultats", 29 | "emailsConditions": "Définir des conditions pour cette notification", 30 | "emailsConditionsField": "Entrez un champ à utiliser comme condition.", 31 | "emailsConditionsFieldHelp": "Seuls les champs (déroulants) et les cases à cocher peuvent être utilisés pour cette condition.", 32 | "emailsConditionsHelp": "Par exemple, si vous ne notifiez cette adresse e-mail que si le champ \"pays\" est défini sur \"Autriche\". Toutes les conditions doivent être remplies. Ajoutez à nouveau l'email avec un autre ensemble conditionnel si nécessaire.", 33 | "emailsConditionsValue": "Entrez la valeur qu'un utilisateur final saisira pour satisfaire cette condition.", 34 | "emailsConditionsValueHtmlHelp": "Utilisez des valeurs séparées par des virgules pour vérifier plusieurs valeurs sur ce champ (une relation OU). Les valeurs qui contiennent réellement des virgules doivent être saisies entre guillemets doubles (par exemple, Proud Mary, The Best, \"River Deep, Mountain High\").", 35 | "enableQueryParams": "Activer la capture des paramètres de requête", 36 | "enableQueryParamsHtmlHelp": "Si activé, tous les paramètres de requête (les paires clé/valeur dans une chaîne de requête) seront collectés lors de la soumission du formulaire. Vous pouvez également définir une liste de clés de paramètres spécifiques que vous souhaitez collecter.", 37 | "errorEmailConfirm": "Le champ de formulaire {{ field }} est configuré pour être utilisé pour l'adresse e-mail de confirmation, mais il permet des formats non valides.", 38 | "fieldLabel": "Étiquette du champ", 39 | "fieldName": "Nom du champ", 40 | "fieldNameHelp": "Pas d'espaces ou de ponctuation autre que des tirets. S'il est laissé vide, le formulaire le remplira avec une version simplifiée de l'étiquette. Changer ce champ après qu'un formulaire est en cours d'utilisation peut causer des problèmes avec des intégrations.", 41 | "fieldRequired": "Ce champ est-il requis ?", 42 | "file": "Pièce jointe", 43 | "fileAllowMultiple": "Autoriser plusieurs pièces jointes", 44 | "fileLimitSize": "Limiter la taille des fichiers ?", 45 | "fileMaxSize": "Taille maximale de la pièce jointe", 46 | "fileMaxSizeError": "Le fichier est trop grand '%1$s' (taille max : %2$s).", 47 | "fileMaxSizeHelp": "En octets", 48 | "fileMissingEarly": "Le fichier temporaire téléchargé {{ path }} a déjà été supprimé, cela aurait dû être la responsabilité de la route de téléchargement", 49 | "fileSizeUnitB": "o", 50 | "fileSizeUnitGB": "Go", 51 | "fileSizeUnitKB": "Ko", 52 | "fileSizeUnitMB": "Mo", 53 | "fileUploadError": "Une erreur s'est produite lors du téléchargement du fichier. Il peut être trop grand ou de type inapproprié.", 54 | "fileUploading": "Téléchargement...", 55 | "form": "Formulaire", 56 | "formContents": "Contenu du formulaire", 57 | "formErrors": "Erreurs trouvées dans la soumission du formulaire", 58 | "formName": "Nom du formulaire", 59 | "forms": "Formulaires", 60 | "globalGroup": "Paramètres du formulaire", 61 | "group": "Groupe", 62 | "groupAdvanced": "Avancé", 63 | "groupAfterSubmission": "Après soumission", 64 | "groupContents": "Contenu du groupe", 65 | "groupContentsHelp": "Contient tous les widgets de formulaire sauf les groupes", 66 | "groupForm": "Formulaire", 67 | "groupLabel": "Étiquette du groupe", 68 | "notFoundForm": "Aucun formulaire correspondant n'a été trouvé", 69 | "queryParamKey": "Clé", 70 | "queryParamLimit": "Limiter la longueur de la valeur du paramètre enregistré (caractères)", 71 | "queryParamLimitHelp": "Entrez un nombre entier pour limiter la longueur de la valeur enregistrée.", 72 | "queryParamList": "Clés de paramètres de requête", 73 | "queryParamListHelp": "Créez un élément de tableau pour chaque valeur de paramètre de requête que vous souhaitez capturer.", 74 | "radio": "Champ radio", 75 | "radioChoice": "Options de champ radio", 76 | "recaptchaConfigError": "Le système de vérification reCAPTCHA peut être hors service ou mal configuré. Veuillez réessayer ou notifier le propriétaire du site.", 77 | "recaptchaEnable": "Activer reCAPTCHA sur le formulaire (prévention du spam)", 78 | "recaptchaEnableHelp": "Pour l'utiliser, un ID de site reCAPTCHA et une clé secrète doivent être configurés dans le code du site web ou les paramètres globaux du site.", 79 | "recaptchaSecret": "Clé secrète reCAPTCHA", 80 | "recaptchaSecretHelp": "Entrez la clé secrète d'un compte reCAPTCHA", 81 | "recaptchaSite": "Clé du site reCAPTCHA", 82 | "recaptchaSiteHelp": "Entrez la clé du site d'un compte reCAPTCHA", 83 | "recaptchaSubmitError": "Un problème est survenu lors de la soumission de votre vérification reCAPTCHA.", 84 | "recaptchaValidationError": "Un problème est survenu lors de la validation de votre soumission de vérification reCAPTCHA.", 85 | "requiredError": "Ce champ est requis", 86 | "select": "Champ de sélection", 87 | "selectAllowMultiple": "Autoriser plusieurs options à être sélectionnées", 88 | "selectBlank": " ", 89 | "selectChoice": "Options de champ de sélection", 90 | "selectSize": "Nombre d'options dans la liste devant être visibles", 91 | "submitLabel": "Étiquette du bouton de soumission", 92 | "templateOptional": "(Facultatif)", 93 | "text": "Champ de texte", 94 | "textArea": "Champ de zone de texte", 95 | "textPlaceholder": "Espace réservé", 96 | "textPlaceholderHelp": "Texte à afficher dans le champ avant que quelqu'un ne l'utilise (par exemple, pour fournir des instructions supplémentaires).", 97 | "textType": "Type d'entrée", 98 | "textTypeDate": "Date", 99 | "textTypeEmail": "E-mail", 100 | "textTypeHelp": "Si vous demandez certaines informations formatées (par exemple, e-mail, url, numéro de téléphone), sélectionnez le type d'entrée pertinent ici. Sinon, utilisez \"Texte\".", 101 | "textTypePassword": "Mot de passe", 102 | "textTypePhone": "Téléphone", 103 | "textTypeText": "Texte", 104 | "textTypeUrl": "URL", 105 | "thankYouBody": "Contenu du message de remerciement", 106 | "thankYouTitle": "Titre du message de remerciement", 107 | "useRecaptcha": "Utilisez Google reCAPTCHA sur les formulaires", 108 | "useRecaptchaHtmlHelp": "reCAPTCHA aide à éviter les soumissions de spam sur les formulaires. Vous aurez besoin d'une clé secrète et clé de site.", 109 | "widgetCaptchaError": "Une erreur est survenue lors de la connexion au service de validation reCAPTCHA. Veuillez recharger la page.", 110 | "widgetForm": "Formulaire", 111 | "widgetFormSelect": "Formulaire à afficher", 112 | "widgetNoScript": "REMARQUE : Le formulaire ci-dessus nécessite JavaScript activé dans le navigateur pour la soumission.", 113 | "widgetSubmit": "Soumettre", 114 | "widgetSubmitError": "Une erreur s'est produite lors de la soumission du formulaire. Veuillez réessayer.", 115 | "widgetSubmitting": "Soumission..." 116 | } 117 | -------------------------------------------------------------------------------- /i18n/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseWidget": "Formulargrundwidget", 3 | "boolean": "Boolean/opt-in Eingabe", 4 | "booleanChecked": "Standardmäßig auf vorab ausgewählt", 5 | "booleanCheckedHelp": "Wenn \"ja,\" wird das Kontrollkästchen im ausgewählten Zustand starten.", 6 | "checkbox": "Kontrollkästchen Eingabe", 7 | "checkboxChoices": "Optionen für Kontrollkästchen Eingabe", 8 | "checkboxChoicesLabel": "Optionslabel", 9 | "checkboxChoicesLabelHelp": "Das lesbare Label, das den Benutzern angezeigt wird.", 10 | "checkboxChoicesValue": "Optionswert", 11 | "checkboxChoicesValueHelp": "Der Wert, der (als Text) in der Datenbank gespeichert wird. Wenn nicht eingegeben, wird das Label verwendet.", 12 | "conditional": "Bedingte Feldgruppe", 13 | "conditionalContentsHelp": "Wenn die obige Bedingung erfüllt ist, werden diese Felder aktiviert.", 14 | "conditionalName": "Formularfeld zur Überprüfung", 15 | "conditionalNameHelp": "Geben Sie den Wert \"Feldname\" für ein Auswahl-, Radio- oder Boolean Formularfeld ein.", 16 | "conditionalValue": "Wert, der diese Gruppe aktiviert", 17 | "conditionalValueHtmlHelp": "Wenn Sie ein Boolean/Opt-in-Feld verwenden, setzen Sie dies auf \"true.\"", 18 | "confEmailEnable": "Bestätigungs-E-Mail senden", 19 | "confEmailEnableHelp": "Aktivieren Sie dies, um eine Nachricht an die Person zu senden, die dieses Formular absendet.", 20 | "confEmailField": "Welches ist Ihr Bestätigungs-E-Mail-Feld?", 21 | "confEmailFieldHelp": "Geben Sie den \"Namen\" des Feldes ein, in dem die Personen ihre E-Mail-Adresse eingeben können. Für die besten Ergebnisse stellen Sie sicher, dass dieses Feld nur E-Mail-Adressen akzeptiert.", 22 | "defaultThankYou": "Danke", 23 | "disabledInEditMode": "Wechseln Sie in den Vorschau- oder veröffentlichten Modus, um das Formular zu testen.", 24 | "divider": "Divider", 25 | "emailField": "Primäre interne E-Mail-Adresse", 26 | "emailFieldHelp": "Sie können eine aus der vorherigen Liste eingeben. Dies ist die Adresse, die als \"Von\"-Adresse für alle generierten E-Mail-Nachrichten verwendet wird.", 27 | "emails": "E-Mail-Adresse(n) für Ergebnisse", 28 | "emailsAddress": "E-Mail-Adresse für Ergebnisse", 29 | "emailsConditions": "Bedingungen für diese Benachrichtigung festlegen", 30 | "emailsConditionsField": "Geben Sie ein Feld an, das Sie als Ihre Bedingung verwenden möchten.", 31 | "emailsConditionsFieldHelp": "Nur Auswahl (Dropdown) und Kontrollkästchenfelder können für diese Bedingung verwendet werden.", 32 | "emailsConditionsHelp": "Zum Beispiel, wenn Sie diese E-Mail-Adresse nur benachrichtigen, wenn das Feld \"Land\" auf \"Österreich\" gesetzt ist. Alle Bedingungen müssen erfüllt sein. Fügen Sie die E-Mail-Adresse erneut mit einer anderen Bedingung hinzu, falls erforderlich.", 33 | "emailsConditionsValue": "Geben Sie den Wert ein, den ein Endbenutzer eingeben wird, um diese Bedingung zu erfüllen.", 34 | "emailsConditionsValueHtmlHelp": "Verwenden Sie durch Kommas getrennte Werte, um mehrere Werte für dieses Feld zu überprüfen (eine ODER-Beziehung). Werte, die tatsächlich Kommata enthalten, sollten in Anführungszeichen eingegeben werden (z.B. Proud Mary, The Best, \"River Deep, Mountain High\").", 35 | "enableQueryParams": "Aktivieren Sie die Erfassung von Abfrageparametern", 36 | "enableQueryParamsHtmlHelp": "Wenn aktiviert, werden alle Abfrageparameter (die Schlüssel/Wert-Paare in einer Abfragezeichenfolge) erfasst, wenn das Formular abgesendet wird. Sie können auch eine Liste spezifischer Parameter-Schlüssel festlegen, die Sie erfassen möchten.", 37 | "errorEmailConfirm": "Das Formularfeld {{ field }} ist für die Bestätigungs-E-Mail-Adresse konfiguriert, erlaubt jedoch keine Nicht-E-Mail-Formate.", 38 | "fieldLabel": "Feldlabel", 39 | "fieldName": "Feldname", 40 | "fieldNameHelp": "Keine Leerzeichen oder Interpunktion außer Bindestrichen. Wenn dieses Feld leer gelassen wird, wird das Formular damit ein vereinfachtes Formular des Labels ausfüllen. Das Ändern dieses Feldes nach der Verwendung eines Formulars kann Probleme mit Integrationen verursachen.", 41 | "fieldRequired": "Ist dieses Feld erforderlich?", 42 | "file": "Dateianhang", 43 | "fileAllowMultiple": "Mehrere Dateianhänge zulassen", 44 | "fileLimitSize": "Dateigröße begrenzen?", 45 | "fileMaxSize": "Maximale Dateigröße für den Anhang", 46 | "fileMaxSizeError": "Die Datei ist zu groß '%1$s' (max. Größe: %2$s).", 47 | "fileMaxSizeHelp": "In Bytes", 48 | "fileMissingEarly": "Die hochgeladene temporäre Datei {{ path }} wurde bereits entfernt, dies hätte in der Verantwortung der Upload-Routen liegen sollen.", 49 | "fileSizeUnitB": "B", 50 | "fileSizeUnitGB": "GB", 51 | "fileSizeUnitKB": "KB", 52 | "fileSizeUnitMB": "MB", 53 | "fileUploadError": "Beim Hochladen der Datei ist ein Fehler aufgetreten. Sie könnte zu groß oder von einem unangemessenen Typ sein.", 54 | "fileUploading": "Hochladen...", 55 | "form": "Formular", 56 | "formContents": "Formularinhalt", 57 | "formErrors": "Fehler im Formulareingang gefunden", 58 | "formName": "Formularname", 59 | "forms": "Formulare", 60 | "globalGroup": "Formulareinstellungen", 61 | "group": "Gruppe", 62 | "groupAdvanced": "Erweitert", 63 | "groupAfterSubmission": "Nach der Einreichung", 64 | "groupContents": "Gruppeninhalt", 65 | "groupContentsHelp": "Enthält alle Formular-Widgets außer Gruppen", 66 | "groupForm": "Formular", 67 | "groupLabel": "Gruppenlabel", 68 | "notFoundForm": "Es wurde kein passendes Formular gefunden", 69 | "queryParamKey": "Schlüssel", 70 | "queryParamLimit": "Länge des gespeicherten Parameterwertes begrenzen (Zeichen)", 71 | "queryParamLimitHelp": "Geben Sie eine ganze Zahl ein, um die Länge des gespeicherten Wertes zu begrenzen.", 72 | "queryParamList": "Abfrageparameter-Schlüssel", 73 | "queryParamListHelp": "Erstellen Sie für jeden Abfrageparameterwert, den Sie erfassen möchten, ein Array-Element.", 74 | "radio": "Radio-Eingabe", 75 | "radioChoice": "Optionen für Radio-Eingabe", 76 | "recaptchaConfigError": "Das reCAPTCHA-Verifizierungssystem kann ausgefallen oder falsch konfiguriert sein. Bitte versuchen Sie es erneut oder benachrichtigen Sie den Seiteninhaber.", 77 | "recaptchaEnable": "reCAPTCHA im Formular aktivieren (Spam-Prävention)", 78 | "recaptchaEnableHelp": "Um es zu verwenden, müssen eine Website-ID und ein geheimer Schlüssel im Webseiten-Code oder in den globalen Einstellungen der Website konfiguriert werden.", 79 | "recaptchaSecret": "reCAPTCHA geheimer Schlüssel", 80 | "recaptchaSecretHelp": "Geben Sie den geheimen Schlüssel von einem reCAPTCHA-Konto ein", 81 | "recaptchaSite": "reCAPTCHA Site-Schlüssel", 82 | "recaptchaSiteHelp": "Geben Sie den Site-Schlüssel von einem reCAPTCHA-Konto ein", 83 | "recaptchaSubmitError": "Beim Einreichen Ihrer reCAPTCHA-Verifizierung trat ein Problem auf.", 84 | "recaptchaValidationError": "Beim Validieren Ihrer reCAPTCHA-Verifizierungsübermittlung trat ein Problem auf.", 85 | "requiredError": "Dieses Feld ist erforderlich", 86 | "select": "Auswahl-Eingabe", 87 | "selectAllowMultiple": "Erlaube die Auswahl mehrerer Optionen", 88 | "selectBlank": " ", 89 | "selectChoice": "Optionen für Auswahl-Eingabe", 90 | "selectSize": "Anzahl der Optionen in der Liste, die sichtbar sein sollten", 91 | "submitLabel": "Beschriftung der Schaltfläche absenden", 92 | "templateOptional": "(Optional)", 93 | "text": "Text Eingabe", 94 | "textArea": "Textbereich Eingabe", 95 | "textPlaceholder": "Platzhalter", 96 | "textPlaceholderHelp": "Text, der im Feld angezeigt wird, bevor jemand es verwendet (z.B. um zusätzliche Anweisungen bereitzustellen).", 97 | "textType": "Eingabetyp", 98 | "textTypeDate": "Datum", 99 | "textTypeEmail": "E-Mail", 100 | "textTypeHelp": "Wenn Sie bestimmte formatierte Informationen anfordern (z.B. E-Mail, URL, Telefonnummer), wählen Sie hier den entsprechenden Eingabetyp aus. Andernfalls verwenden Sie \"Text.\"", 101 | "textTypePassword": "Passwort", 102 | "textTypePhone": "Telefon", 103 | "textTypeText": "Text", 104 | "textTypeUrl": "URL", 105 | "thankYouBody": "Inhalt der Dankesnachricht", 106 | "thankYouTitle": "Titel der Dankesnachricht", 107 | "useRecaptcha": "Verwende Google reCAPTCHA in Formularen", 108 | "useRecaptchaHtmlHelp": "reCAPTCHA hilft, Spam-Einreichungen in Formularen zu vermeiden. Sie benötigen einen geheimen Schlüssel und Site-Schlüssel.", 109 | "widgetCaptchaError": "Beim Verbindungsaufbau zu dem reCAPTCHA-Validierungsdienst ist ein Fehler aufgetreten. Bitte laden Sie die Seite neu.", 110 | "widgetForm": "Formular", 111 | "widgetFormSelect": "Anzuzeigendes Formular", 112 | "widgetNoScript": "HINWEIS: Das obige Formular erfordert aktiviertes JavaScript im Browser für die Einreichung.", 113 | "widgetSubmit": "Einreichen", 114 | "widgetSubmitError": "Beim Einreichen des Formulars ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", 115 | "widgetSubmitting": "Einreichen..." 116 | } 117 | -------------------------------------------------------------------------------- /i18n/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseWidget": "Widget base del formulario", 3 | "boolean": "Entrada booleana/opción de inclusión", 4 | "booleanChecked": "Predefinido como seleccionado", 5 | "booleanCheckedHelp": "Si \"sí\", la casilla comenzará en estado seleccionado.", 6 | "checkbox": "Entrada de casilla de verificación", 7 | "checkboxChoices": "Opciones de entrada de casilla de verificación", 8 | "checkboxChoicesLabel": "Etiqueta de opción", 9 | "checkboxChoicesLabelHelp": "La etiqueta legible mostrada a los usuarios.", 10 | "checkboxChoicesValue": "Valor de opción", 11 | "checkboxChoicesValueHelp": "El valor guardado (como texto) en la base de datos. Si no se ingresa, se utilizará la etiqueta.", 12 | "conditional": "Grupo de campos condicional", 13 | "conditionalContentsHelp": "Si se cumple la condición anterior, estos campos se activarán.", 14 | "conditionalName": "Campo de formulario a verificar", 15 | "conditionalNameHelp": "Ingresa el valor \"Nombre del campo\" para un campo de formulario select, radio o booleano.", 16 | "conditionalValue": "Valor que activa este grupo", 17 | "conditionalValueHtmlHelp": "Si se utiliza un campo booleano/opción de inclusión, configúralo como \"true\".", 18 | "confEmailEnable": "Enviar un correo electrónico de confirmación", 19 | "confEmailEnableHelp": "Habilita esto para enviar un mensaje a la persona que envía este formulario.", 20 | "confEmailField": "¿Cuál es tu campo de correo electrónico de confirmación?", 21 | "confEmailFieldHelp": "Ingresa el valor \"nombre\" del campo donde las personas ingresarán su dirección de correo electrónico. Para obtener mejores resultados, asegúrate de que este campo solo acepte direcciones de correo electrónico.", 22 | "defaultThankYou": "Gracias", 23 | "disabledInEditMode": "Cambia a Vista previa o Modo publicado para probar el formulario.", 24 | "divider": "Divisor", 25 | "emailField": "Dirección de correo electrónico interna principal", 26 | "emailFieldHelp": "Puedes ingresar una de la lista anterior. Esta es la dirección que se utilizará como la dirección \"de\" en los mensajes de correo electrónico generados.", 27 | "emails": "Dirección(es) de correo electrónico para resultados", 28 | "emailsAddress": "Dirección de correo electrónico para resultados", 29 | "emailsConditions": "Establecer condiciones para esta notificación", 30 | "emailsConditionsField": "Ingresa un campo para usar como tu condición.", 31 | "emailsConditionsFieldHelp": "Solo se pueden usar campos (desplegables) y de casilla de verificación para esta condición.", 32 | "emailsConditionsHelp": "Por ejemplo, si solo notifiques esta dirección de correo electrónico si el campo \"país\" está configurado en \"Austria\". Todas las condiciones deben cumplirse. Agrega el correo electrónico nuevamente con otro conjunto condicional si es necesario.", 33 | "emailsConditionsValue": "Ingresa el valor que un usuario final ingresará para cumplir con esta condición.", 34 | "emailsConditionsValueHtmlHelp": "Utiliza valores separados por comas para verificar múltiples valores en este campo (una relación OR). Los valores que realmente contienen comas deben ingresarse entre comillas dobles (por ejemplo, Proud Mary, The Best, \"River Deep, Mountain High\").", 35 | "enableQueryParams": "Habilitar captura de parámetros de consulta", 36 | "enableQueryParamsHtmlHelp": "Si se habilita, todos los parámetros de consulta (las parejas clave/valor en una cadena de consulta) se recopilarán cuando se envíe el formulario. También puedes establecer una lista de claves de parámetros específicas que deseas recopilar.", 37 | "errorEmailConfirm": "El campo del formulario {{ field }} está configurado para ser utilizado como la dirección de correo electrónico de confirmación, pero permite formatos que no son de correo electrónico.", 38 | "fieldLabel": "Etiqueta del campo", 39 | "fieldName": "Nombre del campo", 40 | "fieldNameHelp": "Sin espacios ni puntuación excepto guiones. Si se deja en blanco, el formulario lo rellenará con una forma simplificada de la etiqueta. Cambiar este campo después de que un formulario esté en uso puede causar problemas con las integraciones.", 41 | "fieldRequired": "¿Es este campo requerido?", 42 | "file": "Adjunto de archivo", 43 | "fileAllowMultiple": "Permitir adjuntos de múltiples archivos", 44 | "fileLimitSize": "¿Limitar el tamaño del archivo?", 45 | "fileMaxSize": "Tamaño máximo de adjunto de archivo", 46 | "fileMaxSizeError": "El archivo es demasiado grande '%1$s' (tamaño máximo: %2$s).", 47 | "fileMaxSizeHelp": "En Bytes", 48 | "fileMissingEarly": "El archivo temporal subido {{ path }} ya fue eliminado, esto debería haber sido responsabilidad de la ruta de carga", 49 | "fileSizeUnitB": "B", 50 | "fileSizeUnitGB": "GB", 51 | "fileSizeUnitKB": "KB", 52 | "fileSizeUnitMB": "MB", 53 | "fileUploadError": "Se produjo un error al cargar el archivo. Puede ser demasiado grande o de un tipo inapropiado.", 54 | "fileUploading": "Subiendo...", 55 | "form": "Formulario", 56 | "formContents": "Contenidos del formulario", 57 | "formErrors": "Errores encontrados en la presentación del formulario", 58 | "formName": "Nombre del formulario", 59 | "forms": "Formularios", 60 | "globalGroup": "Configuraciones del formulario", 61 | "group": "Grupo", 62 | "groupAdvanced": "Avanzado", 63 | "groupAfterSubmission": "Después de la presentación", 64 | "groupContents": "Contenidos del grupo", 65 | "groupContentsHelp": "Contiene todos los widgets del formulario excepto grupos", 66 | "groupForm": "Formulario", 67 | "groupLabel": "Etiqueta del grupo", 68 | "notFoundForm": "No se encontró un formulario coincidente", 69 | "queryParamKey": "Clave", 70 | "queryParamLimit": "Limitar la longitud del valor del parámetro guardado (caracteres)", 71 | "queryParamLimitHelp": "Ingresa un número entero para limitar la longitud del valor guardado.", 72 | "queryParamList": "Claves de parámetros de consulta", 73 | "queryParamListHelp": "Crea un elemento de array para cada valor de parámetro de consulta que desees capturar.", 74 | "radio": "Entrada de radio", 75 | "radioChoice": "Opciones de entrada de radio", 76 | "recaptchaConfigError": "El sistema de verificación reCAPTCHA puede estar inactivo o configurado incorrectamente. Intenta nuevamente o notifica al propietario del sitio.", 77 | "recaptchaEnable": "Habilitar reCAPTCHA en el formulario (prevención de spam)", 78 | "recaptchaEnableHelp": "Para usar, se debe configurar un ID de sitio reCAPTCHA y una clave secreta en el código del sitio web o en la configuración global del sitio web.", 79 | "recaptchaSecret": "Clave secreta de reCAPTCHA", 80 | "recaptchaSecretHelp": "Ingresa la clave secreta de una cuenta de reCAPTCHA", 81 | "recaptchaSite": "Clave del sitio de reCAPTCHA", 82 | "recaptchaSiteHelp": "Ingresa la clave del sitio de una cuenta de reCAPTCHA", 83 | "recaptchaSubmitError": "Hubo un problema al enviar tu verificación de reCAPTCHA.", 84 | "recaptchaValidationError": "Hubo un problema al validar tu presentación de verificación de reCAPTCHA.", 85 | "requiredError": "Este campo es requerido", 86 | "select": "Entrada de selección", 87 | "selectAllowMultiple": "Permitir seleccionar múltiples opciones", 88 | "selectBlank": " ", 89 | "selectChoice": "Opciones de entrada de selección", 90 | "selectSize": "Número de opciones en la lista que deberían ser visibles", 91 | "submitLabel": "Etiqueta del botón de envío", 92 | "templateOptional": "(Opcional)", 93 | "text": "Entrada de texto", 94 | "textArea": "Entrada de área de texto", 95 | "textPlaceholder": "Marcador de posición", 96 | "textPlaceholderHelp": "Texto a mostrar en el campo antes de que alguien lo use (por ejemplo, para proporcionar direcciones adicionales).", 97 | "textType": "Tipo de entrada", 98 | "textTypeDate": "Fecha", 99 | "textTypeEmail": "Correo electrónico", 100 | "textTypeHelp": "Si estás solicitando información con un formato específico (por ejemplo, correo electrónico, url, número de teléfono), selecciona el tipo de entrada relevante aquí. Si no, usa \"Texto\".", 101 | "textTypePassword": "Contraseña", 102 | "textTypePhone": "Teléfono", 103 | "textTypeText": "Texto", 104 | "textTypeUrl": "URL", 105 | "thankYouBody": "Contenido del mensaje de agradecimiento", 106 | "thankYouTitle": "Título del mensaje de agradecimiento", 107 | "useRecaptcha": "Usar Google reCAPTCHA en formularios", 108 | "useRecaptchaHtmlHelp": "reCAPTCHA ayuda a evitar envíos de spam en formularios. Necesitarás una clave secreta y clave del sitio.", 109 | "widgetCaptchaError": "Hubo un error al conectar con el servicio de verificación de reCAPTCHA. Por favor recarga la página.", 110 | "widgetForm": "Formulario", 111 | "widgetFormSelect": "Formulario para mostrar", 112 | "widgetNoScript": "NOTA: El formulario anterior requiere JavaScript habilitado en el navegador para su envío.", 113 | "widgetSubmit": "Enviar", 114 | "widgetSubmitError": "Se produjo un error al enviar el formulario. Por favor inténtalo de nuevo.", 115 | "widgetSubmitting": "Enviando..." 116 | } 117 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const os = require('os'); 4 | const multer = require('multer'); 5 | const fields = require('./lib/fields'); 6 | const recaptcha = require('./lib/recaptcha'); 7 | const processor = require('./lib/processor'); 8 | 9 | module.exports = { 10 | extend: '@apostrophecms/piece-type', 11 | options: { 12 | label: 'aposForm:form', 13 | pluralLabel: 'aposForm:forms', 14 | quickCreate: true, 15 | seoFields: false, 16 | openGraph: false, 17 | i18n: { 18 | ns: 'aposForm', 19 | browser: true 20 | }, 21 | shortcut: 'G,O' 22 | }, 23 | bundle: { 24 | directory: 'modules', 25 | modules: getBundleModuleNames() 26 | }, 27 | fields(self) { 28 | let add = fields.initial(self.options); 29 | 30 | if (self.options.emailSubmissions !== false) { 31 | add = { 32 | ...add, 33 | ...fields.emailFields 34 | }; 35 | } 36 | 37 | const group = { 38 | basics: { 39 | label: 'aposForm:groupForm', 40 | fields: [ 'contents' ] 41 | }, 42 | afterSubmit: { 43 | label: 'aposForm:groupAfterSubmission', 44 | fields: [ 45 | 'thankYouHeading', 46 | 'thankYouBody', 47 | 'sendConfirmationEmail', 48 | 'emailConfirmationField' 49 | ] 50 | .concat( 51 | self.options.emailSubmissions !== false 52 | ? [ 53 | 'emails', 54 | 'email' 55 | ] 56 | : [] 57 | ) 58 | }, 59 | advanced: { 60 | label: 'aposForm:groupAdvanced', 61 | fields: [ 62 | 'submitLabel', 63 | 'enableRecaptcha', 64 | 'enableQueryParams', 65 | 'queryParamList' 66 | ] 67 | } 68 | }; 69 | 70 | return { 71 | add, 72 | group 73 | }; 74 | }, 75 | init(self) { 76 | self.ensureCollection(); 77 | 78 | self.cleanOptions(self.options); 79 | }, 80 | methods(self) { 81 | return { 82 | ...recaptcha(self), 83 | ...processor(self), 84 | async ensureCollection() { 85 | self.db = self.apos.db.collection('aposFormSubmissions'); 86 | await self.db.ensureIndex({ 87 | formId: 1, 88 | createdAt: 1 89 | }); 90 | await self.db.ensureIndex({ 91 | formId: 1, 92 | createdAt: -1 93 | }); 94 | }, 95 | processQueryParams(form, input, output, fieldNames) { 96 | if (!input.queryParams || 97 | (typeof input.queryParams !== 'object')) { 98 | output.queryParams = null; 99 | return; 100 | } 101 | 102 | if (Array.isArray(form.queryParamList) && form.queryParamList.length > 0) { 103 | form.queryParamList.forEach(param => { 104 | // Skip if this is an existing field submitted by the form. This value 105 | // capture will be done by populating the form inputs client-side. 106 | if (fieldNames.includes(param.key)) { 107 | return; 108 | } 109 | const value = input.queryParams[param.key]; 110 | 111 | if (value) { 112 | output[param.key] = self.tidyParamValue(param, value); 113 | } else { 114 | output[param.key] = null; 115 | } 116 | }); 117 | } 118 | }, 119 | tidyParamValue(param, value) { 120 | value = self.apos.launder.string(value); 121 | 122 | if (param.lengthLimit && param.lengthLimit > 0) { 123 | value = value.substring(0, (param.lengthLimit)); 124 | } 125 | 126 | return value; 127 | }, 128 | async sendEmailSubmissions(req, form, data) { 129 | if (self.options.emailSubmissions === false || 130 | !form.emails || form.emails.length === 0) { 131 | return; 132 | } 133 | 134 | let emails = []; 135 | 136 | form.emails.forEach(mailRule => { 137 | if (!mailRule.conditions || mailRule.conditions.length === 0) { 138 | emails.push(mailRule.email); 139 | return; 140 | } 141 | 142 | let passed = true; 143 | 144 | mailRule.conditions.forEach(condition => { 145 | if (!condition.value) { 146 | return; 147 | } 148 | 149 | let answer = data[condition.field]; 150 | 151 | if (!answer) { 152 | passed = false; 153 | } else { 154 | // Regex for comma-separation from https://stackoverflow.com/questions/11456850/split-a-string-by-commas-but-ignore-commas-within-double-quotes-using-javascript/11457952#comment56094979_11457952 155 | const regex = /(".*?"|[^",]+)(?=\s*,|\s*$)/g; 156 | let acceptable = condition.value.match(regex); 157 | 158 | acceptable = acceptable.map(value => { 159 | // Remove leading/trailing white space and bounding double-quotes. 160 | value = value.trim(); 161 | 162 | if (value[0] === '"' && value[value.length - 1] === '"') { 163 | value = value.slice(1, -1); 164 | } 165 | 166 | return value.trim(); 167 | }); 168 | 169 | // If the value is stored as a string, convert to an array for checking. 170 | if (!Array.isArray(answer)) { 171 | answer = [ answer ]; 172 | } 173 | 174 | if (!(answer.some(val => acceptable.includes(val)))) { 175 | passed = false; 176 | } 177 | } 178 | }); 179 | 180 | if (passed === true) { 181 | emails.push(mailRule.email); 182 | } 183 | }); 184 | // Get array of email addresses without duplicates. 185 | emails = [ ...new Set(emails) ]; 186 | 187 | if (self.options.testing) { 188 | return emails; 189 | } 190 | 191 | if (emails.length === 0) { 192 | return null; 193 | } 194 | 195 | for (const key in data) { 196 | // Add some space to array lists. 197 | if (Array.isArray(data[key])) { 198 | data[key] = data[key].join(', '); 199 | } 200 | } 201 | 202 | try { 203 | const emailOptions = { 204 | form, 205 | data, 206 | to: emails.join(',') 207 | }; 208 | 209 | await self.sendEmail(req, 'emailSubmission', emailOptions); 210 | 211 | return null; 212 | } catch (err) { 213 | self.apos.util.error('⚠️ @apostrophecms/form submission email notification error: ', err); 214 | 215 | return null; 216 | } 217 | }, 218 | // Should be handled async. Options are: form, data, from, to and subject 219 | async sendEmail(req, emailTemplate, options) { 220 | const form = options.form; 221 | const data = options.data; 222 | return self.email( 223 | req, 224 | emailTemplate, 225 | { 226 | form, 227 | input: data 228 | }, 229 | { 230 | from: options.from || form.email, 231 | to: options.to, 232 | subject: options.subject || form.title 233 | } 234 | ); 235 | }, 236 | // Normalize Multer's `req.files` (array) to the historical multiparty shape 237 | // expected by submit handlers (object keyed by field name with name/path/etc.). 238 | normalizeFiles(req, _res, next) { 239 | const files = Array.isArray(req.files) ? req.files : []; 240 | const mapped = {}; 241 | const counters = {}; 242 | 243 | for (const f of files) { 244 | const base = f.fieldname.replace(/-\d+$/, ''); 245 | counters[base] = (counters[base] || 0) + 1; 246 | const key = `${base}-${counters[base]}`; 247 | 248 | mapped[key] = { 249 | path: f.path, 250 | name: f.originalname, 251 | type: f.mimetype, 252 | size: f.size 253 | }; 254 | } 255 | 256 | req.files = mapped; 257 | next(); 258 | } 259 | }; 260 | }, 261 | helpers(self) { 262 | return { 263 | prependIfPrefix(str) { 264 | if (self.options.classPrefix) { 265 | return `${self.options.classPrefix}${str}`; 266 | } 267 | 268 | return ''; 269 | } 270 | }; 271 | }, 272 | apiRoutes(self) { 273 | return { 274 | post: { 275 | // Route to accept the submitted form. 276 | submit: [ 277 | multer({ dest: os.tmpdir() }).any(), 278 | self.normalizeFiles, 279 | async function (req) { 280 | try { 281 | await self.submitForm(req); 282 | } finally { 283 | // Cleanup temp files (same behavior as before) 284 | for (const file of (Object.values(req.files || {}))) { 285 | try { 286 | fs.unlinkSync(file.path); 287 | } catch (e) { 288 | self.apos.util.warn(req.t('aposForm:fileMissingEarly', { 289 | path: file 290 | })); 291 | } 292 | } 293 | } 294 | } 295 | ] 296 | } 297 | }; 298 | }, 299 | handlers(self) { 300 | return { 301 | submission: { 302 | async saveSubmission(req, form, data) { 303 | if (self.options.saveSubmissions === false) { 304 | return; 305 | } 306 | const submission = { 307 | createdAt: new Date(), 308 | formId: form._id, 309 | data 310 | }; 311 | await self.emit('beforeSaveSubmission', req, { 312 | form, 313 | data, 314 | submission 315 | }); 316 | return self.db.insertOne(submission); 317 | }, 318 | async emailSubmission(req, form, data) { 319 | await self.sendEmailSubmissions(req, form, data); 320 | }, 321 | async emailConfirmation(req, form, data) { 322 | if (form.sendConfirmationEmail !== true || !form.emailConfirmationField) { 323 | return; 324 | } 325 | 326 | // Email validation (Regex reference: https://stackoverflow.com/questions/46155/how-to-validate-an-email-address-in-javascript) 327 | const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; 328 | 329 | if ( 330 | data[form.emailConfirmationField] && 331 | (typeof data[form.emailConfirmationField] !== 'string' || 332 | !re.test(data[form.emailConfirmationField])) 333 | ) { 334 | await self.apos.notify(req, 'aposForm:errorEmailConfirm', { 335 | type: 'warning', 336 | icon: 'alert-circle-icon', 337 | interpolate: { 338 | field: form.emailConfirmationField 339 | } 340 | }); 341 | return null; 342 | } 343 | 344 | try { 345 | const emailOptions = { 346 | form, 347 | data, 348 | to: data[form.emailConfirmationField] 349 | }; 350 | await self.sendEmail(req, 'emailConfirmation', emailOptions); 351 | 352 | return null; 353 | } catch (err) { 354 | self.apos.util.error('⚠️ @apostrophecms/form submission email confirmation error: ', err); 355 | 356 | return null; 357 | } 358 | } 359 | } 360 | }; 361 | } 362 | }; 363 | 364 | function getBundleModuleNames() { 365 | const source = path.join(__dirname, './modules/@apostrophecms'); 366 | return fs 367 | .readdirSync(source, { withFileTypes: true }) 368 | .filter(dirent => dirent.isDirectory()) 369 | .map(dirent => `@apostrophecms/${dirent.name}`); 370 | } 371 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | ApostropheCMS logo 3 | 4 |

Form Builder for ApostropheCMS

5 |

6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

16 |
17 | 18 | **Let content teams build and manage forms without developer intervention.** Editors can create contact forms, surveys, applications, and registrations directly in the CMS, then place them anywhere on your site. Forms automatically handle submissions, email notifications, validation, and spam protection. 19 | 20 | 21 | ## Why Form Builder? 22 | 23 | - **No-Code Form Creation**: Editors build forms through configurable field widgets—no tickets to developers 24 | - **Automatic Data Collection**: Submissions saved to MongoDB with optional email notifications 25 | - **🛡️ Built-in Security**: reCAPTCHA v3 integration and validation prevent spam 26 | - **Design Freedom**: Custom CSS classes and styling hooks for brand consistency 27 | - **Developer-Friendly**: Event hooks, custom validators, and extensible field types 28 | - **Email Ready**: Route submissions to multiple recipients automatically 29 | 30 | 31 | ## Table of Contents 32 | - [Installation](#installation) 33 | - [Usage](#usage) 34 | - [Module Configuration](#module-configuration) 35 | - [How It Works](#how-it-works) 36 | - [Adding Form Widget to Areas](#adding-form-widget-to-areas) 37 | - [Editor Workflow](#editor-workflow) 38 | - [Configuration](#configuration) 39 | - [Main Module Options](#main-module-options) 40 | - [Available Field Types](#available-field-types) 41 | - [Handling Submissions](#handling-submissions) 42 | - [Database Storage](#database-storage) 43 | - [Email Notifications](#email-notifications) 44 | - [Server-Side Events](#server-side-events) 45 | - [Browser Events](#browser-events) 46 | - [Success Event](#success-event) 47 | - [Failure Event](#failure-event) 48 | - [reCAPTCHA Integration](#recaptcha-integration) 49 | - [Configuration Options](#configuration-options) 50 | - [Styling](#styling) 51 | - [Custom CSS Classes](#custom-css-classes) 52 | - [Field-Specific Options](#field-specific-options) 53 | - [Select Field](#select-field) 54 | - [File Upload Field](#file-upload-field) 55 | - [Custom Field Validation](#custom-field-validation) 56 | - [Extending Collectors with the Super Pattern](#extending-collectors-with-the-super-pattern) 57 | - [Example: Minimum Word Count Validation](#example-minimum-word-count-validation) 58 | - [Error Handling](#error-handling) 59 | - [Use Cases](#use-cases) 60 | - [💎 Ready for More?](#-ready-for-more) 61 | - [🚀 **Pro Features for Forms**](#-pro-features-for-forms) 62 | 63 | 64 | ## Installation 65 | 66 | ```bash 67 | npm install @apostrophecms/form 68 | ``` 69 | 70 | ## Usage 71 | 72 | ### Module Configuration 73 | 74 | Configure the form modules in your `app.js` file: 75 | 76 | ```javascript 77 | import apostrophe from 'apostrophe'; 78 | 79 | apostrophe({ 80 | root: import.meta, 81 | shortName: 'my-project', 82 | modules: { 83 | // Main form module (must come first) 84 | '@apostrophecms/form': {}, 85 | // Form widget for adding forms to areas 86 | '@apostrophecms/form-widget': {}, 87 | // Field widgets (include only the types you need) 88 | '@apostrophecms/form-text-field-widget': {}, 89 | '@apostrophecms/form-textarea-field-widget': {}, 90 | '@apostrophecms/form-select-field-widget': {}, 91 | '@apostrophecms/form-radio-field-widget': {}, 92 | '@apostrophecms/form-file-field-widget': {}, 93 | '@apostrophecms/form-checkboxes-field-widget': {}, 94 | '@apostrophecms/form-boolean-field-widget': {}, 95 | '@apostrophecms/form-conditional-widget': {}, 96 | '@apostrophecms/form-divider-widget': {}, 97 | '@apostrophecms/form-group-widget': {} 98 | } 99 | }); 100 | ``` 101 | 102 | **Module order matters:** `@apostrophecms/form` must appear before the widget modules. Include only the field types you want editors to use. 103 | 104 | ### How It Works 105 | 106 | The `@apostrophecms/form` module creates a new **piece-type** called "Forms" in your CMS. This means forms are content that editors create once and can reuse across multiple pages—just like blog posts or products. Create a "Contact Form" once, then place it on your contact page, footer, and sidebar using the form widget. 107 | 108 | ### Adding Form Widget to Areas 109 | 110 | To let editors add forms to a page or piece-type, include the form widget in an area: 111 | 112 | ```javascript 113 | // modules/contact-page/index.js 114 | export default { 115 | extend: '@apostrophecms/piece-page-type', 116 | options: { 117 | label: 'Contact Page' 118 | }, 119 | fields: { 120 | add: { 121 | contactForm: { 122 | type: 'area', 123 | options: { 124 | max: 1, 125 | widgets: { 126 | '@apostrophecms/form': {} 127 | } 128 | } 129 | } 130 | }, 131 | group: { 132 | basics: { 133 | fields: ['contactForm'] 134 | } 135 | } 136 | } 137 | }; 138 | ``` 139 | 140 | ### Editor Workflow 141 | 142 | Once configured, editors can create and manage forms: 143 | 144 | 1. **Create a form**: Click "Forms" in the admin bar and create a new form (e.g., "Contact Form") 145 | 2. **Build the form**: Add field widgets (text fields, email, checkboxes, etc.) and configure options 146 | 3. **Configure submission handling**: Set up email notifications and confirmation messages in the "After-Submission" tab 147 | 4. **Place the form**: Edit any page with a form area, add the form widget, and select your created form 148 | 149 | Editors can now create and manage forms independently. 150 | 151 | ## Configuration 152 | 153 | ### Main Module Options 154 | 155 | Configure `@apostrophecms/form` with these options: 156 | 157 | | Property | Type | Description | 158 | |---|---|---| 159 | | `disableOptionalLabel` | Boolean | Removes "(Optional)" text from optional fields. Default: `false` | 160 | | `formWidgets` | Object | Widget configuration for allowed field types in forms | 161 | | `saveSubmissions` | Boolean | Set to `false` to prevent saving submissions to MongoDB. Default: `true` | 162 | | `emailSubmissions` | Boolean | Set to `false` to hide email notification fields. Default: `true` | 163 | | `recaptchaSecret` | String | Secret key from reCAPTCHA site configuration | 164 | | `recaptchaSite` | String | Site key for reCAPTCHA integration | 165 | | `classPrefix` | String | Namespace for CSS classes on form elements | 166 | 167 | ### Available Field Types 168 | 169 | The `formWidgets` option controls which widgets editors can use when building forms. Configure this in your project-level `/modules/@apostrophecms/form/index.js` file to override the built-in defaults. This is a **global setting** that applies to all forms in your project. 170 | 171 | Default configuration: 172 | 173 | ```javascript 174 | // modules/@apostrophecms/form/index.js 175 | export default { 176 | options: { 177 | formWidgets: { 178 | '@apostrophecms/form-text-field': {}, 179 | '@apostrophecms/form-textarea-field': {}, 180 | '@apostrophecms/form-boolean-field': {}, 181 | '@apostrophecms/form-select-field': {}, 182 | '@apostrophecms/form-radio-field': {}, 183 | '@apostrophecms/form-checkboxes-field': {}, 184 | '@apostrophecms/form-conditional': {}, 185 | '@apostrophecms/form-divider': {}, 186 | '@apostrophecms/rich-text': { 187 | toolbar: [ 188 | 'styles', 'bold', 'italic', 'link', 189 | 'orderedList', 'bulletList' 190 | ] 191 | } 192 | } 193 | } 194 | }; 195 | ``` 196 | 197 | The rich text widget allows editors to add instructions within forms. Any widget type can be included in this configuration. 198 | 199 | > **Need different field types for different forms?** The `formWidgets` option is global and cannot be set per-area or per-page. If you need separate sets of allowed fields (for example, a simple contact form vs. a detailed application form), extend the `@apostrophecms/form` module to create a second form piece-type with its own `formWidgets` configuration. **However**, without additional controls, all editors can use both form types. Use [`@apostrophecms-pro/advanced-permission`](https://github.com/apostrophecms/advanced-permission) to restrict which user groups can create and manage each form type—ensuring junior editors only access basic forms while senior staff can use advanced forms. [Learn more about Pro features](#-ready-for-more). 200 | 201 | ## Handling Submissions 202 | 203 | ### Database Storage 204 | 205 | Submissions are automatically saved to the `aposFormSubmissions` MongoDB collection. To disable database storage: 206 | 207 | ```javascript 208 | // modules/@apostrophecms/form/index.js 209 | export default { 210 | options: { 211 | saveSubmissions: false 212 | } 213 | }; 214 | ``` 215 | 216 | 217 | ### Email Notifications 218 | 219 | If `@apostrophecms/email` is configured, forms can automatically email submissions to multiple recipients. In the form editor, navigate to the "After-Submission" tab and enter comma-separated email addresses in the "Email Address(es) for Results" field. 220 | 221 | To hide email notification fields: 222 | 223 | ```javascript 224 | // modules/@apostrophecms/form/index.js 225 | export default { 226 | options: { 227 | emailSubmissions: false 228 | } 229 | }; 230 | ``` 231 | 232 | > **📧 Email Configuration**: To send form submissions via email, you must first configure the `@apostrophecms/email` module. See the [email configuration guide](https://docs.apostrophecms.org/guide/sending-email.html) for setup instructions. Forms can still save submissions to the database without email configuration. 233 | 234 | ### Server-Side Events 235 | 236 | Form submissions trigger events you can handle in your code for custom processing, integrations, or modifying submission data. For example, you could send submissions to an external CRM, add server-side metadata like query parameters, or trigger custom workflows. 237 | 238 | **`submission` event** - Fires on every form submission: 239 | 240 | ```javascript 241 | // modules/@apostrophecms/form/index.js 242 | export default { 243 | handlers(self) { 244 | return { 245 | 'submission': { 246 | async handleSubmission(req, form, submission) { 247 | // Your custom logic here 248 | console.log('Form submitted:', form.title); 249 | console.log('Data:', submission); 250 | } 251 | } 252 | }; 253 | } 254 | }; 255 | ``` 256 | 257 | **`beforeSaveSubmission` event** - Fires before saving the `info.submission` to the database (if enabled): 258 | 259 | ```javascript 260 | // modules/@apostrophecms/form/index.js 261 | export default { 262 | handlers(self) { 263 | return { 264 | 'beforeSaveSubmission': { 265 | async modifySubmission(req, info) { 266 | // Modify info.submission before it's saved 267 | info.submission.processedAt = new Date(); 268 | } 269 | } 270 | }; 271 | } 272 | }; 273 | ``` 274 | 275 | Event handler arguments: 276 | 277 | | Event | Arguments | Description | 278 | |---|---|---| 279 | | `submission` | `req`, `form`, `submission` | Request object, form document, submission data | 280 | | `beforeSaveSubmission` | `req`, `info` | Request object, object with `form`, `data`, and `submission` properties | 281 | 282 | ### Browser Events 283 | 284 | The form module emits browser events on the `body` element after a submission attempt. You can listen for these to add **custom client-side feedback or analytics**. 285 | 286 | #### Success Event 287 | 288 | `@apostrophecms/form:submission-form` 289 | Fires when a submission is successfully processed. The event detail includes a `form` property. 290 | 291 | ```js 292 | document.body.addEventListener('@apostrophecms/form:submission-form', e => { 293 | // e.detail.form contains the form element/config 294 | console.log('Form submitted successfully:', e.detail.form); 295 | 296 | // Example: show a toast notification 297 | apos.notify('✅ Thanks for your submission!', { type: 'success' }); 298 | 299 | // Example: send analytics event 300 | gtag('event', 'form_submission', { 301 | formTitle: e.detail.form.title || 'Untitled Form' 302 | }); 303 | }); 304 | ``` 305 | #### Failure Event 306 | 307 | `@apostrophecms/form:submission-failed` 308 | Fires when a submission fails due to validation errors, spam protection, or server issues. The event detail includes a `formError` property. 309 | 310 | ```js 311 | document.body.addEventListener('@apostrophecms/form:submission-failed', e => { 312 | // e.detail.formError contains the error info 313 | console.error('Form submission failed:', e.detail.formError); 314 | 315 | // Example: show a custom error banner 316 | apos.notify('⚠️ Something went wrong. Please try again.', { type: 'danger' }); 317 | 318 | // Example: track failed attempts 319 | gtag('event', 'form_submission_failed', { 320 | error: e.detail.formError.message || 'Unknown' 321 | }); 322 | }); 323 | ``` 324 | **Use Cases** 325 | * Replace the default "thank you" UI with a custom success message 326 | * Push events into Google Tag Manager, Segment, or other analytics 327 | * Redirect or scroll the page after a successful submission 328 | * Display tailored error messages on failure 329 | 330 | > **Note:** Forms already support built-in after-submission messages (`thankYouHeading`, `thankYouBody`) and inline error handling. You only need these browser events if you want extra client-side behavior beyond what the module provides out of the box. 331 | 332 | ## reCAPTCHA Integration 333 | 334 | Protect forms from spam with Google reCAPTCHA v3. Set up reCAPTCHA at [google.com/recaptcha](https://www.google.com/recaptcha/) using version 3, then configure your site and secret keys. 335 | 336 | ### Configuration Options 337 | 338 | **Option 1: Hard-code in module configuration** 339 | 340 | ```javascript 341 | // modules/@apostrophecms/form/index.js 342 | export default { 343 | options: { 344 | recaptchaSecret: 'YOUR_SECRET_KEY', 345 | recaptchaSite: 'YOUR_SITE_KEY' 346 | } 347 | }; 348 | ``` 349 | 350 | **Option 2: Allow editors to configure in UI** 351 | 352 | If you don't hard-code both keys, a global settings UI appears where admins can enter them. Once configured, each form has a checkbox to enable reCAPTCHA independently. 353 | 354 | ## Styling 355 | 356 | ### Custom CSS Classes 357 | 358 | Add your own class prefix to form elements for complete styling control: 359 | 360 | ```javascript 361 | // modules/@apostrophecms/form/index.js 362 | export default { 363 | options: { 364 | classPrefix: 'my-form' 365 | } 366 | }; 367 | ``` 368 | 369 | This generates BEM-style classes like `my-form__input`, `my-form__label`, and `my-form__error` on form elements. 370 | 371 | For teams who prefer visual design tools, the [Palette extension](https://apostrophecms.com/extensions/palette-extension) allows in-context CSS customization without writing code. [Learn more about Pro features](#-ready-for-more). 372 | 373 | ## Field-Specific Options 374 | 375 | ### Select Field 376 | 377 | The select field widget supports multiple selections: 378 | 379 | ```javascript 380 | // modules/@apostrophecms/form-select-field-widget/index.js 381 | export default { 382 | options: { 383 | allowMultiple: true // Default: false 384 | } 385 | }; 386 | ``` 387 | 388 | When enabled, two additional fields appear in the widget schema: 389 | 390 | | Property | Type | Description | Default | 391 | |---|---|---|---| 392 | | `allowMultiple` | Boolean | Enable multiple selections. | `false` | 393 | | `size` | Integer | Number of visible options. Set to `0` to use the default compact dropdown; set to `2` or higher to render a listbox showing that many options at once. | `0` | 394 | 395 | ### File Upload Field 396 | 397 | ⚠️ **Security Warning**: File upload fields allow any visitor to upload files to your server, creating potential risks for storage abuse and malicious uploads. This widget is **not included by default**—you must explicitly enable it. 398 | 399 | **Where to implement security measures:** 400 | 401 | 1. **Storage provider level** (AWS S3, Google Cloud Storage, Azure Blob): 402 | - Configure file type restrictions, size limits, and lifecycle policies in your provider's console 403 | - Set up bucket quotas and alerts for unusual upload patterns 404 | - See your provider's documentation for content validation features 405 | 406 | 2. **ApostropheCMS attachment module** (`modules/@apostrophecms/attachment/index.js`): 407 | ```javascript 408 | export default { 409 | options: { 410 | // Restrict file types (extension allowlist) 411 | fileGroups: [ 412 | { 413 | name: 'images', 414 | extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'], 415 | extensionMaps: {}, 416 | contentTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] 417 | }, 418 | { 419 | name: 'office', 420 | extensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx'], 421 | contentTypes: ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'] 422 | } 423 | ], 424 | // Set maximum file size (in bytes) 425 | maximumUploadSize: 10485760 // 10MB 426 | } 427 | }; 428 | ``` 429 | See the [attachment module documentation](https://docs.apostrophecms.org/reference/modules/attachment.html) for complete configuration options. 430 | 431 | 3. **Form submission handler** (for additional validation): 432 | ```javascript 433 | // modules/@apostrophecms/form/index.js 434 | export default { 435 | handlers(self) { 436 | return { 437 | 'beforeSaveSubmission': { 438 | async validateFiles(req, info) { 439 | // Add custom file validation logic here 440 | // Access uploaded files via info.data 441 | } 442 | } 443 | }; 444 | } 445 | }; 446 | ``` 447 | 448 | 4. **Spam protection**: Enable reCAPTCHA v3 (see [reCAPTCHA Integration](#recaptcha-integration) section) 449 | 450 | Files are stored in your configured attachment storage (local uploads or cloud bucket). Form submissions save attachment URLs, not the files themselves. 451 | 452 | **Multiple file uploads**: Like the select field, the file field widget supports an `allowMultiple` option: 453 | 454 | ```javascript 455 | // modules/@apostrophecms/form-file-field-widget/index.js 456 | export default { 457 | options: { 458 | allowMultiple: true // Default: false 459 | } 460 | }; 461 | ``` 462 | 463 | When enabled, users can select and upload multiple files in a single form submission. 464 | 465 | ## Custom Field Validation 466 | 467 | Need business-specific rules like minimum word counts, format requirements, or cross-field dependencies? Extend the built-in field collectors to add custom validation logic before submission. This runs client-side for immediate feedback without server round-trips. 468 | 469 | Each field returns its value from a collector function located on the `apos.aposForm.collectors` array in the browser. You can extend these collector functions to adjust the value or do additional validation before the form posts to the server. Collector functions can be written as asynchronous functions if needed. 470 | 471 | Collector functions take the widget element as an argument and return a response object on a successful submission. The response object properties are: 472 | 473 | | Property | Description | 474 | |---|---| 475 | | `field` | The field element's `name` attribute (identical to the field widget's `name` property) | 476 | | `value` | The field value | 477 | 478 | ### Extending Collectors with the Super Pattern 479 | 480 | These functions can be extended for project-level validation using the super pattern. This involves: 481 | 482 | 1. Assigning the original function to a variable 483 | 2. Creating a new function that uses the original one, adds functionality, and returns an identically structured response 484 | 3. Assigning the new function to the original function property 485 | 486 | ### Example: Minimum Word Count Validation 487 | 488 | ```javascript 489 | // modules/@apostrophecms/form-textarea-field-widget/ui/src/index.js 490 | 491 | export default () => { 492 | const TEXTAREA_WIDGET = '@apostrophecms/form-textarea-field'; 493 | 494 | // 1️⃣ Store the original collector function on `superCollector`. 495 | const superCollector = apos.aposForm.collectors[TEXTAREA_WIDGET].collector; 496 | 497 | // 2️⃣ Create a new collector function that accepts the same widget element 498 | // parameter. 499 | function newCollector(el) { 500 | // Get the response from the original collector. 501 | const response = superCollector(el); 502 | 503 | if (response.value && response.value.split(' ').length < 10) { 504 | // Throwing an object if there are fewer than ten words. 505 | throw { 506 | field: response.field, 507 | message: 'Write at least 10 words' 508 | }; 509 | } else { 510 | // Returning the original response if everything is okay. 511 | return response; 512 | } 513 | } 514 | 515 | // 3️⃣ Assign our new collector to the original property. 516 | apos.aposForm.collectors[TEXTAREA_WIDGET].collector = newCollector; 517 | }; 518 | ``` 519 | 520 | ### Error Handling 521 | 522 | If you want to indicate an error on the field, `throw` an object with the following values (as shown above): 523 | 524 | | Property | Description | 525 | |---|---| 526 | | `field` | The field element's `name` attribute (identical to the field widget's `name` property) | 527 | | `message` | A string to display on the field as an error message | 528 | 529 | ## Use Cases 530 | 531 | **Contact Forms**: Let teams create department-specific contact forms without developer involvement. 532 | 533 | **Lead Generation**: Build conversion-optimized forms with conditional fields and reCAPTCHA protection. 534 | 535 | **Event Registration**: Collect attendee information with file uploads for documents or photos. 536 | 537 | **User Feedback**: Create surveys and feedback forms that route to appropriate team members. 538 | 539 | **Job Applications**: Accept resumes and application materials with validation and email routing. 540 | 541 | ## 💎 Ready for More? 542 | 543 | The open-source form builder provides powerful form creation capabilities, but enterprise teams often need advanced control and workflow features. [**ApostropheCMS Pro**](https://apostrophecms.com/pro) extends form functionality with professional-grade features: 544 | 545 | ### 🚀 **Pro Features for Forms** 546 | 547 | - **🔐 Advanced Permissions** - Control which teams can create, edit, and manage specific form types. Restrict access to sensitive forms like HR applications or customer data collection. Perfect for multi-team organizations that need different form capabilities for different departments. 548 | 549 | - **🌍 Automated Translation** - Automatically translate forms and confirmation messages into multiple languages with AI-powered translation services (DeepL, Google Translate, Azure). Deploy multilingual forms without manual translation work. 550 | 551 | - **📄 Document Management** - Version control for forms with complete audit trails. Track changes to form fields, restore previous versions, and maintain compliance with form modification history. 552 | 553 | - **🎨 Visual Design Tools** - Use the Palette extension for in-context CSS customization of form styling without writing code. Perfect for teams without dedicated frontend developers. 554 | 555 | Create an account on Apostrophe Workspaces and upgrade to [**ApostropheCMS Pro**](https://app.apostrophecms.com/login) or **[contact our team](https://apostrophecms.com/contact-us)** to learn about Pro licensing and access enterprise features that enhance form management, compliance, and team collaboration. 556 | 557 | --- 558 | 559 |
560 |

Made with ❤️ by the ApostropheCMS team. Found this useful? Give us a star on GitHub!

561 |
-------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert').strict; 2 | const testUtil = require('apostrophe/test-lib/test'); 3 | const fileUtils = require('./lib/file-utils'); 4 | const FormData = require('form-data'); 5 | const fs = require('fs'); 6 | const path = require('path'); 7 | 8 | describe('Forms module', function () { 9 | let apos; 10 | 11 | this.timeout(25000); 12 | 13 | after(function () { 14 | return testUtil.destroy(apos); 15 | }); 16 | 17 | // Existence 18 | const formWidgets = { 19 | '@apostrophecms/form-widget': {}, 20 | '@apostrophecms/form-text-field-widget': {}, 21 | '@apostrophecms/form-textarea-field-widget': {}, 22 | '@apostrophecms/form-select-field-widget': {}, 23 | '@apostrophecms/form-radio-field-widget': {}, 24 | '@apostrophecms/form-checkboxes-field-widget': {}, 25 | '@apostrophecms/form-file-field-widget': {}, 26 | '@apostrophecms/form-boolean-field-widget': {}, 27 | '@apostrophecms/form-conditional-widget': {}, 28 | '@apostrophecms/form-divider-widget': {}, 29 | '@apostrophecms/form-group-widget': {} 30 | }; 31 | 32 | let forms; 33 | let textWidgets; 34 | let textareaWidgets; 35 | let selectWidgets; 36 | let radioWidgets; 37 | let checkboxesWidgets; 38 | let fileWidgets; 39 | let booleanWidgets; 40 | let conditionalWidgets; 41 | let dividerWidgets; 42 | let groupWidgets; 43 | 44 | it('should be a property of the apos object', async function () { 45 | apos = await testUtil.create({ 46 | shortname: 'formsTest', 47 | testModule: true, 48 | modules: { 49 | '@apostrophecms/express': { 50 | options: { 51 | port: 4242, 52 | csrfExceptions: [ '/api/v1/@apostrophecms/form-widget/upload' ], 53 | session: { 54 | secret: 'test-the-forms' 55 | }, 56 | apiKeys: { 57 | skeleton_key: { role: 'admin' } 58 | } 59 | } 60 | }, 61 | '@apostrophecms/form': { 62 | options: { 63 | formWidgets: { 64 | '@apostrophecms/form-text-field': {}, 65 | '@apostrophecms/form-textarea-field': {}, 66 | '@apostrophecms/form-select-field': {}, 67 | '@apostrophecms/form-radio-field': {}, 68 | '@apostrophecms/form-checkboxes-field': {}, 69 | '@apostrophecms/form-file-field': {}, 70 | '@apostrophecms/form-boolean-field': {}, 71 | '@apostrophecms/form-conditional': {}, 72 | '@apostrophecms/form-divider': {}, 73 | '@apostrophecms/form-group': {} 74 | } 75 | } 76 | }, 77 | ...formWidgets 78 | } 79 | }); 80 | 81 | const aposForm = '@apostrophecms/form'; 82 | forms = apos.modules[`${aposForm}`]; 83 | const widgets = apos.modules[`${aposForm}-widget`]; 84 | textWidgets = apos.modules[`${aposForm}-text-field-widget`]; 85 | textareaWidgets = apos.modules[`${aposForm}-textarea-field-widget`]; 86 | selectWidgets = apos.modules[`${aposForm}-select-field-widget`]; 87 | radioWidgets = apos.modules[`${aposForm}-radio-field-widget`]; 88 | checkboxesWidgets = apos.modules[`${aposForm}-checkboxes-field-widget`]; 89 | fileWidgets = apos.modules[`${aposForm}-file-field-widget`]; 90 | booleanWidgets = apos.modules[`${aposForm}-boolean-field-widget`]; 91 | conditionalWidgets = apos.modules[`${aposForm}-conditional-widget`]; 92 | dividerWidgets = apos.modules[`${aposForm}-divider-widget`]; 93 | groupWidgets = apos.modules[`${aposForm}-group-widget`]; 94 | 95 | assert(forms.__meta.name === `${aposForm}`); 96 | assert(widgets.__meta.name === `${aposForm}-widget`); 97 | assert(textWidgets.__meta.name === `${aposForm}-text-field-widget`); 98 | assert(textareaWidgets.__meta.name === `${aposForm}-textarea-field-widget`); 99 | assert(selectWidgets.__meta.name === `${aposForm}-select-field-widget`); 100 | assert(radioWidgets.__meta.name === `${aposForm}-radio-field-widget`); 101 | assert(checkboxesWidgets.__meta.name === `${aposForm}-checkboxes-field-widget`); 102 | assert(fileWidgets.__meta.name === `${aposForm}-file-field-widget`); 103 | assert(booleanWidgets.__meta.name === `${aposForm}-boolean-field-widget`); 104 | assert(conditionalWidgets.__meta.name === `${aposForm}-conditional-widget`); 105 | assert(dividerWidgets.__meta.name === `${aposForm}-divider-widget`); 106 | assert(groupWidgets.__meta.name === `${aposForm}-group-widget`); 107 | }); 108 | 109 | // Submissions collection exists. 110 | it('should have a default collection for submissions', async function () { 111 | apos.db.collection('aposFormSubmissions', function (err, collection) { 112 | assert(!err); 113 | assert(collection); 114 | }); 115 | }); 116 | 117 | // Create a form 118 | const form1 = { 119 | _id: 'form1:en:published', 120 | archived: false, 121 | type: '@apostrophecms/form', 122 | title: 'First test form', 123 | slug: 'test-form-one', 124 | contents: { 125 | _id: 'form1ContentsArea890', 126 | metaType: 'area', 127 | items: [ 128 | { 129 | _id: 'dogNameId', 130 | fieldLabel: 'Dog name', 131 | fieldName: 'DogName', 132 | required: true, 133 | type: '@apostrophecms/form-text-field' 134 | }, 135 | { 136 | _id: 'dogTraitsId', 137 | fieldLabel: 'Check all that apply', 138 | fieldName: 'DogTraits', 139 | required: true, 140 | type: '@apostrophecms/form-checkboxes-field', 141 | choices: [ 142 | { 143 | label: 'Runs fast', 144 | value: 'Runs fast' 145 | }, 146 | { 147 | label: 'It\'s a dog', 148 | value: 'It\'s a dog' 149 | }, 150 | { 151 | label: 'Likes treats', 152 | value: 'Likes treats' 153 | } 154 | ] 155 | }, 156 | { 157 | _id: 'dogBreedId', 158 | fieldLabel: 'Dog breed', 159 | fieldName: 'DogBreed', 160 | required: false, 161 | type: '@apostrophecms/form-radio-field', 162 | choices: [ 163 | { 164 | label: 'Irish Wolfhound', 165 | value: 'Irish Wolfhound' 166 | }, 167 | { 168 | label: 'Cesky Terrier', 169 | value: 'Cesky Terrier' 170 | }, 171 | { 172 | label: 'Dachshund', 173 | value: 'Dachshund' 174 | }, 175 | { 176 | label: 'Pumi', 177 | value: 'Pumi' 178 | } 179 | ] 180 | }, 181 | { 182 | _id: 'dogPhotoId', 183 | fieldLabel: 'Photo of your dog', 184 | fieldName: 'DogPhoto', 185 | required: false, 186 | type: '@apostrophecms/form-file-field' 187 | }, 188 | { 189 | _id: 'agreeId', 190 | fieldLabel: 'Opt-in to participate', 191 | fieldName: 'agree', 192 | required: true, 193 | checked: false, 194 | type: '@apostrophecms/form-boolean-field' 195 | } 196 | ] 197 | }, 198 | enableQueryParams: true, 199 | queryParamList: [ 200 | { 201 | id: 'source', 202 | key: 'source' 203 | }, 204 | { 205 | id: 'memberId', 206 | key: 'member-id', 207 | lengthLimit: 6 208 | } 209 | ] 210 | }; 211 | 212 | let savedForm1; 213 | 214 | it('should create a form', async function () { 215 | const req = apos.task.getReq(); 216 | 217 | await apos.doc.db.insertOne(form1); 218 | 219 | const form = await apos.doc.getManager('@apostrophecms/form').find(req, {}).toObject(); 220 | 221 | savedForm1 = form; 222 | assert(form); 223 | assert(form.title === 'First test form'); 224 | }); 225 | 226 | it('should have the same widgets in conditional widget area as the main form widgets area, execpt conditional itself', function () { 227 | const formWidgets = { 228 | ...forms.schema.find(field => field.name === 'contents').options.widgets 229 | }; 230 | 231 | const conditionalWidgetWidgets = conditionalWidgets.schema.find(field => field.name === 'contents').options.widgets; 232 | 233 | const actual = (Object.keys(formWidgets)).sort(); 234 | const expected = (Object.keys(conditionalWidgetWidgets).concat('@apostrophecms/form-conditional')).sort(); 235 | 236 | assert.deepEqual(actual, expected); 237 | }); 238 | 239 | it('should have the same widgets in group widget area as the main form widgets area, execpt group itself', function () { 240 | const formWidgets = { 241 | ...forms.schema.find(field => field.name === 'contents').options.widgets 242 | }; 243 | 244 | const groupWidgetWidgets = groupWidgets.schema.find(field => field.name === 'contents').options.widgets; 245 | 246 | const actual = (Object.keys(formWidgets)).sort(); 247 | const expected = (Object.keys(groupWidgetWidgets).concat('@apostrophecms/form-group')).sort(); 248 | 249 | assert.deepEqual(actual, expected); 250 | }); 251 | 252 | // Submitting gets 200 response 253 | const submission1 = { 254 | DogName: 'Jasper', 255 | DogTraits: [ 256 | 'Runs fast', 257 | 'Likes treats' 258 | ], 259 | DogPhoto: 'files-pending', // Indicating a file upload. 260 | DogBreed: 'Irish Wolfhound', 261 | DogToy: 'Frisbee', 262 | LifeStory: 'Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Donec ullamcorper nulla non metus auctor fringilla.', 263 | agree: true, 264 | queryParams: { 265 | 'member-id': '123456789', 266 | source: 'newspaper', 267 | malicious: 'evil' 268 | } 269 | }; 270 | 271 | it('should accept a valid submission', async function () { 272 | const formData = new FormData(); 273 | submission1._id = savedForm1._id; 274 | formData.append('data', JSON.stringify(submission1)); 275 | formData.append('DogPhoto-0', fs.createReadStream(path.join(__dirname, '/lib/upload_tests/upload-test.txt'))); 276 | 277 | try { 278 | await apos.http.post( 279 | '/api/v1/@apostrophecms/form/submit?apikey=skeleton_key', 280 | { 281 | body: formData 282 | } 283 | ); 284 | } catch (error) { 285 | assert(!error); 286 | } 287 | }); 288 | 289 | // Submission is stored in the db 290 | it('can find the form submission in the database', async function () { 291 | try { 292 | const doc = await apos.db.collection('aposFormSubmissions').findOne({ 293 | 'data.DogName': 'Jasper' 294 | }); 295 | 296 | const uploadRegex = /^\/uploads\/attachments\/\w+-upload-test.txt$/; 297 | assert(doc.data.DogPhoto[0].match(uploadRegex)); 298 | 299 | assert(doc.data.DogBreed === 'Irish Wolfhound'); 300 | } catch (err) { 301 | assert(!err); 302 | } 303 | }); 304 | 305 | // Submission captures and limits query parameters 306 | it('can find query parameter data saved and limited', async function () { 307 | const doc = await apos.db.collection('aposFormSubmissions').findOne({ 308 | 'data.DogName': 'Jasper' 309 | }); 310 | 311 | assert(doc.data['member-id'] === '123456'); 312 | assert(doc.data.source === 'newspaper'); 313 | assert(doc.data.malicious === undefined); 314 | }); 315 | 316 | // Submission is not stored in the db if disabled. 317 | let apos2; 318 | const form2 = { ...form1 }; 319 | form2.slug = 'test-form-two'; 320 | form2._id = 'form2:en:published'; 321 | let savedForm2; 322 | const submission2 = { ...submission1 }; 323 | 324 | it('should be a property of the apos2 object', async function () { 325 | apos2 = await testUtil.create({ 326 | shortName: 'formsTest2', 327 | testModule: true, 328 | modules: { 329 | '@apostrophecms/express': { 330 | options: { 331 | port: 5252, 332 | session: { 333 | secret: 'test-the-forms-more' 334 | }, 335 | apiKeys: { 336 | skeleton_key: { role: 'admin' } 337 | } 338 | } 339 | }, 340 | '@apostrophecms/form': { 341 | options: { 342 | saveSubmissions: false 343 | } 344 | }, 345 | ...formWidgets 346 | } 347 | }); 348 | 349 | const forms = apos2.modules['@apostrophecms/form']; 350 | 351 | assert(forms.__meta.name === '@apostrophecms/form'); 352 | }); 353 | 354 | it('should not save in the database if disabled', async function () { 355 | const req = apos2.task.getReq(); 356 | 357 | await apos2.doc.db.insertOne(form2); 358 | 359 | const form = await apos2.doc.getManager('@apostrophecms/form').find(req, {}).toObject(); 360 | 361 | savedForm2 = form; 362 | 363 | submission2._id = savedForm2._id; 364 | const formData = new FormData(); 365 | formData.append('data', JSON.stringify(submission2)); 366 | 367 | try { 368 | await apos2.http.post( 369 | '/api/v1/@apostrophecms/form/submit?apikey=skeleton_key', 370 | { 371 | body: formData 372 | } 373 | ); 374 | } catch (error) { 375 | assert(!error); 376 | } 377 | 378 | const doc = await apos2.db.collection('aposFormSubmissions').findOne({ 379 | 'data.DogName': 'Jasper' 380 | }); 381 | 382 | assert(!doc); 383 | }); 384 | 385 | it('destroys the second instance', async function () { 386 | testUtil.destroy(apos2); 387 | }); 388 | 389 | // Get form errors returned from missing required data. 390 | const submission3 = { 391 | agree: true 392 | }; 393 | 394 | it('should return errors for missing data', async function () { 395 | submission3._id = savedForm1._id; 396 | const formData = new FormData(); 397 | formData.append('data', JSON.stringify(submission3)); 398 | 399 | try { 400 | await apos.http.post( 401 | '/api/v1/@apostrophecms/form/submit?apikey=skeleton_key', 402 | { 403 | body: formData 404 | } 405 | ); 406 | assert(false); 407 | } catch (error) { 408 | assert(error); 409 | assert(error.status === 400); 410 | assert(error.body.data.formErrors.length === 2); 411 | assert(error.body.data.formErrors[0].error === 'required'); 412 | assert(error.body.data.formErrors[1].error === 'required'); 413 | } 414 | }); 415 | 416 | // Test basic reCAPTCHA requirements. 417 | let apos3; 418 | let savedForm3; 419 | const submission4 = { ...submission1 }; 420 | const form3 = { 421 | ...form1, 422 | emails: [ 423 | { 424 | id: 'emailCondOne', 425 | email: 'emailOne@example.net', 426 | conditions: [] 427 | }, 428 | { 429 | id: 'emailCondTwo', 430 | email: 'emailTwo@example.net', 431 | conditions: [ 432 | { 433 | field: 'DogTraits', 434 | value: 'Likes treats, It\'s a dog' 435 | }, 436 | { 437 | field: 'DogBreed', 438 | value: 'Cesky Terrier, Pumi' 439 | } 440 | ] 441 | }, 442 | { 443 | id: 'emailCondThree', 444 | email: 'emailThree@example.net', 445 | conditions: [ 446 | { 447 | field: 'DogTraits', 448 | value: 'Likes treats, "Comma, test"' 449 | } 450 | ] 451 | } 452 | ] 453 | }; 454 | form3.slug = 'test-form-three'; 455 | form3._id = 'form3:en:published'; 456 | form3.enableRecaptcha = true; 457 | 458 | it('should be a property of the apos3 object', async function () { 459 | apos3 = await testUtil.create({ 460 | shortName: 'formsTest3', 461 | testModule: true, 462 | modules: { 463 | '@apostrophecms/express': { 464 | options: { 465 | port: 6000, 466 | session: { 467 | secret: 'test-the-forms-more' 468 | }, 469 | apiKeys: { 470 | skeleton_key: { role: 'admin' } 471 | } 472 | } 473 | }, 474 | '@apostrophecms/form': { 475 | options: { 476 | emailSubmissions: true, 477 | testing: true, 478 | // reCAPTCHA test keys https://developers.google.com/recaptcha/docs/faq#id-like-to-run-automated-tests-with-recaptcha-what-should-i-do 479 | recaptchaSite: '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI', 480 | recaptchaSecret: '6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe' 481 | } 482 | }, 483 | ...formWidgets 484 | } 485 | }); 486 | 487 | const forms = apos3.modules['@apostrophecms/form']; 488 | assert(forms.__meta.name === '@apostrophecms/form'); 489 | }); 490 | 491 | it('should return a form error if missing required reCAPTHCA token', async function () { 492 | const req = apos3.task.getReq(); 493 | 494 | await apos3.doc.db.insertOne(form3) 495 | .then(function () { 496 | return apos3.doc.getManager('@apostrophecms/form').find(req, {}) 497 | .toObject(); 498 | }) 499 | .then(function (form) { 500 | savedForm3 = form; 501 | }) 502 | .catch(function (err) { 503 | console.error(err); 504 | assert(!err); 505 | }); 506 | 507 | submission4._id = savedForm3._id; 508 | const formData = new FormData(); 509 | formData.append('data', JSON.stringify(submission4)); 510 | 511 | try { 512 | await apos3.http.post( 513 | '/api/v1/@apostrophecms/form/submit?apikey=skeleton_key', 514 | { 515 | body: formData 516 | } 517 | ); 518 | // Don't make it here. 519 | assert(false); 520 | } catch (error) { 521 | assert(error.status === 400); 522 | assert(error.body.data.formErrors[0].error === 'recaptcha'); 523 | assert(error.body.data.formErrors[0].global === true); 524 | } 525 | }); 526 | 527 | it('should submit successfully with a reCAPTCHA token', async function () { 528 | submission4.recaptcha = 'validRecaptchaToken'; 529 | const formData = new FormData(); 530 | formData.append('data', JSON.stringify(submission4)); 531 | 532 | try { 533 | await apos3.http.post( 534 | '/api/v1/@apostrophecms/form/submit?apikey=skeleton_key', 535 | { 536 | body: formData 537 | } 538 | ); 539 | assert('👍'); 540 | } catch (error) { 541 | assert(!error); 542 | } 543 | }); 544 | 545 | const submission5 = { 546 | DogName: 'Jenkins', 547 | DogTraits: [ 548 | 'Runs fast', 549 | 'Comma, test' 550 | ], 551 | DogBreed: 'Irish Wolfhound' 552 | }; 553 | 554 | const submission6 = { 555 | DogName: 'Jenkins', 556 | DogTraits: [ 557 | 'Runs fast', 558 | 'Likes treats' 559 | ], 560 | DogBreed: 'Cesky Terrier' 561 | }; 562 | 563 | it('should populate email notification lists based on conditions', async function () { 564 | const req = apos3.task.getReq(); 565 | 566 | const emailSetOne = await apos3.modules['@apostrophecms/form'] 567 | .sendEmailSubmissions(req, savedForm3, submission5); 568 | 569 | assert(emailSetOne.length === 2); 570 | assert(emailSetOne.indexOf('emailOne@example.net') > -1); 571 | assert(emailSetOne.indexOf('emailTwo@example.net') === -1); 572 | assert(emailSetOne.indexOf('emailThree@example.net') > -1); 573 | 574 | const emailSetTwo = await apos3.modules['@apostrophecms/form'] 575 | .sendEmailSubmissions(req, savedForm3, submission6); 576 | 577 | assert(emailSetTwo.length === 3); 578 | assert(emailSetTwo.indexOf('emailOne@example.net') > -1); 579 | assert(emailSetTwo.indexOf('emailTwo@example.net') > -1); 580 | assert(emailSetTwo.indexOf('emailThree@example.net') > -1); 581 | }); 582 | 583 | it('destroys the third instance', async function () { 584 | await testUtil.destroy(apos3); 585 | }); 586 | 587 | // Individual tests for sanitizeFormField methods on field widgets. 588 | it('sanitizes text widget input', function () { 589 | const widget = { fieldName: 'textField' }; 590 | const output1 = {}; 591 | const input1 = { textField: 'A valid string.' }; 592 | 593 | textWidgets.sanitizeFormField(widget, input1, output1); 594 | 595 | assert(output1.textField === 'A valid string.'); 596 | 597 | const input2 = { textField: 127 }; 598 | const output2 = {}; 599 | 600 | textWidgets.sanitizeFormField(widget, input2, output2); 601 | 602 | assert(output2.textField === '127'); 603 | 604 | const input3 = { textField: null }; 605 | const output3 = {}; 606 | 607 | textWidgets.sanitizeFormField(widget, input3, output3); 608 | 609 | assert(output3.textField === ''); 610 | }); 611 | 612 | it('sanitizes textArea widget input', function () { 613 | const widget = { fieldName: 'textAreaField' }; 614 | const longText = 'Nullam id dolor id nibh ultricies vehicula ut id elit. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Aenean lacinia bibendum nulla sed consectetur.'; 615 | 616 | const input1 = { textAreaField: longText }; 617 | const output1 = {}; 618 | 619 | textareaWidgets.sanitizeFormField(widget, input1, output1); 620 | assert(output1.textAreaField === longText); 621 | 622 | const input2 = { textAreaField: [ 127, 0 ] }; 623 | const output2 = {}; 624 | 625 | textareaWidgets.sanitizeFormField(widget, input2, output2); 626 | 627 | assert(!output2.textAreaField); 628 | }); 629 | 630 | it('sanitizes select widget input', function () { 631 | const widget = { 632 | fieldName: 'selectField', 633 | choices: [ 634 | { value: 'first' }, 635 | { value: 'second' }, 636 | { value: 'third' }, 637 | { value: 'fourth' } 638 | ] 639 | }; 640 | const input1 = { selectField: 'second' }; 641 | const output1 = {}; 642 | 643 | selectWidgets.sanitizeFormField(widget, input1, output1); 644 | 645 | assert(output1.selectField === 'second'); 646 | 647 | const input2 = { selectField: 'ninetieth' }; 648 | const output2 = {}; 649 | 650 | selectWidgets.sanitizeFormField(widget, input2, output2); 651 | 652 | assert(!output2.selectField); 653 | }); 654 | 655 | it('sanitizes radio widget input', function () { 656 | const widget = { 657 | fieldName: 'radioField', 658 | choices: [ 659 | { value: 'first' }, 660 | { value: 'second' }, 661 | { value: 'third' }, 662 | { value: 'fourth' } 663 | ] 664 | }; 665 | const input1 = { radioField: 'second' }; 666 | const output1 = {}; 667 | 668 | radioWidgets.sanitizeFormField(widget, input1, output1); 669 | 670 | assert(output1.radioField === 'second'); 671 | 672 | const input2 = { radioField: 'ninetieth' }; 673 | const output2 = {}; 674 | 675 | radioWidgets.sanitizeFormField(widget, input2, output2); 676 | 677 | assert(!output2.radioField); 678 | }); 679 | 680 | it('sanitizes checkbox widget input', function () { 681 | const widget = { 682 | fieldName: 'checkboxField', 683 | choices: [ 684 | { value: 'first' }, 685 | { value: 'second' }, 686 | { value: 'third' }, 687 | { value: 'fourth' } 688 | ] 689 | }; 690 | const input1 = { checkboxField: [ 'second', 'fourth', 'seventeenth' ] }; 691 | const output1 = {}; 692 | 693 | checkboxesWidgets.sanitizeFormField(widget, input1, output1); 694 | 695 | assert(output1.checkboxField.length === 2); 696 | assert(output1.checkboxField[0] === 'second'); 697 | assert(output1.checkboxField[1] === 'fourth'); 698 | }); 699 | 700 | let fileId; 701 | 702 | it('should upload a test file using the attachments api', async function () { 703 | const attachment = await fileUtils.insert('upload-test.txt', apos); 704 | 705 | fileId = attachment._id; 706 | }); 707 | 708 | it('sanitizes file widget input', async function () { 709 | const widget = { fieldName: 'fileField' }; 710 | const output1 = {}; 711 | const input1 = { fileField: [ fileId ] }; 712 | 713 | await fileWidgets.sanitizeFormField(widget, input1, output1); 714 | 715 | assert(output1.fileField[0] === `/uploads/attachments/${fileId}-upload-test.txt`); 716 | 717 | const input2 = { fileField: '8675309' }; 718 | const output2 = {}; 719 | 720 | await fileWidgets.sanitizeFormField(widget, input2, output2); 721 | 722 | assert(Array.isArray(output2.fileField)); 723 | assert(output2.fileField.length === 0); 724 | }); 725 | 726 | const uploadTarget = `${__dirname}/public/uploads/`; 727 | 728 | it('should clear uploads material if any', async function () { 729 | await fileUtils.wipeIt(uploadTarget, apos); 730 | }); 731 | 732 | it('sanitizes boolean widget input', function () { 733 | const widget = { fieldName: 'booleanField' }; 734 | const output1 = {}; 735 | const input1 = { booleanField: true }; 736 | 737 | booleanWidgets.sanitizeFormField(widget, input1, output1); 738 | 739 | assert(output1.booleanField === true); 740 | 741 | const input2 = { booleanField: false }; 742 | const output2 = {}; 743 | 744 | booleanWidgets.sanitizeFormField(widget, input2, output2); 745 | 746 | assert(output2.booleanField === false); 747 | }); 748 | 749 | it('should accept multiple files for a single file field when allowMultiple is true', async function () { 750 | // Update the existing form's file field to allow multiple 751 | await apos.doc.db.updateOne( 752 | { _id: savedForm1._id }, 753 | { $set: { 'contents.items.$[w].allowMultiple': true } }, 754 | { arrayFilters: [ { 'w._id': 'dogPhotoId' } ] } 755 | ); 756 | 757 | const formData = new FormData(); 758 | const multi = { 759 | ...submission1, 760 | _id: savedForm1._id, 761 | DogName: 'Cerberus' 762 | }; 763 | formData.append('data', JSON.stringify(multi)); 764 | 765 | // Two files for the same field: DogPhoto-1 and DogPhoto-2 766 | formData.append('DogPhoto-1', fs.createReadStream(path.join(__dirname, '/lib/upload_tests/upload-test.txt'))); 767 | formData.append('DogPhoto-2', fs.createReadStream(path.join(__dirname, '/lib/upload_tests/upload-test.txt'))); 768 | 769 | await apos.http.post( 770 | '/api/v1/@apostrophecms/form/submit?apikey=skeleton_key', 771 | { body: formData } 772 | ); 773 | 774 | const doc = await apos.db.collection('aposFormSubmissions').findOne({ 775 | 'data.DogName': 'Cerberus' 776 | }); 777 | 778 | const uploadRegex = /^\/uploads\/attachments\/\w+-upload-test.txt$/; 779 | // Expect at least two entries for DogPhoto 780 | assert(Array.isArray(doc.data.DogPhoto)); 781 | assert(doc.data.DogPhoto.length >= 2); 782 | assert(uploadRegex.test(doc.data.DogPhoto[0])); 783 | assert(uploadRegex.test(doc.data.DogPhoto[1])); 784 | }); 785 | 786 | }); 787 | --------------------------------------------------------------------------------