From 8c811f179c4f02010f28084b6a3107e6fc5a14d2 Mon Sep 17 00:00:00 2001 From: brendanjbond Date: Wed, 16 Oct 2024 10:35:17 -0500 Subject: [PATCH 1/2] add intentionallyHidden ephemeral state; change clearOnHide behavior to only clear when conditionally hidden; add tests; minor linting --- package.json | 2 +- src/process/__tests__/process.test.ts | 814 ++++++++++-------- .../clearHidden/__tests__/clearHidden.test.ts | 60 ++ .../{clearHidden.ts => clearHidden/index.ts} | 2 +- src/process/hideChildren.ts | 6 +- src/process/validation/index.ts | 7 +- .../rules/__tests__/validateRequired.test.ts | 10 + .../validation/rules/validateRequired.ts | 2 +- src/types/BaseComponent.ts | 1 + 9 files changed, 542 insertions(+), 362 deletions(-) create mode 100644 src/process/clearHidden/__tests__/clearHidden.test.ts rename src/process/{clearHidden.ts => clearHidden/index.ts} (95%) diff --git a/package.json b/package.json index daea8b91..b1fc2cb6 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 6a3e4779..c4291317 100644 --- a/src/process/__tests__/process.test.ts +++ b/src/process/__tests__/process.test.ts @@ -6,68 +6,6 @@ import { process, processSync, ProcessTargets } from '../index'; import { fastCloneDeep } from 'utils'; import { addressComponentWithOtherCondComponents, addressComponentWithOtherCondComponents2, clearOnHideWithCustomCondition, clearOnHideWithHiddenParent, forDataGridRequired, skipValidForConditionallyHiddenComp, 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); - }); -}); -*/ - describe('Process Tests', () => { it('Should submit data within a nested form.', async () => { const form = { @@ -832,7 +770,6 @@ describe('Process Tests', () => { 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', pathName: '/', onLine: true, - }, data: { number: 23, @@ -948,7 +885,6 @@ describe('Process Tests', () => { }, owner: '65ea3601c3792e416cabcb2a', access: [], - _vnote: '', state: 'submitted', form: '65ea368b705068f84a93c87a', @@ -1526,7 +1462,6 @@ describe('Process Tests', () => { }); }); - // TODO: test case naming it('Should not unset submission data of nested forms with identical keys', () => { const parentForm = { display: 'form', @@ -3511,7 +3446,7 @@ describe('Process Tests', () => { assert.equal(context.scope.errors.length, 0); }); -it('Should not unset values for conditionally visible fields with different formats of condtion based on selectboxes value', async () => { + it('Should not unset values for conditionally visible fields with different formats of condtion based on selectboxes value', async () => { const form = { _id: '66ffa92ac25689df8702f283', title: 'cond NEW', @@ -3657,7 +3592,7 @@ it('Should not unset values for conditionally visible fields with different form assert.deepEqual(context.data, data); }); - it('Should not return error for the form with conditionals based on the Day component', () => { + it('Should not return error for the form with conditionals based on the Day component', () => { const form = { _id: '66ffe59b598a729e707869bf', title: '9143 condition day', @@ -4009,29 +3944,6 @@ it('Should not unset values for conditionally visible fields with different form }); }); - it('Should not return fields from conditionally hidden containers, clearOnHide = true', async () => { - 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 () => { const { form, submission } = skipValidForConditionallyHiddenComp; @@ -4093,82 +4005,6 @@ it('Should not unset values for conditionally visible fields with different form expect((context.scope as ValidationScope).errors).to.have.length(0); }); - it('Should not return fields from conditionally hidden containers, clearOnHide = false', async () => { - 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 () => { - 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 () => { - 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 () => { const { form, submission } = forDataGridRequired; @@ -4380,199 +4216,465 @@ it('Should not unset values for conditionally visible fields with different form }); }); - /* - 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" - } - } - ]; + describe('clearOnHide', function () { + it('Should not include submission data from conditionally hidden containers when clearOnHide ("Omit Data When Conditionally Hidden" is true', async () => { + const { form, submission } = clearOnHideWithCustomCondition; + const context = { + form, + submission, + data: submission.data, + components: form.components, + processors: ProcessTargets.submission, + scope: {}, + config: { + server: true, + }, + }; - helper - .form('test', components) - .submission({ - selector: 'one', - noClear: 'testing' - }) - .execute(function(err) { - if (err) { - return done(err); - } + processSync(context); + context.processors = ProcessTargets.evaluator; + processSync(context); - var submission = helper.getLastSubmission(); - assert.deepEqual({selector: 'one', noClear: 'testing'}, submission.data); - done(); - }); - }); + expect(context.data).to.deep.equal({ + candidates:[{candidate:{data:{}}}], + submit: true + }); + }); - 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" - } - } - ]; + it('Should not return fields from conditionally hidden containers, clearOnHide = false', async () => { + 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 () => { + 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 () => { + 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' }); + }); - helper - .form('test', components) - .submission({ - selector: 'one', - clearMe: 'Clear Me!!!!' - }) - .execute(function(err) { - if (err) { - return done(err); - } + 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 }); + }); - var submission = helper.getLastSubmission(); - assert.deepEqual({selector: 'one'}, submission.data); - done(); - }); - }); - */ + 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..85406fb5 --- /dev/null +++ b/src/process/clearHidden/__tests__/clearHidden.test.ts @@ -0,0 +1,60 @@ +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' + }; + const _ = 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', + }; + const _ = clearHiddenProcess(context); + expect(context.data).to.deep.equal({}); + }); + +}); diff --git a/src/process/clearHidden.ts b/src/process/clearHidden/index.ts similarity index 95% rename from src/process/clearHidden.ts rename to src/process/clearHidden/index.ts index 39c22b33..3b0c0886 100644 --- a/src/process/clearHidden.ts +++ b/src/process/clearHidden/index.ts @@ -35,7 +35,7 @@ export const clearHiddenProcess: ProcessorFnSync = (context) = const shouldClearValueWhenHidden = !component.hasOwnProperty('clearOnHide') || component.clearOnHide; - if (shouldClearValueWhenHidden && (isConditionallyHidden || component.hidden || component.ephemeralState?.conditionallyHidden)) { + if (shouldClearValueWhenHidden && (isConditionallyHidden || component.ephemeralState?.conditionallyHidden)) { unset(data, path); scope.clearHidden[path] = true; } diff --git a/src/process/hideChildren.ts b/src/process/hideChildren.ts index d1ad4dbe..9f04e8fd 100644 --- a/src/process/hideChildren.ts +++ b/src/process/hideChildren.ts @@ -22,9 +22,13 @@ export const hideChildrenProcessor: ProcessorFnSync = (context) 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 7469479b..7b99a58f 100644 --- a/src/process/validation/index.ts +++ b/src/process/validation/index.ts @@ -60,7 +60,7 @@ export function isInputComponent(context: ValidationContext): boolean { } export function isValueHidden(context: ValidationContext): boolean { - const { component, config } = context; + const { component } = context; if (component.protected) { return false; } @@ -78,6 +78,9 @@ export function isForcedHidden(context: ValidationContext, isConditionallyHidden if (isConditionallyHidden(context as ConditionsContext)) { return true; } + if (component.ephemeralState?.intentionallyHidden) { + return true; + } if (component.hasOwnProperty('hidden')) { return !!component.hidden; } @@ -96,7 +99,7 @@ export const _shouldSkipValidation = (context: ValidationContext, isConditionall ) { return true; } - const { validateWhenHidden = false } = component || {}; + const { validateWhenHidden = false } = component; const rules = [ // Skip validation if component is readOnly // () => this.options.readOnly, diff --git a/src/process/validation/rules/__tests__/validateRequired.test.ts b/src/process/validation/rules/__tests__/validateRequired.test.ts index 0ae84619..955c43fe 100644 --- a/src/process/validation/rules/__tests__/validateRequired.test.ts +++ b/src/process/validation/rules/__tests__/validateRequired.test.ts @@ -78,6 +78,16 @@ it('Should not validate a hidden component that has the hidden property set to t expect(context.scope.errors.length).to.equal(0); }); +it('Should validate a hidden component that has the `validateWhenHidden` property set to true.', async () => { + 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 () => { const component = {...simpleTextField}; component.validate = { required: true }; diff --git a/src/process/validation/rules/validateRequired.ts b/src/process/validation/rules/validateRequired.ts index d467ef02..04876b84 100644 --- a/src/process/validation/rules/validateRequired.ts +++ b/src/process/validation/rules/validateRequired.ts @@ -56,7 +56,7 @@ const valueIsPresent = (value: any, considerFalseTruthy: boolean, isNestedDataty 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 c723cf79..d399e7a4 100644 --- a/src/types/BaseComponent.ts +++ b/src/types/BaseComponent.ts @@ -25,6 +25,7 @@ export type BaseComponent = { hidden?: boolean; ephemeralState?: { conditionallyHidden?: boolean; + intentionallyHidden?: boolean; } clearOnHide?: boolean; refreshOn?: string; From 489516198e0afe2d9ad50ee586ae8ff6e3476b9d Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Mon, 4 Nov 2024 16:43:35 -0600 Subject: [PATCH 2/2] FIO-9308: Fixed the paths with nested forms by ensuring we are always dealing with the absolute paths with clearOnHide, conditions, filters, and validations. --- src/process/__tests__/process.test.ts | 148 +++++++++++++++++++++++++- src/process/clearHidden/index.ts | 10 +- src/process/conditions/index.ts | 8 +- src/process/filter/index.ts | 4 +- src/process/hideChildren.ts | 4 +- src/process/validation/index.ts | 9 +- src/utils/conditions.ts | 14 ++- src/utils/logic.ts | 8 +- 8 files changed, 183 insertions(+), 22 deletions(-) diff --git a/src/process/__tests__/process.test.ts b/src/process/__tests__/process.test.ts index ab6ab5fe..7ce9b638 100644 --- a/src/process/__tests__/process.test.ts +++ b/src/process/__tests__/process.test.ts @@ -14,6 +14,7 @@ import { skipValidForLogicallyHiddenComp, skipValidWithHiddenParentComp, } from './fixtures'; +import { get } from 'lodash'; describe('Process Tests', function () { it('Should submit data within a nested form.', async function () { @@ -912,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: {}, @@ -4335,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', + ); }); }); diff --git a/src/process/clearHidden/index.ts b/src/process/clearHidden/index.ts index b6525386..d97c4f08 100644 --- a/src/process/clearHidden/index.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 = @@ -40,8 +42,8 @@ export const clearHiddenProcess: ProcessorFnSync = (context) = shouldClearValueWhenHidden && (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 b8b86e9d..944774ec 100644 --- a/src/process/hideChildren.ts +++ b/src/process/hideChildren.ts @@ -7,15 +7,17 @@ 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) { diff --git a/src/process/validation/index.ts b/src/process/validation/index.ts index 0198b36a..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 { @@ -109,11 +109,12 @@ 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) @@ -171,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/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, }); }