diff --git a/package.json b/package.json index f41df024..8d457794 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@formio/core", - "version": "2.1.0-dev.tt.13", + "version": "2.3.0-dev.160.cabaa43", "description": "The core Form.io renderering framework.", "main": "lib/index.js", "exports": { diff --git a/src/process/__tests__/process.test.ts b/src/process/__tests__/process.test.ts index 49b57db9..7ce9b638 100644 --- a/src/process/__tests__/process.test.ts +++ b/src/process/__tests__/process.test.ts @@ -14,68 +14,7 @@ import { skipValidForLogicallyHiddenComp, skipValidWithHiddenParentComp, } from './fixtures'; - -/* -describe('Process Tests', () => { - it('Should perform the processes using the processReduced method.', async () => { - const reduced: ReducerScope = process({ - components: form1.components, - data: data1a.data, - scope: { - processes: {} - } - }); - const targets = processReduceTargets(reduced.processes); - expect(targets.length).to.equal(5); - expect(targets[0].target).to.equal('server'); - expect(Object.keys(targets[0].processes).length).to.equal(1); - expect(targets[0].processes.defaultValue.length).to.equal(6); - expect(targets[1].target).to.equal('custom'); - expect(Object.keys(targets[1].processes).length).to.equal(1); - expect(targets[1].processes.customDefaultValue.length).to.equal(1); - expect(targets[2].target).to.equal('server'); - expect(Object.keys(targets[2].processes).length).to.equal(1); - expect(targets[2].processes.fetch.length).to.equal(1); - expect(targets[3].target).to.equal('custom'); - expect(Object.keys(targets[3].processes).length).to.equal(1); - expect(targets[3].processes.calculate.length).to.equal(6); - expect(targets[4].target).to.equal('server'); - expect(Object.keys(targets[4].processes).length).to.equal(2); - expect(targets[4].processes.conditions.length).to.equal(1); - expect(targets[4].processes.validate.length).to.equal(28); - const scope = {errors: []}; - - // Reset all values that will be calculated. - reduced.data.subtotal = 0; - reduced.data.taxes = 0; - reduced.data.total = 0; - reduced.data.cart.forEach((item: any) => { - item.price = 0; - }); - for (let i = 0; i < targets.length; i++) { - await processReduced({ - components: form1.components, - data: reduced.data, - processes: targets[i].processes, - fetch: (url: string, options?: RequestInit | undefined): Promise => { - return Promise.resolve({ - json: () => { - return Promise.resolve(subs); - } - } as Response); - }, - scope - }); - } - expect(reduced.data.subtotal).to.equal(100); - expect(reduced.data.taxes).to.equal(8); - expect(reduced.data.total).to.equal(108); - expect(reduced.data.cart[0].price).to.equal(30); - expect(reduced.data.cart[1].price).to.equal(20); - expect(reduced.data.cart[2].price).to.equal(10); - }); -}); -*/ +import { get } from 'lodash'; describe('Process Tests', function () { it('Should submit data within a nested form.', async function () { @@ -950,7 +889,6 @@ describe('Process Tests', function () { }, owner: '65ea3601c3792e416cabcb2a', access: [], - _vnote: '', state: 'submitted', form: '65ea368b705068f84a93c87a', @@ -975,6 +913,148 @@ describe('Process Tests', function () { assert.equal(context.scope.errors.length, 0); }); + it('Should allow data from a Conditionally shown nested form when another nested form is conditionally not shown.', async function () { + const form = { + components: [ + { + label: 'Radio', + values: [ + { + label: 'Show A', + value: 'a', + shortcut: '', + }, + { + label: 'Show B', + value: 'b', + shortcut: '', + }, + ], + key: 'radio', + type: 'radio', + input: true, + }, + { + label: 'Form', + conditional: { + show: true, + conjunction: 'all', + conditions: [ + { + component: 'radio', + operator: 'isEqual', + value: 'a', + }, + ], + }, + type: 'form', + key: 'form', + input: true, + components: [ + { + label: 'Form', + key: 'form', + type: 'form', + input: true, + components: [ + { + label: 'Text Field', + validate: { + required: true, + }, + key: 'textField', + type: 'textfield', + input: true, + }, + { + label: 'Text Field', + key: 'textField1', + type: 'textfield', + input: true, + }, + ], + }, + ], + }, + { + label: 'Form', + key: 'form1', + conditional: { + show: true, + conjunction: 'all', + conditions: [ + { + component: 'radio', + operator: 'isEqual', + value: 'b', + }, + ], + }, + type: 'form', + input: true, + components: [ + { + label: 'Form', + key: 'form', + type: 'form', + input: true, + components: [ + { + label: 'Text Field', + validate: { + required: true, + }, + key: 'textField', + type: 'textfield', + input: true, + }, + { + label: 'Text Field', + key: 'textField1', + type: 'textfield', + input: true, + }, + ], + }, + ], + }, + ], + }; + const submission = { + data: { + radio: 'b', + form1: { + data: { + form: { + data: { + textField: 'one 1', + textField1: 'two 2', + }, + }, + }, + }, + }, + }; + const errors: any = []; + const context = { + form, + submission, + data: submission.data, + components: form.components, + processors: ProcessTargets.submission, + scope: { errors }, + config: { + server: true, + }, + }; + processSync(context); + submission.data = context.data; + context.processors = ProcessTargets.evaluator; + processSync(context); + assert.equal(get(context.submission.data, 'form1.data.form.data.textField'), 'one 1'); + assert.equal(get(context.submission.data, 'form1.data.form.data.textField1'), 'two 2'); + }); + it('should remove submission data not in a nested form definition', async function () { const form = { _id: {}, @@ -4398,7 +4478,10 @@ describe('Process Tests', function () { processSync(context); assert.deepEqual(context.data, data); context.scope.conditionals.forEach((cond: any) => { - assert.equal(cond.conditionallyHidden, cond.path === 'postalCode'); + assert.equal( + cond.conditionallyHidden, + cond.path === 'pmta.data.contacts.data.applicantOrganization.data.address.data.postalCode', + ); }); }); @@ -4592,30 +4675,6 @@ describe('Process Tests', function () { }); }); - it('Should not return fields from conditionally hidden containers, clearOnHide = true', async function () { - const { form, submission } = clearOnHideWithCustomCondition; - const context = { - form, - submission, - data: submission.data, - components: form.components, - processors: ProcessTargets.submission, - scope: {}, - config: { - server: true, - }, - }; - - processSync(context); - context.processors = ProcessTargets.evaluator; - processSync(context); - - expect(context.data).to.deep.equal({ - candidates: [{ candidate: { data: {} } }], - submit: true, - }); - }); - it('Should skip child validation with conditional', async function () { const { form, submission } = skipValidForConditionallyHiddenComp; const context = { @@ -4676,83 +4735,6 @@ describe('Process Tests', function () { expect((context.scope as ValidationScope).errors).to.have.length(0); }); - it('Should not return fields from conditionally hidden containers, clearOnHide = false', async function () { - const { form, submission } = clearOnHideWithCustomCondition; - const containerComponent = getComponent(form.components, 'section6') as ContainerComponent; - containerComponent.clearOnHide = false; - const context = { - form, - submission, - data: submission.data, - components: form.components, - processors: ProcessTargets.submission, - scope: {}, - config: { - server: true, - }, - }; - - processSync(context); - context.processors = ProcessTargets.evaluator; - processSync(context); - - expect(context.data).to.deep.equal({ - candidates: [{ candidate: { data: { section6: {} } } }], - submit: true, - }); - }); - - it('Should not validate fields from hidden containers, clearOnHide = false', async function () { - const { form, submission } = clearOnHideWithHiddenParent; - const context = { - form, - submission, - data: submission.data, - components: form.components, - processors: ProcessTargets.submission, - scope: { errors: [] }, - config: { - server: true, - }, - }; - - processSync(context); - context.processors = ProcessTargets.evaluator; - processSync(context); - - expect(context.data).to.deep.equal({ - candidates: [{ candidate: { data: { section6: {} } } }], - submit: true, - }); - expect(context.scope.errors.length).to.equal(0); - }); - - it('Should not return fields from hidden containers, clearOnHide = true', async function () { - const { form, submission } = clearOnHideWithHiddenParent; - const containerComponent = getComponent(form.components, 'section6') as ContainerComponent; - containerComponent.clearOnHide = true; - const context = { - form, - submission, - data: submission.data, - components: form.components, - processors: ProcessTargets.submission, - scope: {}, - config: { - server: true, - }, - }; - - processSync(context); - context.processors = ProcessTargets.evaluator; - processSync(context); - - expect(context.data).to.deep.equal({ - candidates: [{ candidate: { data: {} } }], - submit: true, - }); - }); - it('Should validate when all child components are empty in required Data Grid', async function () { const { form, submission } = forDataGridRequired; const context = { @@ -4963,199 +4945,465 @@ describe('Process Tests', function () { }); }); - /* - it('Should not clearOnHide when set to false', async () => { - var components = [ - { - "input": true, - "tableView": true, - "inputType": "radio", - "label": "Selector", - "key": "selector", - "values": [ - { - "value": "one", - "label": "One" - }, - { - "value": "two", - "label": "Two" - } - ], - "defaultValue": "", - "protected": false, - "persistent": true, - "validate": { - "required": false, - "custom": "", - "customPrivate": false - }, - "type": "radio", - "conditional": { - "show": "", - "when": null, - "eq": "" - } - }, - { - "input": false, - "title": "Panel", - "theme": "default", - "components": [ - { - "input": true, - "tableView": true, - "inputType": "text", - "inputMask": "", - "label": "No Clear Field", - "key": "noClear", - "placeholder": "", - "prefix": "", - "suffix": "", - "multiple": false, - "defaultValue": "", - "protected": false, - "unique": false, - "persistent": true, - "clearOnHide": false, - "validate": { - "required": false, - "minLength": "", - "maxLength": "", - "pattern": "", - "custom": "", - "customPrivate": false - }, - "conditional": { - "show": null, - "when": null, - "eq": "" - }, - "type": "textfield" - } - ], - "type": "panel", - "key": "panel", - "conditional": { - "show": "true", - "when": "selector", - "eq": "two" - } - } - ]; - - helper - .form('test', components) - .submission({ - selector: 'one', - noClear: 'testing' - }) - .execute(function(err) { - if (err) { - return done(err); - } - - var submission = helper.getLastSubmission(); - assert.deepEqual({selector: 'one', noClear: 'testing'}, submission.data); - done(); - }); - }); - - it('Should clearOnHide when set to true', async () => { - var components = [ - { - "input": true, - "tableView": true, - "inputType": "radio", - "label": "Selector", - "key": "selector", - "values": [ - { - "value": "one", - "label": "One" - }, - { - "value": "two", - "label": "Two" - } - ], - "defaultValue": "", - "protected": false, - "persistent": true, - "validate": { - "required": false, - "custom": "", - "customPrivate": false - }, - "type": "radio", - "conditional": { - "show": "", - "when": null, - "eq": "" - } - }, - { - "input": false, - "title": "Panel", - "theme": "default", - "components": [ - { - "input": true, - "tableView": true, - "inputType": "text", - "inputMask": "", - "label": "Clear Me", - "key": "clearMe", - "placeholder": "", - "prefix": "", - "suffix": "", - "multiple": false, - "defaultValue": "", - "protected": false, - "unique": false, - "persistent": true, - "clearOnHide": true, - "validate": { - "required": false, - "minLength": "", - "maxLength": "", - "pattern": "", - "custom": "", - "customPrivate": false - }, - "conditional": { - "show": null, - "when": null, - "eq": "" - }, - "type": "textfield" - } - ], - "type": "panel", - "key": "panel", - "conditional": { - "show": "true", - "when": "selector", - "eq": "two" - } - } - ]; - - helper - .form('test', components) - .submission({ - selector: 'one', - clearMe: 'Clear Me!!!!' - }) - .execute(function(err) { - if (err) { - return done(err); - } - - var submission = helper.getLastSubmission(); - assert.deepEqual({selector: 'one'}, submission.data); - done(); - }); - }); - */ + describe('clearOnHide', function () { + it('Should not include submission data from conditionally hidden containers when clearOnHide ("Omit Data When Conditionally Hidden" is true', async function () { + const { form, submission } = clearOnHideWithCustomCondition; + const context = { + form, + submission, + data: submission.data, + components: form.components, + processors: ProcessTargets.submission, + scope: {}, + config: { + server: true, + }, + }; + + processSync(context); + context.processors = ProcessTargets.evaluator; + processSync(context); + + expect(context.data).to.deep.equal({ + candidates: [{ candidate: { data: {} } }], + submit: true, + }); + }); + + it('Should not return fields from conditionally hidden containers, clearOnHide = false', async function () { + const { form, submission } = clearOnHideWithCustomCondition; + const containerComponent = getComponent(form.components, 'section6') as ContainerComponent; + containerComponent.clearOnHide = false; + const context = { + form, + submission, + data: submission.data, + components: form.components, + processors: ProcessTargets.submission, + scope: {}, + config: { + server: true, + }, + }; + + processSync(context); + context.processors = ProcessTargets.evaluator; + processSync(context); + + expect(context.data).to.deep.equal({ + candidates: [{ candidate: { data: { section6: {} } } }], + submit: true, + }); + }); + + it('Should not validate fields from hidden containers, clearOnHide = false', async function () { + const { form, submission } = clearOnHideWithHiddenParent; + const context = { + form, + submission, + data: submission.data, + components: form.components, + processors: ProcessTargets.submission, + scope: { errors: [] }, + config: { + server: true, + }, + }; + + processSync(context); + context.processors = ProcessTargets.evaluator; + processSync(context); + + expect(context.data).to.deep.equal({ + candidates: [{ candidate: { data: { section6: { c: {}, d: [] } } } }], + submit: true, + }); + expect(context.scope.errors.length).to.equal(0); + }); + + it('Should include submission data from hidden containers even when clearOnHide ("Omit Data When Conditionally Hidden" is true', async function () { + const { form, submission } = clearOnHideWithHiddenParent; + const containerComponent = getComponent(form.components, 'section6') as ContainerComponent; + containerComponent.clearOnHide = true; + const context = { + form, + submission, + data: submission.data, + components: form.components, + processors: ProcessTargets.submission, + scope: {}, + config: { + server: true, + }, + }; + + processSync(context); + context.processors = ProcessTargets.evaluator; + processSync(context); + + expect(context.data).to.deep.equal({ + candidates: [{ candidate: { data: { section6: { c: {}, d: [] } } } }], + submit: true, + }); + }); + + it('Should include submission data for simple fields that are intentionally hidden, even when clearOnHide ("Omit When Conditionally Hidden") is true', async function () { + const components = [ + { + label: 'Text Field', + tableView: true, + key: 'textField', + type: 'textfield', + input: true, + hidden: true, + clearOnHide: true, + }, + ]; + const submission = { + data: { + textField: 'test', + }, + }; + const context = { + form: { components }, + submission, + data: submission.data, + components, + processors: ProcessTargets.evaluator, + scope: {}, + }; + processSync(context); + expect(context.data).to.deep.equal({ textField: 'test' }); + }); + + it('Should include submission data for simple components that are intentionally hidden when clearOnHide ("Omit When Conditionally Hidden") is false', async function () { + const components = [ + { + label: 'Text Field', + tableView: true, + key: 'textField', + type: 'textfield', + input: true, + hidden: true, + clearOnHide: false, + }, + ]; + const submission = { + data: { + textField: 'test', + }, + }; + const context = { + form: { components }, + submission, + data: submission.data, + components, + processors: ProcessTargets.evaluator, + scope: {}, + }; + processSync(context); + expect(context.data).to.deep.equal({ textField: 'test' }); + }); + + it('Should include submission data for container components that are intentionally hidden, even when clearOnHide ("Omit When Conditionally Hidden") is true', async function () { + const components = [ + { + key: 'container', + type: 'container', + input: true, + hidden: true, + clearOnHide: true, + components: [ + { + label: 'Text Field', + tableView: true, + key: 'textField', + type: 'textfield', + input: true, + clearOnHide: true, + }, + ], + }, + ]; + const submission = { + data: { + container: { + textField: 'test', + }, + }, + }; + const context = { + form: { components }, + submission, + data: submission.data, + components, + processors: ProcessTargets.evaluator, + scope: {}, + }; + processSync(context); + expect(context.data).to.deep.equal({ container: { textField: 'test' } }); + }); + + it('Should include submission data for container components that are intentionally hidden when clearOnHide ("Omit When Conditionally Hidden") is false', async function () { + const components = [ + { + key: 'container', + type: 'container', + input: true, + hidden: true, + clearOnHide: false, + components: [ + { + label: 'Text Field', + tableView: true, + key: 'textField', + type: 'textfield', + input: true, + clearOnHide: true, + }, + ], + }, + ]; + const submission = { + data: { + container: { + textField: 'test', + }, + }, + }; + const context = { + form: { components }, + submission, + data: submission.data, + components, + processors: ProcessTargets.evaluator, + scope: {}, + }; + processSync(context); + expect(context.data).to.deep.equal({ container: { textField: 'test' } }); + }); + + it("Should not include submission data for simple fields that are conditionally hidden when clearOnHide ('Omit When Conditionally Hidden') is true", async function () { + const components = [ + { + type: 'checkbox', + key: 'selector', + label: 'Selector', + input: true, + }, + { + label: 'Text Field', + tableView: true, + key: 'textField', + type: 'textfield', + input: true, + conditional: { + show: true, + when: 'selector', + eq: true, + }, + clearOnHide: true, + }, + ]; + const submission = { + data: { + selector: false, + textField: 'test', + }, + }; + const context = { + submission, + data: submission.data, + components, + processors: ProcessTargets.evaluator, + scope: {}, + }; + processSync(context); + expect(context.data).to.deep.equal({ selector: false }); + }); + + it("Should include submission data for simple fields that are conditionally hidden when clearOnHide ('Omit When Conditionally Hidden') is false", async function () { + const components = [ + { + type: 'checkbox', + key: 'selector', + label: 'Selector', + input: true, + }, + { + label: 'Text Field', + tableView: true, + key: 'textField', + type: 'textfield', + input: true, + conditional: { + show: true, + when: 'selector', + eq: true, + }, + clearOnHide: false, + }, + ]; + const submission = { + data: { + selector: false, + textField: 'test', + }, + }; + const context = { + submission, + data: submission.data, + components, + processors: ProcessTargets.evaluator, + scope: {}, + }; + processSync(context); + expect(context.data).to.deep.equal({ selector: false, textField: 'test' }); + }); + + it("Should not include submission data for container components that are conditionally hidden when clearOnHide ('Omit When Conditionally Hidden') is true", async function () { + const components = [ + { + type: 'checkbox', + key: 'selector', + label: 'Selector', + input: true, + }, + { + key: 'container', + type: 'container', + input: true, + conditional: { + show: true, + when: 'selector', + eq: true, + }, + clearOnHide: true, + components: [ + { + label: 'Text Field', + tableView: true, + key: 'textField', + type: 'textfield', + input: true, + }, + ], + }, + ]; + const submission = { + data: { + selector: false, + container: { + textField: 'test', + }, + }, + }; + const context = { + submission, + data: submission.data, + components, + processors: ProcessTargets.evaluator, + scope: {}, + }; + processSync(context); + expect(context.data).to.deep.equal({ selector: false }); + }); + + it("Should include submission data for container components that are conditionally hidden when clearOnHide ('Omit When Conditionally Hidden') is false (but not their children, assuming clearOnHide is true or omitted in the child)", async function () { + const components = [ + { + type: 'checkbox', + key: 'selector', + label: 'Selector', + input: true, + }, + { + key: 'container', + type: 'container', + input: true, + conditional: { + show: true, + when: 'selector', + eq: true, + }, + clearOnHide: false, + components: [ + { + label: 'Text Field', + tableView: true, + key: 'textField', + type: 'textfield', + input: true, + }, + ], + }, + ]; + const submission = { + data: { + selector: false, + container: { + textField: 'test', + }, + }, + }; + const context = { + submission, + data: submission.data, + components, + processors: ProcessTargets.evaluator, + scope: {}, + }; + processSync(context); + expect(context.data).to.deep.equal({ selector: false, container: {} }); + }); + + it("Should include submission data for container components that are conditionally hidden when clearOnHide ('Omit When Conditionally Hidden') is false (and include their children when clearOnHide is false in the child)", async function () { + const components = [ + { + type: 'checkbox', + key: 'selector', + label: 'Selector', + input: true, + }, + { + key: 'container', + type: 'container', + input: true, + conditional: { + show: true, + when: 'selector', + eq: true, + }, + clearOnHide: false, + components: [ + { + label: 'Text Field', + tableView: true, + key: 'textField', + type: 'textfield', + input: true, + clearOnHide: false, + }, + ], + }, + ]; + const submission = { + data: { + selector: false, + container: { + textField: 'test', + }, + }, + }; + const context = { + submission, + data: submission.data, + components, + processors: ProcessTargets.evaluator, + scope: {}, + }; + processSync(context); + expect(context.data).to.deep.equal({ selector: false, container: { textField: 'test' } }); + }); + }); }); diff --git a/src/process/clearHidden/__tests__/clearHidden.test.ts b/src/process/clearHidden/__tests__/clearHidden.test.ts new file mode 100644 index 00000000..ddb42e1a --- /dev/null +++ b/src/process/clearHidden/__tests__/clearHidden.test.ts @@ -0,0 +1,63 @@ +import { expect } from 'chai'; + +import { clearHiddenProcess } from '../index'; + +describe('clearHidden', function () { + it('Shoud not clear conditionally hidden component data when clearOnHide is false', function () { + // Test case data + const context = { + component: { + type: 'textfield', + key: 'foo', + clearOnHide: false, + input: true, + }, + data: { + foo: 'bar', + }, + value: 'foo', + row: {}, + scope: { + clearHidden: {}, + conditionals: [ + { + path: 'foo', + conditionallyHidden: true, + }, + ], + }, + path: 'foo', + }; + clearHiddenProcess(context); + expect(context.data).to.deep.equal({ foo: 'bar' }); + }); + + it('Should clear conditiionally hidden component data when clearOnHide is true', function () { + // Test case data + const context = { + component: { + type: 'textfield', + key: 'foo', + clearOnHide: true, + input: true, + }, + data: { + foo: 'bar', + }, + value: 'foo', + row: {}, + scope: { + clearHidden: {}, + conditionals: [ + { + path: 'foo', + conditionallyHidden: true, + }, + ], + }, + path: 'foo', + }; + clearHiddenProcess(context); + expect(context.data).to.deep.equal({}); + }); +}); diff --git a/src/process/clearHidden.ts b/src/process/clearHidden/index.ts similarity index 73% rename from src/process/clearHidden.ts rename to src/process/clearHidden/index.ts index ef2054e4..d97c4f08 100644 --- a/src/process/clearHidden.ts +++ b/src/process/clearHidden/index.ts @@ -6,6 +6,7 @@ import { ProcessorFnSync, ConditionsScope, } from 'types'; +import { getComponentAbsolutePath } from 'utils/formUtil'; type ClearHiddenScope = ProcessorScope & { clearHidden: { @@ -17,7 +18,8 @@ type ClearHiddenScope = ProcessorScope & { * This processor function checks components for the `hidden` property and unsets corresponding data */ export const clearHiddenProcess: ProcessorFnSync = (context) => { - const { component, data, path, value, scope } = context; + const { component, data, value, scope, path } = context; + const absolutePath = getComponentAbsolutePath(component) || path; // No need to unset the value if it's undefined if (value === undefined) { @@ -30,7 +32,7 @@ export const clearHiddenProcess: ProcessorFnSync = (context) = // Check if there's a conditional set for the component and if it's marked as conditionally hidden const isConditionallyHidden = (scope as ConditionsScope).conditionals?.find((cond) => { - return path === cond.path && cond.conditionallyHidden; + return absolutePath === cond.path && cond.conditionallyHidden; }); const shouldClearValueWhenHidden = @@ -38,10 +40,10 @@ export const clearHiddenProcess: ProcessorFnSync = (context) = if ( shouldClearValueWhenHidden && - (isConditionallyHidden || component.hidden || component.ephemeralState?.conditionallyHidden) + (isConditionallyHidden || component.ephemeralState?.conditionallyHidden) ) { - unset(data, path); - scope.clearHidden[path] = true; + unset(data, absolutePath); + scope.clearHidden[absolutePath] = true; } }; diff --git a/src/process/conditions/index.ts b/src/process/conditions/index.ts index ac24d75f..f469bde3 100644 --- a/src/process/conditions/index.ts +++ b/src/process/conditions/index.ts @@ -15,6 +15,7 @@ import { isSimpleConditional, isJSONConditional, } from 'utils/conditions'; +import { getComponentAbsolutePath } from 'utils/formUtil'; const hasCustomConditions = (context: ConditionsContext): boolean => { const { component } = context; @@ -83,7 +84,8 @@ export const isConditionallyHidden = (context: ConditionsContext): boolean => { export type ConditionallyHidden = (context: ConditionsContext) => boolean; export const conditionalProcess = (context: ConditionsContext, isHidden: ConditionallyHidden) => { - const { scope, path } = context; + const { scope, path, component } = context; + const absolutePath = getComponentAbsolutePath(component) || path; if (!hasConditions(context)) { return; } @@ -91,9 +93,9 @@ export const conditionalProcess = (context: ConditionsContext, isHidden: Conditi if (!scope.conditionals) { scope.conditionals = []; } - let conditionalComp = scope.conditionals.find((cond) => cond.path === path); + let conditionalComp = scope.conditionals.find((cond) => cond.path === absolutePath); if (!conditionalComp) { - conditionalComp = { path, conditionallyHidden: false }; + conditionalComp = { path: absolutePath, conditionallyHidden: false }; scope.conditionals.push(conditionalComp); } diff --git a/src/process/filter/index.ts b/src/process/filter/index.ts index 72186af1..d8dbab4c 100644 --- a/src/process/filter/index.ts +++ b/src/process/filter/index.ts @@ -4,9 +4,9 @@ import { Utils } from 'utils'; import { get, isObject } from 'lodash'; import { getComponentAbsolutePath } from 'utils/formUtil'; export const filterProcessSync: ProcessorFnSync = (context: FilterContext) => { - const { scope, component } = context; + const { scope, component, path } = context; const { value } = context; - const absolutePath = getComponentAbsolutePath(component); + const absolutePath = getComponentAbsolutePath(component) || path; if (!scope.filter) scope.filter = {}; if (value !== undefined) { const modelType = Utils.getModelType(component); diff --git a/src/process/hideChildren.ts b/src/process/hideChildren.ts index aa5e7114..944774ec 100644 --- a/src/process/hideChildren.ts +++ b/src/process/hideChildren.ts @@ -7,24 +7,33 @@ import { ProcessorFn, } from 'types'; import { registerEphemeralState } from 'utils'; +import { getComponentAbsolutePath } from 'utils/formUtil'; /** * This processor function checks components for the `hidden` property and, if children are present, sets them to hidden as well. */ export const hideChildrenProcessor: ProcessorFnSync = (context) => { const { component, path, parent, scope } = context; + const absolutePath = getComponentAbsolutePath(component) || path; // Check if there's a conditional set for the component and if it's marked as conditionally hidden const isConditionallyHidden = scope.conditionals?.find((cond) => { - return path === cond.path && cond.conditionallyHidden; + return absolutePath === cond.path && cond.conditionallyHidden; }); if (!scope.conditionals) { scope.conditionals = []; } - if (isConditionallyHidden || component.hidden || parent?.ephemeralState?.conditionallyHidden) { + if (isConditionallyHidden || parent?.ephemeralState?.conditionallyHidden) { registerEphemeralState(component, 'conditionallyHidden', true); } + + if ( + (component.hasOwnProperty('hidden') && !!component.hidden) || + parent?.ephemeralState?.intentionallyHidden + ) { + registerEphemeralState(component, 'intentionallyHidden', true); + } }; export const hideChildrenProcessorAsync: ProcessorFn = async (context) => { diff --git a/src/process/validation/index.ts b/src/process/validation/index.ts index daacee41..f8be96d5 100644 --- a/src/process/validation/index.ts +++ b/src/process/validation/index.ts @@ -15,7 +15,7 @@ import { evaluationRules, rules, serverRules } from './rules'; import find from 'lodash/find'; import get from 'lodash/get'; import pick from 'lodash/pick'; -import { getComponentAbsolutePath, getComponentPath } from 'utils/formUtil'; +import { getComponentAbsolutePath } from 'utils/formUtil'; import { getErrorMessage } from 'utils/error'; import { FieldError } from 'error'; import { @@ -87,7 +87,6 @@ export function isValueHidden(context: ValidationContext): boolean { } return false; } - export function isForcedHidden( context: ValidationContext, isConditionallyHidden: ConditionallyHidden, @@ -96,6 +95,9 @@ export function isForcedHidden( if (isConditionallyHidden(context as ConditionsContext)) { return true; } + if (component.ephemeralState?.intentionallyHidden) { + return true; + } if (component.hasOwnProperty('hidden')) { return !!component.hidden; } @@ -107,18 +109,19 @@ export const _shouldSkipValidation = ( isConditionallyHidden: ConditionallyHidden, ) => { const { component, scope, path } = context; + const absolutePath = getComponentAbsolutePath(component) || path; if ( (scope as ConditionsScope)?.conditionals && (find((scope as ConditionsScope).conditionals, { - path: getComponentPath(component, path), + path: absolutePath, conditionallyHidden: true, }) || component.ephemeralState?.conditionallyHidden === true) ) { return true; } - const { validateWhenHidden = false } = component || {}; + const { validateWhenHidden = false } = component; const rules = [ // Skip validation if component is readOnly // () => this.options.readOnly, @@ -169,8 +172,8 @@ export function shouldValidateServer(context: ValidationContext): boolean { } function handleError(error: FieldError | null, context: ValidationContext) { - const { scope, component } = context; - const absolutePath = getComponentAbsolutePath(component); + const { scope, component, path } = context; + const absolutePath = getComponentAbsolutePath(component) || path; if (error) { const cleanedError = cleanupValidationError(error); cleanedError.context.path = absolutePath; diff --git a/src/process/validation/rules/__tests__/validateRequired.test.ts b/src/process/validation/rules/__tests__/validateRequired.test.ts index 941de965..06f51e0a 100644 --- a/src/process/validation/rules/__tests__/validateRequired.test.ts +++ b/src/process/validation/rules/__tests__/validateRequired.test.ts @@ -81,6 +81,16 @@ describe('validateRequired', function () { expect(context.scope.errors.length).to.equal(0); }); + it('Should validate a hidden component that has the `validateWhenHidden` property set to true.', async function () { + const component = { ...hiddenRequiredField }; + component.validateWhenHidden = true; + const data = {}; + const context = generateProcessorContext(component, data) as ProcessorsContext; + context.processors = [validateProcessInfo]; + await processOne(context); + expect(context.scope.errors.length).to.equal(1); + }); + it('Validating a simple component that is required but conditionally hidden', async function () { const component = { ...simpleTextField }; component.validate = { required: true }; diff --git a/src/process/validation/rules/validateRequired.ts b/src/process/validation/rules/validateRequired.ts index 746ab7e2..07cd8236 100644 --- a/src/process/validation/rules/validateRequired.ts +++ b/src/process/validation/rules/validateRequired.ts @@ -73,10 +73,7 @@ const valueIsPresent = ( export const shouldValidate = (context: ValidationContext) => { const { component } = context; - if ( - component.validate?.required && - !(component.hidden || component.ephemeralState?.conditionallyHidden) - ) { + if (component.validate?.required) { return true; } return false; diff --git a/src/types/BaseComponent.ts b/src/types/BaseComponent.ts index 4b976a44..7d2a04df 100644 --- a/src/types/BaseComponent.ts +++ b/src/types/BaseComponent.ts @@ -33,6 +33,7 @@ export type BaseComponent = { hidden?: boolean; ephemeralState?: { conditionallyHidden?: boolean; + intentionallyHidden?: boolean; }; clearOnHide?: boolean; refreshOn?: string; diff --git a/src/utils/conditions.ts b/src/utils/conditions.ts index d1d4cd78..b0b13bbe 100644 --- a/src/utils/conditions.ts +++ b/src/utils/conditions.ts @@ -1,6 +1,11 @@ import { ConditionsContext, JSONConditional, LegacyConditional, SimpleConditional } from 'types'; import { EvaluatorFn, evaluate, JSONLogicEvaluator } from 'modules/jsonlogic'; -import { flattenComponents, getComponent, getComponentActualValue } from './formUtil'; +import { + flattenComponents, + getComponent, + getComponentAbsolutePath, + getComponentActualValue, +} from './formUtil'; import { has, isObject, map, every, some, find, filter, isBoolean, split } from 'lodash'; import ConditionOperators from './operators'; @@ -17,10 +22,11 @@ export const isSimpleConditional = (conditional: any): conditional is SimpleCond }; export function conditionallyHidden(context: ConditionsContext) { - const { scope, path } = context; - if (scope.conditionals && path) { + const { scope, path, component } = context; + const absolutePath = getComponentAbsolutePath(component) || path; + if (scope.conditionals && absolutePath) { const hidden = find(scope.conditionals, (conditional) => { - return conditional.path === path; + return conditional.path === absolutePath; }); return hidden?.conditionallyHidden; } diff --git a/src/utils/logic.ts b/src/utils/logic.ts index b8a7360f..2d1d7a29 100644 --- a/src/utils/logic.ts +++ b/src/utils/logic.ts @@ -18,6 +18,7 @@ import { import { get, set, clone, isEqual, assign } from 'lodash'; import { evaluate, interpolate } from 'modules/jsonlogic'; import { registerEphemeralState } from './utils'; +import { getComponentAbsolutePath } from './formUtil'; export const hasLogic = (context: LogicContext): boolean => { const { component } = context; @@ -69,6 +70,7 @@ export function setActionBooleanProperty( action: LogicActionPropertyBoolean, ): boolean { const { component, scope, path } = context; + const absolutePath = getComponentAbsolutePath(component) || path; const property = action.property.value; const currentValue = get(component, property, false).toString(); const newValue = action.state.toString(); @@ -77,19 +79,19 @@ export function setActionBooleanProperty( // If this is "logic" forcing a component to set hidden property, then we will set the "conditionallyHidden" // flag which will trigger the clearOnHide functionality. - if (property === 'hidden' && path) { + if (property === 'hidden' && absolutePath) { if (!(scope as ConditionsScope).conditionals) { (scope as ConditionsScope).conditionals = []; } const conditionallyHidden = (scope as ConditionsScope).conditionals?.find((cond: any) => { - return cond.path === path; + return cond.path === absolutePath; }); if (conditionallyHidden) { conditionallyHidden.conditionallyHidden = !!component.hidden; registerEphemeralState(component, 'conditionallyHidden', !!component.hidden); } else { (scope as ConditionsScope).conditionals?.push({ - path, + path: absolutePath, conditionallyHidden: !!component.hidden, }); }