diff --git a/packages/form-js-viewer/src/Form.js b/packages/form-js-viewer/src/Form.js index 816867bd3..c493c0389 100644 --- a/packages/form-js-viewer/src/Form.js +++ b/packages/form-js-viewer/src/Form.js @@ -256,7 +256,7 @@ export default class Form { const workingErrors = {}; validateFieldRecursively(workingErrors, formFieldRegistry.getForm()); - const filteredErrors = this._applyConditions(workingErrors, data, { getFilterPath: getErrorPath }); + const filteredErrors = this._applyConditions(workingErrors, data, { getFilterPath: getErrorPath, leafNodeDeletionOnly: true }); this._setState({ errors: filteredErrors }); return filteredErrors; diff --git a/packages/form-js-viewer/src/core/PathRegistry.js b/packages/form-js-viewer/src/core/PathRegistry.js index 3bf506720..90e2d416a 100644 --- a/packages/form-js-viewer/src/core/PathRegistry.js +++ b/packages/form-js-viewer/src/core/PathRegistry.js @@ -183,8 +183,8 @@ export default class PathRegistry { result = result && callResult; } - // stop executing if false is specifically returned - if (result === false) { + // stop executing if false is specifically returned or if preventing recursion + if (result === false || context.preventRecursion) { return result; } diff --git a/packages/form-js-viewer/src/features/expressionLanguage/ConditionChecker.js b/packages/form-js-viewer/src/features/expressionLanguage/ConditionChecker.js index 1a0f9386d..a5430fb71 100644 --- a/packages/form-js-viewer/src/features/expressionLanguage/ConditionChecker.js +++ b/packages/form-js-viewer/src/features/expressionLanguage/ConditionChecker.js @@ -1,6 +1,6 @@ import { unaryTest } from 'feelin'; import { get, isString, set, values, isObject } from 'min-dash'; -import { clone } from '../../util'; +import { buildExpressionContext, clone } from '../../util'; /** * @typedef {object} Condition @@ -17,37 +17,108 @@ export default class ConditionChecker { /** * For given data, remove properties based on condition. * - * @param {Object} properties * @param {Object} data + * @param {Object} contextData * @param {Object} [options] * @param {Function} [options.getFilterPath] + * @param {boolean} [options.leafNodeDeletionOnly] */ - applyConditions(properties, data = {}, options = {}) { + applyConditions(data, contextData = {}, options = {}) { - const newProperties = clone(properties); + const workingData = clone(data); const { - getFilterPath = (field) => this._pathRegistry.getValuePath(field) + getFilterPath = (field, indexes) => this._pathRegistry.getValuePath(field, { indexes }), + leafNodeDeletionOnly = false } = options; + const _applyConditionsWithinScope = (rootField, scopeContext, startHidden = false) => { + + const { + indexes = {}, + expressionIndexes = [], + scopeData = contextData, + parentScopeData = null + } = scopeContext; + + this._pathRegistry.executeRecursivelyOnFields(rootField, ({ field, isClosed, isRepeatable, context }) => { + + const { + conditional, + components, + id + } = field; + + // build the expression context in the right format + const localExpressionContext = buildExpressionContext({ + this: scopeData, + data: contextData, + i: expressionIndexes, + parent: parentScopeData + }); + + context.isHidden = startHidden || context.isHidden || (conditional && this._checkHideCondition(conditional, localExpressionContext)); + + // if a field is repeatable and visible, we need to implement custom recursion on its children + if (isRepeatable && (!context.isHidden || leafNodeDeletionOnly)) { + + // prevent the regular recursion behavior of executeRecursivelyOnFields + context.preventRecursion = true; + + const repeaterValuePath = this._pathRegistry.getValuePath(field, { indexes }); + const repeaterValue = get(contextData, repeaterValuePath); + + // quit early if there are no children or data associated with the repeater + if (!Array.isArray(repeaterValue) || !repeaterValue.length || !Array.isArray(components) || !components.length) { + return; + } + + for (let i = 0; i < repeaterValue.length; i++) { + + // create a new scope context for each index + const newScopeContext = { + indexes: { ...indexes, [id]: i }, + expressionIndexes: [ ...expressionIndexes, i + 1 ], + scopeData: repeaterValue[i], + parentScopeData: scopeData + }; + + // for each child component, apply conditions within the new repetition scope + components.forEach(component => { + _applyConditionsWithinScope(component, newScopeContext, context.isHidden); + }); + + } + + } + + // if we have a hidden repeatable field, and the data structure allows, we clear it directly at the root and stop recursion + if (context.isHidden && !leafNodeDeletionOnly && isRepeatable) { + context.preventRecursion = true; + this._cleanlyClearDataAtPath(getFilterPath(field, indexes), workingData); + } + + // for simple leaf fields, we always clear + if (context.isHidden && isClosed) { + this._cleanlyClearDataAtPath(getFilterPath(field, indexes), workingData); + } + + }); + + }; + + // apply conditions starting with the root of the form const form = this._formFieldRegistry.getForm(); if (!form) { throw new Error('form field registry has no form'); } - this._pathRegistry.executeRecursivelyOnFields(form, ({ field, isClosed, isRepeatable, context }) => { - const { conditional: condition } = field; - - context.isHidden = context.isHidden || (condition && this._checkHideCondition(condition, data)); - - // if a field is a leaf node (or repeatable, as they behave similarly), and hidden, we need to clear the value from the data from each index - if (context.isHidden && (isClosed || isRepeatable)) { - this._clearObjectValueRecursively(getFilterPath(field), newProperties); - } + _applyConditionsWithinScope(form, { + scopeData: contextData }); - return newProperties; + return workingData; } /** @@ -96,7 +167,7 @@ export default class ConditionChecker { return result === true; } - _clearObjectValueRecursively(valuePath, obj) { + _cleanlyClearDataAtPath(valuePath, obj) { const workingValuePath = [ ...valuePath ]; let recurse = false; @@ -104,9 +175,17 @@ export default class ConditionChecker { set(obj, workingValuePath, undefined); workingValuePath.pop(); const parentObject = get(obj, workingValuePath); - recurse = isObject(parentObject) && !values(parentObject).length && !!workingValuePath.length; + recurse = !!workingValuePath.length && (this._isEmptyObject(parentObject) || this._isEmptyArray(parentObject)); } while (recurse); } + + _isEmptyObject(parentObject) { + return isObject(parentObject) && !values(parentObject).length; + } + + _isEmptyArray(parentObject) { + return Array.isArray(parentObject) && (!parentObject.length || parentObject.every(item => item === undefined)); + } } ConditionChecker.$inject = [ diff --git a/packages/form-js-viewer/test/spec/Form.spec.js b/packages/form-js-viewer/test/spec/Form.spec.js index 7335c4016..f07c7dad8 100644 --- a/packages/form-js-viewer/test/spec/Form.spec.js +++ b/packages/form-js-viewer/test/spec/Form.spec.js @@ -16,6 +16,7 @@ import customButtonModule from './custom'; import conditionSchema from './condition.json'; import conditionErrorsSchema from './condition-errors.json'; +import conditionErrorsDynamicListSchema from './condition-errors-dynamic-list.json'; import hiddenFieldsConditionalSchema from './hidden-fields-conditional.json'; import hiddenFieldsExpressionSchema from './hidden-fields-expression.json'; import disabledSchema from './disabled.json'; @@ -723,6 +724,69 @@ describe('Form', function() { expect(errors).to.be.empty; }); + + it('should NOT add errors for hidden dynamic list elements', async function() { + + // given + const initialData = { + hideList: false, + list: [ + { + element: null, + hideElement: true + }, + { + element: null, + hideElement: false + }, + ] + }; + + await bootstrapForm({ + container, + data: initialData, + schema: conditionErrorsDynamicListSchema + }); + + // when + const errors = form.validate(); + + // then + expect(errors['Element_x'][0]).to.be.undefined; + expect(errors['Element_x'][1]).to.not.be.empty; + }); + + + it('should NOT add errors for fully hidden dynamic list', async function() { + + // given + const initialData = { + hideList: true, + list: [ + { + element: null, + hideElement: true + }, + { + element: null, + hideElement: false + }, + ] + }; + + await bootstrapForm({ + container, + data: initialData, + schema: conditionErrorsDynamicListSchema + }); + + // when + const errors = form.validate(); + + // then + expect(errors).to.be.empty; + }); + }); diff --git a/packages/form-js-viewer/test/spec/condition-errors-dynamic-list.json b/packages/form-js-viewer/test/spec/condition-errors-dynamic-list.json new file mode 100644 index 000000000..074cfa526 --- /dev/null +++ b/packages/form-js-viewer/test/spec/condition-errors-dynamic-list.json @@ -0,0 +1,45 @@ +{ + "$schema": "../../../form-json-schema/resources/schema.json", + "components": [ + { + "label": "Hide List", + "type": "checkbox", + "key": "hideList", + "id": "HideList_x" + }, + { + "label": "List", + "type": "dynamiclist", + "path": "list", + "id": "List_x", + "conditional": { + "hide": "=this.hideList" + }, + "isRepeating": true, + "components": [ + { + "label": "Element", + "type": "number", + "key": "element", + "id": "Element_x", + "conditional": { + "hide": "=this.hideElement" + }, + "validate": { + "required": true + } + }, + { + "label": "Hide Element", + "type": "checkbox", + "key": "hideElement", + "id": "HideElement_x" + } + ] + } + ], + "type": "default", + "id": "Form_0k71hbi", + "exporter": {}, + "schemaVersion": 15 +} \ No newline at end of file