Skip to content

Commit

Permalink
feat: implement proper variable hiding within repeated field
Browse files Browse the repository at this point in the history
Related to #808
  • Loading branch information
Skaiir committed Dec 12, 2023
1 parent 0c4f873 commit 0301e9c
Show file tree
Hide file tree
Showing 5 changed files with 208 additions and 20 deletions.
2 changes: 1 addition & 1 deletion packages/form-js-viewer/src/Form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions packages/form-js-viewer/src/core/PathRegistry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,37 +17,108 @@ export default class ConditionChecker {
/**
* For given data, remove properties based on condition.
*
* @param {Object<string, any>} properties
* @param {Object<string, any>} data
* @param {Object<string, any>} 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;
}

/**
Expand Down Expand Up @@ -96,17 +167,25 @@ export default class ConditionChecker {
return result === true;
}

_clearObjectValueRecursively(valuePath, obj) {
_cleanlyClearDataAtPath(valuePath, obj) {
const workingValuePath = [ ...valuePath ];
let recurse = false;

do {
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 = [
Expand Down
64 changes: 64 additions & 0 deletions packages/form-js-viewer/test/spec/Form.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
});

});


Expand Down
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit 0301e9c

Please sign in to comment.