diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..9acbc642 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,50 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Mocha: current file", + "program": "${workspaceFolder}/node_modules/.bin/mocha", + "args": [ + "-r", + "ts-node/register", + "-b", + "-t", + "0", + "-r", + "tsconfig-paths/register", + "-r", + "jsdom-global/register", + "-r", + "mock-local-storage", + "'${file}'" + ], + "console": "integratedTerminal" + }, + { + "type": "node", + "request": "launch", + "name": "Mocha Tests", + "program": "${workspaceFolder}/node_modules/.bin/mocha", + "args": [ + "-r", + "ts-node/register", + "-b", + "-t", + "0", + "-r", + "tsconfig-paths/register", + "-r", + "mock-local-storage", + "-r", + "jsdom-global/register", + "'src/**/__tests__/*.test.ts'" + ], + "console": "integratedTerminal" + } + ] +} diff --git a/Changelog.md b/Changelog.md index 270d96e1..04e38d8a 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,4 +1,8 @@ -## [Unreleased: 2.3.0-rc.1] +## [Unreleased: 2.3.0-rc.2] +### Changed + - Regression | Nested Form | Components in Nested forms should not validate hidden components without Validate When Hidden = true + +## 2.3.0-rc.1 ### Changed - updated thresholds to current values - FIO-8450: Fix custom error message for unique validation diff --git a/Readme.md b/Readme.md index 16429cea..d35b42e4 100644 --- a/Readme.md +++ b/Readme.md @@ -60,3 +60,7 @@ FormioCore.render(document.getElementById('data-table'), { ``` See https://formio.github.io/core for more examples of how to use this library. + +### Debug + +[Instructions on how to debug with API Server](https://formio.atlassian.net/wiki/spaces/SD/pages/184025089/Debugging+formio+core) diff --git a/package.json b/package.json index 00cc383f..f1e9302b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "scripts": { "test": "TEST=1 nyc --reporter=lcov --reporter=text --reporter=text-summary mocha -r ts-node/register -r tsconfig-paths/register -r mock-local-storage -r jsdom-global/register -t 0 'src/**/__tests__/*.test.ts'", "lib": "tsc --project tsconfig.json && tsc-alias -p tsconfig.json", + "lib:watch": "tsc -w & tsc-alias -w", "replace": "node -r tsconfig-paths/register -r ts-node/register ./lib/base/array/ArrayComponent.js", "test:debug": "mocha -r ts-node/register -r tsconfig-paths/register -r mock-local-storage -r jsdom-global/register --debug-brk --inspect '**/*.spec.ts'", "docs": "./node_modules/typedoc/bin/typedoc --exclude '*.spec.ts' src/*.ts src/**/*.ts src/**/**/*.ts", diff --git a/src/process/__tests__/fixtures/clearOnHideWithCustomCondition.json b/src/process/__tests__/fixtures/clearOnHideWithCustomCondition.json new file mode 100644 index 00000000..ec32b4df --- /dev/null +++ b/src/process/__tests__/fixtures/clearOnHideWithCustomCondition.json @@ -0,0 +1,156 @@ +{ + "form": { + "display": "form", + "components": [ + { + "title": "__information_on_the_appointee", + "theme": "primary", + "collapsible": false, + "key": "HeadingNestedFormCandidates", + "type": "panel", + "label": "Appointees", + "input": false, + "tableView": false, + "components": [ + { + "label": "Appointees", + "hideLabel": true, + "tableView": false, + + "addAnother": "__add_appointee", + "modal": true, + "saveRow": "Close", + "rowDrafts": true, + "key": "candidates", + "type": "editgrid", + "displayAsTable": false, + "input": true, + "components": [ + { + "label": "Appointee", + "tableView": false, + "key": "candidate", + "type": "container", + "input": true, + "components": [ + { + "label": "Data", + "tableView": false, + "key": "data", + "type": "container", + "input": true, + "components": [ + { + "label": "Tabs", + "components": [ + { + "label": "__6_time_commitment", + "key": "section6tab", + "components": [ + { + "label": "Section 6", + "tableView": false, + "clearOnHide": true, + "validateWhenHidden": false, + "key": "section6", + "properties": { + "clearHiddenOnSave": "true" + }, + "customConditional": "show = false;", + "type": "container", + "input": true, + "components": [ + { + "title": "__6_dash_time_commitment", + "theme": "primary", + "collapsible": false, + "key": "heading6", + "type": "panel", + "label": "Time Commitment", + "input": false, + "tableView": false, + "components": [ + { + "label": "__a_information_to_be_provided_by_the_supervised_entity_the", + "description": "__ul_li_see_the_report_on_declared_time_commitment_of", + "autoExpand": false, + "tableView": true, + "validate": { + "required": true + }, + "key": "entityExpectedTimeCommit", + "type": "textarea", + "input": true + }, + { + "label": "c", + "tableView": false, + "key": "c", + "type": "container", + "input": true, + "components": [] + }, + { + "label": "__d_list_of_executive_and_non_executive_directorships_and_other", + "description": "__for_each_directorship_or_other_activity_a_separate_row_needs", + "tableView": false, + "addAnother": "__add_another", + "validate": { + "required": true + }, + "rowDrafts": false, + "key": "d", + "type": "editgrid", + "input": true, + "components": [] + } + ] + } + ] + } + ] + } + ], + "key": "tabs1", + "type": "tabs", + "input": false, + "tableView": false + } + ] + } + ] + } + ] + } + ] + }, + { + "label": "Submit", + "action": "saveState", + "showValidations": false, + "tableView": false, + "key": "submit", + "type": "button", + "input": true, + "state": "draft" + } + ] + }, + "submission": { + "data": { + "candidates": [ + { + "candidate": { + "data": { + "section6": { + "c": {}, + "d": [] + } + } + } + } + ], + "submit": true + } + } +} diff --git a/src/process/__tests__/fixtures/clearOnHideWithHiddenParent.json b/src/process/__tests__/fixtures/clearOnHideWithHiddenParent.json new file mode 100644 index 00000000..763abf01 --- /dev/null +++ b/src/process/__tests__/fixtures/clearOnHideWithHiddenParent.json @@ -0,0 +1,158 @@ +{ + "form": { + "display": "form", + "components": [ + { + "title": "__information_on_the_appointee", + "theme": "primary", + "collapsible": false, + "key": "HeadingNestedFormCandidates", + "type": "panel", + "label": "Appointees", + "input": false, + "tableView": false, + "components": [ + { + "label": "Appointees", + "hideLabel": true, + "tableView": false, + + "addAnother": "__add_appointee", + "modal": true, + "saveRow": "Close", + "rowDrafts": true, + "key": "candidates", + "type": "editgrid", + "displayAsTable": false, + "input": true, + "components": [ + { + "label": "Appointee", + "tableView": false, + "key": "candidate", + "type": "container", + "input": true, + "components": [ + { + "label": "Data", + "tableView": false, + "key": "data", + "type": "container", + "input": true, + "components": [ + { + "label": "Tabs", + "components": [ + { + "label": "__6_time_commitment", + "key": "section6tab", + "components": [ + { + "label": "Section 6", + "tableView": false, + "clearOnHide": false, + "validateWhenHidden": false, + "key": "section6", + "properties": { + "clearHiddenOnSave": "true" + }, + "hidden": true, + "type": "container", + "input": true, + "components": [ + { + "title": "__6_dash_time_commitment", + "theme": "primary", + "collapsible": false, + "key": "heading6", + "type": "panel", + "label": "Time Commitment", + "input": false, + "tableView": false, + "components": [ + { + "label": "__a_information_to_be_provided_by_the_supervised_entity_the", + "description": "__ul_li_see_the_report_on_declared_time_commitment_of", + "autoExpand": false, + "tableView": true, + "validate": { + "required": true + }, + "key": "entityExpectedTimeCommit", + "type": "textarea", + "input": true + }, + { + "label": "c", + "tableView": false, + "key": "c", + "type": "container", + "input": true, + "components": [], + "clearOnHide": true + }, + { + "label": "__d_list_of_executive_and_non_executive_directorships_and_other", + "description": "__for_each_directorship_or_other_activity_a_separate_row_needs", + "tableView": false, + "addAnother": "__add_another", + "validate": { + "required": true + }, + "rowDrafts": false, + "key": "d", + "type": "editgrid", + "input": true, + "components": [], + "clearOnHide": true + } + ] + } + ] + } + ] + } + ], + "key": "tabs1", + "type": "tabs", + "input": false, + "tableView": false + } + ] + } + ] + } + ] + } + ] + }, + { + "label": "Submit", + "action": "saveState", + "showValidations": false, + "tableView": false, + "key": "submit", + "type": "button", + "input": true, + "state": "draft" + } + ] + }, + "submission": { + "data": { + "candidates": [ + { + "candidate": { + "data": { + "section6": { + "c": {}, + "d": [] + } + } + } + } + ], + "submit": true + } + } +} diff --git a/src/process/__tests__/fixtures/index.ts b/src/process/__tests__/fixtures/index.ts new file mode 100644 index 00000000..05ef6349 --- /dev/null +++ b/src/process/__tests__/fixtures/index.ts @@ -0,0 +1,10 @@ +import clearOnHideWithCustomCondition from './clearOnHideWithCustomCondition.json'; +import clearOnHideWithHiddenParent from './clearOnHideWithHiddenParent.json'; +import skipValidForConditionallyHiddenComp from './skipValidForConditionallyHiddenComp.json'; +import skipValidForLogicallyHiddenComp from './skipValidForLogicallyHiddenComp.json'; +import skipValidWithHiddenParentComp from './skipValidWithHiddenParentComp.json'; +import data1a from './data1a.json'; +import form1 from './form1.json'; +import subs from './subs.json'; + +export { clearOnHideWithCustomCondition, clearOnHideWithHiddenParent, skipValidForLogicallyHiddenComp, skipValidForConditionallyHiddenComp, skipValidWithHiddenParentComp, data1a, form1, subs }; diff --git a/src/process/__tests__/fixtures/skipValidForConditionallyHiddenComp.json b/src/process/__tests__/fixtures/skipValidForConditionallyHiddenComp.json new file mode 100644 index 00000000..0812334e --- /dev/null +++ b/src/process/__tests__/fixtures/skipValidForConditionallyHiddenComp.json @@ -0,0 +1,67 @@ +{ + "form": { + "name": "conditional", + "path": "conditional", + "type": "form", + "display": "form", + "components": [ + { + "label": "Checkbox", + "tableView": false, + "validateWhenHidden": false, + "key": "checkbox", + "type": "checkbox", + "input": true + }, + { + "collapsible": false, + "key": "panel", + "conditional": { + "show": false, + "conjunction": "all", + "conditions": [ + { + "component": "checkbox", + "operator": "isEqual", + "value": true + } + ] + }, + "type": "panel", + "label": "Panel", + "input": false, + "tableView": false, + "components": [ + { + "label": "Text Field", + "applyMaskOn": "change", + "tableView": true, + "validate": { + "required": true + }, + "validateWhenHidden": false, + "key": "textField", + "type": "textfield", + "input": true + } + ] + }, + { + "type": "button", + "label": "Submit", + "key": "submit", + "disableOnInvalid": true, + "input": true, + "tableView": false + } + ], + "created": "2024-08-02T10:28:35.696Z", + "modified": "2024-08-02T10:28:35.704Z" + }, + "submission": { + "data": { + "checkbox": true, + "submit": true + } + } +} \ No newline at end of file diff --git a/src/process/__tests__/fixtures/skipValidForLogicallyHiddenComp.json b/src/process/__tests__/fixtures/skipValidForLogicallyHiddenComp.json new file mode 100644 index 00000000..ee9a887b --- /dev/null +++ b/src/process/__tests__/fixtures/skipValidForLogicallyHiddenComp.json @@ -0,0 +1,86 @@ +{ + "form": { + "type": "form", + "display": "form", + "components": [ + { + "label": "Checkbox", + "tableView": false, + "validateWhenHidden": false, + "key": "checkbox", + "type": "checkbox", + "input": true + }, + { + "collapsible": false, + "key": "panel", + "logic": [ + { + "name": "1", + "trigger": { + "type": "simple", + "simple": { + "show": true, + "conjunction": "all", + "conditions": [ + { + "component": "checkbox", + "operator": "isEqual", + "value": true + } + ] + } + }, + "actions": [ + { + "name": "12", + "type": "property", + "property": { + "label": "Hidden", + "value": "hidden", + "type": "boolean" + }, + "state": true + } + ] + } + ], + "type": "panel", + "label": "Panel", + "input": false, + "tableView": false, + "components": [ + { + "label": "Text Field", + "applyMaskOn": "change", + "tableView": true, + "validate": { + "required": true + }, + "validateWhenHidden": false, + "key": "textField", + "type": "textfield", + "input": true + } + ] + }, + { + "type": "button", + "label": "Submit", + "key": "submit", + "disableOnInvalid": true, + "input": true, + "tableView": false + } + ], + "created": "2024-08-02T10:28:35.696Z", + "modified": "2024-08-02T10:28:35.704Z" + }, + "submission": { + "data": { + "checkbox": true, + "submit": true + } + } +} + diff --git a/src/process/__tests__/fixtures/skipValidWithHiddenParentComp.json b/src/process/__tests__/fixtures/skipValidWithHiddenParentComp.json new file mode 100644 index 00000000..94f9fcb9 --- /dev/null +++ b/src/process/__tests__/fixtures/skipValidWithHiddenParentComp.json @@ -0,0 +1,55 @@ +{ + "form": { + "type": "form", + "display": "form", + "components": [ + { + "label": "Checkbox", + "tableView": false, + "validateWhenHidden": false, + "key": "checkbox", + "type": "checkbox", + "input": true + }, + { + "collapsible": false, + "hidden": true, + "key": "panel", + "type": "panel", + "label": "Panel", + "input": false, + "tableView": false, + "components": [ + { + "label": "Text Field", + "applyMaskOn": "change", + "tableView": true, + "validate": { + "required": true + }, + "validateWhenHidden": false, + "key": "textField", + "type": "textfield", + "input": true + } + ] + }, + { + "type": "button", + "label": "Submit", + "key": "submit", + "disableOnInvalid": true, + "input": true, + "tableView": false + } + ], + "created": "2024-08-02T11:13:50.020Z", + "modified": "2024-08-02T11:14:14.155Z" + }, + "submission": { + "data": { + "checkbox": true, + "submit": true + } + } +} \ No newline at end of file diff --git a/src/process/__tests__/process.test.ts b/src/process/__tests__/process.test.ts index 4e5ed176..40bc0d7a 100644 --- a/src/process/__tests__/process.test.ts +++ b/src/process/__tests__/process.test.ts @@ -1,10 +1,9 @@ import { expect } from 'chai'; -import { processSync, ProcessTargets } from '../index'; -import { ValidationScope } from 'types'; -const assert = require('assert'); -const form1 = require('./fixtures/form1.json'); -const data1a = require('./fixtures/data1a.json'); -const subs = require('./fixtures/subs.json'); +import assert from 'node:assert' +import type { ContainerComponent, ValidationScope } from 'types'; +import { getComponent } from 'utils/formUtil'; +import { process, processSync, ProcessTargets } from '../index'; +import { clearOnHideWithCustomCondition, clearOnHideWithHiddenParent, skipValidForConditionallyHiddenComp, skipValidForLogicallyHiddenComp, skipValidWithHiddenParentComp } from './fixtures' /* describe('Process Tests', () => { it('Should perform the processes using the processReduced method.', async () => { @@ -831,7 +830,7 @@ 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, @@ -947,7 +946,7 @@ describe('Process Tests', () => { }, owner: '65ea3601c3792e416cabcb2a', access: [], - + _vnote: '', state: 'submitted', form: '65ea368b705068f84a93c87a', @@ -970,7 +969,7 @@ describe('Process Tests', () => { context.processors = ProcessTargets.evaluator; processSync(context); console.log(context.scope.errors); - + assert.equal(context.scope.errors.length, 0); }); it('should remove submission data not in a nested form definition', async function () { @@ -2702,6 +2701,85 @@ describe('Process Tests', () => { }); }); + it('Should include submission data for logically visible fields', async () => { + const form = { + display: 'form', + components: [ + { + type: 'textfield', + key: 'textField', + label: 'Text Field', + input: true, + }, + { + type: 'textarea', + key: 'textArea', + label: 'Text Area', + input: true, + hidden: true, + logic: [ + { + name: 'Show When Not Empty', + trigger: { + type: 'simple' as const, + simple: { + show: true, + conjunction: 'all', + conditions: [ + { + component: 'textField', + operator: 'isNotEmpty', + }, + ], + }, + }, + actions: [ + { + name: 'Show', + type: 'property' as const, + property: { + label: 'Hidden', + value: 'hidden', + type: 'boolean' as const, + }, + state: false, + }, + ], + }, + ], + }, + ], + }; + + const submission = { + data: { + textField: 'not empty', + textArea: 'should be conditionally visible', + }, + }; + + const context = { + form, + submission, + data: submission.data, + components: form.components, + processors: ProcessTargets.evaluator, + scope: {}, + config: { + server: true, + }, + }; + processSync(context); + expect((context.scope as any).conditionals).to.deep.equal([{ + path: 'textArea', + conditionallyHidden: false, + }]); + expect(context.data).to.deep.equal({ + textArea: 'should be conditionally visible', + textField: 'not empty', + }); + }); + describe('Required component validation in nested form in DataGrid/EditGrid', () => { const nestedForm = { key: 'form', @@ -2800,184 +2878,167 @@ describe('Process Tests', () => { expect((context.scope as ValidationScope).errors).to.have.length(1); }); }); - it('Should not return fields from conditionally hidden containers', async () => { - const form = { - display: 'form', - "components": [ - { - "title": "__information_on_the_appointee", - "theme": "primary", - "collapsible": false, - "key": "HeadingNestedFormCandidates", - "type": "panel", - "label": "Appointees", - "input": false, - "tableView": false, - "components": [ - { - "label": "Appointees", - "hideLabel": true, - "tableView": false, - - "addAnother": "__add_appointee", - "modal": true, - "saveRow": "Close", - "rowDrafts": true, - "key": "candidates", - "type": "editgrid", - "displayAsTable": false, - "input": true, - "components": [ - { - "label": "Appointee", - "tableView": false, - "key": "candidate", - "type": "container", - "input": true, - "components": [ - { - "label": "Data", - "tableView": false, - "key": "data", - "type": "container", - "input": true, - "components": [ - { - "label": "Tabs", - "components": [ - { - "label": "__6_time_commitment", - "key": "section6tab", - "components": [ - { - "label": "Section 6", - "tableView": false, - "clearOnHide": true, - "validateWhenHidden": false, - "key": "section6", - "properties": { - "clearHiddenOnSave": "true" - }, - "customConditional": "show = false;", - "type": "container", - "input": true, - "components": [ - { - "title": "__6_dash_time_commitment", - "theme": "primary", - "collapsible": false, - "key": "heading6", - "type": "panel", - "label": "Time Commitment", - "input": false, - "tableView": false, - "components": [ - { - "label": "__a_information_to_be_provided_by_the_supervised_entity_the", - "description": "__ul_li_see_the_report_on_declared_time_commitment_of", - "autoExpand": false, - "tableView": true, - "validate": { - "required": true - }, - "key": "entityExpectedTimeCommit", - "type": "textarea", - "input": true - }, - { - "label": "c", - "tableView": false, - "key": "c", - "type": "container", - "input": true, - "components": [] - }, - { - "label": "__d_list_of_executive_and_non_executive_directorships_and_other", - "description": "__for_each_directorship_or_other_activity_a_separate_row_needs", - "tableView": false, - "addAnother": "__add_another", - "validate": { - "required": true - }, - "rowDrafts": false, - "key": "d", - "type": "editgrid", - "input": true, - "components": [] - } - ] - } - ] - } - ] - } - ], - "key": "tabs1", - "type": "tabs", - "input": false, - "tableView": false - } - ] - } - ] - } - ] - } - ] - }, - { - "label": "Submit", - "action": "saveState", - "showValidations": false, - "tableView": false, - "key": "submit", - "type": "button", - "input": true, - "state": "draft" - } - ], - - }; - const submission = { - "data": { - "candidates": [ - { - "candidate": { - "data": { - "section6": { - "c": {}, - "d": [] - } - } - } - } - ], - "submit": 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); - console.log(JSON.stringify(context.data, null, 2)) - expect(context.data).to.deep.equal({ - candidates:[{candidate:{data:{}}}], - submit: true + 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; + 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.scope as ValidationScope).errors).to.have.length(0); + }); + + it('Should skip child validation with hidden parent component', async () => { + const { form, submission } = skipValidWithHiddenParentComp; + const context = { + form, + submission, + data: submission.data, + components: form.components, + processors: ProcessTargets.submission, + scope: {}, + config: { + server: true, + }, + }; + + await process(context); + context.processors = ProcessTargets.evaluator; + await process(context); + expect((context.scope as ValidationScope).errors).to.have.length(0); + }); + + it('Should skip child validation with logic', async () => { + const { form, submission } = skipValidForLogicallyHiddenComp; + const context = { + form, + submission, + data: submission.data, + components: form.components, + processors: ProcessTargets.submission, + scope: {}, + config: { + server: true, + }, + }; + + processSync(context as any); + context.processors = ProcessTargets.evaluator; + processSync(context as any); + 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 return 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: {}, + 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 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:{section6:{}}}}], + submit: true + }); + }); describe('For EditGrid:', () => { const components = [ @@ -3021,7 +3082,7 @@ describe('Process Tests', () => { textField: 'test', invalidField: 'bad', }, - + }, }, { @@ -3046,7 +3107,7 @@ describe('Process Tests', () => { context.processors = ProcessTargets.evaluator; processSync(context); console.log(JSON.stringify(context.data, null, 2)); - + expect((context.scope as ValidationScope).errors).to.have.length(0); expect(context.data).to.deep.equal({ editGrid: [{ form: { data: { textField: 'test' } } }], @@ -3083,7 +3144,7 @@ describe('Process Tests', () => { processSync(context); expect((context.scope as ValidationScope).errors).to.have.length(1); }); - + }); }); /* diff --git a/src/process/clearHidden.ts b/src/process/clearHidden.ts index e2e12598..370a844b 100644 --- a/src/process/clearHidden.ts +++ b/src/process/clearHidden.ts @@ -4,8 +4,9 @@ import { ProcessorContext, ProcessorInfo, ProcessorFnSync, - ConditionsScope + ConditionsScope, } from "types"; +import { isParentHidden } from 'utils/isParentHidden'; type ClearHiddenScope = ProcessorScope & { clearHidden: { @@ -18,17 +19,24 @@ type ClearHiddenScope = ProcessorScope & { */ export const clearHiddenProcess: ProcessorFnSync = (context) => { const { component, data, path, value, scope } = context; + + // No need to unset the value if it's undefined + if (value === undefined) { + return; + } + if (!scope.clearHidden) { scope.clearHidden = {}; } - const conditionallyHidden = (scope as ConditionsScope).conditionals?.find((cond) => { - return cond.path === path; + + // 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.includes(cond.path) && cond.conditionallyHidden; }); - if ( - conditionallyHidden?.conditionallyHidden && - (value !== undefined) && - (!component.hasOwnProperty('clearOnHide') || component.clearOnHide) - ) { + + const shouldClearValueWhenHidden = !component.hasOwnProperty('clearOnHide') || component.clearOnHide; + + if (shouldClearValueWhenHidden && (isConditionallyHidden || isParentHidden(component))) { unset(data, path); scope.clearHidden[path] = true; } diff --git a/src/process/conditions/index.ts b/src/process/conditions/index.ts index a3d83d7b..211a702e 100644 --- a/src/process/conditions/index.ts +++ b/src/process/conditions/index.ts @@ -1,7 +1,6 @@ import { ProcessorFn, ProcessorFnSync, ConditionsScope, ProcessorInfo, ConditionsContext, SimpleConditional, JSONConditional, LegacyConditional, SimpleConditionalConditions, Component, NestedComponent, FilterScope } from 'types'; -import { Utils } from 'utils'; import { set } from 'lodash'; -import { componentInfo, getComponentKey, getComponentPath } from 'utils/formUtil'; +import { componentInfo, eachComponentData, getComponentPath } from 'utils/formUtil'; import { checkCustomConditional, checkJsonConditional, @@ -12,15 +11,6 @@ import { isJSONConditional } from 'utils/conditions'; -const skipOnServer = (context: ConditionsContext): boolean => { - const { component, config } = context; - const clearOnHide = component.hasOwnProperty('clearOnHide') ? component.clearOnHide : true; - if (config?.server && !clearOnHide) { - // No need to run conditionals on server unless clearOnHide is set. - return true; - } - return false; -}; const hasCustomConditions = (context: ConditionsContext): boolean => { const { component } = context; @@ -89,7 +79,7 @@ export const isConditionallyHidden = (context: ConditionsContext): boolean => { export type ConditionallyHidden = (context: ConditionsContext) => boolean; export const conditionalProcess = (context: ConditionsContext, isHidden: ConditionallyHidden) => { - const { component, data, row, scope, path } = context; + const { component, row, scope, path } = context; if (!hasConditions(context)) { return; } @@ -102,25 +92,9 @@ export const conditionalProcess = (context: ConditionsContext, isHidden: Conditi scope.conditionals.push(conditionalComp); } - if (skipOnServer(context)) { - return false; - } - conditionalComp.conditionallyHidden = conditionalComp.conditionallyHidden || isHidden(context); if (conditionalComp.conditionallyHidden) { - const info = componentInfo(component); - if (info.hasColumns || info.hasComps || info.hasRows) { - // If this is a container component, we need to add all the child components as conditionally hidden as well. - Utils.eachComponentData([component], row, (comp: Component, data: any, compRow: any, compPath: string) => { - if (comp !== component) { - scope.conditionals?.push({ path: getComponentPath(comp, compPath), conditionallyHidden: true }); - } - set(comp, 'hidden', true); - }); - } - else { - set(component, 'hidden', true); - } + set(component, 'hidden', true); } }; diff --git a/src/process/hideChildren.ts b/src/process/hideChildren.ts new file mode 100644 index 00000000..2d2c81de --- /dev/null +++ b/src/process/hideChildren.ts @@ -0,0 +1,56 @@ +import { set } from 'lodash'; +import { + ProcessorScope, + ProcessorContext, + ProcessorInfo, + ProcessorFnSync, + ConditionsScope, + Component, + ProcessorFn, +} from "types"; +import { componentInfo, eachComponentData, getComponentPath } 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, row, scope } = context; + // 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.includes(cond.path) && cond.conditionallyHidden; + }); + if (component.hidden && isConditionallyHidden) { + const info = componentInfo(component); + if (info.hasColumns || info.hasComps || info.hasRows) { + // If this is a container component, we need to make the mutation to all the child components as well. + eachComponentData([component], row, (comp: Component, data: any, compRow: any, compPath: string) => { + if (comp !== component) { + // the path set here is not the absolute path, but the path relative to the parent component + (scope as ConditionsScope).conditionals?.push({ path: getComponentPath(comp, compPath), conditionallyHidden: true }); + set(comp, 'hidden', true); + } + }); + } + } else if (component.hidden) { + const info = componentInfo(component); + if (info.hasColumns || info.hasComps || info.hasRows) { + // If this is a container component, we need to make the mutation to all the child components as well. + eachComponentData([component], row, (comp: Component, data: any, compRow: any, compPath: string) => { + if (comp !== component) { + set(comp, 'hidden', true); + } + }); + } + } +} + +export const hideChildrenProcessorAsync: ProcessorFn = async (context) => { + return hideChildrenProcessor(context); +}; + +export const hideChildrenProcessorInfo: ProcessorInfo, void> = { + name: 'hideChildren', + shouldProcess: () => true, + processSync: hideChildrenProcessor, + process: hideChildrenProcessorAsync, +} diff --git a/src/process/index.ts b/src/process/index.ts index a7209284..423b0417 100644 --- a/src/process/index.ts +++ b/src/process/index.ts @@ -10,3 +10,5 @@ export * from './processOne'; export * from './process'; export * from './normalize'; export * from './dereference'; +export * from './clearHidden'; +export * from './hideChildren'; diff --git a/src/process/process.ts b/src/process/process.ts index 1eab42cc..8843b42d 100644 --- a/src/process/process.ts +++ b/src/process/process.ts @@ -1,5 +1,3 @@ -import get from 'lodash/get'; -import set from 'lodash/set'; import { ProcessContext, ProcessTarget, @@ -30,6 +28,7 @@ import { filterProcessInfo } from './filter'; import { normalizeProcessInfo } from './normalize'; import { dereferenceProcessInfo } from './dereference'; import { clearHiddenProcessInfo } from './clearHidden'; +import { hideChildrenProcessorInfo } from './hideChildren'; export async function process( context: ProcessContext @@ -132,6 +131,7 @@ export const ProcessorMap: Record> = { validate: validateProcessInfo, validateCustom: validateCustomProcessInfo, validateServer: validateServerProcessInfo, + hideChildren: hideChildrenProcessorInfo }; export const ProcessTargets: ProcessTarget = { @@ -149,6 +149,7 @@ export const ProcessTargets: ProcessTarget = { calculateProcessInfo, logicProcessInfo, conditionProcessInfo, + hideChildrenProcessorInfo, clearHiddenProcessInfo, validateProcessInfo, ], diff --git a/src/process/validation/index.ts b/src/process/validation/index.ts index 43f737c6..5322a3f4 100644 --- a/src/process/validation/index.ts +++ b/src/process/validation/index.ts @@ -2,13 +2,12 @@ import { ConditionsScope, ProcessorFn, ProcessorFnSync, ProcessorInfo, Validatio import { evaluationRules, rules, serverRules } from "./rules"; import find from "lodash/find"; import get from "lodash/get"; -import set from "lodash/set"; import pick from "lodash/pick"; import { getComponentAbsolutePath, getComponentPath } from "utils/formUtil"; import { getErrorMessage } from "utils/error"; import { FieldError } from "error"; import { ConditionallyHidden, isConditionallyHidden, isCustomConditionallyHidden, isSimpleConditionallyHidden } from "processes/conditions"; -import { validate } from 'fast-json-patch'; +import { isParentHidden } from 'utils/isParentHidden'; // Cleans up validation errors to remove unnessesary parts // and make them transferable to ivm. @@ -96,6 +95,7 @@ export function isForcedHidden(context: ValidationContext, isConditionallyHidden export const _shouldSkipValidation = (context: ValidationContext, isConditionallyHidden: ConditionallyHidden) => { const { component, scope, path } = context; + if ( (scope as ConditionsScope)?.conditionals && find((scope as ConditionsScope).conditionals, { @@ -117,7 +117,7 @@ export const _shouldSkipValidation = (context: ValidationContext, isConditionall () => isForcedHidden(context, isConditionallyHidden) && !validateWhenHidden, ]; - return rules.some(pred => pred()); + return rules.some(pred => pred());; }; export const shouldSkipValidationCustom: SkipValidationFn = (context: ValidationContext) => { diff --git a/src/process/validation/rules/__tests__/validateMask.test.ts b/src/process/validation/rules/__tests__/validateMask.test.ts index 3a6ae3ae..f01d8e19 100644 --- a/src/process/validation/rules/__tests__/validateMask.test.ts +++ b/src/process/validation/rules/__tests__/validateMask.test.ts @@ -86,3 +86,33 @@ it('Validating a mutil-mask component should return null if the value matches th result = await validateMask(context); expect(result).to.equal(null); }); + +it('Validating a mask component should return null if the instance contains a skipMaskValidation property', async () => { + const component = { ...simpleTextField, inputMask: '999-999-9999' }; + const data = { + component: '1234', + }; + const context = generateProcessorContext(component, data); + let result = await validateMask(context); + expect(result).to.be.instanceOf(FieldError); + expect(result?.errorKeyOrMessage).to.equal('mask'); + (context as any).instance = { skipMaskValidation: true }; + result = await validateMask(context); + expect(result).to.equal(null); +}); + +it('Validating a mask component should return null if the validate object contains a skipMaskValidation', async () => { + const component = { + ...simpleTextField, + inputMask: '999-999-9999', + validate: { + skipMaskValidation: true, + }, + }; + const data = { + component: '1234', + }; + const context = generateProcessorContext(component, data); + const result = await validateMask(context); + expect(result).to.equal(null); +}); diff --git a/src/process/validation/rules/validateMask.ts b/src/process/validation/rules/validateMask.ts index a30c4ff3..c56e11a0 100644 --- a/src/process/validation/rules/validateMask.ts +++ b/src/process/validation/rules/validateMask.ts @@ -13,12 +13,19 @@ const isMaskType = (obj: any): obj is DataObject & { maskName: string; value: st ); }; -const isValidatableComponent = (component: any): component is TextFieldComponent => { +const isValidatableComponent = (component: any, instance: any): component is TextFieldComponent => { + if (!component) return false; + + const { type, inputMask, inputMasks, validate } = component; + // For some reason we skip mask validation for time components - return ((component && component.type && component.type !== 'time') && - (component && component.hasOwnProperty('inputMask') && !!component.inputMask) || - (component && component.hasOwnProperty('inputMasks') && !isEmpty(component.inputMasks)) - ); + if (type === 'time') return false; + + const hasInputMask = inputMask || !isEmpty(inputMasks); + // Include instance.skipMaskValidation check to maintain backward compatibility + const skipMaskValidation = validate?.skipMaskValidation || instance?.skipMaskValidation; + + return hasInputMask && !skipMaskValidation; }; function getMaskByLabel(component: TextFieldComponent, maskName: string | undefined) { @@ -90,8 +97,8 @@ export function matchInputMask(value: any, inputMask: any) { } export const shouldValidate = (context: ValidationContext) => { - const { component, value } = context; - if (!isValidatableComponent(component) || !value) { + const { component, value, instance } = context; + if (!isValidatableComponent(component, instance) || !value) { return false; } if (value == null) { diff --git a/src/types/Component.ts b/src/types/Component.ts index 5fec266f..c81139de 100644 --- a/src/types/Component.ts +++ b/src/types/Component.ts @@ -72,6 +72,7 @@ export type TextFieldComponent = BaseComponent & { maxWords?: number | string; pattern?: string; patternMessage?: string; + skipMaskValidation?: boolean; }; }; diff --git a/src/types/Form.ts b/src/types/Form.ts index c92ec744..f821f6d9 100644 --- a/src/types/Form.ts +++ b/src/types/Form.ts @@ -3,35 +3,35 @@ import { Access, Component, ProjectId, SubmissionId } from 'types'; export type FormId = string; export interface Form { - _id: FormId; - _vid: number; + _id?: FormId; + _vid?: number; - title: string; - name: string; - path: string; - type: FormType; + title?: string; + name?: string; + path?: string; + type?: FormType; display?: FormDisplay; action?: string; tags?: string[]; - access: Access[]; - submissionAccess: Access[]; - fieldMatchAccess: any; - owner: SubmissionId; - machineName: string; + access?: Access[]; + submissionAccess?: Access[]; + fieldMatchAccess?: any; + owner?: SubmissionId; + machineName?: string; components: Component[]; settings?: FormSettings; - properties: Record; - project: ProjectId; - revisions: 'current' | 'original' | ''; - submissionRevisions: 'true' | ''; + properties?: Record; + project?: ProjectId; + revisions?: 'current' | 'original' | ''; + submissionRevisions?: 'true' | ''; controller?: string; builder?: boolean; page?: number; // Database timestamps - created: Date | string; - modified: Date | string; - deleted: Date | string; + created?: Date | string; + modified?: Date | string; + deleted?: Date | string; } export type FormType = 'form' | 'resource'; diff --git a/src/utils/formUtil.ts b/src/utils/formUtil.ts index 04517398..9568b146 100644 --- a/src/utils/formUtil.ts +++ b/src/utils/formUtil.ts @@ -227,7 +227,7 @@ export const componentFormPath = (component: any, parentPath: string, path: stri path = path || componentPath(component, parentPath); if (isComponentModelType(component, 'dataObject')) { return `${path}.data`; - } + } if (isComponentNestedDataType(component)) { return path; } @@ -287,9 +287,8 @@ export const eachComponentDataAsync = async ( await eachComponentDataAsync(component.components, data, fn, componentDataPath(component, path, compPath), index, component, includeAll); } return true; - } else { - return false; } + return false; }, true, path, @@ -341,9 +340,9 @@ export const eachComponentData = ( eachComponentData(component.components, data, fn, componentDataPath(component, path, compPath), index, component, includeAll); } return true; - } else { - return false; } + + return false }, true, path, @@ -680,7 +679,7 @@ export function getComponent( ): (Component | undefined) { let result; eachComponent(components, (component: Component, path: any) => { - if ((path === key) || (component.path === key)) { + if ((path === key) || (component.path === key) || (component.input && (component.key === key))) { result = component; return true; } diff --git a/src/utils/isParentHidden.ts b/src/utils/isParentHidden.ts new file mode 100644 index 00000000..7972da09 --- /dev/null +++ b/src/utils/isParentHidden.ts @@ -0,0 +1,15 @@ +import type { BaseComponent, Component } from 'types'; + +export const isParentHidden = (comp: Component) => { + let parentComponent: BaseComponent | undefined = comp.parent; + + while (parentComponent) { + if (parentComponent.hidden) { + return true; + } + // Exit if there's a circular reference in 'parent' prop + parentComponent = parentComponent === parentComponent.parent ? undefined : parentComponent.parent; + } + + return false; +}; diff --git a/src/utils/logic.ts b/src/utils/logic.ts index 3cf641d8..96a12278 100644 --- a/src/utils/logic.ts +++ b/src/utils/logic.ts @@ -1,8 +1,9 @@ -import { ConditionsScope, LogicContext, ProcessorContext } from "types"; +import { Component, ConditionsScope, LogicContext, ProcessorContext } from "types"; import { checkCustomConditional, checkJsonConditional, checkLegacyConditional, checkSimpleConditional, conditionallyHidden, isLegacyConditional } from "./conditions"; import { LogicActionCustomAction, LogicActionMergeComponentSchema, LogicActionProperty, LogicActionPropertyBoolean, LogicActionPropertyString, LogicActionValue } from "types/AdvancedLogic"; import { get, set, clone, isEqual, assign } from 'lodash'; import { evaluate, interpolate } from 'modules/jsonlogic'; +import { componentInfo, eachComponentData, getComponentPath } from "./formUtil"; export const hasLogic = (context: LogicContext): boolean => { const { component } = context; @@ -41,35 +42,35 @@ export const checkTrigger = (context: LogicContext, trigger: any): boolean => { }; export function setActionBooleanProperty(context: LogicContext, action: LogicActionPropertyBoolean): boolean { - const { component, scope, path } = context; + const { component, scope, path, row } = context; const property = action.property.value; const currentValue = get(component, property, false).toString(); const newValue = action.state.toString(); if (currentValue !== newValue) { set(component, property, newValue === 'true'); - // If this is "logic" forcing a component to be hidden, then we will set the "conditionallyHidden" + // 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' && - component.hidden && path ) { - if (!(scope as any).conditionals) { - (scope as any).conditionals = []; + if (!(scope as ConditionsScope).conditionals) { + (scope as ConditionsScope).conditionals = []; } - const conditionalyHidden = (scope as any).conditionals.find((cond: any) => { + const conditionalyHidden = (scope as ConditionsScope).conditionals?.find((cond: any) => { return cond.path === path }); if (conditionalyHidden) { - conditionalyHidden.conditionallyHidden = true; + conditionalyHidden.conditionallyHidden = !!component.hidden; } else { - (scope as any).conditionals.push({ + (scope as ConditionsScope).conditionals?.push({ path, - conditionallyHidden: true + conditionallyHidden: !!component.hidden, }); } + set(component, 'hidden', !!component.hidden); } return true; }