diff --git a/Changelog.md b/Changelog.md index 380a2a649b..d19eed6624 100644 --- a/Changelog.md +++ b/Changelog.md @@ -243,6 +243,16 @@ Formio.createForm(document.getElementById('formio'), 'https://examples.form.io/e - FIO-8091: Fixed missing metadata for html5 select component with default value - FIO-7445: fixed an issue with interpolated data in HTML - FIO-7507: publish-dev-tag-to-npm + - FIO-8330: fixed saving draft if saveDraft and skipDraftRestore are true + - FIO-7595: fixed incorrect value for conditionally hidden Checkbox + - FIO-8342: fixed triggering saveDraft after submitting the form + - FIO-8240: fixed skipDraftRestore effect for the nested Forms + - FIO-8360 fixed submission state for nested form + - FIO-7195: Fixes an issue where Select, Radio and SelectBoxes components with URL DataSource show values instead of labels in modal preview + - FIO-8302: Fixed issue with wizard api key overriding window.property objects + - FIO-8326: Recaptcha now requires type of event to be selected + - FIO-8234: Fixes an issue where Select with Resource data source renders values instead of labels in the read only mode + - FIO-8366: API key is not unique translation ## 5.0.0-rc.37 ### Fixed diff --git a/package.json b/package.json index 31c0f1fa06..ae7362536b 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "core-js": "^3.37.0", "dialog-polyfill": "^0.5.6", "dom-autoscroller": "^2.3.4", - "dompurify": "^3.1.0", + "dompurify": "^3.1.1", "downloadjs": "^1.4.7", "dragula": "^3.7.3", "eventemitter3": "^5.0.1", diff --git a/src/Webform.js b/src/Webform.js index c129dfb8b2..b7d2f42caf 100644 --- a/src/Webform.js +++ b/src/Webform.js @@ -216,12 +216,19 @@ export default class Webform extends NestedDataComponent { this.language = this.i18next.language; // See if we need to restore the draft from a user. - if (this.options.saveDraft && !this.options.skipDraftRestore) { + if (this.options.saveDraft) { this.formReady.then(()=> { - const user = Formio.getUser(); - // Only restore a draft if the submission isn't explicitly set. - if (user && !this.submissionSet) { - this.restoreDraft(user._id); + if (!this.options.skipDraftRestore) { + const user = Formio.getUser(); + // Only restore a draft if the submission isn't explicitly set. + if (user && !this.submissionSet) { + this.restoreDraft(user._id); + } + } + else { + // Enable drafts + this.draftEnabled = true; + this.savingDraft = false; } }); } @@ -773,7 +780,7 @@ export default class Webform extends NestedDataComponent { const draft = fastCloneDeep(this.submission); draft.state = 'draft'; - if (!this.savingDraft) { + if (!this.savingDraft && !this.submitting) { this.emit('saveDraftBegin'); this.savingDraft = true; this.formio.saveSubmission(draft).then((sub) => { diff --git a/src/Webform.unit.js b/src/Webform.unit.js index f2c5ac71d8..991b7eaca0 100644 --- a/src/Webform.unit.js +++ b/src/Webform.unit.js @@ -35,7 +35,7 @@ import { formWithCollapsedPanel, formWithCustomFormatDate, tooltipActivateCheckbox, - formWithObjectValueSelect + formWithObjectValueSelect, } from '../test/formtest'; import UpdateErrorClassesWidgets from '../test/forms/updateErrorClasses-widgets'; import nestedModalWizard from '../test/forms/nestedModalWizard'; @@ -78,7 +78,9 @@ import formWithDeeplyNestedConditionalComps from '../test/forms/formWithDeeplyNe import formWithValidation from '../test/forms/formWithValidation'; import formWithNotAllowedTags from '../test/forms/formWithNotAllowedTags'; import formWithValidateWhenHidden from '../test/forms/formWithValidateWhenHidden'; +import formWithSelectRadioUrlDataSource from '../test/forms/selectRadioUrlDataSource'; const SpySanitize = sinon.spy(FormioUtils, 'sanitize'); + global.requestAnimationFrame = (cb) => cb(); global.cancelAnimationFrame = () => {}; @@ -1270,7 +1272,7 @@ describe('Webform tests', function() { .catch((err) => done(err)); }); - it('Should show validation errors and update validation errors list when opening and editing edit grid rows in draft modal mode after pushing submit btn', function(done) { + it('Should show validation errors and update validation errors list when opening and editing edit grid rows in draft modal mode after pushing submit btn',function(done) { const formElement = document.createElement('div'); const formWithDraftModals = new Webform(formElement, { sanitize: true }); @@ -4601,6 +4603,7 @@ describe('Webform tests', function() { const originalMakeRequest = Formio.makeRequest; let saveDraftCalls = 0; let restoreDraftCalls = 0; + let state = null; const scenario = { restoreDraftError: false, saveDraftError: false, @@ -4624,6 +4627,11 @@ describe('Webform tests', function() { ? Promise.reject('Save Draft Error') : Promise.resolve(fastCloneDeep(data)); } + if (type === 'submission' && method === 'post') { + state = data.state; + saveDraftCalls = ++saveDraftCalls; + return Promise.resolve(fastCloneDeep(data)); + } if (type === 'form' && method === 'get') { return Promise.resolve(fastCloneDeep({ _id: '65cdd69efb1b9683c216fa1d', @@ -4707,6 +4715,7 @@ describe('Webform tests', function() { afterEach(() => { saveDraftCalls = 0; restoreDraftCalls = 0; + state = null; scenario.restoreDraftError = false; scenario.saveDraftError = false; }); @@ -4810,6 +4819,94 @@ describe('Webform tests', function() { }, 200); }).catch((err) => done(err)); }); + + it('Should save the draft after changing the data if skipDraftRestore is set as true', function(done) { + const formElement = document.createElement('div'); + Formio.createForm( + formElement, + 'http://localhost:3000/zarbzxibjafpcjb/testdrafterrors', + { + saveDraft: true, + skipDraftRestore: true + } + ).then((form) => { + setTimeout(() => { + assert.equal(restoreDraftCalls, 0, 'Should not restore Draft'); + assert.equal(saveDraftCalls, 0); + assert.equal(_.isUndefined(form.submission.state), true); + const tfInput = form.getComponent('textField').refs.input[0]; + tfInput.value = 'test'; + const inputEvent = new Event('input'); + tfInput.dispatchEvent(inputEvent); + setTimeout(() => { + assert.equal(restoreDraftCalls, 0); + assert.equal(saveDraftCalls, 1, 'Should save Draft'); + assert.equal(state, 'draft'); + done(); + }, 300); + },200); + }).catch((err) => done(err)); + }); + }); + + it('Should render labels for Select, Radio and selectBoxes components when Data Source is URL', (done) => { + const element = document.createElement('div'); + const form = new Webform(element); + const originalMakeRequest = Formio.makeRequest; + + Formio.makeRequest = function() { + return new Promise(resolve => { + const values = [ + { name : 'Alabama', abbreviation : 'AL' }, + { name : 'Alaska', abbreviation: 'AK' }, + { name: 'American Samoa', abbreviation: 'AS' } + ]; + resolve(values); + }); + }; + + form.setForm(formWithSelectRadioUrlDataSource).then(() => { + const selectBoxes = form.getComponent('selectBoxes'); + const select = form.getComponent('select'); + const radio = form.getComponent('radio'); + + selectBoxes.componentModal.openModal(); + select.componentModal.openModal(); + radio.componentModal.openModal(); + + setTimeout(() => { + form.setSubmission({ + data: { + selectBoxes: { AL: false, AK: true, AS: true }, + select: 'AL', + radio: 'AL', + } + }); + + setTimeout(() => { + selectBoxes.componentModal.closeModal(); + select.componentModal.closeModal(); + radio.componentModal.closeModal(); + + setTimeout(() => { + const previewSelectBoxes = selectBoxes.element.querySelector('[ref="openModal"]'); + const previewSelect = select.element.querySelector('[ref="openModal"]'); + const previewRadio = radio.element.querySelector('[ref="openModal"]'); + + assert.equal(previewSelectBoxes.innerHTML, '\n Alaska, American Samoa\n', 'Should show labels as a selected value' + + ' for SelectBoxes component'); + assert.equal(previewRadio.innerHTML, '\n Alabama\n', 'Should show label as a selected value' + + ' for Radio component'); + assert.equal(previewSelect.innerHTML, '\n Alabama\n', 'Should show label as a selected value' + + ' for Select component'); + + Formio.makeRequest = originalMakeRequest; + done(); + }, 300); + }, 300); + }, 300); + }) + .catch((err) => done(err)); }); for (const formTest of FormTests) { diff --git a/src/WebformBuilder.js b/src/WebformBuilder.js index ea86442243..256386f271 100644 --- a/src/WebformBuilder.js +++ b/src/WebformBuilder.js @@ -1285,14 +1285,10 @@ export default class WebformBuilder extends Component { this.webform.everyComponent((comp) => { const path = comp.path; - const errors = comp.visibleErrors || []; if (repeatablePaths.includes(path)) { - comp.setCustomValidity(`API Key is not unique: ${comp.key}`); + comp.setCustomValidity(this.t('apiKey', { key: comp.key })); hasInvalidComponents = true; } - else if (errors.length && errors[0].message?.startsWith('API Key is not unique')) { - comp.setCustomValidity(''); - } }); this.emit('builderFormValidityChange', hasInvalidComponents); diff --git a/src/WebformBuilder.unit.js b/src/WebformBuilder.unit.js index 27d34ea946..ba01b0b515 100644 --- a/src/WebformBuilder.unit.js +++ b/src/WebformBuilder.unit.js @@ -3,7 +3,7 @@ import Harness from '../test/harness'; import WebformBuilder from './WebformBuilder'; import Builders from './builders'; import { Formio } from './Formio'; -import { uniqueApiKeys, uniqueApiKeysLayout, uniqueApiKeysSameLevel, columnsForm, resourceKeyCamelCase } from '../test/formtest'; +import { uniqueApiKeys, uniqueApiKeysLayout, uniqueApiKeysSameLevel, columnsForm, resourceKeyCamelCase, uniqueApiKeysTranslation } from '../test/formtest'; import sameApiKeysLayoutComps from '../test/forms/sameApiKeysLayoutComps'; import testApiKeysUniquifying from '../test/forms/testApiKeysUniquifying'; import formBasedOnWizard from '../test/forms/formBasedOnWizard'; @@ -32,6 +32,27 @@ describe('WebformBuilder tests', function() { done(); }, 500); }); + it('Should show API Key is not unique: {{key}} error when api keys are the same', (done) => { + const builder = Harness.getBuilder(); + builder.i18next.currentLanguage = { apiKey: 'translated api key error {{key}}' }; + builder.webform.setForm(uniqueApiKeysTranslation).then(()=>{ + builder.highlightInvalidComponents(); + const component = builder.webform.getComponent(['textField']); + assert.equal(component.visibleErrors.length, 1); + done(); + }).catch(done); + }); + + it('Should show translated api key error {{key}} when apiKey is overridden in i18next translations', (done) => { + const builder = Harness.getBuilder(); + builder.i18next.currentLanguage = { apiKey: 'translated api key error {{key}}' }; + builder.webform.setForm(uniqueApiKeysTranslation).then(() => { + builder.highlightInvalidComponents(); + const component = builder.webform.getComponent(['textField']); + assert.equal(component.visibleErrors[0].message,'translated api key error textField'); + done(); + }).catch(done); + }); it('Should not show unique API error when components with same keys are inside and outside of the Data component', (done) => { const builder = Harness.getBuilder(); diff --git a/src/components/_classes/component/Component.js b/src/components/_classes/component/Component.js index 9daa721c25..233d3c54b5 100644 --- a/src/components/_classes/component/Component.js +++ b/src/components/_classes/component/Component.js @@ -8,7 +8,7 @@ import { processOne, processOneSync, validateProcessInfo } from '@formio/core/pr import { Formio } from '../../../Formio'; import * as FormioUtils from '../../../utils/utils'; import { - fastCloneDeep, boolValue, getComponentPath, isInsideScopingComponent, currentTimezone + fastCloneDeep, boolValue, getComponentPath, isInsideScopingComponent, currentTimezone, getScriptPlugin } from '../../../utils/utils'; import Element from '../../../Element'; import ComponentModal from '../componentModal/ComponentModal'; @@ -2133,8 +2133,7 @@ export default class Component extends Element { /** * Add a new input error to this element. * - * @param message - * @param dirty + * @param {{level: string, message: string}[]} messages */ addMessages(messages) { if (!messages) { @@ -3750,7 +3749,7 @@ Component.requireLibrary = function(name, property, src, polling) { }.bind(Component.externalLibraries[name]); } // See if the plugin already exists. - const plugin = _.get(window, property); + const plugin = getScriptPlugin(property); if (plugin) { Component.externalLibraries[name].resolve(plugin); } @@ -3795,7 +3794,7 @@ Component.requireLibrary = function(name, property, src, polling) { // if no callback is provided, then check periodically for the script. if (polling) { setTimeout(function checkLibrary() { - const plugin = _.get(window, property); + const plugin = getScriptPlugin(property); if (plugin) { Component.externalLibraries[name].resolve(plugin); } diff --git a/src/components/checkbox/Checkbox.js b/src/components/checkbox/Checkbox.js index 457a770938..7532f905da 100644 --- a/src/components/checkbox/Checkbox.js +++ b/src/components/checkbox/Checkbox.js @@ -202,17 +202,8 @@ export default class CheckBoxComponent extends Field { } setValue(value, flags = {}) { - if ( - this.setCheckedState(value) !== undefined || - (!this.input && value !== undefined && (this.visible || this.conditionallyVisible() || !this.component.clearOnHide)) - ) { - const changed = this.updateValue(value, flags); - if (this.isHtmlRenderMode() && flags && flags.fromSubmission && changed) { - this.redraw(); - } - return changed; - } - return false; + this.setCheckedState(value); + return super.setValue(value, flags); } getValueAsString(value) { diff --git a/src/components/checkbox/Checkbox.unit.js b/src/components/checkbox/Checkbox.unit.js index 27fe3e0bba..2428c843d0 100644 --- a/src/components/checkbox/Checkbox.unit.js +++ b/src/components/checkbox/Checkbox.unit.js @@ -10,7 +10,8 @@ import { customDefaultComponent, comp2, comp3, - comp4 + comp4, + comp5 } from './fixtures'; describe('Checkbox Component', () => { @@ -91,4 +92,27 @@ describe('Checkbox Component', () => { }, 300); }).catch((err) => done(err)); }); + + it('Should set the value for the checkbox if it set before the component from checbox`s condition', (done) => { + const form = _.cloneDeep(comp5); + const element = document.createElement('div'); + const data = { + textField: 'test', + checkboxBefore: true, + checkboxAfter: true + }; + Formio.createForm(element, form).then(form => { + form.setValue({ data }, { sanitize: true }); + const checkboxBefore = form.getComponent('checkboxBefore'); + const checkboxAfter = form.getComponent('checkboxAfter'); + setTimeout(() => { + const inputBefore = Harness.testElements(checkboxBefore, 'input[type="checkbox"]', 1)[0]; + assert.equal(inputBefore.checked, true); + const inputAfter = Harness.testElements(checkboxAfter, 'input[type="checkbox"]', 1)[0]; + assert.equal(inputAfter.checked, true); + assert.deepEqual(form.data, data); + done(); + }, 300); + }).catch((err) => done(err)); + }); }); diff --git a/src/components/checkbox/fixtures/comp5.js b/src/components/checkbox/fixtures/comp5.js new file mode 100644 index 0000000000..bbce3af06d --- /dev/null +++ b/src/components/checkbox/fixtures/comp5.js @@ -0,0 +1,53 @@ +export default { + title: '7595', + name: '7595', + path: '7595', + type: 'form', + display: 'form', + components:[ + { + label: 'Checkbox', + tableView: false, + validateWhenHidden: false, + key: 'checkboxBefore', + conditional: { + show: true, + conjunction: 'all', + conditions: [ + { + component: 'textField', + operator: 'isNotEmpty' + } + ] + }, + type: 'checkbox', + input: true + }, + { + label: 'Text Field', + applyMaskOn: 'change', + tableView: true, + key: 'textField', + type: 'textfield', + input: true + }, + { + label: 'Checkbox', + tableView: false, + validateWhenHidden: false, + key: 'checkboxAfter', + conditional: { + show: true, + conjunction: 'all', + conditions: [ + { + component: 'textField', + operator: 'isNotEmpty' + } + ] + }, + type: 'checkbox', + input: true + }, + ] +}; diff --git a/src/components/checkbox/fixtures/index.js b/src/components/checkbox/fixtures/index.js index 326574b525..6696fe560c 100644 --- a/src/components/checkbox/fixtures/index.js +++ b/src/components/checkbox/fixtures/index.js @@ -3,4 +3,5 @@ import customDefaultComponent from './customDefaultComponent'; import comp2 from './comp2'; import comp3 from './comp3'; import comp4 from './comp4'; -export { comp1, comp2, comp3, comp4, customDefaultComponent }; +import comp5 from './comp5'; +export { comp1, comp2, comp3, comp4, comp5, customDefaultComponent }; diff --git a/src/components/form/Form.js b/src/components/form/Form.js index 0141cb6c6e..c2162a5356 100644 --- a/src/components/form/Form.js +++ b/src/components/form/Form.js @@ -230,6 +230,9 @@ export default class FormComponent extends Component { if (this.options.saveDraftThrottle) { options.saveDraftThrottle = this.options.saveDraftThrottle; } + if (this.options.skipDraftRestore) { + options.skipDraftRestore = this.options.skipDraftRestore; + } return options; } /* eslint-enable max-statements */ @@ -649,9 +652,10 @@ export default class FormComponent extends Component { } const isAlreadySubmitted = submission && submission._id && submission.form; + const isDraftSubmission = this.options.saveDraft && submission.state === 'draft'; // This submission has already been submitted, so just return the reference data. - if (isAlreadySubmitted && !this.subForm?.wizard) { + if (isAlreadySubmitted && !this.subForm?.wizard && !isDraftSubmission) { this.dataValue = submission; return Promise.resolve(this.dataValue); } diff --git a/src/components/form/Form.unit.js b/src/components/form/Form.unit.js index 9d2d8c5474..ca0fa6ffa7 100644 --- a/src/components/form/Form.unit.js +++ b/src/components/form/Form.unit.js @@ -1,3 +1,5 @@ +import _ from 'lodash'; +import { fastCloneDeep } from '../../utils/utils'; import Harness from '../../../test/harness'; import FormComponent from './Form'; import { expect } from 'chai'; @@ -8,9 +10,12 @@ import { comp3, comp4, comp5, - comp6 + comp6, + comp7, + comp8 } from './fixtures'; import Webform from '../../Webform'; +import { Formio } from '../../formio.form.js'; import formModalEdit from './fixtures/formModalEdit'; import { formComponentWithConditionalRenderingForm } from '../../../test/formtest'; @@ -273,3 +278,153 @@ describe('Wizard Component', () => { }); }); }); + +describe('SaveDraft functionality for Nested Form', () => { + const originalMakeRequest = Formio.makeRequest; + let saveDraftCalls = 0; + let restoreDraftCalls = 0; + let state = null; + let subFormState = null; + + const restoredDraftData = { + parent: 'test Parent', + form: { nested: 'test Nested' }, + submit: false, + }; + + before((done) => { + Formio.setUser({ + _id: '123' + }); + + Formio.makeRequest = (formio, type, url, method, data) => { + if (type === 'submission' && ['put', 'post'].includes(method)) { + state = data.state; + subFormState = _.get(data, 'data.form.state', null); + if (state === 'draft') { + saveDraftCalls = ++saveDraftCalls; + } + return Promise.resolve(fastCloneDeep(data)); + } + + if (type === 'form' && method === 'get') { + return Promise.resolve(fastCloneDeep(_.endsWith(url, 'parent') ? comp7 : comp8)); + } + + if (type === 'submissions' && method === 'get' && _.includes(url, 'parent')) { + restoreDraftCalls = ++restoreDraftCalls; + return Promise.resolve([ + fastCloneDeep({ + _id: '662259f500773e9994360c72', + form: '66051dae494c977c47028fac', + owner: '63ceaccebe0090345b109da7', + data: restoredDraftData, + project: '65b0ccbaf019a907ac01a869', + state: 'draft', + }), + ]); + } + + if (type === 'submissions' && method === 'get') { + restoreDraftCalls = ++restoreDraftCalls; + return Promise.resolve([ + fastCloneDeep({ + _id: '660e75e4e8c776f1225142aa', + form: '63e4deda12b88c4f05c125cf', + owner: '63ceaccebe0090345b109da7', + data: restoredDraftData.form, + project: '65b0ccbaf019a907ac01a869', + state: 'draft', + }), + ]); + } + }; + + done(); + }); + + afterEach(() => { + saveDraftCalls = 0; + restoreDraftCalls = 0; + state = null; + subFormState = null; + }); + + after((done) => { + Formio.makeRequest = originalMakeRequest; + Formio.setUser(); + done(); + }); + + it('Should save draft for Nested Form', function(done) { + const formElement = document.createElement('div'); + Formio.createForm( + formElement, + 'http://localhost:3000/idwqwhclwioyqbw/testdraftparent', + { + saveDraft: true + } + ).then((form) => { + setTimeout(() => { + const tfNestedInput = form.getComponent('form.nested').refs.input[0]; + tfNestedInput.value = 'testNested'; + const inputEvent = new Event('input'); + tfNestedInput.dispatchEvent(inputEvent); + setTimeout(() => { + assert.equal(saveDraftCalls, 1); + assert.equal(state, 'draft'); + done(); + }, 1000); + }, 200); + }).catch((err) => done(err)); + }); + + it('Should not restore draft for Nested Form if skipDraftRestore is set as true', function(done) { + const formElement = document.createElement('div'); + Formio.createForm( + formElement, + 'http://localhost:3000/idwqwhclwioyqbw/testdraftparent', + { + saveDraft: true, + skipDraftRestore: true + } + ).then((form) => { + setTimeout(() => { + assert.equal(restoreDraftCalls, 0); + assert.equal(saveDraftCalls, 0); + assert.equal(_.isUndefined(form.submission.state), true); + done(); + }, 200); + }).catch((err) => done(err)); + }); + + it('Should change state of the nested sumbmission to submitted after submit parent form', function(done) { + const formElement = document.createElement('div'); + Formio.createForm( + formElement, + 'http://localhost:3000/idwqwhclwioyqbw/testdraftparent', + { + saveDraft: true + } + ).then((form)=>{ + setTimeout(()=>{ + const tfNestedInput = form.getComponent('form.nested').refs.input[0]; + tfNestedInput.value = 'testNested Update'; + const inputEvent = new Event('input'); + tfNestedInput.dispatchEvent(inputEvent); + setTimeout(()=>{ + assert.equal(saveDraftCalls, 1); + const clickEvent = new Event('click'); + const submitBtn = form.element.querySelector('[name="data[submit]"]'); + submitBtn.dispatchEvent(clickEvent); + setTimeout(()=> { + assert.equal(saveDraftCalls, 1); + assert.equal(state, 'submitted'); + assert.equal(subFormState, 'submitted'); + done(); + }, 500); + }, 300); + }, 200); + }).catch((err) => done(err)); + }); +}); diff --git a/src/components/form/fixtures/comp7.js b/src/components/form/fixtures/comp7.js new file mode 100644 index 0000000000..a71c9a459e --- /dev/null +++ b/src/components/form/fixtures/comp7.js @@ -0,0 +1,35 @@ +export default { + _id: '66051dae494c977c47028fac', + title: 'test draft parent', + name: 'testDraftParent', + path: 'testdraftparent', + type: 'form', + display: 'form', + components: [ + { + label: 'Parent Form Field', + tableView: true, + key: 'parent', + type: 'textfield', + input: true, + }, + { + label: 'Form', + tableView: true, + src: 'http://localhost:3000/idwqwhclwioyqbw/testdraftnested', + key: 'form', + type: 'form', + input: true, + }, + { + type: 'button', + label: 'Submit', + key: 'submit', + disableOnInvalid: true, + input: true, + tableView: false, + }, + ], + project: '63cead09be0090345b109e22', + machineName: 'idwqwhclwioyqbw:testdraftparent' +}; diff --git a/src/components/form/fixtures/comp8.js b/src/components/form/fixtures/comp8.js new file mode 100644 index 0000000000..c31c73e202 --- /dev/null +++ b/src/components/form/fixtures/comp8.js @@ -0,0 +1,27 @@ +export default { + _id: '63e4deda12b88c4f05c125cf', + title: 'test draft nested', + name: 'testDraftNested', + path: 'testdraftnested', + type: 'form', + display: 'form', + components: [ + { + label: 'Nested Form Field', + tableView: true, + key: 'nested', + type: 'textfield', + input: true, + }, + { + type: 'button', + label: 'Submit', + key: 'submit', + disableOnInvalid: true, + input: true, + tableView: false, + }, + ], + project: '63cead09be0090345b109e22', + machineName: 'idwqwhclwioyqbw:testdraftparent' +}; diff --git a/src/components/form/fixtures/index.js b/src/components/form/fixtures/index.js index 182144551c..df7499d337 100644 --- a/src/components/form/fixtures/index.js +++ b/src/components/form/fixtures/index.js @@ -5,5 +5,7 @@ import comp3 from './comp3'; import comp4 from './comp4'; import comp5 from './comp5'; import comp6 from './comp6'; -export { formModalEdit, comp1, comp2, comp3, comp4, comp5, comp6 }; +import comp7 from './comp7'; +import comp8 from './comp8'; +export { formModalEdit, comp1, comp2, comp3, comp4, comp5, comp6, comp7, comp8 }; diff --git a/src/components/radio/Radio.js b/src/components/radio/Radio.js index 34228bda79..985560344a 100644 --- a/src/components/radio/Radio.js +++ b/src/components/radio/Radio.js @@ -261,18 +261,21 @@ export default class RadioComponent extends ListComponent { return false; } - getValueAsString(value) { + getValueAsString(value, options = {}) { if (_.isObject(value)) { value = JSON.stringify(value); } else if (!_.isString(value)) { value = _.toString(value); } - if (this.component.dataSrc !== 'values') { + + const isModalPreviewWithUrlDataSource = options.modalPreview && this.component.dataSrc === 'url'; + if (this.component.dataSrc !== 'values' && !isModalPreviewWithUrlDataSource) { return value; } - const option = _.find(this.component.values, (v) => v.value === value); + const values = isModalPreviewWithUrlDataSource ? this.loadedOptions : this.component.values; + const option = _.find(values, (v) => v.value === value); if (!value) { return _.get(option, 'label', ''); diff --git a/src/components/recaptcha/editForm/ReCaptcha.edit.display.js b/src/components/recaptcha/editForm/ReCaptcha.edit.display.js index 37cedf27f5..c32d35c6f4 100644 --- a/src/components/recaptcha/editForm/ReCaptcha.edit.display.js +++ b/src/components/recaptcha/editForm/ReCaptcha.edit.display.js @@ -15,6 +15,9 @@ export default [ value: 'buttonClick' } ], + validate: { + required: true + }, weight: 650 }, { diff --git a/src/components/select/Select.js b/src/components/select/Select.js index 26641a7edb..4d9b4a5f9d 100644 --- a/src/components/select/Select.js +++ b/src/components/select/Select.js @@ -267,9 +267,10 @@ export default class SelectComponent extends ListComponent { selectValueAndLabel(data) { const value = this.getOptionValue((this.isEntireObjectDisplay() && !this.itemValue(data)) ? data : this.itemValue(data)); + const readOnlyResourceLabelData = this.options.readOnly && (this.component.dataSrc === 'resource' || this.component.dataSrc === 'url') && this.selectData; return { value, - label: this.itemTemplate((this.isEntireObjectDisplay() && !_.isObject(data.data)) ? { data: data } : data, value) + label: this.itemTemplate((this.isEntireObjectDisplay() && !_.isObject(data.data)) ? { data: data } : readOnlyResourceLabelData || data, value) }; } @@ -1752,6 +1753,11 @@ export default class SelectComponent extends ListComponent { asString(value, options = {}) { value = value ?? this.getValue(); + + if (options.modalPreview && this.selectData) { + const { label } = this.selectValueAndLabel(value); + return label; + } //need to convert values to strings to be able to compare values with available options that are strings const convertToString = (data, valueProperty) => { if (valueProperty) { diff --git a/src/components/select/Select.unit.js b/src/components/select/Select.unit.js index b478b183cc..e89a1c987c 100644 --- a/src/components/select/Select.unit.js +++ b/src/components/select/Select.unit.js @@ -32,6 +32,7 @@ import { comp20, comp21, comp22, + comp23, } from './fixtures'; // eslint-disable-next-line max-statements @@ -1276,5 +1277,69 @@ describe('Select Component with Entire Object Value Property', () => { done(); }); }); + + it('Should render label for Select components when Data Source is Resource in read only mode', (done) => { + const element = document.createElement('div'); + Formio.createForm(element, comp23, { readOnly: true }).then((form) => { + const select = form.getComponent('select'); + form.setSubmission({ + metadata: { + selectData: { + select: { + data: { + textField1: 'A', + }, + }, + }, + timezone: 'Europe/Kiev', + offset: 180, + origin: 'http://localhost:3001', + referrer: '', + browserName: 'Netscape', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + pathName: '/', + onLine: true, + headers: { + host: 'qvecgdgwpwujbpi.localhost:3000', + connection: 'keep-alive', + 'content-length': '457', + 'sec-ch-ua': '"Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"', + accept: 'application/json', + 'content-type': 'application/json', + 'sec-ch-ua-mobile': '?0', + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + 'sec-ch-ua-platform': '"Windows"', + origin: 'http://localhost:3001', + 'sec-fetch-site': 'cross-site', + 'sec-fetch-mode': 'cors', + 'sec-fetch-dest': 'empty', + referer: 'http://localhost:3001/', + 'accept-encoding': 'gzip, deflate, br, zstd', + 'accept-language': 'en-US,en;q=0.9,ru-RU;q=0.8,ru;q=0.7', + }, + }, + data: { + select: 1, + select1: { + textField1: 'A', + textField2: '1', + submit: true, + }, + submit: true, + }, + state: 'submitted', + }); + + setTimeout(() => { + const previewSelect = select.element.querySelector('[aria-selected="true"] span'); + + assert.equal(previewSelect.innerHTML, 'A', 'Should show label as a selected value' + + ' for Select component'); + + done(); + }, 300); + }) + .catch((err) => done(err)); + }); }); diff --git a/src/components/select/fixtures/comp23.js b/src/components/select/fixtures/comp23.js new file mode 100644 index 0000000000..eaf8a9dd95 --- /dev/null +++ b/src/components/select/fixtures/comp23.js @@ -0,0 +1,38 @@ +export default { + title: 'FIO-8234', + name: 'fio8234', + path: 'fio8234', + type: 'form', + display: 'form', + components: [ + { + label: 'Select', + widget: 'choicesjs', + tableView: true, + dataSrc: 'resource', + data: { + resource: '665446284c9b0163c3e0c7e6', + }, + template: '{{ item.data.textField1 }}', + validate: { + select: false, + }, + key: 'select', + type: 'select', + searchField: 'data.textField2__regex', + input: true, + noRefreshOnScroll: false, + addResource: false, + reference: false, + valueProperty: 'data.textField2', + }, + { + type: 'button', + label: 'Submit', + key: 'submit', + disableOnInvalid: true, + input: true, + tableView: false, + }, + ], +}; diff --git a/src/components/select/fixtures/index.js b/src/components/select/fixtures/index.js index 4dddb75df9..aad09b2860 100644 --- a/src/components/select/fixtures/index.js +++ b/src/components/select/fixtures/index.js @@ -20,4 +20,5 @@ import comp19 from './comp19'; import comp20 from './comp20'; import comp21 from './comp21'; import comp22 from './comp22'; -export { comp1, comp2, comp4, comp5, comp6, comp7, comp8, comp9, comp10, comp11, comp12, comp13, comp14, comp15, comp16, comp17, comp18, comp19, comp20, comp21, comp22 }; +import comp23 from './comp23'; +export { comp1, comp2, comp4, comp5, comp6, comp7, comp8, comp9, comp10, comp11, comp12, comp13, comp14, comp15, comp16, comp17, comp18, comp19, comp20, comp21, comp22, comp23 }; diff --git a/src/components/selectboxes/SelectBoxes.js b/src/components/selectboxes/SelectBoxes.js index cbb4583db9..f4a4199c8e 100644 --- a/src/components/selectboxes/SelectBoxes.js +++ b/src/components/selectboxes/SelectBoxes.js @@ -189,12 +189,15 @@ export default class SelectBoxesComponent extends RadioComponent { return changed; } - getValueAsString(value) { + getValueAsString(value, options = {}) { if (!value) { return ''; } if (this.isSelectURL) { + if (options.modalPreview && this.loadedOptions) { + return this.loadedOptions.filter((option) => value[option.value]).map((option) => option.label).join(', '); + } return _(value).pickBy((val) => val).keys().join(', '); } return _(this.component.values || []) diff --git a/src/translations/en.js b/src/translations/en.js index 986556546a..efcb553c38 100644 --- a/src/translations/en.js +++ b/src/translations/en.js @@ -71,4 +71,5 @@ export default { submitButtonAriaLabel:'Submit Form button. Click to submit the form', reCaptchaTokenValidationError: 'ReCAPTCHA: Token validation error', reCaptchaTokenNotSpecifiedError: 'ReCAPTCHA: Token is not specified in submission', + apiKey: 'API Key is not unique: {{key}}' }; diff --git a/src/utils/utils.js b/src/utils/utils.js index c06750eb3f..b0d2765552 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -150,6 +150,22 @@ export function getElementRect(element) { }; } +/** + * Get non HTMLElement property in the window object + * @param {String} property + * @return {any || undefined} + */ +export function getScriptPlugin(property) { + const obj = window[property]; + if ( + typeof HTMLElement === 'object' ? obj instanceof HTMLElement : //DOM2 + obj && typeof obj === 'object' && true && obj.nodeType === 1 && typeof obj.nodeName === 'string' + ) { + return undefined; + } + return obj; +} + /** * Determines the boolean value of a setting. * diff --git a/test/forms/selectRadioUrlDataSource.js b/test/forms/selectRadioUrlDataSource.js new file mode 100644 index 0000000000..512d963d5f --- /dev/null +++ b/test/forms/selectRadioUrlDataSource.js @@ -0,0 +1,95 @@ +export default { + type: 'form', + display: 'form', + components: [ + { + label: 'Select Boxes - URL', + optionsLabelPosition: 'right', + tableView: true, + modalEdit: true, + dataSrc: 'url', + values: [ + { + label: '', + value: '', + shortcut: '', + }, + ], + valueProperty: 'abbreviation', + key: 'selectBoxes', + type: 'selectboxes', + data: { + url: 'https://gists.rawgit.com/mshafrir/2646763/raw/states_titlecase.json', + headers: [ + { + key: '', + value: '', + }, + ], + }, + template: '{{ item.name }}', + input: true, + inputType: 'checkbox', + }, + { + label: 'Radio', + optionsLabelPosition: 'right', + inline: false, + tableView: true, + modalEdit: true, + dataSrc: 'url', + values: [ + { + label: '', + value: '', + shortcut: '', + }, + ], + valueProperty: 'abbreviation', + key: 'radio', + type: 'radio', + data: { + url: 'https://gists.rawgit.com/mshafrir/2646763/raw/states_titlecase.json', + headers: [ + { + key: '', + value: '', + }, + ], + }, + template: '{{ item.name }}', + input: true, + }, + { + label: 'Select', + widget: 'choicesjs', + tableView: true, + modalEdit: true, + dataSrc: 'url', + data: { + url: 'https://gists.rawgit.com/mshafrir/2646763/raw/states_titlecase.json', + headers: [ + { + key: '', + value: '', + }, + ], + }, + valueProperty: 'abbreviation', + template: '{{ item.name }}', + key: 'select', + type: 'select', + disableLimit: false, + noRefreshOnScroll: false, + input: true, + }, + { + type: 'button', + label: 'Submit', + key: 'submit', + disableOnInvalid: true, + input: true, + tableView: false, + }, + ], +}; diff --git a/test/formtest/index.d.ts b/test/formtest/index.d.ts index dfd6d0a3d1..ec628ae9ca 100644 --- a/test/formtest/index.d.ts +++ b/test/formtest/index.d.ts @@ -1 +1 @@ -export { advanced, basic, data, defaults, layout, premium, settingErrors, clearOnHide, manualOverride, uniqueApiKeys, uniqueApiKeysLayout, uniqueApiKeysSameLevel, validationOnBlur, calculateValueWithManualOverride, calculateValueWithSubmissionMetadata, displayAsModalEditGrid, formWithAdvancedLogic, formWithPatternValidation, calculatedSelectboxes, columnsForm, calculateZeroValue, formWithConditionalLogic, formWithCalculatedValueWithoutOverriding, formWithTimeComponent, formWithEditGridModalDrafts, formWithBlurValidationInsidePanel, modalEditComponents, calculatedNotPersistentValue, calculateValueInEditingMode, initiallyCollapsedPanel, multipleTextareaInsideConditionalComponent, formComponentWithConditionalRenderingForm, disabledNestedForm, propertyActions, formWithEditGridAndNestedDraftModalRow, formWithDateTimeComponents, formWithCollapsedPanel, formWithCustomFormatDate, wizardWithHiddenPanel, wizardWithSimpleConditionalPage, wizardWithTooltip, resourceKeyCamelCase, tooltipActivateCheckbox }; +export { advanced, basic, data, defaults, layout, premium, settingErrors, clearOnHide, manualOverride, uniqueApiKeys, uniqueApiKeysLayout, uniqueApiKeysSameLevel, validationOnBlur, calculateValueWithManualOverride, calculateValueWithSubmissionMetadata, displayAsModalEditGrid, formWithAdvancedLogic, formWithPatternValidation, calculatedSelectboxes, columnsForm, calculateZeroValue, formWithConditionalLogic, formWithCalculatedValueWithoutOverriding, formWithTimeComponent, formWithEditGridModalDrafts, formWithBlurValidationInsidePanel, modalEditComponents, calculatedNotPersistentValue, calculateValueInEditingMode, initiallyCollapsedPanel, multipleTextareaInsideConditionalComponent, formComponentWithConditionalRenderingForm, disabledNestedForm, propertyActions, formWithEditGridAndNestedDraftModalRow, formWithDateTimeComponents, formWithCollapsedPanel, formWithCustomFormatDate, wizardWithHiddenPanel, wizardWithSimpleConditionalPage, wizardWithTooltip, resourceKeyCamelCase, tooltipActivateCheckbox, uniqueApiKeysTranslation }; diff --git a/test/formtest/index.js b/test/formtest/index.js index 4b21cb8e25..ac4d15f9fa 100644 --- a/test/formtest/index.js +++ b/test/formtest/index.js @@ -10,6 +10,7 @@ const manualOverride = require('./manualOverride.json'); const uniqueApiKeys = require('./uniqueApiKeys.json'); const uniqueApiKeysLayout = require('./uniqueApiKeysLayout.json'); const uniqueApiKeysSameLevel = require('./uniqueApiKeysSameLevel.json'); +const uniqueApiKeysTranslation = require('./uniqueApiKeysTranslation.json'); const validationOnBlur = require('./validationOnBlur.json'); const calculateValueWithManualOverride = require('./calculateValueWithManualOverride.json'); const calculateValueWithSubmissionMetadata = require('./calculateValueWithSubmissionMetadata.json'); @@ -56,6 +57,7 @@ module.exports = { uniqueApiKeys, uniqueApiKeysLayout, uniqueApiKeysSameLevel, + uniqueApiKeysTranslation, validationOnBlur, calculateValueWithManualOverride, calculateValueWithSubmissionMetadata, diff --git a/test/formtest/uniqueApiKeysTranslation.json b/test/formtest/uniqueApiKeysTranslation.json new file mode 100644 index 0000000000..47aad0aaad --- /dev/null +++ b/test/formtest/uniqueApiKeysTranslation.json @@ -0,0 +1,53 @@ +{ + "_id": "6650ac72fab50fab9668c1e8", + "title": "uniqueAPIKeysTranslation", + "name": "uniqueApiKeysTranslation", + "path": "uniqueapikeystranslation", + "type": "form", + "display": "form", + "tags": [], + "access": [ + { + "type": "read_all", + "roles": [ + "664ccde9fab50fab9641b6d8", + "664ccde9fab50fab9641b6e0", + "664ccde9fab50fab9641b6e4" + ] + } + ], + "submissionAccess": [], + "owner": "659f068baa3ac0e81cf5f6c0", + "components": [ + { + "label": "Text Field", + "applyMaskOn": "change", + "tableView": true, + "key": "textField", + "type": "textfield", + "input": true + }, + { + "label": "Text Area", + "applyMaskOn": "change", + "autoExpand": false, + "tableView": true, + "key": "textField", + "type": "textarea", + "input": true + } + ], + "settings": { + + }, + "properties": { + + }, + "project": "664ccde8fab50fab9641b6d1", + "controller": "", + "revisions": "", + "submissionRevisions": "", + "_vid": 0, + "created": "2024-05-24T15:04:18.259Z", + "modified": "2024-05-24T15:04:18.261Z" +} diff --git a/test/renders/form-bootstrap-readOnly-uniqueApiKeysTranslation.html b/test/renders/form-bootstrap-readOnly-uniqueApiKeysTranslation.html new file mode 100644 index 0000000000..d4c14b9795 --- /dev/null +++ b/test/renders/form-bootstrap-readOnly-uniqueApiKeysTranslation.html @@ -0,0 +1,27 @@ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/test/renders/form-bootstrap-uniqueApiKeysTranslation.html b/test/renders/form-bootstrap-uniqueApiKeysTranslation.html new file mode 100644 index 0000000000..a1dccafb00 --- /dev/null +++ b/test/renders/form-bootstrap-uniqueApiKeysTranslation.html @@ -0,0 +1,23 @@ +
+
+
+ +
+ +
+
+
+
+ +
+ +
+
+
+
+
+
\ No newline at end of file diff --git a/yarn.lock b/yarn.lock index d34ee45783..a46cc5c265 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2587,11 +2587,16 @@ domhandler@^2.3.0: dependencies: domelementtype "1" -dompurify@^3.0.6, dompurify@^3.1.0: +dompurify@^3.0.6: version "3.1.0" resolved "https://registry.npmjs.org/dompurify/-/dompurify-3.1.0.tgz#8c6b9fe986969a33aa4686bd829cbe8e14dd9445" integrity sha512-yoU4rhgPKCo+p5UrWWWNKiIq+ToGqmVVhk0PmMYBK4kRsR3/qhemNFL8f6CFmBd4gMwm3F4T7HBoydP5uY07fA== +dompurify@^3.1.1: + version "3.1.4" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.1.4.tgz#42121304b2b3a6bae22f80131ff8a8f3f3c56be2" + integrity sha512-2gnshi6OshmuKil8rMZuQCGiUF3cUxHY3NGDzUAdUx/NPEe5DVnO8BDoAQouvgwnx0R/+a6jUn36Z0FSdq8vww== + domutils@1.5, domutils@1.5.1: version "1.5.1" resolved "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf" @@ -7757,7 +7762,7 @@ string-replace-loader@^3.1.0: loader-utils "^2.0.0" schema-utils "^3.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -7784,6 +7789,15 @@ string-width@^3.0.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" +string-width@^4.1.0, string-width@^4.2.0: + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -7849,7 +7863,7 @@ stringifier@^1.3.0: traverse "^0.6.6" type-name "^2.0.1" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -7877,6 +7891,13 @@ strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -8949,7 +8970,7 @@ workerpool@6.2.1: resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -8966,6 +8987,15 @@ wrap-ansi@^2.0.0: string-width "^1.0.1" strip-ansi "^3.0.1" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"