From bf2959c94f246ac42da6c7286c659da4751721ca Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Fri, 8 Nov 2024 11:42:45 -0600 Subject: [PATCH 01/10] Refactor the paths to ensure that we are always referencing the correct path through the scope. --- src/modules/jsonlogic/index.ts | 15 +- src/process/__tests__/process.test.ts | 9 +- src/process/calculation/index.ts | 5 +- src/process/clearHidden.ts | 10 +- src/process/clearHidden/index.ts | 52 +++ src/process/conditions/index.ts | 10 +- src/process/defaultValue/index.ts | 5 +- src/process/fetch/index.ts | 5 +- src/process/filter/index.ts | 16 +- src/process/hideChildren.ts | 10 +- src/process/populate/index.ts | 7 +- src/process/processOne.ts | 70 +-- src/process/validation/index.ts | 15 +- .../__tests__/validateValueProperty.test.ts | 9 +- .../validation/rules/validateCustom.ts | 5 +- src/process/validation/rules/validateJson.ts | 5 +- .../validation/rules/validateMultiple.ts | 3 +- .../validation/rules/validateRequired.ts | 5 +- .../validation/rules/validateValueProperty.ts | 10 +- src/sdk/__tests__/Formio.test.ts | 5 + src/types/BaseComponent.ts | 17 +- src/types/PassedComponentInstance.ts | 2 +- src/utils/Evaluator.ts | 18 + src/utils/__tests__/formUtil.test.ts | 242 +++++------ src/utils/conditions.ts | 19 +- .../formUtil/__tests__/eachComponent.test.ts | 39 +- src/utils/formUtil/eachComponent.ts | 49 +-- src/utils/formUtil/eachComponentAsync.ts | 61 +-- src/utils/formUtil/eachComponentData.ts | 148 +++---- src/utils/formUtil/eachComponentDataAsync.ts | 120 ++---- src/utils/formUtil/index.ts | 407 +++++++++++++++--- src/utils/logic.ts | 12 +- src/utils/utils.ts | 29 +- 33 files changed, 791 insertions(+), 643 deletions(-) create mode 100644 src/process/clearHidden/index.ts diff --git a/src/modules/jsonlogic/index.ts b/src/modules/jsonlogic/index.ts index 14b76cc8..7ebba3f8 100644 --- a/src/modules/jsonlogic/index.ts +++ b/src/modules/jsonlogic/index.ts @@ -1,5 +1,6 @@ import { BaseEvaluator, EvaluatorOptions } from 'utils'; import { jsonLogic } from './jsonLogic'; +import { EvaluatorContext, normalizeContext } from 'utils/Evaluator'; export class JSONLogicEvaluator extends BaseEvaluator { public static evaluate( func: any, @@ -24,12 +25,6 @@ export class JSONLogicEvaluator extends BaseEvaluator { } } -export type EvaluatorContext = { - evalContext?: (context: any) => any; - instance?: any; - [key: string]: any; -}; - export type EvaluatorFn = (context: EvaluatorContext) => any; export function evaluate( @@ -40,7 +35,9 @@ export function evaluate( options: EvaluatorOptions = {}, ) { const { evalContext, instance } = context; - const evalContextValue = evalContext ? evalContext(context) : context; + const evalContextValue = evalContext + ? evalContext(normalizeContext(context)) + : normalizeContext(context); if (evalContextFn) { evalContextFn(evalContextValue); } @@ -63,7 +60,9 @@ export function interpolate( evalContextFn?: EvaluatorFn, ): string { const { evalContext, instance } = context; - const evalContextValue = evalContext ? evalContext(context) : context; + const evalContextValue = evalContext + ? evalContext(normalizeContext(context)) + : normalizeContext(context); if (evalContextFn) { evalContextFn(evalContextValue); } diff --git a/src/process/__tests__/process.test.ts b/src/process/__tests__/process.test.ts index 77d02182..f4b7df96 100644 --- a/src/process/__tests__/process.test.ts +++ b/src/process/__tests__/process.test.ts @@ -14,7 +14,7 @@ import { skipValidForLogicallyHiddenComp, skipValidWithHiddenParentComp, } from './fixtures'; -import { get } from 'lodash'; +import _ from 'lodash'; /* describe('Process Tests', () => { @@ -959,6 +959,7 @@ describe('Process Tests', function () { const errors: any = []; const context = { + _, form, submission, data: submission.data, @@ -1114,8 +1115,8 @@ describe('Process Tests', function () { 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'); + 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 () { @@ -3437,7 +3438,7 @@ describe('Process Tests', function () { }); }); - it('Should allow the submission to go through without errors if there is no the subform reference value', async function () { + it('Should allow the submission to go through without errors if there is no subform reference value', async function () { const form = { _id: '66bc5cff7ca1729623a182db', title: 'form2', diff --git a/src/process/calculation/index.ts b/src/process/calculation/index.ts index 30d3c6c3..60aee20c 100644 --- a/src/process/calculation/index.ts +++ b/src/process/calculation/index.ts @@ -7,6 +7,7 @@ import { ProcessorInfo, } from 'types'; import { set } from 'lodash'; +import { normalizeContext } from 'utils/Evaluator'; export const shouldCalculate = (context: CalculationContext): boolean => { const { component, config } = context; @@ -23,7 +24,9 @@ export const calculateProcessSync: ProcessorFnSync = ( if (!shouldCalculate(context)) { return; } - const evalContextValue = evalContext ? evalContext(context) : context; + const evalContextValue = evalContext + ? evalContext(normalizeContext(context)) + : normalizeContext(context); evalContextValue.value = value || null; if (!scope.calculated) scope.calculated = []; const newValue = JSONLogicEvaluator.evaluate(component.calculateValue, evalContextValue, 'value'); diff --git a/src/process/clearHidden.ts b/src/process/clearHidden.ts index ce55e11d..bb92dd6a 100644 --- a/src/process/clearHidden.ts +++ b/src/process/clearHidden.ts @@ -6,7 +6,6 @@ import { ProcessorFnSync, ConditionsScope, } from 'types'; -import { getComponentAbsolutePath } from 'utils/formUtil'; type ClearHiddenScope = ProcessorScope & { clearHidden: { @@ -19,7 +18,6 @@ type ClearHiddenScope = ProcessorScope & { */ export const clearHiddenProcess: ProcessorFnSync = (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) { @@ -32,7 +30,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 absolutePath === cond.path && cond.conditionallyHidden; + return path === cond.path && cond.conditionallyHidden; }); const shouldClearValueWhenHidden = @@ -40,10 +38,10 @@ export const clearHiddenProcess: ProcessorFnSync = (context) = if ( shouldClearValueWhenHidden && - (isConditionallyHidden || component.hidden || component.ephemeralState?.conditionallyHidden) + (isConditionallyHidden || component.hidden || component.scope?.conditionallyHidden) ) { - unset(data, absolutePath); - scope.clearHidden[absolutePath] = true; + unset(data, path); + scope.clearHidden[path] = true; } }; diff --git a/src/process/clearHidden/index.ts b/src/process/clearHidden/index.ts new file mode 100644 index 00000000..91e7f8e3 --- /dev/null +++ b/src/process/clearHidden/index.ts @@ -0,0 +1,52 @@ +import { unset } from 'lodash'; +import { + ProcessorScope, + ProcessorContext, + ProcessorInfo, + ProcessorFnSync, + ConditionsScope, +} from 'types'; + +type ClearHiddenScope = ProcessorScope & { + clearHidden: { + [path: string]: boolean; + }; +}; + +/** + * This processor function checks components for the `hidden` property and unsets corresponding data + */ +export const clearHiddenProcess: ProcessorFnSync = (context) => { + const { component, data, value, scope, path } = context; + + // No need to unset the value if it's undefined + if (value === undefined) { + return; + } + + if (!scope.clearHidden) { + scope.clearHidden = {}; + } + + // 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; + }); + + const shouldClearValueWhenHidden = + !component.hasOwnProperty('clearOnHide') || component.clearOnHide; + + if ( + shouldClearValueWhenHidden && + (isConditionallyHidden || component.scope?.conditionallyHidden) + ) { + unset(data, path); + scope.clearHidden[path] = true; + } +}; + +export const clearHiddenProcessInfo: ProcessorInfo, void> = { + name: 'clearHidden', + shouldProcess: () => true, + processSync: clearHiddenProcess, +}; diff --git a/src/process/conditions/index.ts b/src/process/conditions/index.ts index f469bde3..5356e10e 100644 --- a/src/process/conditions/index.ts +++ b/src/process/conditions/index.ts @@ -5,7 +5,7 @@ import { ProcessorInfo, ConditionsContext, } from 'types'; -import { registerEphemeralState } from 'utils'; +import { setComponentScope } from 'utils/formUtil'; import { checkCustomConditional, checkJsonConditional, @@ -15,7 +15,6 @@ import { isSimpleConditional, isJSONConditional, } from 'utils/conditions'; -import { getComponentAbsolutePath } from 'utils/formUtil'; const hasCustomConditions = (context: ConditionsContext): boolean => { const { component } = context; @@ -85,7 +84,6 @@ export type ConditionallyHidden = (context: ConditionsContext) => boolean; export const conditionalProcess = (context: ConditionsContext, isHidden: ConditionallyHidden) => { const { scope, path, component } = context; - const absolutePath = getComponentAbsolutePath(component) || path; if (!hasConditions(context)) { return; } @@ -93,16 +91,16 @@ export const conditionalProcess = (context: ConditionsContext, isHidden: Conditi if (!scope.conditionals) { scope.conditionals = []; } - let conditionalComp = scope.conditionals.find((cond) => cond.path === absolutePath); + let conditionalComp = scope.conditionals.find((cond) => cond.path === path); if (!conditionalComp) { - conditionalComp = { path: absolutePath, conditionallyHidden: false }; + conditionalComp = { path, conditionallyHidden: false }; scope.conditionals.push(conditionalComp); } conditionalComp.conditionallyHidden = conditionalComp.conditionallyHidden || isHidden(context) === true; if (conditionalComp.conditionallyHidden) { - registerEphemeralState(context.component, 'conditionallyHidden', true); + setComponentScope(component, 'conditionallyHidden', true); } }; diff --git a/src/process/defaultValue/index.ts b/src/process/defaultValue/index.ts index 8a206f77..6c5f3907 100644 --- a/src/process/defaultValue/index.ts +++ b/src/process/defaultValue/index.ts @@ -8,6 +8,7 @@ import { } from 'types'; import { set, has } from 'lodash'; import { getComponentKey } from 'utils/formUtil'; +import { normalizeContext } from 'utils/Evaluator'; export const hasCustomDefaultValue = (context: DefaultValueContext): boolean => { const { component } = context; @@ -48,7 +49,9 @@ export const customDefaultValueProcessSync: ProcessorFnSync = ( } let defaultValue = null; if (component.customDefaultValue) { - const evalContextValue = evalContext ? evalContext(context) : context; + const evalContextValue = evalContext + ? evalContext(normalizeContext(context)) + : normalizeContext(context); evalContextValue.value = null; defaultValue = JSONLogicEvaluator.evaluate( component.customDefaultValue, diff --git a/src/process/fetch/index.ts b/src/process/fetch/index.ts index b0537a75..2a93cf3e 100644 --- a/src/process/fetch/index.ts +++ b/src/process/fetch/index.ts @@ -10,6 +10,7 @@ import { import { get, set } from 'lodash'; import { Evaluator } from 'utils'; import { getComponentKey } from 'utils/formUtil'; +import { normalizeContext } from 'utils/Evaluator'; export const shouldFetch = (context: FetchContext): boolean => { const { component, config } = context; @@ -38,7 +39,9 @@ export const fetchProcess: ProcessorFn = async (context: FetchContex return; } if (!scope.fetched) scope.fetched = {}; - const evalContextValue = evalContext ? evalContext(context) : context; + const evalContextValue = evalContext + ? evalContext(normalizeContext(context)) + : normalizeContext(context); const url = Evaluator.interpolateString(get(component, 'fetch.url', ''), evalContextValue); if (!url) { return; diff --git a/src/process/filter/index.ts b/src/process/filter/index.ts index d8dbab4c..9c9f56d1 100644 --- a/src/process/filter/index.ts +++ b/src/process/filter/index.ts @@ -1,46 +1,44 @@ import { FilterContext, FilterScope, ProcessorFn, ProcessorFnSync, ProcessorInfo } from 'types'; import { set } from 'lodash'; -import { Utils } from 'utils'; import { get, isObject } from 'lodash'; -import { getComponentAbsolutePath } from 'utils/formUtil'; +import { getModelType } from 'utils/formUtil'; export const filterProcessSync: ProcessorFnSync = (context: FilterContext) => { const { scope, component, path } = context; const { value } = context; - const absolutePath = getComponentAbsolutePath(component) || path; if (!scope.filter) scope.filter = {}; if (value !== undefined) { - const modelType = Utils.getModelType(component); + const modelType = getModelType(component); switch (modelType) { case 'dataObject': - scope.filter[absolutePath] = { + scope.filter[path] = { compModelType: modelType, include: true, value: { data: {} }, }; break; case 'nestedArray': - scope.filter[absolutePath] = { + scope.filter[path] = { compModelType: modelType, include: true, value: [], }; break; case 'nestedDataArray': - scope.filter[absolutePath] = { + scope.filter[path] = { compModelType: modelType, include: true, value: Array.isArray(value) ? value.map((v) => ({ ...v, data: {} })) : [], }; break; case 'object': - scope.filter[absolutePath] = { + scope.filter[path] = { compModelType: modelType, include: true, value: component.type === 'address' ? false : {}, }; break; default: - scope.filter[absolutePath] = { + scope.filter[path] = { compModelType: modelType, include: true, }; diff --git a/src/process/hideChildren.ts b/src/process/hideChildren.ts index 5037e8c4..e730a299 100644 --- a/src/process/hideChildren.ts +++ b/src/process/hideChildren.ts @@ -6,26 +6,24 @@ import { ConditionsScope, ProcessorFn, } from 'types'; -import { registerEphemeralState } from 'utils'; -import { getComponentAbsolutePath } from 'utils/formUtil'; +import { setComponentScope } 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 absolutePath === cond.path && cond.conditionallyHidden; + return path === cond.path && cond.conditionallyHidden; }); if (!scope.conditionals) { scope.conditionals = []; } - if (isConditionallyHidden || component.hidden || parent?.ephemeralState?.conditionallyHidden) { - registerEphemeralState(component, 'conditionallyHidden', true); + if (isConditionallyHidden || component.hidden || parent?.scope?.conditionallyHidden) { + setComponentScope(component, 'conditionallyHidden', true); } }; diff --git a/src/process/populate/index.ts b/src/process/populate/index.ts index 40ce6916..cecc8fc7 100644 --- a/src/process/populate/index.ts +++ b/src/process/populate/index.ts @@ -1,15 +1,14 @@ import { get, set } from 'lodash'; import { PopulateContext, PopulateScope, ProcessorFnSync } from 'types'; -import { componentPath, getContextualRowPath, getModelType } from 'utils/formUtil'; // This processor ensures that a "linked" row context is provided to every component. export const populateProcessSync: ProcessorFnSync = (context: PopulateContext) => { const { component, path, scope } = context; const { data } = scope; - const compDataPath = componentPath(component, getContextualRowPath(component, path)); - const compData: any = get(data, compDataPath); + const compDataPath = component.path || ''; + const compData: any = get(data, compDataPath, data); if (!scope.populated) scope.populated = []; - switch (getModelType(component)) { + switch (component.modelType) { case 'nestedArray': if (!compData || !compData.length) { set(data, compDataPath, [{}]); diff --git a/src/process/processOne.ts b/src/process/processOne.ts index 50d2ad83..07a122dd 100644 --- a/src/process/processOne.ts +++ b/src/process/processOne.ts @@ -1,40 +1,37 @@ import { get, set } from 'lodash'; -import { Component, ProcessorsContext, ProcessorType } from 'types'; -import { getComponentKey } from 'utils/formUtil'; -import { resetEphemeralState } from 'utils'; - -export function dataValue(component: Component, row: any) { - const key = getComponentKey(component); - return key ? get(row, key) : undefined; -} +import { ProcessorsContext, ProcessorType } from 'types'; export async function processOne(context: ProcessorsContext) { - const { processors, component, path } = context; + const { processors, row, component } = context; // Create a getter for `value` that is always derived from the current data object if (typeof context.value === 'undefined') { Object.defineProperty(context, 'value', { enumerable: true, get() { + if ( + !component.type || + component.modelType === 'none' || + component.modelType === 'content' + ) { + return undefined; + } return get(context.data, context.path); }, set(newValue: any) { + if ( + !component.type || + component.modelType === 'none' || + component.modelType === 'content' + ) { + // Do not set the value if the model type is 'none' or 'content' + return; + } set(context.data, context.path, newValue); }, }); } - // Define the component path - Object.defineProperty(component, 'path', { - enumerable: false, - writable: true, - value: path, - }); - - // If the component has ephemeral state, then we need to reset it in case this is e.g. a data grid, - // in which each row needs to be validated independently - resetEphemeralState(component); - - if (!context.row) { + if (!row) { return; } context.processor = ProcessorType.Custom; @@ -46,33 +43,40 @@ export async function processOne(context: ProcessorsContext(context: ProcessorsContext) { - const { processors, component, path } = context; + const { processors, row, component } = context; // Create a getter for `value` that is always derived from the current data object if (typeof context.value === 'undefined') { Object.defineProperty(context, 'value', { enumerable: true, get() { + if ( + !component.type || + component.modelType === 'none' || + component.modelType === 'content' + ) { + return undefined; + } return get(context.data, context.path); }, set(newValue: any) { + if ( + !component.type || + component.modelType === 'none' || + component.modelType === 'content' + ) { + // Do not set the value if the model type is 'none' or 'content' + return; + } set(context.data, context.path, newValue); }, }); } - // Define the component path - Object.defineProperty(component, 'path', { - enumerable: false, - writable: true, - value: path, - }); - - // If the component has ephemeral state, then we need to reset the ephemeral state in case this is e.g. a data grid, in which each row needs to be validated independently - resetEphemeralState(component); - - if (!context.row) { + if (!row) { return; } + + // Process the components. context.processor = ProcessorType.Custom; for (const processor of processors) { if (processor?.processSync) { diff --git a/src/process/validation/index.ts b/src/process/validation/index.ts index 187e502b..780dbf6a 100644 --- a/src/process/validation/index.ts +++ b/src/process/validation/index.ts @@ -15,7 +15,6 @@ import { evaluationRules, rules, serverRules } from './rules'; import find from 'lodash/find'; import get from 'lodash/get'; import pick from 'lodash/pick'; -import { getComponentAbsolutePath } from 'utils/formUtil'; import { getErrorMessage } from 'utils/error'; import { FieldError } from 'error'; import { @@ -107,15 +106,14 @@ 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: absolutePath, + path, conditionallyHidden: true, }) || - component.ephemeralState?.conditionallyHidden === true) + component.scope?.conditionallyHidden === true) ) { return true; } @@ -170,23 +168,22 @@ export function shouldValidateServer(context: ValidationContext): boolean { } function handleError(error: FieldError | null, context: ValidationContext) { - const { scope, component, path } = context; - const absolutePath = getComponentAbsolutePath(component) || path; + const { scope, path } = context; if (error) { const cleanedError = cleanupValidationError(error); - cleanedError.context.path = absolutePath; + cleanedError.context.path = path; if ( !find(scope.errors, { errorKeyOrMessage: cleanedError.errorKeyOrMessage, context: { - path: absolutePath, + path: path, }, }) ) { if (!scope.validated) scope.validated = []; if (!scope.errors) scope.errors = []; scope.errors.push(cleanedError); - scope.validated.push({ path: absolutePath, error: cleanedError }); + scope.validated.push({ path, error: cleanedError }); } } } diff --git a/src/process/validation/rules/__tests__/validateValueProperty.test.ts b/src/process/validation/rules/__tests__/validateValueProperty.test.ts index a8393ef5..b796ed45 100644 --- a/src/process/validation/rules/__tests__/validateValueProperty.test.ts +++ b/src/process/validation/rules/__tests__/validateValueProperty.test.ts @@ -2,10 +2,7 @@ import { expect } from 'chai'; import { set } from 'lodash'; import { FieldError } from 'error'; import { SelectBoxesComponent } from 'types'; -import { - simpleRadioField, - simpleSelectBoxes -} from './fixtures/components'; +import { simpleRadioField, simpleSelectBoxes } from './fixtures/components'; import { generateProcessorContext } from './fixtures/util'; import { validateValueProperty } from '../validateValueProperty'; @@ -45,7 +42,7 @@ describe('validateValueProperty', function () { headers: [], }, }; - const data = {component: { 'true': true }}; + const data = { component: { true: true } }; const context = generateProcessorContext(component, data); const result = await validateValueProperty(context); @@ -62,7 +59,7 @@ describe('validateValueProperty', function () { headers: [], }, }; - const data = {component: { 'true': true }}; + const data = { component: { true: true } }; const context = generateProcessorContext(component, data); set(context, 'instance.options.building', true); diff --git a/src/process/validation/rules/validateCustom.ts b/src/process/validation/rules/validateCustom.ts index 960b150a..5819e412 100644 --- a/src/process/validation/rules/validateCustom.ts +++ b/src/process/validation/rules/validateCustom.ts @@ -1,6 +1,7 @@ import { RuleFn, RuleFnSync, ProcessorInfo, ValidationContext } from 'types'; import { FieldError, ProcessorError } from 'error'; import { Evaluator } from 'utils'; +import { normalizeContext } from 'utils/Evaluator'; export const validateCustom: RuleFn = async (context: ValidationContext) => { return validateCustomSync(context); @@ -27,8 +28,8 @@ export const validateCustomSync: RuleFnSync = (context: ValidationContext) => { ...(instance?.evalContext ? instance.evalContext() : evalContext - ? evalContext(context) - : context), + ? evalContext(normalizeContext(context)) + : normalizeContext(context)), component, data, row, diff --git a/src/process/validation/rules/validateJson.ts b/src/process/validation/rules/validateJson.ts index 3ee2069e..582abc3f 100644 --- a/src/process/validation/rules/validateJson.ts +++ b/src/process/validation/rules/validateJson.ts @@ -3,6 +3,7 @@ import { FieldError } from 'error'; import { RuleFn, RuleFnSync, ValidationContext } from 'types'; import { ProcessorInfo } from 'types/process/ProcessorInfo'; import { isObject } from 'lodash'; +import { normalizeContext } from 'utils/Evaluator'; export const shouldValidate = (context: ValidationContext) => { const { component } = context; @@ -23,7 +24,9 @@ export const validateJsonSync: RuleFnSync = (context: ValidationContext) => { } const func = component?.validate?.json; - const evalContextValue = evalContext ? evalContext(context) : context; + const evalContextValue = evalContext + ? evalContext(normalizeContext(context)) + : normalizeContext(context); evalContextValue.value = value || null; const valid: true | string = JSONLogicEvaluator.evaluate( func, diff --git a/src/process/validation/rules/validateMultiple.ts b/src/process/validation/rules/validateMultiple.ts index 93498727..b539c48f 100644 --- a/src/process/validation/rules/validateMultiple.ts +++ b/src/process/validation/rules/validateMultiple.ts @@ -9,7 +9,6 @@ import { ValidationContext, } from 'types'; import { ProcessorInfo } from 'types/process/ProcessorInfo'; -import { getModelType } from 'utils/formUtil'; export const isEligible = (component: Component) => { // TODO: would be nice if this was type safe @@ -84,7 +83,7 @@ export const validateMultipleSync: RuleFnSync = (context: ValidationContext) => const shouldBeMultipleArray = !!component.multiple; const isRequired = !!component.validate?.required; - const compModelType = getModelType(component); + const compModelType = component.modelType || ''; const underlyingValueShouldBeArray = ['nestedArray', 'nestedDataArray'].indexOf(compModelType) !== -1 || (isTagsComponent(component) && component.storeas === 'array'); diff --git a/src/process/validation/rules/validateRequired.ts b/src/process/validation/rules/validateRequired.ts index 746ab7e2..5208f3ae 100644 --- a/src/process/validation/rules/validateRequired.ts +++ b/src/process/validation/rules/validateRequired.ts @@ -73,10 +73,7 @@ const valueIsPresent = ( export const shouldValidate = (context: ValidationContext) => { const { component } = context; - if ( - component.validate?.required && - !(component.hidden || component.ephemeralState?.conditionallyHidden) - ) { + if (component.validate?.required && !(component.hidden || component.scope?.conditionallyHidden)) { return true; } return false; diff --git a/src/process/validation/rules/validateValueProperty.ts b/src/process/validation/rules/validateValueProperty.ts index 4f9c56fe..70718de2 100644 --- a/src/process/validation/rules/validateValueProperty.ts +++ b/src/process/validation/rules/validateValueProperty.ts @@ -3,15 +3,11 @@ import { ListComponent, RuleFn, RuleFnSync, ValidationContext } from 'types'; import { ProcessorInfo } from 'types/process/ProcessorInfo'; const isValidatableListComponent = (comp: any): comp is ListComponent => { - return ( - comp && - comp.type && - comp.type === 'selectboxes' - ); + return comp && comp.type && comp.type === 'selectboxes'; }; export const shouldValidate = (context: ValidationContext) => { - const { component, instance} = context; + const { component, instance } = context; if (!isValidatableListComponent(component)) { return false; } @@ -39,7 +35,7 @@ export const validateValuePropertySync: RuleFnSync = (context: ValidationContext Object.entries(value as any).some( ([key, value]) => value && (key === '[object Object]' || key === 'true' || key === 'false'), ) || - (instance && instance.loadedOptions?.some(option => option.invalid)) + (instance && instance.loadedOptions?.some((option) => option.invalid)) ) { return error; } diff --git a/src/sdk/__tests__/Formio.test.ts b/src/sdk/__tests__/Formio.test.ts index 6d7d90ca..1d7c1339 100644 --- a/src/sdk/__tests__/Formio.test.ts +++ b/src/sdk/__tests__/Formio.test.ts @@ -1896,6 +1896,7 @@ describe('Formio.js Tests', function () { submissionAccess: [], components: [ { + type: 'hidden', defaultPermission: 'read', key: 'groupField', }, @@ -1952,6 +1953,7 @@ describe('Formio.js Tests', function () { submissionAccess: [], components: [ { + type: 'hidden', defaultPermission: 'create', key: 'groupField', }, @@ -2008,6 +2010,7 @@ describe('Formio.js Tests', function () { submissionAccess: [], components: [ { + type: 'hidden', defaultPermission: 'write', key: 'groupField', }, @@ -2064,6 +2067,7 @@ describe('Formio.js Tests', function () { submissionAccess: [], components: [ { + type: 'hidden', defaultPermission: 'admin', key: 'groupField', }, @@ -2136,6 +2140,7 @@ describe('Formio.js Tests', function () { submissionAccess: [], components: [ { + type: 'hidden', defaultPermission: 'read', key: 'groupField', }, diff --git a/src/types/BaseComponent.ts b/src/types/BaseComponent.ts index 4b976a44..1c3428c0 100644 --- a/src/types/BaseComponent.ts +++ b/src/types/BaseComponent.ts @@ -14,11 +14,22 @@ export type SimpleConditional = { conditions: SimpleConditionalConditions; }; +export type ComponentScope = { + path?: string; // The "form" path to the component including non-layout parent components. + fullPath?: string; // The "form" path to the component including all parent components (including layout components). + localPath?: string; // The "form" path to the component local to the current "nested form" component. + fullLocalPath?: string; // The "form" path to the component local to the current "nested form" component including all parent components (including layout components). + dataPath?: string; // The "full" data path to a component. + localDataPath?: string; // The "local" data path to a component referenced from the parent nested form. + dataIndex?: number; // The current data index (rowIndex) for this component. + conditionallyHidden?: boolean; +}; + export type BaseComponent = { input: boolean; type: string; key: string; - path?: string; + path?: string; // The "form" path to the component including non-layout parent components. parent?: BaseComponent; tableView?: boolean; placeholder?: string; @@ -31,9 +42,7 @@ export type BaseComponent = { unique?: boolean; persistent?: boolean | string; hidden?: boolean; - ephemeralState?: { - conditionallyHidden?: boolean; - }; + scope?: ComponentScope; clearOnHide?: boolean; refreshOn?: string; redrawOn?: string; diff --git a/src/types/PassedComponentInstance.ts b/src/types/PassedComponentInstance.ts index bb1a308b..fc40c331 100644 --- a/src/types/PassedComponentInstance.ts +++ b/src/types/PassedComponentInstance.ts @@ -19,5 +19,5 @@ export type PassedComponentInstance = { evaluate: (expression: string, additionalContext?: Record) => any; interpolate: (text: string, additionalContext?: Record) => string; shouldSkipValidation: (data?: DataObject, row?: DataObject) => boolean; - loadedOptions?: Array<{invalid: boolean, value: any, label: string}> + loadedOptions?: Array<{ invalid: boolean; value: any; label: string }>; }; diff --git a/src/utils/Evaluator.ts b/src/utils/Evaluator.ts index 3eb8da2e..01ffd929 100644 --- a/src/utils/Evaluator.ts +++ b/src/utils/Evaluator.ts @@ -1,10 +1,28 @@ import { noop, trim, keys, get, set, isObject, values } from 'lodash'; +import { getComponentLocalData } from './formUtil'; export interface EvaluatorOptions { noeval?: boolean; data?: any; } +export type EvaluatorContext = { + evalContext?: (context: any) => any; + instance?: any; + [key: string]: any; +}; + +export function normalizeContext(context: any): any { + const { component, data } = context; + return { + ...context, + ...{ + path: component.scope?.localDataPath, + data: getComponentLocalData(component, data), + }, + }; +} + // BaseEvaluator is for extending. export class BaseEvaluator { static templateSettings = { diff --git a/src/utils/__tests__/formUtil.test.ts b/src/utils/__tests__/formUtil.test.ts index 5e004c94..e0baae0b 100644 --- a/src/utils/__tests__/formUtil.test.ts +++ b/src/utils/__tests__/formUtil.test.ts @@ -15,6 +15,7 @@ import { getComponentActualValue, hasCondition, getModelType, + setComponentScope, } from '../formUtil'; const writtenNumber = (n: number | null) => { @@ -109,16 +110,13 @@ describe('formUtil', function () { }, }, }; - const path = 'a.b.c'; - const actual = getContextualRowData( - { - type: 'textfield', - input: true, - key: 'c', - }, - path, - data, - ); + const component = { + type: 'textfield', + input: true, + key: 'c', + }; + setComponentScope(component, 'dataPath', 'a.b.c'); + const actual = getContextualRowData(component, data); const expected = { c: 'hello' }; expect(actual).to.deep.equal(expected); }); @@ -131,16 +129,13 @@ describe('formUtil', function () { }, }, }; - const path = 'a.b'; - const actual = getContextualRowData( - { - type: 'textfield', - input: true, - key: 'b', - }, - path, - data, - ); + const component = { + type: 'textfield', + input: true, + key: 'b', + }; + setComponentScope(component, 'dataPath', 'a.b'); + const actual = getContextualRowData(component, data); const expected = { b: { c: 'hello' } }; expect(actual).to.deep.equal(expected); }); @@ -153,16 +148,13 @@ describe('formUtil', function () { }, }, }; - const path = 'a'; - const actual = getContextualRowData( - { - type: 'textfield', - input: true, - key: 'a', - }, - path, - data, - ); + const component = { + type: 'textfield', + input: true, + key: 'a', + }; + setComponentScope(component, 'dataPath', 'a'); + const actual = getContextualRowData(component, data); const expected = { a: { b: { c: 'hello' } } }; expect(actual).to.deep.equal(expected); }); @@ -176,16 +168,13 @@ describe('formUtil', function () { }, d: 'there', }; - const path = ''; - const actual = getContextualRowData( - { - type: 'textfield', - input: true, - key: 'd', - }, - path, - data, - ); + const component = { + type: 'textfield', + input: true, + key: 'd', + }; + setComponentScope(component, 'dataPath', ''); + const actual = getContextualRowData(component, data); const expected = { a: { b: { c: 'hello' } }, d: 'there' }; expect(actual).to.deep.equal(expected); }); @@ -197,16 +186,13 @@ describe('formUtil', function () { { b: 'foo', c: 'bar' }, ], }; - const path = 'a[0].b'; - const actual = getContextualRowData( - { - type: 'textfield', - input: true, - key: 'b', - }, - path, - data, - ); + const component = { + type: 'textfield', + input: true, + key: 'b', + }; + setComponentScope(component, 'dataPath', 'a[0].b'); + const actual = getContextualRowData(component, data); const expected = { b: 'hello', c: 'world' }; expect(actual).to.deep.equal(expected); }); @@ -218,16 +204,13 @@ describe('formUtil', function () { { b: 'foo', c: 'bar' }, ], }; - const path = 'a[1].b'; - const actual = getContextualRowData( - { - type: 'textfield', - input: true, - key: 'b', - }, - path, - data, - ); + const component = { + type: 'textfield', + input: true, + key: 'b', + }; + setComponentScope(component, 'dataPath', 'a[1].b'); + const actual = getContextualRowData(component, data); const expected = { b: 'foo', c: 'bar' }; expect(actual).to.deep.equal(expected); }); @@ -239,16 +222,13 @@ describe('formUtil', function () { { b: 'foo', c: 'bar' }, ], }; - const path = 'a'; - const actual = getContextualRowData( - { - type: 'textfield', - input: true, - key: 'a', - }, - path, - data, - ); + const component = { + type: 'textfield', + input: true, + key: 'a', + }; + setComponentScope(component, 'dataPath', 'a'); + const actual = getContextualRowData(component, data); const expected = { a: [ { b: 'hello', c: 'world' }, @@ -265,16 +245,12 @@ describe('formUtil', function () { { b: 'foo', c: 'bar' }, ], }; - const path = ''; - const actual = getContextualRowData( - { - type: 'textfield', - input: true, - key: 'a', - }, - path, - data, - ); + const component = { + type: 'textfield', + input: true, + key: 'a', + }; + const actual = getContextualRowData(component, data); const expected = { a: [ { b: 'hello', c: 'world' }, @@ -293,16 +269,13 @@ describe('formUtil', function () { ], }, }; - const path = 'a.b[0].c'; - const actual = getContextualRowData( - { - type: 'textfield', - input: true, - key: 'c', - }, - path, - data, - ); + const component = { + type: 'textfield', + input: true, + key: 'c', + }; + setComponentScope(component, 'dataPath', 'a.b[0].c'); + const actual = getContextualRowData(component, data); const expected = { c: 'hello', d: 'world' }; expect(actual).to.deep.equal(expected); }); @@ -316,16 +289,13 @@ describe('formUtil', function () { ], }, }; - const path = 'a.b[1].c'; - const actual = getContextualRowData( - { - type: 'textfield', - input: true, - key: 'c', - }, - path, - data, - ); + const component = { + type: 'textfield', + input: true, + key: 'c', + }; + setComponentScope(component, 'dataPath', 'a.b[1].c'); + const actual = getContextualRowData(component, data); const expected = { c: 'foo', d: 'bar' }; expect(actual).to.deep.equal(expected); }); @@ -339,16 +309,13 @@ describe('formUtil', function () { ], }, }; - const path = 'a.b'; - const actual = getContextualRowData( - { - type: 'textfield', - input: true, - key: 'b', - }, - path, - data, - ); + const component = { + type: 'textfield', + input: true, + key: 'b', + }; + setComponentScope(component, 'dataPath', 'a.b'); + const actual = getContextualRowData(component, data); const expected = { b: [ { c: 'hello', d: 'world' }, @@ -367,16 +334,13 @@ describe('formUtil', function () { ], }, }; - const path = 'a'; - const actual = getContextualRowData( - { - type: 'textfield', - input: true, - key: 'a', - }, - path, - data, - ); + const component = { + type: 'textfield', + input: true, + key: 'a', + }; + setComponentScope(component, 'dataPath', 'a'); + const actual = getContextualRowData(component, data); const expected = { a: { b: [ @@ -397,16 +361,13 @@ describe('formUtil', function () { ], }, }; - const path = ''; - const actual = getContextualRowData( - { - type: 'textfield', - input: true, - key: 'a', - }, - path, - data, - ); + const component = { + type: 'textfield', + input: true, + key: 'a', + }; + setComponentScope(component, 'dataPath', ''); + const actual = getContextualRowData(component, data); const expected = { a: { b: [ @@ -427,16 +388,13 @@ describe('formUtil', function () { ], }, }; - const path = 'a.b[0].c.e'; - const actual = getContextualRowData( - { - type: 'textfield', - input: true, - key: 'c.e', - }, - path, - data, - ); + const component = { + type: 'textfield', + input: true, + key: 'c.e', + }; + setComponentScope(component, 'dataPath', 'a.b[0].c.e'); + const actual = getContextualRowData(component, data); const expected = { c: { e: 'zed' }, d: 'world' }; expect(actual).to.deep.equal(expected); }); @@ -712,8 +670,6 @@ describe('formUtil', function () { rowResults.set(path, [component, value]); }, undefined, - undefined, - undefined, true, ); expect(rowResults.size).to.equal(2); @@ -1479,8 +1435,6 @@ describe('formUtil', function () { rowResults.set(path, [component, value]); }, undefined, - undefined, - undefined, true, ); expect(rowResults.size).to.equal(2); diff --git a/src/utils/conditions.ts b/src/utils/conditions.ts index b0b13bbe..fd1f3d91 100644 --- a/src/utils/conditions.ts +++ b/src/utils/conditions.ts @@ -1,13 +1,9 @@ import { ConditionsContext, JSONConditional, LegacyConditional, SimpleConditional } from 'types'; import { EvaluatorFn, evaluate, JSONLogicEvaluator } from 'modules/jsonlogic'; -import { - flattenComponents, - getComponent, - getComponentAbsolutePath, - getComponentActualValue, -} from './formUtil'; +import { flattenComponents, getComponent, getComponentActualValue } from './formUtil'; import { has, isObject, map, every, some, find, filter, isBoolean, split } from 'lodash'; import ConditionOperators from './operators'; +import { normalizeContext } from './Evaluator'; export const isJSONConditional = (conditional: any): conditional is JSONConditional => { return conditional && conditional.json && isObject(conditional.json); @@ -22,11 +18,10 @@ export const isSimpleConditional = (conditional: any): conditional is SimpleCond }; export function conditionallyHidden(context: ConditionsContext) { - const { scope, path, component } = context; - const absolutePath = getComponentAbsolutePath(component) || path; - if (scope.conditionals && absolutePath) { + const { scope, path } = context; + if (scope.conditionals && path) { const hidden = find(scope.conditionals, (conditional) => { - return conditional.path === absolutePath; + return conditional.path === path; }); return hidden?.conditionallyHidden; } @@ -100,7 +95,9 @@ export function checkJsonConditional( if (!conditional || !isJSONConditional(conditional)) { return null; } - const evalContextValue = evalContext ? evalContext(context) : context; + const evalContextValue = evalContext + ? evalContext(normalizeContext(context)) + : normalizeContext(context); return JSONLogicEvaluator.evaluate(conditional.json, evalContextValue); } diff --git a/src/utils/formUtil/__tests__/eachComponent.test.ts b/src/utils/formUtil/__tests__/eachComponent.test.ts index 94d9beaa..269ccf2e 100644 --- a/src/utils/formUtil/__tests__/eachComponent.test.ts +++ b/src/utils/formUtil/__tests__/eachComponent.test.ts @@ -666,18 +666,18 @@ describe('eachComponent', function () { expect(numComps).to.be.equal(8); }); - it('Should provide the paths to all of the components', function () { + it('Should provide the paths to all of the components if includeAll=true', function () { const paths = [ 'one', 'parent1', - 'two', - 'parent2', - 'three', - '', - 'four', - 'five', - 'six', - 'seven', + 'parent1.two', + 'parent1.parent2', + 'parent1.parent2.three', + 'parent1.parent2', + 'parent1.parent2.four', + 'parent1.parent2.five', + 'parent1.parent2.six', + 'parent1.parent2.seven', 'eight', ]; const testPaths: string[] = []; @@ -879,14 +879,13 @@ describe('eachComponent', function () { ]; const rowResults: Map = new Map(); eachComponent( - components[0].components, + components, (component: Component, path: string) => { rowResults.set(path, component); }, true, - 'dataGrid', ); - expect(rowResults.size).to.equal(2); + expect(rowResults.size).to.equal(3); expect(rowResults.get('dataGrid.nestedTextField')).to.deep.equal({ type: 'textfield', key: 'nestedTextField', @@ -903,14 +902,14 @@ describe('eachComponent', function () { const paths = [ 'a', 'b', - 'c', - 'd', - 'f', - 'f.g', - 'f.h', - 'f.i', - 'e', - 'j', + 'b.c', + 'b.c.d', + 'b.c.f', + 'b.c.f.g', + 'b.c.f.h', + 'b.c.f.i', + 'b.c.e', + 'b.j', 'k', 'k.n', 'k.n.o', diff --git a/src/utils/formUtil/eachComponent.ts b/src/utils/formUtil/eachComponent.ts index 3034149f..fff35d84 100644 --- a/src/utils/formUtil/eachComponent.ts +++ b/src/utils/formUtil/eachComponent.ts @@ -1,5 +1,5 @@ import { Component, EachComponentCallback } from 'types'; -import { componentInfo, componentPath, componentFormPath } from './index'; +import { componentInfo, setDefaultComponentPaths, setParentReference } from './index'; /** * Iterate through each component within a form. @@ -10,8 +10,6 @@ import { componentInfo, componentPath, componentFormPath } from './index'; * The iteration function to invoke for each component. * @param {Boolean} includeAll * Whether or not to include layout components. - * @param {String} path - * The current data path of the element. Example: data.user.firstName * @param {Object} parent * The parent object. */ @@ -19,7 +17,6 @@ export function eachComponent( components: Component[], fn: EachComponentCallback, includeAll?: boolean, - path: string = '', parent?: Component, ) { if (!components) return; @@ -29,57 +26,27 @@ export function eachComponent( } const info = componentInfo(component); let noRecurse = false; - // Keep track of parent references. - if (parent) { - // Ensure we don't create infinite JSON structures. - Object.defineProperty(component, 'parent', { - enumerable: false, - writable: true, - value: JSON.parse(JSON.stringify(parent)), - }); - Object.defineProperty(component.parent, 'parent', { - enumerable: false, - writable: true, - value: parent.parent, - }); - Object.defineProperty(component.parent, 'path', { - enumerable: false, - writable: true, - value: parent.path, - }); - delete component.parent.components; - delete component.parent.componentMap; - delete component.parent.columns; - delete component.parent.rows; - } - - const compPath = componentPath(component, path); + setParentReference(component, parent); + setDefaultComponentPaths(component); if (includeAll || component.tree || !info.layout) { - noRecurse = !!fn(component, compPath, components, parent); + const path = includeAll ? component.scope?.fullPath || '' : component.path || ''; + noRecurse = !!fn(component, path, components, parent); } if (!noRecurse) { if (info.hasColumns) { component.columns.forEach((column: any) => - eachComponent(column.components, fn, includeAll, path, parent ? component : null), + eachComponent(column.components, fn, includeAll, component), ); } else if (info.hasRows) { component.rows.forEach((row: any) => { if (Array.isArray(row)) { - row.forEach((column) => - eachComponent(column.components, fn, includeAll, path, parent ? component : null), - ); + row.forEach((column) => eachComponent(column.components, fn, includeAll, component)); } }); } else if (info.hasComps) { - eachComponent( - component.components, - fn, - includeAll, - componentFormPath(component, path, compPath), - parent ? component : null, - ); + eachComponent(component.components, fn, includeAll, component); } } }); diff --git a/src/utils/formUtil/eachComponentAsync.ts b/src/utils/formUtil/eachComponentAsync.ts index 36985a32..4c9e503b 100644 --- a/src/utils/formUtil/eachComponentAsync.ts +++ b/src/utils/formUtil/eachComponentAsync.ts @@ -1,11 +1,11 @@ -import { componentInfo, componentPath, componentFormPath } from './index'; +import { Component } from 'types'; +import { componentInfo, setDefaultComponentPaths, setParentReference } from './index'; // Async each component. export async function eachComponentAsync( - components: any[], + components: Component[], fn: any, includeAll = false, - path = '', parent?: any, ) { if (!components) return; @@ -13,71 +13,32 @@ export async function eachComponentAsync( if (!components[i]) { continue; } - const component = components[i]; + const component: any = components[i]; const info = componentInfo(component); - // Keep track of parent references. - if (parent) { - // Ensure we don't create infinite JSON structures. - Object.defineProperty(component, 'parent', { - enumerable: false, - writable: true, - value: JSON.parse(JSON.stringify(parent)), - }); - Object.defineProperty(component.parent, 'parent', { - enumerable: false, - writable: true, - value: parent.parent, - }); - Object.defineProperty(component.parent, 'path', { - enumerable: false, - writable: true, - value: parent.path, - }); - delete component.parent.components; - delete component.parent.componentMap; - delete component.parent.columns; - delete component.parent.rows; - } - const compPath = componentPath(component, path); + setParentReference(component, parent); + setDefaultComponentPaths(component); if (includeAll || component.tree || !info.layout) { - if (await fn(component, compPath, components, parent)) { + const path = includeAll ? component.scope?.fullPath || '' : component.path || ''; + if (await fn(component, path, components, parent)) { continue; } } if (info.hasColumns) { for (let j = 0; j < component.columns.length; j++) { - await eachComponentAsync( - component.columns[j]?.components, - fn, - includeAll, - path, - parent ? component : null, - ); + await eachComponentAsync(component.columns[j]?.components, fn, includeAll, component); } } else if (info.hasRows) { for (let j = 0; j < component.rows.length; j++) { const row = component.rows[j]; if (Array.isArray(row)) { for (let k = 0; k < row.length; k++) { - await eachComponentAsync( - row[k]?.components, - fn, - includeAll, - path, - parent ? component : null, - ); + await eachComponentAsync(row[k]?.components, fn, includeAll, component); } } } } else if (info.hasComps) { - await eachComponentAsync( - component.components, - fn, - includeAll, - componentFormPath(component, path, compPath), - parent ? component : null, - ); + await eachComponentAsync(component.components, fn, includeAll, component); } } } diff --git a/src/utils/formUtil/eachComponentData.ts b/src/utils/formUtil/eachComponentData.ts index 10e4a673..69b0106a 100644 --- a/src/utils/formUtil/eachComponentData.ts +++ b/src/utils/formUtil/eachComponentData.ts @@ -1,5 +1,4 @@ -import { isEmpty, get, set, has } from 'lodash'; - +import { get } from 'lodash'; import { Component, DataObject, @@ -9,139 +8,100 @@ import { HasRows, } from 'types'; import { - getContextualRowData, isComponentNestedDataType, - getModelType, - componentDataPath, componentInfo, - componentFormPath, + getContextualRowData, + shouldProcessComponent, + componentPath, + setComponentScope, + resetComponentScope, + COMPONENT_PATH, + setComponentPaths, } from './index'; import { eachComponent } from './eachComponent'; +/** + * Iterates through each component as well as its data, and triggers a callback for every component along + * with the contextual data for that component in addition to the absolute path for that component. + * @param components - The array of JSON components to iterate through. + * @param data - The contextual data object for the components. + * @param fn - The callback function to trigger for each component following the signature (component, data, row, path, components, index, parent). + * @param parent - The parent component. + * @param includeAll + * @returns + */ export const eachComponentData = ( components: Component[], data: DataObject, fn: EachComponentDataCallback, - path = '', - index?: number, parent?: Component, includeAll: boolean = false, ) => { - if (!components || !data) { + if (!components) { return; } return eachComponent( components, (component, compPath, componentComponents, compParent) => { - const row = getContextualRowData(component, compPath, data); - if (fn(component, data, row, compPath, componentComponents, index, compParent) === true) { + setComponentPaths(component, { + dataPath: componentPath(component, COMPONENT_PATH.DATA), + localDataPath: componentPath(component, COMPONENT_PATH.LOCAL_DATA), + }); + const row = getContextualRowData(component, data); + if ( + fn( + component, + data, + row, + component.scope?.dataPath || '', + componentComponents, + component.scope?.dataIndex, + compParent, + ) === true + ) { + resetComponentScope(component); return true; } if (isComponentNestedDataType(component)) { - const value = get(data, compPath, data) as DataObject; + const value = get(data, component.scope?.dataPath || '') as DataObject; if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { - const nestedComponentPath = - getModelType(component) === 'nestedDataArray' - ? `${compPath}[${i}].data` - : `${compPath}[${i}]`; - eachComponentData( - component.components, - data, - fn, - nestedComponentPath, - i, - component, - includeAll, - ); + setComponentScope(component, 'dataIndex', i); + eachComponentData(component.components, data, fn, component, includeAll); } + resetComponentScope(component); return true; - } else if (isEmpty(row) && !includeAll) { - // Tree components may submit empty objects; since we've already evaluated the parent tree/layout component, we won't worry about constituent elements - return true; - } - if (getModelType(component) === 'dataObject') { - // No need to bother processing all the children data if there is no data for this form or the reference value has not been loaded. - const nestedFormValue: any = get(data, component.path); - const noReferenceAttached = - nestedFormValue?._id && isEmpty(nestedFormValue.data) && !has(nestedFormValue, 'form'); - const shouldProcessNestedFormData = nestedFormValue?._id - ? !noReferenceAttached - : has(data, component.path); - if (shouldProcessNestedFormData) { - // For nested forms, we need to reset the "data" and "path" objects for all of the children components, and then re-establish the data when it is done. - const childPath: string = componentDataPath(component, path, compPath); - const childData: any = get(data, childPath, {}); - eachComponentData( - component.components, - childData, - fn, - '', - index, - component, - includeAll, - ); - set(data, childPath, childData); - } } else { - eachComponentData( - component.components, - data, - fn, - componentDataPath(component, path, compPath), - index, - component, - includeAll, - ); + if (!includeAll && !shouldProcessComponent(component, row, value)) { + resetComponentScope(component); + return true; + } + eachComponentData(component.components, data, fn, component, includeAll); } + resetComponentScope(component); return true; - } else if (getModelType(component) === 'none') { + } else if (!component.type || component.modelType === 'none') { const info = componentInfo(component); if (info.hasColumns) { - const columnsComponent = component as HasColumns; - columnsComponent.columns.forEach((column: any) => - eachComponentData( - column.components, - data, - fn, - componentFormPath(columnsComponent, path, columnsComponent.path), - index, - component, - ), + (component as HasColumns).columns.forEach((column: any) => + eachComponentData(column.components, data, fn, component), ); } else if (info.hasRows) { - const rowsComponent = component as HasRows; - rowsComponent.rows.forEach((row: any) => { + (component as HasRows).rows.forEach((row: any) => { if (Array.isArray(row)) { - row.forEach((row) => - eachComponentData( - row.components, - data, - fn, - componentFormPath(rowsComponent, path, rowsComponent.path), - index, - component, - ), - ); + row.forEach((row) => eachComponentData(row.components, data, fn, component)); } }); } else if (info.hasComps) { - const componentWithChildren = component as HasChildComponents; - eachComponentData( - componentWithChildren.components, - data, - fn, - componentFormPath(componentWithChildren, path, componentWithChildren.path), - index, - component, - ); + eachComponentData((component as HasChildComponents).components, data, fn, component); } + resetComponentScope(component); return true; } + resetComponentScope(component); return false; }, true, - path, parent, ); }; diff --git a/src/utils/formUtil/eachComponentDataAsync.ts b/src/utils/formUtil/eachComponentDataAsync.ts index 3889be0e..7fb6861e 100644 --- a/src/utils/formUtil/eachComponentDataAsync.ts +++ b/src/utils/formUtil/eachComponentDataAsync.ts @@ -1,20 +1,16 @@ -import { get, set, has, isEmpty } from 'lodash'; +import { get } from 'lodash'; +import { Component, DataObject, EachComponentDataAsyncCallback, HasColumns, HasRows } from 'types'; import { - Component, - DataObject, - EachComponentDataAsyncCallback, - HasChildComponents, - HasColumns, - HasRows, -} from 'types'; -import { - getContextualRowData, isComponentNestedDataType, - getModelType, - componentDataPath, componentInfo, - componentFormPath, + getContextualRowData, + shouldProcessComponent, + setComponentScope, + resetComponentScope, + componentPath, + COMPONENT_PATH, + setComponentPaths, } from './index'; import { eachComponentAsync } from './eachComponentAsync'; @@ -23,8 +19,6 @@ export const eachComponentDataAsync = async ( components: Component[], data: DataObject, fn: EachComponentDataAsyncCallback, - path = '', - index?: number, parent?: Component, includeAll: boolean = false, ) => { @@ -34,103 +28,69 @@ export const eachComponentDataAsync = async ( return await eachComponentAsync( components, async (component: any, compPath: string, componentComponents: any, compParent: any) => { - const row = getContextualRowData(component, compPath, data); + setComponentPaths(component, { + dataPath: componentPath(component, COMPONENT_PATH.DATA), + localDataPath: componentPath(component, COMPONENT_PATH.LOCAL_DATA), + }); + const row = getContextualRowData(component, data); if ( - (await fn(component, data, row, compPath, componentComponents, index, compParent)) === true + (await fn( + component, + data, + row, + component.scope?.dataPath || '', + componentComponents, + component.scope?.dataIndex, + compParent, + )) === true ) { + resetComponentScope(component); return true; } if (isComponentNestedDataType(component)) { - const value = get(data, compPath, data); + const value = get(data, component.scope?.dataPath || ''); if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { - const nestedComponentPath = - getModelType(component) === 'nestedDataArray' - ? `${compPath}[${i}].data` - : `${compPath}[${i}]`; - await eachComponentDataAsync( - component.components, - data, - fn, - nestedComponentPath, - i, - component, - includeAll, - ); + setComponentScope(component, 'dataIndex', i); + await eachComponentDataAsync(component.components, data, fn, component, includeAll); } + resetComponentScope(component); return true; - } else if (isEmpty(row) && !includeAll) { - // Tree components may submit empty objects; since we've already evaluated the parent tree/layout component, we won't worry about constituent elements - return true; - } - if (getModelType(component) === 'dataObject') { - // No need to bother processing all the children data if there is no data for this form or the reference value has not been loaded. - const nestedFormValue: any = get(data, component.path); - const noReferenceAttached = - nestedFormValue?._id && isEmpty(nestedFormValue.data) && !has(nestedFormValue, 'form'); - const shouldProcessNestedFormData = nestedFormValue?._id - ? !noReferenceAttached - : has(data, component.path); - if (shouldProcessNestedFormData) { - // For nested forms, we need to reset the "data" and "path" objects for all of the children components, and then re-establish the data when it is done. - const childPath: string = componentDataPath(component, path, compPath); - const childData: any = get(data, childPath, null); - await eachComponentDataAsync( - component.components, - childData, - fn, - '', - index, - component, - includeAll, - ); - set(data, childPath, childData); - } } else { - await eachComponentDataAsync( - component.components, - data, - fn, - componentDataPath(component, path, compPath), - index, - component, - includeAll, - ); + if (!includeAll && !shouldProcessComponent(component, row, value)) { + resetComponentScope(component); + return true; + } + await eachComponentDataAsync(component.components, data, fn, component, includeAll); } + resetComponentScope(component); return true; - } else if (getModelType(component) === 'none') { + } else if (!component.type || component.modelType === 'none') { const info = componentInfo(component); if (info.hasColumns) { const columnsComponent = component as HasColumns; for (const column of columnsComponent.columns) { - await eachComponentDataAsync(column.components, data, fn, path, index, component); + await eachComponentDataAsync(column.components, data, fn, component); } } else if (info.hasRows) { const rowsComponent = component as HasRows; for (const rowArray of rowsComponent.rows) { if (Array.isArray(rowArray)) { for (const row of rowArray) { - await eachComponentDataAsync(row.components, data, fn, path, index, component); + await eachComponentDataAsync(row.components, data, fn, component); } } } } else if (info.hasComps) { - const componentWithChildren = component as HasChildComponents; - await eachComponentDataAsync( - componentWithChildren.components, - data, - fn, - componentFormPath(componentWithChildren, path, componentWithChildren.path), - index, - component, - ); + await eachComponentDataAsync(component.components, data, fn, component); } + resetComponentScope(component); return true; } + resetComponentScope(component); return false; }, true, - path, parent, ); }; diff --git a/src/utils/formUtil/index.ts b/src/utils/formUtil/index.ts index 0350a54f..6215d292 100644 --- a/src/utils/formUtil/index.ts +++ b/src/utils/formUtil/index.ts @@ -18,6 +18,7 @@ import { isBoolean, omit, every, + escapeRegExp, } from 'lodash'; import { compare, applyPatch } from 'fast-json-patch'; @@ -40,6 +41,7 @@ import { SimpleConditional, AddressComponent, SelectComponent, + ComponentScope, } from 'types'; import { Evaluator } from '../Evaluator'; import { eachComponent } from './eachComponent'; @@ -108,6 +110,127 @@ export function uniqueName(name: string, template?: string, evalContext?: any) { return uniqueName; } +/** + * Defines the Component paths used for every component within a form. This allows for + * quick reference to either the "form" path or the "data" path of a component. These paths are + * defined as follows. + * + * - Form Path: The path to a component within the Form JSON. This path is used to locate a component provided a nested Form JSON object. + * - Data Path: The path to the data value of a component within the data model for the form. This path is used to provide the value path provided the Submission JSON object. + * + * These paths can also be broken into two different path "types". Local and Full paths. + * + * - Local Path: This is the path relative to the "current" form. This is used inside of a nested form to identify components and values relative to the current form in context. + * - Full Path: This is the path that is absolute to the root form object. Any nested form paths will include the parent form path as part of the value for the provided path. + */ +export enum COMPONENT_PATH { + /** + * The "form" path to the component including all parent paths (exclusive of layout components). This path is used to uniquely identify component within a form inclusive of any parent form paths. + * + * For example: Suppose you have the following form structure. + * - Root + * - Panel 1 (panel) + * - Form (form) + * - Panel 2 (panel2) + * - Data Grid (dataGrid) + * - Panel 3 (panel3) + * - TextField (textField) + * + * The "path" to the TextField component from the perspective of a configuration within the Form, would be "form.dataGrid.textField" + */ + FORM = 'path', + + /** + * The "form" path to the component including all parent paths (inclusive of layout componnts). This path is used to uniquely identify component within a form inclusive of any parent form paths. + * + * For example: Suppose you have the following form structure. + * - Root + * - Panel 1 (panel) + * - Form (form) + * - Panel 2 (panel2) + * - Data Grid (dataGrid) + * - Panel 3 (panel3) + * - TextField (textField) + * + * The "fullPath" to the TextField component from the perspective of a configuration within the Form, would be "panel1.form.panel2.dataGrid.panel3.textField" + */ + FULL_FORM = 'fullPath', + + /** + * The local "form" path to the component. This is the local path to any component within a form. This + * path is consistent no matter if this form is nested within another form or not. All form configurations + * are in relation to this path since forms are configured independently. The difference between a form path + * and a dataPath is that this includes any parent layout components to locate the component provided a form JSON. + * This path does NOT include any layout components. + * + * For example: Suppose you have the following form structure. + * - Root + * - Panel 1 (panel) + * - Form (form) + * - Panel 2 (panel2) + * - Data Grid (dataGrid) + * - Panel 3 (panel3) + * - TextField (textField) + * + * The "path" to the TextField component from the perspective of a configuration within the Form, would be "dataGrid.textField" + */ + LOCAL_FORM = 'localPath', + + /** + * The local "form" path to the component. This is the local path to any component within a form. This + * path is consistent no matter if this form is nested within another form or not. All form configurations + * are in relation to this path since forms are configured independently. The difference between a form path + * and a dataPath is that this includes any parent layout components to locate the component provided a form JSON. + * This path does NOT include any layout components. + * + * For example: Suppose you have the following form structure. + * - Root + * - Panel 1 (panel) + * - Form (form) + * - Panel 2 (panel2) + * - Data Grid (dataGrid) + * - Panel 3 (panel3) + * - TextField (textField) + * + * The "path" to the TextField component from the perspective of a configuration within the Form, would be "dataGrid.textField" + */ + FULL_LOCAL_FORM = 'fullLocalPath', + + /** + * The "data" path to the component including all parent paths. This path is used to fetch the data value of a component within a data model, inclusive of any parent data paths of nested forms. + * + * For example: Suppose you have the following form structure. + * - Root + * - Panel 1 (panel) + * - Form (form) + * - Panel 2 (panel2) + * - Data Grid (dataGrid) + * - Panel 3 (panel3) + * - TextField (textField) + * + * The "dataPath" to the TextField component would be "form.data.dataGrid[1].textField" + */ + DATA = 'dataPath', + + /** + * The "data" path is the local path to the data value for any component. The difference between this path + * and the "path" is that this path is used to locate the data value for a component within the data model. + * and does not include any keys for layout components. + * + * For example: Suppose you have the following form structure. + * - Root + * - Panel 1 (panel) + * - Form (form) + * - Panel 2 (panel2) + * - Data Grid (dataGrid) + * - Panel 3 (panel3) + * - TextField (textField) + * + * The "localDataPath" to the TextField component from the perspective of a configuration within the Form, would be "dataGrid[1].textField" + */ + LOCAL_DATA = 'localDataPath', +} + /** * Defines model types for known components. * For now, these will be the only model types supported by the @formio/core library. @@ -187,19 +310,6 @@ export function getModelType(component: Component): keyof typeof MODEL_TYPES_OF_ return 'any'; } -export function getComponentAbsolutePath(component: Component) { - const paths = [component.path]; - while (component.parent) { - component = component.parent; - // We only need to do this for nested forms because they reset the data contexts for the children. - if (getModelType(component) === 'dataObject') { - paths[paths.length - 1] = `data.${paths[paths.length - 1]}`; - paths.push(component.path); - } - } - return paths.reverse().join('.'); -} - export function getComponentPath(component: Component, path: string) { const key = getComponentKey(component); if (!key) { @@ -208,7 +318,7 @@ export function getComponentPath(component: Component, path: string) { if (!path) { return key; } - if (path.match(new RegExp(`${key}$`))) { + if (path.match(new RegExp(`${escapeRegExp(key)}$`))) { return path; } return getModelType(component) === 'none' ? `${path}.${key}` : path; @@ -225,50 +335,205 @@ export function isComponentNestedDataType(component: any): component is HasChild ); } -export function componentPath(component: Component, parentPath?: string): string { - parentPath = component.parentPath || parentPath; - const key = getComponentKey(component); - if (!key) { - // If the component does not have a key, then just always return the parent path. - return parentPath || ''; +export function setComponentScope( + component: Component, + name: keyof NonNullable, + value: string | boolean | number, +) { + if (!component.scope) { + Object.defineProperty(component, 'scope', { + enumerable: false, + configurable: true, + writable: true, + value: {}, + }); } - return parentPath ? `${parentPath}.${key}` : key; + Object.defineProperty(component.scope, name, { + enumerable: false, + writable: false, + configurable: true, + value, + }); } -export const componentDataPath = (component: any, parentPath: string, path: string): string => { - parentPath = component.parentPath || parentPath; - path = path || componentPath(component, parentPath); - // See if we are a nested component. - if (component.components && Array.isArray(component.components)) { - if (getModelType(component) === 'dataObject') { - return `${path}.data`; - } - if (getModelType(component) === 'nestedArray') { - return `${path}[0]`; - } - if (getModelType(component) === 'nestedDataArray') { - return `${path}[0].data`; +export function resetComponentScope(component: Component) { + if (component.scope) { + delete component.scope; + } +} + +/** + * Return the component path provided the type of the component path. + * @param component - The component JSON. + * @param type - The type of path to return. + * @returns + */ +export function componentPath(component: Component, type: COMPONENT_PATH): string { + const parent = component.parent; + const compModel = getModelType(component); + + // Relative paths are only referenced from the current form. + const relative = + type === COMPONENT_PATH.LOCAL_FORM || + type === COMPONENT_PATH.FULL_LOCAL_FORM || + type === COMPONENT_PATH.LOCAL_DATA; + + // Full paths include all layout component ids in the path. + const fullPath = type === COMPONENT_PATH.FULL_FORM || type === COMPONENT_PATH.FULL_LOCAL_FORM; + + // See if this is a data path. + const dataPath = type === COMPONENT_PATH.DATA || type === COMPONENT_PATH.LOCAL_DATA; + + // Determine if this component should include its key. + const includeKey = + fullPath || (!!component.type && compModel !== 'none' && compModel !== 'content'); + + // The key is provided if the component can have data or if we are fetching the full path. + const key = includeKey ? getComponentKey(component) : ''; + + if (!parent) { + // Return the key if there is no parent. + return key; + } + + // Get the parent model type. + const parentModel = getModelType(parent); + + // If there is a parent, then we only return the key if the parent is a nested form and it is a relative path. + if (relative && parentModel === 'dataObject') { + return key; + } + + // Return the parent path. + let parentPath = parent.scope ? (parent.scope as any)[type] || '' : ''; + + // For data paths (where we wish to get the path to the data), we need to ensure we append the parent + // paths to the end of the path so that any component within this component properly references their data. + if (dataPath && parentPath) { + if ( + parent.scope?.dataIndex !== undefined && + (parentModel === 'nestedArray' || parentModel === 'nestedDataArray') + ) { + parentPath += `[${parent.scope?.dataIndex}]`; } - if (isComponentNestedDataType(component)) { - return path; + if (parentModel === 'dataObject' || parentModel === 'nestedDataArray') { + parentPath += '.data'; } - return parentPath; } - return path; + + // Return the parent path with its relative component path (if applicable). + return parentPath ? (key ? `${parentPath}.${key}` : parentPath) : key; +} + +/** + * The types of paths that can be set on a component. + */ +export type ComponentPaths = { + path?: string; + fullPath?: string; + localPath?: string; + fullLocalPath?: string; + dataPath?: string; + localDataPath?: string; }; -export const componentFormPath = (component: any, parentPath: string, path: string): string => { - parentPath = component.parentPath || parentPath; - path = path || componentPath(component, parentPath); - if (getModelType(component) === 'dataObject') { - return `${path}.data`; +/** + * @param component - The component to establish paths for. + * @param paths - The ComponentPaths object to set the paths on this component. + */ +export function setComponentPaths(component: Component, paths: ComponentPaths = {}) { + Object.defineProperty(component, 'modelType', { + enumerable: false, + writable: true, + value: getModelType(component), + }); + if (paths.hasOwnProperty(COMPONENT_PATH.FORM)) { + // Do not mutate the component path if it is already set. + if (!component.path) { + Object.defineProperty(component, 'path', { + enumerable: false, + writable: true, + value: paths[COMPONENT_PATH.FORM], + }); + } + setComponentScope(component, COMPONENT_PATH.FORM, paths[COMPONENT_PATH.FORM] || ''); } - if (isComponentNestedDataType(component)) { - return path; + if (paths.hasOwnProperty(COMPONENT_PATH.FULL_FORM)) { + setComponentScope(component, COMPONENT_PATH.FULL_FORM, paths[COMPONENT_PATH.FULL_FORM] || ''); } - return parentPath; -}; + if (paths.hasOwnProperty(COMPONENT_PATH.LOCAL_FORM)) { + setComponentScope(component, COMPONENT_PATH.LOCAL_FORM, paths[COMPONENT_PATH.LOCAL_FORM] || ''); + } + if (paths.hasOwnProperty(COMPONENT_PATH.FULL_LOCAL_FORM)) { + setComponentScope( + component, + COMPONENT_PATH.FULL_LOCAL_FORM, + paths[COMPONENT_PATH.FULL_LOCAL_FORM] || '', + ); + } + if (paths.hasOwnProperty(COMPONENT_PATH.DATA)) { + setComponentScope(component, COMPONENT_PATH.DATA, paths[COMPONENT_PATH.DATA] || ''); + } + if (paths.hasOwnProperty(COMPONENT_PATH.LOCAL_DATA)) { + setComponentScope(component, COMPONENT_PATH.LOCAL_DATA, paths[COMPONENT_PATH.LOCAL_DATA] || ''); + } +} +export function setDefaultComponentPaths(component: Component) { + setComponentPaths(component, { + path: componentPath(component, COMPONENT_PATH.FORM), + fullPath: componentPath(component, COMPONENT_PATH.FULL_FORM), + localPath: componentPath(component, COMPONENT_PATH.LOCAL_FORM), + fullLocalPath: componentPath(component, COMPONENT_PATH.FULL_LOCAL_FORM), + }); +} + +/** + * Sets the parent reference on a component, and ensures the component paths are set as well + * as removes any circular references. + * @param component + * @param parent + * @returns + */ +export function setParentReference(component: Component, parent?: Component) { + if (!parent) { + return; + } + const parentRef = JSON.parse(JSON.stringify(parent)); + delete parentRef.components; + delete parentRef.componentMap; + delete parentRef.columns; + delete parentRef.rows; + setComponentPaths(parentRef, { + path: parent.path, + localPath: parent.scope?.localPath || '', + fullPath: parent.scope?.fullPath || '', + fullLocalPath: parent.scope?.fullLocalPath || '', + }); + Object.defineProperty(parentRef, 'scope', { + enumerable: false, + writable: true, + value: parent.scope, + }); + Object.defineProperty(parentRef, 'parent', { + enumerable: false, + writable: true, + value: parent.parent, + }); + Object.defineProperty(component, 'parent', { + enumerable: false, + writable: true, + value: parentRef, + }); +} + +/** + * Provided a component, this will return the "data" key for that component in the contextual data + * object. + * + * @param component + * @returns + */ export function getComponentKey(component: Component) { if ( component.type === 'checkbox' && @@ -280,15 +545,44 @@ export function getComponentKey(component: Component) { return component.key; } -export function getContextualRowPath(component: Component, path: string): string { - return path.replace(new RegExp(`.?${getComponentKey(component)}$`), ''); +export function getContextualRowPath(component: Component): string { + return ( + component.scope?.dataPath?.replace( + new RegExp(`.?${escapeRegExp(getComponentKey(component))}$`), + '', + ) || '' + ); } -export function getContextualRowData(component: Component, path: string, data: any): any { - const rowPath = getContextualRowPath(component, path); +export function getContextualRowData(component: Component, data: any): any { + const rowPath = getContextualRowPath(component); return rowPath ? get(data, rowPath, null) : data; } +export function getComponentLocalData(component: Component, data: any): string { + const parentPath = + component.scope?.dataPath?.replace( + new RegExp(`.?${escapeRegExp(component.scope?.localDataPath)}$`), + '', + ) || ''; + return parentPath ? get(data, parentPath, null) : data; +} + +export function shouldProcessComponent(component: Component, row: any, value: any): boolean { + if (isEmpty(row)) { + return false; + } + if (component.modelType === 'dataObject') { + const noReferenceAttached = value && value._id && isEmpty(value.data) && !has(value, 'form'); + const shouldProcessNestedFormData = + value && value._id ? !noReferenceAttached : value !== undefined; + if (!shouldProcessNestedFormData) { + return false; + } + } + return true; +} + export function componentInfo(component: any) { const hasColumns = component.columns && Array.isArray(component.columns); const hasRows = component.rows && Array.isArray(component.rows); @@ -395,7 +689,9 @@ export function isLayoutComponent(component: Component) { */ export function matchComponent(component: Component, query: any) { if (isString(query)) { - return component.key === query || component.path === query; + return ( + component.key === query || component.scope?.localPath === query || component.path === query + ); } else { let matches = false; forOwn(query, (value, key) => { @@ -425,7 +721,12 @@ export function getComponent( eachComponent( components, (component: Component, path: any) => { - if (path === key || (component.input && component.key === key)) { + if ( + path === key || + component.scope?.localPath === key || + component.path === key || + (component.input !== false && component.key === key) + ) { result = component; return true; } diff --git a/src/utils/logic.ts b/src/utils/logic.ts index 2d1d7a29..33b31758 100644 --- a/src/utils/logic.ts +++ b/src/utils/logic.ts @@ -17,8 +17,7 @@ import { } from 'types/AdvancedLogic'; import { get, set, clone, isEqual, assign } from 'lodash'; import { evaluate, interpolate } from 'modules/jsonlogic'; -import { registerEphemeralState } from './utils'; -import { getComponentAbsolutePath } from './formUtil'; +import { setComponentScope } from 'utils/formUtil'; export const hasLogic = (context: LogicContext): boolean => { const { component } = context; @@ -70,7 +69,6 @@ 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(); @@ -79,19 +77,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' && absolutePath) { + if (property === 'hidden' && path) { if (!(scope as ConditionsScope).conditionals) { (scope as ConditionsScope).conditionals = []; } const conditionallyHidden = (scope as ConditionsScope).conditionals?.find((cond: any) => { - return cond.path === absolutePath; + return cond.path === path; }); if (conditionallyHidden) { conditionallyHidden.conditionallyHidden = !!component.hidden; - registerEphemeralState(component, 'conditionallyHidden', !!component.hidden); + setComponentScope(component, 'conditionallyHidden', !!component.hidden); } else { (scope as ConditionsScope).conditionals?.push({ - path: absolutePath, + path, conditionallyHidden: !!component.hidden, }); } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index b78ad80a..9f705c28 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,5 +1,5 @@ import { isBoolean, isString } from 'lodash'; -import { BaseComponent, Component, ResourceToDomOptions } from 'types'; +import { ResourceToDomOptions } from 'types'; /** * Escapes RegEx characters in provided String value. @@ -43,27 +43,6 @@ export function unescapeHTML(str: string) { return doc.documentElement.textContent; } -export function registerEphemeralState( - component: Component, - name: keyof NonNullable, - value: any, -) { - if (!component.ephemeralState) { - Object.defineProperty(component, 'ephemeralState', { - enumerable: false, - configurable: true, - writable: true, - value: {}, - }); - } - Object.defineProperty(component.ephemeralState, name, { - enumerable: false, - writable: false, - configurable: true, - value, - }); -} - export function attachResourceToDom(options: ResourceToDomOptions) { const { name, formio, onload, rootElement } = options; let { src } = options; @@ -120,9 +99,3 @@ export function attachResourceToDom(options: ResourceToDomOptions) { } }); } - -export function resetEphemeralState(component: Component) { - if (component.ephemeralState) { - delete component.ephemeralState; - } -} From 3eaca60ebb9fc436eff9ba1e57bb7cd7d843aaf4 Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Fri, 8 Nov 2024 13:55:54 -0600 Subject: [PATCH 02/10] No need to include the clearHidden folder. --- src/process/clearHidden/index.ts | 52 -------------------------------- 1 file changed, 52 deletions(-) delete mode 100644 src/process/clearHidden/index.ts diff --git a/src/process/clearHidden/index.ts b/src/process/clearHidden/index.ts deleted file mode 100644 index 91e7f8e3..00000000 --- a/src/process/clearHidden/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { unset } from 'lodash'; -import { - ProcessorScope, - ProcessorContext, - ProcessorInfo, - ProcessorFnSync, - ConditionsScope, -} from 'types'; - -type ClearHiddenScope = ProcessorScope & { - clearHidden: { - [path: string]: boolean; - }; -}; - -/** - * This processor function checks components for the `hidden` property and unsets corresponding data - */ -export const clearHiddenProcess: ProcessorFnSync = (context) => { - const { component, data, value, scope, path } = context; - - // No need to unset the value if it's undefined - if (value === undefined) { - return; - } - - if (!scope.clearHidden) { - scope.clearHidden = {}; - } - - // 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; - }); - - const shouldClearValueWhenHidden = - !component.hasOwnProperty('clearOnHide') || component.clearOnHide; - - if ( - shouldClearValueWhenHidden && - (isConditionallyHidden || component.scope?.conditionallyHidden) - ) { - unset(data, path); - scope.clearHidden[path] = true; - } -}; - -export const clearHiddenProcessInfo: ProcessorInfo, void> = { - name: 'clearHidden', - shouldProcess: () => true, - processSync: clearHiddenProcess, -}; From 24f8874083555f2ae278601571aad5d7e70fdb6b Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Fri, 8 Nov 2024 14:09:36 -0600 Subject: [PATCH 03/10] Code cleanup. --- src/process/populate/index.ts | 25 ++++++++-------- src/process/processOne.ts | 29 ++++++------------- .../validation/rules/validateMultiple.ts | 3 +- src/types/process/populate/PopulateScope.ts | 1 - src/utils/formUtil/eachComponentData.ts | 3 +- src/utils/formUtil/eachComponentDataAsync.ts | 3 +- src/utils/formUtil/index.ts | 2 +- 7 files changed, 28 insertions(+), 38 deletions(-) diff --git a/src/process/populate/index.ts b/src/process/populate/index.ts index cecc8fc7..96b4ceef 100644 --- a/src/process/populate/index.ts +++ b/src/process/populate/index.ts @@ -1,32 +1,31 @@ -import { get, set } from 'lodash'; +import { set } from 'lodash'; import { PopulateContext, PopulateScope, ProcessorFnSync } from 'types'; +import { getModelType } from 'utils/formUtil'; // This processor ensures that a "linked" row context is provided to every component. export const populateProcessSync: ProcessorFnSync = (context: PopulateContext) => { - const { component, path, scope } = context; + const { component, path, scope, value } = context; const { data } = scope; - const compDataPath = component.path || ''; - const compData: any = get(data, compDataPath, data); if (!scope.populated) scope.populated = []; - switch (component.modelType) { + switch (getModelType(component)) { case 'nestedArray': - if (!compData || !compData.length) { - set(data, compDataPath, [{}]); - scope.row = get(data, compDataPath)[0]; + if (!value || !value.length) { + const newValue = [{}]; + set(data, path, newValue); + scope.row = newValue[0]; scope.populated.push({ path, - row: get(data, compDataPath)[0], }); } break; case 'dataObject': case 'object': - if (!compData || typeof compData !== 'object') { - set(data, compDataPath, {}); - scope.row = get(data, compDataPath); + if (!value || typeof value !== 'object') { + const newValue = {}; + set(data, value, newValue); + scope.row = newValue; scope.populated.push({ path, - row: get(data, compDataPath), }); } break; diff --git a/src/process/processOne.ts b/src/process/processOne.ts index 07a122dd..cb09f883 100644 --- a/src/process/processOne.ts +++ b/src/process/processOne.ts @@ -1,5 +1,6 @@ import { get, set } from 'lodash'; import { ProcessorsContext, ProcessorType } from 'types'; +import { getModelType } from 'utils/formUtil'; export async function processOne(context: ProcessorsContext) { const { processors, row, component } = context; @@ -8,21 +9,15 @@ export async function processOne(context: ProcessorsContext(context: ProcessorsContext { // TODO: would be nice if this was type safe @@ -83,7 +84,7 @@ export const validateMultipleSync: RuleFnSync = (context: ValidationContext) => const shouldBeMultipleArray = !!component.multiple; const isRequired = !!component.validate?.required; - const compModelType = component.modelType || ''; + const compModelType = getModelType(component); const underlyingValueShouldBeArray = ['nestedArray', 'nestedDataArray'].indexOf(compModelType) !== -1 || (isTagsComponent(component) && component.storeas === 'array'); diff --git a/src/types/process/populate/PopulateScope.ts b/src/types/process/populate/PopulateScope.ts index 8f81f21a..fb390902 100644 --- a/src/types/process/populate/PopulateScope.ts +++ b/src/types/process/populate/PopulateScope.ts @@ -4,6 +4,5 @@ export type PopulateScope = { row?: any; populated?: Array<{ path: string; - row: any; }>; } & ProcessorScope; diff --git a/src/utils/formUtil/eachComponentData.ts b/src/utils/formUtil/eachComponentData.ts index 69b0106a..3db63cf9 100644 --- a/src/utils/formUtil/eachComponentData.ts +++ b/src/utils/formUtil/eachComponentData.ts @@ -17,6 +17,7 @@ import { resetComponentScope, COMPONENT_PATH, setComponentPaths, + getModelType, } from './index'; import { eachComponent } from './eachComponent'; @@ -80,7 +81,7 @@ export const eachComponentData = ( } resetComponentScope(component); return true; - } else if (!component.type || component.modelType === 'none') { + } else if (!component.type || getModelType(component) === 'none') { const info = componentInfo(component); if (info.hasColumns) { (component as HasColumns).columns.forEach((column: any) => diff --git a/src/utils/formUtil/eachComponentDataAsync.ts b/src/utils/formUtil/eachComponentDataAsync.ts index 7fb6861e..08af9be6 100644 --- a/src/utils/formUtil/eachComponentDataAsync.ts +++ b/src/utils/formUtil/eachComponentDataAsync.ts @@ -11,6 +11,7 @@ import { componentPath, COMPONENT_PATH, setComponentPaths, + getModelType, } from './index'; import { eachComponentAsync } from './eachComponentAsync'; @@ -65,7 +66,7 @@ export const eachComponentDataAsync = async ( } resetComponentScope(component); return true; - } else if (!component.type || component.modelType === 'none') { + } else if (!component.type || getModelType(component) === 'none') { const info = componentInfo(component); if (info.hasColumns) { const columnsComponent = component as HasColumns; diff --git a/src/utils/formUtil/index.ts b/src/utils/formUtil/index.ts index 6215d292..0a2a5a74 100644 --- a/src/utils/formUtil/index.ts +++ b/src/utils/formUtil/index.ts @@ -572,7 +572,7 @@ export function shouldProcessComponent(component: Component, row: any, value: an if (isEmpty(row)) { return false; } - if (component.modelType === 'dataObject') { + if (getModelType(component) === 'dataObject') { const noReferenceAttached = value && value._id && isEmpty(value.data) && !has(value, 'form'); const shouldProcessNestedFormData = value && value._id ? !noReferenceAttached : value !== undefined; From d50be803174af01b54a3397dc4f8c710482dfd37 Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Wed, 13 Nov 2024 21:38:38 -0600 Subject: [PATCH 04/10] Removed side effects attaching to the component json. --- src/error/FieldError.ts | 2 +- src/modules/jsonlogic/index.ts | 4 +- src/process/calculation/index.ts | 2 +- src/process/defaultValue/index.ts | 3 +- src/process/fetch/index.ts | 3 +- src/process/filter/__tests__/filter.test.ts | 1 - src/process/process.ts | 8 +- .../rules/__tests__/fixtures/util.ts | 8 +- .../rules/__tests__/validateRequired.test.ts | 3 +- .../validation/rules/validateCustom.ts | 2 +- src/process/validation/rules/validateJson.ts | 2 +- src/process/validation/util.ts | 8 +- src/types/BaseComponent.ts | 8 - src/types/formUtil.ts | 148 ++++- src/types/process/ProcessorContext.ts | 2 + src/utils/Evaluator.ts | 12 - src/utils/__tests__/formUtil.test.ts | 156 ++--- src/utils/conditions.ts | 77 +-- .../formUtil/__tests__/eachComponent.test.ts | 53 -- src/utils/formUtil/__tests__/index.test.ts | 20 + src/utils/formUtil/eachComponent.ts | 23 +- src/utils/formUtil/eachComponentAsync.ts | 30 +- src/utils/formUtil/eachComponentData.ts | 47 +- src/utils/formUtil/eachComponentDataAsync.ts | 87 ++- src/utils/formUtil/index.ts | 561 ++++++++---------- src/utils/operators/IsEqualTo.js | 5 +- 26 files changed, 665 insertions(+), 610 deletions(-) create mode 100644 src/utils/formUtil/__tests__/index.test.ts diff --git a/src/error/FieldError.ts b/src/error/FieldError.ts index b1730083..f49d8a69 100644 --- a/src/error/FieldError.ts +++ b/src/error/FieldError.ts @@ -1,5 +1,5 @@ -import { getComponentErrorField } from 'process/validation/util'; import { ValidationContext } from 'types'; +import { getComponentErrorField } from 'utils/formUtil'; type FieldErrorContext = ValidationContext & { field?: string; diff --git a/src/modules/jsonlogic/index.ts b/src/modules/jsonlogic/index.ts index 7ebba3f8..94a8f653 100644 --- a/src/modules/jsonlogic/index.ts +++ b/src/modules/jsonlogic/index.ts @@ -1,6 +1,6 @@ -import { BaseEvaluator, EvaluatorOptions } from 'utils'; +import { normalizeContext } from 'utils/formUtil'; import { jsonLogic } from './jsonLogic'; -import { EvaluatorContext, normalizeContext } from 'utils/Evaluator'; +import { BaseEvaluator, EvaluatorOptions, EvaluatorContext } from 'utils/Evaluator'; export class JSONLogicEvaluator extends BaseEvaluator { public static evaluate( func: any, diff --git a/src/process/calculation/index.ts b/src/process/calculation/index.ts index 60aee20c..b708ea9c 100644 --- a/src/process/calculation/index.ts +++ b/src/process/calculation/index.ts @@ -7,7 +7,7 @@ import { ProcessorInfo, } from 'types'; import { set } from 'lodash'; -import { normalizeContext } from 'utils/Evaluator'; +import { normalizeContext } from 'utils/formUtil'; export const shouldCalculate = (context: CalculationContext): boolean => { const { component, config } = context; diff --git a/src/process/defaultValue/index.ts b/src/process/defaultValue/index.ts index 6c5f3907..37d88ed8 100644 --- a/src/process/defaultValue/index.ts +++ b/src/process/defaultValue/index.ts @@ -7,8 +7,7 @@ import { DefaultValueContext, } from 'types'; import { set, has } from 'lodash'; -import { getComponentKey } from 'utils/formUtil'; -import { normalizeContext } from 'utils/Evaluator'; +import { getComponentKey, normalizeContext } from 'utils/formUtil'; export const hasCustomDefaultValue = (context: DefaultValueContext): boolean => { const { component } = context; diff --git a/src/process/fetch/index.ts b/src/process/fetch/index.ts index 2a93cf3e..92c5eeaa 100644 --- a/src/process/fetch/index.ts +++ b/src/process/fetch/index.ts @@ -9,8 +9,7 @@ import { } from 'types'; import { get, set } from 'lodash'; import { Evaluator } from 'utils'; -import { getComponentKey } from 'utils/formUtil'; -import { normalizeContext } from 'utils/Evaluator'; +import { getComponentKey, normalizeContext } from 'utils/formUtil'; export const shouldFetch = (context: FetchContext): boolean => { const { component, config } = context; diff --git a/src/process/filter/__tests__/filter.test.ts b/src/process/filter/__tests__/filter.test.ts index 031a355a..b20de5d7 100644 --- a/src/process/filter/__tests__/filter.test.ts +++ b/src/process/filter/__tests__/filter.test.ts @@ -35,7 +35,6 @@ describe('Filter processor', function () { type: 'editgrid', key: 'editGrid', input: true, - path: 'editGrid', components: [ { type: 'textfield', diff --git a/src/process/process.ts b/src/process/process.ts index cab0e157..dc3105c2 100644 --- a/src/process/process.ts +++ b/src/process/process.ts @@ -29,11 +29,10 @@ export async function process( context: ProcessContext, ): Promise { const { instances, components, data, scope, flat, processors } = context; - await eachComponentDataAsync( components, data, - async (component, compData, row, path, components, index, parent) => { + async (component, compData, row, path, components, index, parent, paths) => { // Skip processing if row is null or undefined if (!row) { return; @@ -44,6 +43,7 @@ export async function process( component, components, path, + paths, row, index, instance: instances ? instances[path] : undefined, @@ -69,11 +69,10 @@ export async function process( export function processSync(context: ProcessContext): ProcessScope { const { instances, components, data, scope, flat, processors } = context; - eachComponentData( components, data, - (component, compData, row, path, components, index, parent) => { + (component, compData, row, path, components, index, parent, paths) => { // Skip processing if row is null or undefined if (!row) { return; @@ -84,6 +83,7 @@ export function processSync(context: ProcessContext) component, components, path, + paths, row, index, instance: instances ? instances[path] : undefined, diff --git a/src/process/validation/rules/__tests__/fixtures/util.ts b/src/process/validation/rules/__tests__/fixtures/util.ts index 7f704b8f..70d46efb 100644 --- a/src/process/validation/rules/__tests__/fixtures/util.ts +++ b/src/process/validation/rules/__tests__/fixtures/util.ts @@ -11,9 +11,15 @@ export const generateProcessorContext = ( return { component, data, - form, + form: form ? form : { components: [component] }, scope: { errors: [] }, row: data, + paths: { + path: component.key, + localPath: component.key, + fullPath: component.key, + fullLocalPath: component.key, + }, path: component.key, value, config: { diff --git a/src/process/validation/rules/__tests__/validateRequired.test.ts b/src/process/validation/rules/__tests__/validateRequired.test.ts index 941de965..51babd4b 100644 --- a/src/process/validation/rules/__tests__/validateRequired.test.ts +++ b/src/process/validation/rules/__tests__/validateRequired.test.ts @@ -15,6 +15,7 @@ import { processOne } from 'processes/processOne'; import { generateProcessorContext } from './fixtures/util'; import { ProcessorsContext, SelectBoxesComponent, ValidationScope } from 'types'; import { validateProcessInfo } from 'processes/validation'; +import { conditionProcessInfo } from 'processes/conditions'; describe('validateRequired', function () { it('Validating a simple component that is required and not present in the data will return a field error', async function () { @@ -66,7 +67,7 @@ describe('validateRequired', function () { const component = conditionallyHiddenRequiredHiddenField; const data = { otherData: 'hideme' }; const context = generateProcessorContext(component, data) as ProcessorsContext; - context.processors = [validateProcessInfo]; + context.processors = [conditionProcessInfo, validateProcessInfo]; await processOne(context); expect(context.scope.errors.length).to.equal(0); }); diff --git a/src/process/validation/rules/validateCustom.ts b/src/process/validation/rules/validateCustom.ts index 5819e412..e480bac7 100644 --- a/src/process/validation/rules/validateCustom.ts +++ b/src/process/validation/rules/validateCustom.ts @@ -1,7 +1,7 @@ import { RuleFn, RuleFnSync, ProcessorInfo, ValidationContext } from 'types'; import { FieldError, ProcessorError } from 'error'; import { Evaluator } from 'utils'; -import { normalizeContext } from 'utils/Evaluator'; +import { normalizeContext } from 'utils/formUtil'; export const validateCustom: RuleFn = async (context: ValidationContext) => { return validateCustomSync(context); diff --git a/src/process/validation/rules/validateJson.ts b/src/process/validation/rules/validateJson.ts index 582abc3f..d713556d 100644 --- a/src/process/validation/rules/validateJson.ts +++ b/src/process/validation/rules/validateJson.ts @@ -3,7 +3,7 @@ import { FieldError } from 'error'; import { RuleFn, RuleFnSync, ValidationContext } from 'types'; import { ProcessorInfo } from 'types/process/ProcessorInfo'; import { isObject } from 'lodash'; -import { normalizeContext } from 'utils/Evaluator'; +import { normalizeContext } from 'utils/formUtil'; export const shouldValidate = (context: ValidationContext) => { const { component } = context; diff --git a/src/process/validation/util.ts b/src/process/validation/util.ts index 25d6d8f1..b0efd075 100644 --- a/src/process/validation/util.ts +++ b/src/process/validation/util.ts @@ -1,5 +1,5 @@ import { FieldError } from 'error'; -import { Component, ValidationContext } from 'types'; +import { Component } from 'types'; import { Evaluator, unescapeHTML } from 'utils'; import { VALIDATION_ERRORS } from './i18n'; import _isEmpty from 'lodash/isEmpty'; @@ -18,12 +18,6 @@ export function isEmptyObject(obj: any) { return !!obj && Object.keys(obj).length === 0 && obj.constructor === Object; } -export function getComponentErrorField(component: Component, context: ValidationContext) { - const toInterpolate = - component.errorLabel || component.label || component.placeholder || component.key; - return Evaluator.interpolate(toInterpolate, context); -} - export function toBoolean(value: any) { switch (typeof value) { case 'string': diff --git a/src/types/BaseComponent.ts b/src/types/BaseComponent.ts index 1c3428c0..f8641efe 100644 --- a/src/types/BaseComponent.ts +++ b/src/types/BaseComponent.ts @@ -15,13 +15,6 @@ export type SimpleConditional = { }; export type ComponentScope = { - path?: string; // The "form" path to the component including non-layout parent components. - fullPath?: string; // The "form" path to the component including all parent components (including layout components). - localPath?: string; // The "form" path to the component local to the current "nested form" component. - fullLocalPath?: string; // The "form" path to the component local to the current "nested form" component including all parent components (including layout components). - dataPath?: string; // The "full" data path to a component. - localDataPath?: string; // The "local" data path to a component referenced from the parent nested form. - dataIndex?: number; // The current data index (rowIndex) for this component. conditionallyHidden?: boolean; }; @@ -30,7 +23,6 @@ export type BaseComponent = { type: string; key: string; path?: string; // The "form" path to the component including non-layout parent components. - parent?: BaseComponent; tableView?: boolean; placeholder?: string; prefix?: string; diff --git a/src/types/formUtil.ts b/src/types/formUtil.ts index 050f239f..faf74cee 100644 --- a/src/types/formUtil.ts +++ b/src/types/formUtil.ts @@ -1,4 +1,139 @@ -import { Component, DataObject } from 'types'; +import { Component } from './Component'; +import { DataObject } from './DataObject'; + +/** + * Defines the Component paths used for every component within a form. This allows for + * quick reference to either the "form" path or the "data" path of a component. These paths are + * defined as follows. + * + * - Form Path: The path to a component within the Form JSON. This path is used to locate a component provided a nested Form JSON object. + * - Data Path: The path to the data value of a component within the data model for the form. This path is used to provide the value path provided the Submission JSON object. + * + * These paths can also be broken into two different path "types". Local and Full paths. + * + * - Local Path: This is the path relative to the "current" form. This is used inside of a nested form to identify components and values relative to the current form in context. + * - Full Path: This is the path that is absolute to the root form object. Any nested form paths will include the parent form path as part of the value for the provided path. + */ +export enum ComponentPath { + /** + * The "form" path to the component including all parent paths (exclusive of layout components). This path is used to uniquely identify component within a form inclusive of any parent form paths. + * + * For example: Suppose you have the following form structure. + * - Root + * - Panel 1 (panel) + * - Form (form) + * - Panel 2 (panel2) + * - Data Grid (dataGrid) + * - Panel 3 (panel3) + * - TextField (textField) + * + * The "path" to the TextField component from the perspective of a configuration within the Form, would be "form.dataGrid.textField" + */ + path = 'path', + + /** + * The "form" path to the component including all parent paths (inclusive of layout componnts). This path is used to uniquely identify component within a form inclusive of any parent form paths. + * + * For example: Suppose you have the following form structure. + * - Root + * - Panel 1 (panel) + * - Form (form) + * - Panel 2 (panel2) + * - Data Grid (dataGrid) + * - Panel 3 (panel3) + * - TextField (textField) + * + * The "fullPath" to the TextField component from the perspective of a configuration within the Form, would be "panel1.form.panel2.dataGrid.panel3.textField" + */ + fullPath = 'fullPath', + + /** + * The local "form" path to the component. This is the local path to any component within a form. This + * path is consistent no matter if this form is nested within another form or not. All form configurations + * are in relation to this path since forms are configured independently. The difference between a form path + * and a dataPath is that this includes any parent layout components to locate the component provided a form JSON. + * This path does NOT include any layout components. + * + * For example: Suppose you have the following form structure. + * - Root + * - Panel 1 (panel) + * - Form (form) + * - Panel 2 (panel2) + * - Data Grid (dataGrid) + * - Panel 3 (panel3) + * - TextField (textField) + * + * The "path" to the TextField component from the perspective of a configuration within the Form, would be "dataGrid.textField" + */ + localPath = 'localPath', + + /** + * The local "form" path to the component. This is the local path to any component within a form. This + * path is consistent no matter if this form is nested within another form or not. All form configurations + * are in relation to this path since forms are configured independently. The difference between a form path + * and a dataPath is that this includes any parent layout components to locate the component provided a form JSON. + * This path does NOT include any layout components. + * + * For example: Suppose you have the following form structure. + * - Root + * - Panel 1 (panel) + * - Form (form) + * - Panel 2 (panel2) + * - Data Grid (dataGrid) + * - Panel 3 (panel3) + * - TextField (textField) + * + * The "path" to the TextField component from the perspective of a configuration within the Form, would be "panel2.dataGrid.panel3.textField" + */ + fullLocalPath = 'fullLocalPath', + + /** + * The "data" path to the component including all parent paths. This path is used to fetch the data value of a component within a data model, inclusive of any parent data paths of nested forms. + * + * For example: Suppose you have the following form structure. + * - Root + * - Panel 1 (panel) + * - Form (form) + * - Panel 2 (panel2) + * - Data Grid (dataGrid) + * - Panel 3 (panel3) + * - TextField (textField) + * + * The "dataPath" to the TextField component would be "form.data.dataGrid[1].textField" + */ + dataPath = 'dataPath', + + /** + * The "data" path is the local path to the data value for any component. The difference between this path + * and the "path" is that this path is used to locate the data value for a component within the data model. + * and does not include any keys for layout components. + * + * For example: Suppose you have the following form structure. + * - Root + * - Panel 1 (panel) + * - Form (form) + * - Panel 2 (panel2) + * - Data Grid (dataGrid) + * - Panel 3 (panel3) + * - TextField (textField) + * + * The "localDataPath" to the TextField component from the perspective of a configuration within the Form, would be "dataGrid[1].textField" + */ + localDataPath = 'localDataPath', +} + +/** + * The types of paths that can be set on a component. + */ +export type ComponentPaths = { + path?: string; + fullPath?: string; + localPath?: string; + fullLocalPath?: string; + dataPath?: string; + localDataPath?: string; + dataIndex?: number; +}; export type EachComponentDataAsyncCallback = ( component: Component, @@ -8,6 +143,7 @@ export type EachComponentDataAsyncCallback = ( components?: Component[], index?: number, parent?: Component | null, + paths?: ComponentPaths, ) => Promise; export type EachComponentDataCallback = ( @@ -18,6 +154,7 @@ export type EachComponentDataCallback = ( components?: Component[], index?: number, parent?: Component | null, + paths?: ComponentPaths, ) => boolean | void; export type EachComponentCallback = ( @@ -25,6 +162,15 @@ export type EachComponentCallback = ( path: string, components?: Component[], parent?: Component, + paths?: ComponentPaths, ) => boolean | void; +export type EachComponentAsyncCallback = ( + component: Component, + path: string, + components?: Component[], + parent?: Component, + paths?: ComponentPaths, +) => Promise; + export type FetchFn = (url: string, options?: RequestInit) => Promise; diff --git a/src/types/process/ProcessorContext.ts b/src/types/process/ProcessorContext.ts index b4a28d51..600912e9 100644 --- a/src/types/process/ProcessorContext.ts +++ b/src/types/process/ProcessorContext.ts @@ -1,5 +1,6 @@ import { Component, + ComponentPaths, DataObject, Form, PassedComponentInstance, @@ -16,6 +17,7 @@ export type ProcessorContext = { row: any; value?: any; form?: Form; + paths?: ComponentPaths; submission?: Submission; components?: Component[]; instance?: PassedComponentInstance; diff --git a/src/utils/Evaluator.ts b/src/utils/Evaluator.ts index 01ffd929..55c6cc6f 100644 --- a/src/utils/Evaluator.ts +++ b/src/utils/Evaluator.ts @@ -1,5 +1,4 @@ import { noop, trim, keys, get, set, isObject, values } from 'lodash'; -import { getComponentLocalData } from './formUtil'; export interface EvaluatorOptions { noeval?: boolean; @@ -12,17 +11,6 @@ export type EvaluatorContext = { [key: string]: any; }; -export function normalizeContext(context: any): any { - const { component, data } = context; - return { - ...context, - ...{ - path: component.scope?.localDataPath, - data: getComponentLocalData(component, data), - }, - }; -} - // BaseEvaluator is for extending. export class BaseEvaluator { static templateSettings = { diff --git a/src/utils/__tests__/formUtil.test.ts b/src/utils/__tests__/formUtil.test.ts index e0baae0b..426ac010 100644 --- a/src/utils/__tests__/formUtil.test.ts +++ b/src/utils/__tests__/formUtil.test.ts @@ -12,10 +12,9 @@ import { findComponents, getComponent, flattenComponents, - getComponentActualValue, + getComponentValue, hasCondition, getModelType, - setComponentScope, } from '../formUtil'; const writtenNumber = (n: number | null) => { @@ -115,8 +114,9 @@ describe('formUtil', function () { input: true, key: 'c', }; - setComponentScope(component, 'dataPath', 'a.b.c'); - const actual = getContextualRowData(component, data); + const actual = getContextualRowData(component, data, { + dataPath: 'a.b.c', + }); const expected = { c: 'hello' }; expect(actual).to.deep.equal(expected); }); @@ -134,8 +134,9 @@ describe('formUtil', function () { input: true, key: 'b', }; - setComponentScope(component, 'dataPath', 'a.b'); - const actual = getContextualRowData(component, data); + const actual = getContextualRowData(component, data, { + dataPath: 'a.b', + }); const expected = { b: { c: 'hello' } }; expect(actual).to.deep.equal(expected); }); @@ -153,8 +154,9 @@ describe('formUtil', function () { input: true, key: 'a', }; - setComponentScope(component, 'dataPath', 'a'); - const actual = getContextualRowData(component, data); + const actual = getContextualRowData(component, data, { + dataPath: 'a', + }); const expected = { a: { b: { c: 'hello' } } }; expect(actual).to.deep.equal(expected); }); @@ -173,7 +175,6 @@ describe('formUtil', function () { input: true, key: 'd', }; - setComponentScope(component, 'dataPath', ''); const actual = getContextualRowData(component, data); const expected = { a: { b: { c: 'hello' } }, d: 'there' }; expect(actual).to.deep.equal(expected); @@ -191,8 +192,9 @@ describe('formUtil', function () { input: true, key: 'b', }; - setComponentScope(component, 'dataPath', 'a[0].b'); - const actual = getContextualRowData(component, data); + const actual = getContextualRowData(component, data, { + dataPath: 'a[0].b', + }); const expected = { b: 'hello', c: 'world' }; expect(actual).to.deep.equal(expected); }); @@ -209,8 +211,9 @@ describe('formUtil', function () { input: true, key: 'b', }; - setComponentScope(component, 'dataPath', 'a[1].b'); - const actual = getContextualRowData(component, data); + const actual = getContextualRowData(component, data, { + dataPath: 'a[1].b', + }); const expected = { b: 'foo', c: 'bar' }; expect(actual).to.deep.equal(expected); }); @@ -227,8 +230,9 @@ describe('formUtil', function () { input: true, key: 'a', }; - setComponentScope(component, 'dataPath', 'a'); - const actual = getContextualRowData(component, data); + const actual = getContextualRowData(component, data, { + dataPath: 'a', + }); const expected = { a: [ { b: 'hello', c: 'world' }, @@ -274,8 +278,9 @@ describe('formUtil', function () { input: true, key: 'c', }; - setComponentScope(component, 'dataPath', 'a.b[0].c'); - const actual = getContextualRowData(component, data); + const actual = getContextualRowData(component, data, { + dataPath: 'a.b[0].c', + }); const expected = { c: 'hello', d: 'world' }; expect(actual).to.deep.equal(expected); }); @@ -294,8 +299,9 @@ describe('formUtil', function () { input: true, key: 'c', }; - setComponentScope(component, 'dataPath', 'a.b[1].c'); - const actual = getContextualRowData(component, data); + const actual = getContextualRowData(component, data, { + dataPath: 'a.b[1].c', + }); const expected = { c: 'foo', d: 'bar' }; expect(actual).to.deep.equal(expected); }); @@ -314,8 +320,9 @@ describe('formUtil', function () { input: true, key: 'b', }; - setComponentScope(component, 'dataPath', 'a.b'); - const actual = getContextualRowData(component, data); + const actual = getContextualRowData(component, data, { + dataPath: 'a.b', + }); const expected = { b: [ { c: 'hello', d: 'world' }, @@ -339,8 +346,9 @@ describe('formUtil', function () { input: true, key: 'a', }; - setComponentScope(component, 'dataPath', 'a'); - const actual = getContextualRowData(component, data); + const actual = getContextualRowData(component, data, { + dataPath: 'a', + }); const expected = { a: { b: [ @@ -366,7 +374,6 @@ describe('formUtil', function () { input: true, key: 'a', }; - setComponentScope(component, 'dataPath', ''); const actual = getContextualRowData(component, data); const expected = { a: { @@ -393,8 +400,9 @@ describe('formUtil', function () { input: true, key: 'c.e', }; - setComponentScope(component, 'dataPath', 'a.b[0].c.e'); - const actual = getContextualRowData(component, data); + const actual = getContextualRowData(component, data, { + dataPath: 'a.b[0].c.e', + }); const expected = { c: { e: 'zed' }, d: 'world' }; expect(actual).to.deep.equal(expected); }); @@ -669,7 +677,6 @@ describe('formUtil', function () { const value = get(data, path); rowResults.set(path, [component, value]); }, - undefined, true, ); expect(rowResults.size).to.equal(2); @@ -778,7 +785,7 @@ describe('formUtil', function () { tableView: true, }, { - type: 'editGrid', + type: 'editgrid', key: 'nestedEditGrid', input: true, tableView: true, @@ -1434,7 +1441,6 @@ describe('formUtil', function () { const value = get(data, path); rowResults.set(path, [component, value]); }, - undefined, true, ); expect(rowResults.size).to.equal(2); @@ -1459,49 +1465,65 @@ describe('formUtil', function () { }); }); - describe('getComponentActualValue', function () { + describe('getComponentValue', function () { it('Should return correct value for component inside inside panel inside editGrid', function () { - const component = { - label: 'Radio', - optionsLabelPosition: 'right', - inline: false, - tableView: false, - values: [ - { label: 'yes', value: 'yes', shortcut: '' }, - { label: 'no', value: 'no', shortcut: '' }, - ], - key: 'radio', - type: 'radio', - input: true, - path: 'editGrid.radio', - parent: { - collapsible: false, - key: 'panel', - type: 'panel', - label: 'Panel', - input: false, - tableView: false, - path: 'editGrid[0].panel', - parent: { - label: 'Edit Grid', - tableView: false, - rowDrafts: false, - key: 'editGrid', - type: 'editgrid', - path: 'editGrid', - displayAsTable: false, - input: true, - }, - }, - }; const compPath = 'editGrid.radio'; const data = { - editGrid: [{ radio: 'yes', textArea: 'test' }], + form: { + data: { + editGrid: [{ radio: 'yes', textArea: 'test' }], + }, + }, submit: true, }; - const row = { radio: 'yes', textArea: 'test' }; - - const value = getComponentActualValue(component, compPath, data, row); + const value = getComponentValue( + { + components: [ + { + type: 'panel', + label: 'Panel', + key: 'panel', + input: false, + components: [ + { + type: 'form', + key: 'form', + input: false, + components: [ + { + type: 'panel', + key: 'panel', + label: 'Panel', + input: false, + components: [ + { + type: 'editgrid', + key: 'editGrid', + input: true, + components: [ + { + type: 'radio', + key: 'radio', + label: 'Radio', + input: true, + values: [ + { label: 'yes', value: 'yes', shortcut: '' }, + { label: 'no', value: 'no', shortcut: '' }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + data, + compPath, + ); expect(value).to.equal('yes'); }); }); diff --git a/src/utils/conditions.ts b/src/utils/conditions.ts index fd1f3d91..f703e4ad 100644 --- a/src/utils/conditions.ts +++ b/src/utils/conditions.ts @@ -1,9 +1,8 @@ import { ConditionsContext, JSONConditional, LegacyConditional, SimpleConditional } from 'types'; import { EvaluatorFn, evaluate, JSONLogicEvaluator } from 'modules/jsonlogic'; -import { flattenComponents, getComponent, getComponentActualValue } from './formUtil'; -import { has, isObject, map, every, some, find, filter, isBoolean, split } from 'lodash'; +import { getComponent, getComponentValue, normalizeContext } from './formUtil'; +import { has, isObject, map, every, some, find, filter } from 'lodash'; import ConditionOperators from './operators'; -import { normalizeContext } from './Evaluator'; export const isJSONConditional = (conditional: any): conditional is JSONConditional => { return conditional && conditional.json && isObject(conditional.json); @@ -65,11 +64,11 @@ export function checkLegacyConditional( conditional: LegacyConditional, context: ConditionsContext, ): boolean | null { - const { row, data, component } = context; + const { data, form } = context; if (!conditional || !isLegacyConditional(conditional) || !conditional.when) { return null; } - const value: any = getComponentActualValue(component, conditional.when, data, row); + const value: any = getComponentValue(form, data, conditional.when); const eq = String(conditional.eq); const show = String(conditional.show); if (isObject(value) && has(value, eq)) { @@ -101,25 +100,6 @@ export function checkJsonConditional( return JSONLogicEvaluator.evaluate(conditional.json, evalContextValue); } -/** - * Checks if condition can potentially have a value path instead of component path. - * @param condition - * @returns {boolean} - */ -function isConditionPotentiallyBasedOnValuePath(condition: any = {}) { - let comparedValue; - try { - comparedValue = JSON.parse(condition.value); - } catch (ignoreError) { - comparedValue = condition.value; - } - return ( - isBoolean(comparedValue) && - (condition.component || '').split('.').length > 1 && - condition.operator === 'isEqual' - ); -} - /** * Checks the simple conditionals. * @param conditional @@ -130,7 +110,7 @@ export function checkSimpleConditional( conditional: SimpleConditional, context: ConditionsContext, ): boolean | null { - const { component, data, row, instance, form } = context; + const { component, data, instance, form, paths } = context; if (!conditional || !isSimpleConditional(conditional)) { return null; } @@ -142,52 +122,21 @@ export function checkSimpleConditional( const conditionsResult = filter( map(conditions, (cond) => { const { operator } = cond; - let { value: comparedValue, component: conditionComponentPath } = cond; + const { value: comparedValue, component: conditionComponentPath } = cond; if (!conditionComponentPath) { // Ignore conditions if there is no component path. return null; } const formComponents = form?.components || []; - let conditionComponent = getComponent(formComponents, conditionComponentPath, true); - // If condition componenet is not found, check if conditionComponentPath is value path. - // Need to handle condtions like: - // { - // "component": "selectBoxes.a", - // "operator": "isEqual", - // "value": "true" - // } - if ( - !conditionComponent && - isConditionPotentiallyBasedOnValuePath(cond) && - formComponents.length - ) { - const flattenedComponents = flattenComponents(formComponents, true); - const pathParts = split(conditionComponentPath, '.'); - const valuePathParts = []; - - while (!conditionComponent && pathParts.length) { - conditionComponent = flattenedComponents[`${pathParts.join('.')}`]; - if (!conditionComponent) { - valuePathParts.unshift(pathParts.pop()); - } - } - if ( - conditionComponent && - conditionComponent.type === 'selectboxes' && - valuePathParts.length - ) { - console.warn( - 'Condition based on selectboxes has wrong format. Resave the form in the form builder to fix it.', - ); - conditionComponentPath = pathParts.join('.'); - comparedValue = valuePathParts.join('.'); - } - } - + const conditionComponent = getComponent( + formComponents, + conditionComponentPath, + true, + paths?.dataIndex, + ); const value = conditionComponent - ? getComponentActualValue(conditionComponent, conditionComponentPath, data, row) + ? getComponentValue(form, data, conditionComponentPath, paths?.dataIndex) : null; - const ConditionOperator = ConditionOperators[operator]; return ConditionOperator ? new ConditionOperator().getResult({ diff --git a/src/utils/formUtil/__tests__/eachComponent.test.ts b/src/utils/formUtil/__tests__/eachComponent.test.ts index 269ccf2e..7bb3df1e 100644 --- a/src/utils/formUtil/__tests__/eachComponent.test.ts +++ b/src/utils/formUtil/__tests__/eachComponent.test.ts @@ -1041,57 +1041,4 @@ describe('eachComponent', function () { ); expect(contentComponentsAmount).to.be.equal(1); }); - - it('should not mutate the path property if contained in component', function () { - const components = [ - { - type: 'textfield', - key: 'textField', - input: true, - path: 'doNotMutate', - }, - { - type: 'container', - key: 'container', - input: true, - path: 'doNotMutate', - components: [ - { - type: 'textfield', - key: 'nestedTextField', - path: 'doNotMutate', - input: true, - }, - { - type: 'textarea', - key: 'nestedTextArea', - path: 'doNotMutate', - input: true, - }, - ], - }, - ]; - eachComponent( - components, - (component: Component, path: string) => { - if (component.key === 'textField') { - expect(component.path).to.equal('doNotMutate'); - expect(path).to.equal('textField'); - } - if (component.key === 'container') { - expect(component.path).to.equal('doNotMutate'); - expect(path).to.equal('container'); - } - if (component.key === 'nestedTextField') { - expect(component.path).to.equal('doNotMutate'); - expect(path).to.equal('container.nestedTextField'); - } - if (component.key === 'nestedTextArea') { - expect(component.path).to.equal('doNotMutate'); - expect(path).to.equal('container.nestedTextArea'); - } - }, - true, - ); - }); }); diff --git a/src/utils/formUtil/__tests__/index.test.ts b/src/utils/formUtil/__tests__/index.test.ts new file mode 100644 index 00000000..f2c1e041 --- /dev/null +++ b/src/utils/formUtil/__tests__/index.test.ts @@ -0,0 +1,20 @@ +import { assert } from 'chai'; +import { getComponentLocalData } from '../index'; +describe('Form Utils', function () { + it('getComponentLocalData', function () { + assert.deepEqual( + getComponentLocalData( + { + dataPath: 'firstName', + localDataPath: 'firstName', + }, + { + firstName: 'Joe', + }, + ), + { + firstName: 'Joe', + } as any, + ); + }); +}); diff --git a/src/utils/formUtil/eachComponent.ts b/src/utils/formUtil/eachComponent.ts index fff35d84..e46ec4ea 100644 --- a/src/utils/formUtil/eachComponent.ts +++ b/src/utils/formUtil/eachComponent.ts @@ -1,5 +1,5 @@ -import { Component, EachComponentCallback } from 'types'; -import { componentInfo, setDefaultComponentPaths, setParentReference } from './index'; +import { Component, EachComponentCallback, ComponentPaths } from 'types'; +import { componentInfo, getComponentPaths } from './index'; /** * Iterate through each component within a form. @@ -17,36 +17,41 @@ export function eachComponent( components: Component[], fn: EachComponentCallback, includeAll?: boolean, + parentPaths?: string | ComponentPaths, parent?: Component, ) { if (!components) return; + if (typeof parentPaths === 'string') { + parentPaths = { path: parentPaths }; + } components.forEach((component: any) => { if (!component) { return; } const info = componentInfo(component); let noRecurse = false; - setParentReference(component, parent); - setDefaultComponentPaths(component); + const compPaths = getComponentPaths(component, parent, parentPaths); if (includeAll || component.tree || !info.layout) { - const path = includeAll ? component.scope?.fullPath || '' : component.path || ''; - noRecurse = !!fn(component, path, components, parent); + const path = includeAll ? compPaths.fullPath : compPaths.path; + noRecurse = !!fn(component, path || '', components, parent, compPaths); } if (!noRecurse) { if (info.hasColumns) { component.columns.forEach((column: any) => - eachComponent(column.components, fn, includeAll, component), + eachComponent(column.components, fn, includeAll, compPaths, component), ); } else if (info.hasRows) { component.rows.forEach((row: any) => { if (Array.isArray(row)) { - row.forEach((column) => eachComponent(column.components, fn, includeAll, component)); + row.forEach((column) => + eachComponent(column.components, fn, includeAll, compPaths, component), + ); } }); } else if (info.hasComps) { - eachComponent(component.components, fn, includeAll, component); + eachComponent(component.components, fn, includeAll, compPaths, component); } } }); diff --git a/src/utils/formUtil/eachComponentAsync.ts b/src/utils/formUtil/eachComponentAsync.ts index 4c9e503b..f035c922 100644 --- a/src/utils/formUtil/eachComponentAsync.ts +++ b/src/utils/formUtil/eachComponentAsync.ts @@ -1,44 +1,52 @@ -import { Component } from 'types'; -import { componentInfo, setDefaultComponentPaths, setParentReference } from './index'; +import { Component, EachComponentAsyncCallback, ComponentPaths } from 'types'; +import { componentInfo, getComponentPaths } from './index'; // Async each component. export async function eachComponentAsync( components: Component[], - fn: any, + fn: EachComponentAsyncCallback, includeAll = false, + parentPaths?: string | ComponentPaths, parent?: any, ) { if (!components) return; + if (typeof parentPaths === 'string') { + parentPaths = { path: parentPaths }; + } for (let i = 0; i < components.length; i++) { if (!components[i]) { continue; } const component: any = components[i]; const info = componentInfo(component); - setParentReference(component, parent); - setDefaultComponentPaths(component); - + const compPaths = getComponentPaths(component, parent, parentPaths); if (includeAll || component.tree || !info.layout) { - const path = includeAll ? component.scope?.fullPath || '' : component.path || ''; - if (await fn(component, path, components, parent)) { + const path = includeAll ? compPaths.fullPath : compPaths.path; + if (await fn(component, path || '', components, parent, compPaths)) { continue; } } if (info.hasColumns) { for (let j = 0; j < component.columns.length; j++) { - await eachComponentAsync(component.columns[j]?.components, fn, includeAll, component); + await eachComponentAsync( + component.columns[j]?.components, + fn, + includeAll, + compPaths, + component, + ); } } else if (info.hasRows) { for (let j = 0; j < component.rows.length; j++) { const row = component.rows[j]; if (Array.isArray(row)) { for (let k = 0; k < row.length; k++) { - await eachComponentAsync(row[k]?.components, fn, includeAll, component); + await eachComponentAsync(row[k]?.components, fn, includeAll, compPaths, component); } } } } else if (info.hasComps) { - await eachComponentAsync(component.components, fn, includeAll, component); + await eachComponentAsync(component.components, fn, includeAll, compPaths, component); } } } diff --git a/src/utils/formUtil/eachComponentData.ts b/src/utils/formUtil/eachComponentData.ts index 3db63cf9..93416a7a 100644 --- a/src/utils/formUtil/eachComponentData.ts +++ b/src/utils/formUtil/eachComponentData.ts @@ -6,17 +6,14 @@ import { HasChildComponents, HasColumns, HasRows, + ComponentPaths, } from 'types'; import { isComponentNestedDataType, componentInfo, getContextualRowData, shouldProcessComponent, - componentPath, - setComponentScope, resetComponentScope, - COMPONENT_PATH, - setComponentPaths, getModelType, } from './index'; import { eachComponent } from './eachComponent'; @@ -35,40 +32,40 @@ export const eachComponentData = ( components: Component[], data: DataObject, fn: EachComponentDataCallback, - parent?: Component, includeAll: boolean = false, + parent?: Component, + parentPaths?: ComponentPaths, ) => { if (!components) { return; } return eachComponent( components, - (component, compPath, componentComponents, compParent) => { - setComponentPaths(component, { - dataPath: componentPath(component, COMPONENT_PATH.DATA), - localDataPath: componentPath(component, COMPONENT_PATH.LOCAL_DATA), - }); - const row = getContextualRowData(component, data); + (component, compPath, componentComponents, compParent, compPaths) => { + const row = getContextualRowData(component, data, compPaths); if ( fn( component, data, row, - component.scope?.dataPath || '', + compPaths?.dataPath || '', componentComponents, - component.scope?.dataIndex, + compPaths?.dataIndex, compParent, + compPaths, ) === true ) { resetComponentScope(component); return true; } if (isComponentNestedDataType(component)) { - const value = get(data, component.scope?.dataPath || '') as DataObject; + const value = get(data, compPaths?.dataPath || '') as DataObject; if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { - setComponentScope(component, 'dataIndex', i); - eachComponentData(component.components, data, fn, component, includeAll); + if (compPaths) { + compPaths.dataIndex = i; + } + eachComponentData(component.components, data, fn, includeAll, component, compPaths); } resetComponentScope(component); return true; @@ -77,7 +74,7 @@ export const eachComponentData = ( resetComponentScope(component); return true; } - eachComponentData(component.components, data, fn, component, includeAll); + eachComponentData(component.components, data, fn, includeAll, component, compPaths); } resetComponentScope(component); return true; @@ -85,16 +82,25 @@ export const eachComponentData = ( const info = componentInfo(component); if (info.hasColumns) { (component as HasColumns).columns.forEach((column: any) => - eachComponentData(column.components, data, fn, component), + eachComponentData(column.components, data, fn, includeAll, component, compPaths), ); } else if (info.hasRows) { (component as HasRows).rows.forEach((row: any) => { if (Array.isArray(row)) { - row.forEach((row) => eachComponentData(row.components, data, fn, component)); + row.forEach((row) => + eachComponentData(row.components, data, fn, includeAll, component, compPaths), + ); } }); } else if (info.hasComps) { - eachComponentData((component as HasChildComponents).components, data, fn, component); + eachComponentData( + (component as HasChildComponents).components, + data, + fn, + includeAll, + component, + compPaths, + ); } resetComponentScope(component); return true; @@ -103,6 +109,7 @@ export const eachComponentData = ( return false; }, true, + parentPaths, parent, ); }; diff --git a/src/utils/formUtil/eachComponentDataAsync.ts b/src/utils/formUtil/eachComponentDataAsync.ts index 08af9be6..f591bfe3 100644 --- a/src/utils/formUtil/eachComponentDataAsync.ts +++ b/src/utils/formUtil/eachComponentDataAsync.ts @@ -1,16 +1,20 @@ import { get } from 'lodash'; -import { Component, DataObject, EachComponentDataAsyncCallback, HasColumns, HasRows } from 'types'; +import { + Component, + DataObject, + EachComponentDataAsyncCallback, + HasColumns, + HasRows, + ComponentPaths, + HasChildComponents, +} from 'types'; import { isComponentNestedDataType, componentInfo, getContextualRowData, shouldProcessComponent, - setComponentScope, resetComponentScope, - componentPath, - COMPONENT_PATH, - setComponentPaths, getModelType, } from './index'; import { eachComponentAsync } from './eachComponentAsync'; @@ -20,28 +24,31 @@ export const eachComponentDataAsync = async ( components: Component[], data: DataObject, fn: EachComponentDataAsyncCallback, - parent?: Component, includeAll: boolean = false, + parent?: Component, + parentPaths?: ComponentPaths, ) => { if (!components || !data) { return; } return await eachComponentAsync( components, - async (component: any, compPath: string, componentComponents: any, compParent: any) => { - setComponentPaths(component, { - dataPath: componentPath(component, COMPONENT_PATH.DATA), - localDataPath: componentPath(component, COMPONENT_PATH.LOCAL_DATA), - }); - const row = getContextualRowData(component, data); + async ( + component: Component, + compPath: string, + componentComponents: Component[] | undefined, + compParent: Component | undefined, + compPaths: ComponentPaths | undefined, + ) => { + const row = getContextualRowData(component, data, compPaths); if ( (await fn( component, data, row, - component.scope?.dataPath || '', + compPaths?.dataPath || '', componentComponents, - component.scope?.dataIndex, + compPaths?.dataIndex, compParent, )) === true ) { @@ -49,11 +56,20 @@ export const eachComponentDataAsync = async ( return true; } if (isComponentNestedDataType(component)) { - const value = get(data, component.scope?.dataPath || ''); + const value = get(data, compPaths?.dataPath || ''); if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { - setComponentScope(component, 'dataIndex', i); - await eachComponentDataAsync(component.components, data, fn, component, includeAll); + if (compPaths) { + compPaths.dataIndex = i; + } + await eachComponentDataAsync( + component.components, + data, + fn, + includeAll, + component, + compPaths, + ); } resetComponentScope(component); return true; @@ -62,7 +78,14 @@ export const eachComponentDataAsync = async ( resetComponentScope(component); return true; } - await eachComponentDataAsync(component.components, data, fn, component, includeAll); + await eachComponentDataAsync( + component.components, + data, + fn, + includeAll, + component, + compPaths, + ); } resetComponentScope(component); return true; @@ -71,19 +94,40 @@ export const eachComponentDataAsync = async ( if (info.hasColumns) { const columnsComponent = component as HasColumns; for (const column of columnsComponent.columns) { - await eachComponentDataAsync(column.components, data, fn, component); + await eachComponentDataAsync( + column.components, + data, + fn, + includeAll, + component, + compPaths, + ); } } else if (info.hasRows) { const rowsComponent = component as HasRows; for (const rowArray of rowsComponent.rows) { if (Array.isArray(rowArray)) { for (const row of rowArray) { - await eachComponentDataAsync(row.components, data, fn, component); + await eachComponentDataAsync( + row.components, + data, + fn, + includeAll, + component, + compPaths, + ); } } } } else if (info.hasComps) { - await eachComponentDataAsync(component.components, data, fn, component); + await eachComponentDataAsync( + (component as HasChildComponents).components, + data, + fn, + includeAll, + component, + compPaths, + ); } resetComponentScope(component); return true; @@ -92,6 +136,7 @@ export const eachComponentDataAsync = async ( return false; }, true, + parentPaths, parent, ); }; diff --git a/src/utils/formUtil/index.ts b/src/utils/formUtil/index.ts index 0a2a5a74..9f8b8fd4 100644 --- a/src/utils/formUtil/index.ts +++ b/src/utils/formUtil/index.ts @@ -4,7 +4,6 @@ import { set, isEmpty, isNil, - isObject, has, isString, forOwn, @@ -14,7 +13,6 @@ import { isPlainObject, isArray, isEqual, - trim, isBoolean, omit, every, @@ -42,6 +40,10 @@ import { AddressComponent, SelectComponent, ComponentScope, + ComponentPaths, + ComponentPath, + Form, + ValidationContext, } from 'types'; import { Evaluator } from '../Evaluator'; import { eachComponent } from './eachComponent'; @@ -110,127 +112,6 @@ export function uniqueName(name: string, template?: string, evalContext?: any) { return uniqueName; } -/** - * Defines the Component paths used for every component within a form. This allows for - * quick reference to either the "form" path or the "data" path of a component. These paths are - * defined as follows. - * - * - Form Path: The path to a component within the Form JSON. This path is used to locate a component provided a nested Form JSON object. - * - Data Path: The path to the data value of a component within the data model for the form. This path is used to provide the value path provided the Submission JSON object. - * - * These paths can also be broken into two different path "types". Local and Full paths. - * - * - Local Path: This is the path relative to the "current" form. This is used inside of a nested form to identify components and values relative to the current form in context. - * - Full Path: This is the path that is absolute to the root form object. Any nested form paths will include the parent form path as part of the value for the provided path. - */ -export enum COMPONENT_PATH { - /** - * The "form" path to the component including all parent paths (exclusive of layout components). This path is used to uniquely identify component within a form inclusive of any parent form paths. - * - * For example: Suppose you have the following form structure. - * - Root - * - Panel 1 (panel) - * - Form (form) - * - Panel 2 (panel2) - * - Data Grid (dataGrid) - * - Panel 3 (panel3) - * - TextField (textField) - * - * The "path" to the TextField component from the perspective of a configuration within the Form, would be "form.dataGrid.textField" - */ - FORM = 'path', - - /** - * The "form" path to the component including all parent paths (inclusive of layout componnts). This path is used to uniquely identify component within a form inclusive of any parent form paths. - * - * For example: Suppose you have the following form structure. - * - Root - * - Panel 1 (panel) - * - Form (form) - * - Panel 2 (panel2) - * - Data Grid (dataGrid) - * - Panel 3 (panel3) - * - TextField (textField) - * - * The "fullPath" to the TextField component from the perspective of a configuration within the Form, would be "panel1.form.panel2.dataGrid.panel3.textField" - */ - FULL_FORM = 'fullPath', - - /** - * The local "form" path to the component. This is the local path to any component within a form. This - * path is consistent no matter if this form is nested within another form or not. All form configurations - * are in relation to this path since forms are configured independently. The difference between a form path - * and a dataPath is that this includes any parent layout components to locate the component provided a form JSON. - * This path does NOT include any layout components. - * - * For example: Suppose you have the following form structure. - * - Root - * - Panel 1 (panel) - * - Form (form) - * - Panel 2 (panel2) - * - Data Grid (dataGrid) - * - Panel 3 (panel3) - * - TextField (textField) - * - * The "path" to the TextField component from the perspective of a configuration within the Form, would be "dataGrid.textField" - */ - LOCAL_FORM = 'localPath', - - /** - * The local "form" path to the component. This is the local path to any component within a form. This - * path is consistent no matter if this form is nested within another form or not. All form configurations - * are in relation to this path since forms are configured independently. The difference between a form path - * and a dataPath is that this includes any parent layout components to locate the component provided a form JSON. - * This path does NOT include any layout components. - * - * For example: Suppose you have the following form structure. - * - Root - * - Panel 1 (panel) - * - Form (form) - * - Panel 2 (panel2) - * - Data Grid (dataGrid) - * - Panel 3 (panel3) - * - TextField (textField) - * - * The "path" to the TextField component from the perspective of a configuration within the Form, would be "dataGrid.textField" - */ - FULL_LOCAL_FORM = 'fullLocalPath', - - /** - * The "data" path to the component including all parent paths. This path is used to fetch the data value of a component within a data model, inclusive of any parent data paths of nested forms. - * - * For example: Suppose you have the following form structure. - * - Root - * - Panel 1 (panel) - * - Form (form) - * - Panel 2 (panel2) - * - Data Grid (dataGrid) - * - Panel 3 (panel3) - * - TextField (textField) - * - * The "dataPath" to the TextField component would be "form.data.dataGrid[1].textField" - */ - DATA = 'dataPath', - - /** - * The "data" path is the local path to the data value for any component. The difference between this path - * and the "path" is that this path is used to locate the data value for a component within the data model. - * and does not include any keys for layout components. - * - * For example: Suppose you have the following form structure. - * - Root - * - Panel 1 (panel) - * - Form (form) - * - Panel 2 (panel2) - * - Data Grid (dataGrid) - * - Panel 3 (panel3) - * - TextField (textField) - * - * The "localDataPath" to the TextField component from the perspective of a configuration within the Form, would be "dataGrid[1].textField" - */ - LOCAL_DATA = 'localDataPath', -} - /** * Defines model types for known components. * For now, these will be the only model types supported by the @formio/core library. @@ -292,36 +173,32 @@ export function getModelType(component: Component): keyof typeof MODEL_TYPES_OF_ return component.modelType; } + let modelType: keyof typeof MODEL_TYPES_OF_KNOWN_COMPONENTS = 'any'; + // Otherwise, check for known component types. for (const type of Object.keys( MODEL_TYPES_OF_KNOWN_COMPONENTS, ) as (keyof typeof MODEL_TYPES_OF_KNOWN_COMPONENTS)[]) { if (MODEL_TYPES_OF_KNOWN_COMPONENTS[type].includes(component.type)) { - return type; + modelType = type; + break; } } // Otherwise check for components that assert no value. - if (component.input === false) { - return 'none'; + if (modelType === 'any' && component.input === false) { + modelType = 'none'; } - // Otherwise default to any. - return 'any'; -} + // To speed up performance of getModelType, we will set the modelType on the component as a non-enumerable property. + Object.defineProperty(component, 'modelType', { + enumerable: false, + writable: true, + value: modelType, + }); -export function getComponentPath(component: Component, path: string) { - const key = getComponentKey(component); - if (!key) { - return path; - } - if (!path) { - return key; - } - if (path.match(new RegExp(`${escapeRegExp(key)}$`))) { - return path; - } - return getModelType(component) === 'none' ? `${path}.${key}` : path; + // Otherwise default to any. + return modelType; } export function isComponentNestedDataType(component: any): component is HasChildComponents { @@ -340,6 +217,9 @@ export function setComponentScope( name: keyof NonNullable, value: string | boolean | number, ) { + if (!component) { + return; + } if (!component.scope) { Object.defineProperty(component, 'scope', { enumerable: false, @@ -368,21 +248,31 @@ export function resetComponentScope(component: Component) { * @param type - The type of path to return. * @returns */ -export function componentPath(component: Component, type: COMPONENT_PATH): string { - const parent = component.parent; +export function componentPath( + component: Component, + parent: Component | undefined | null, + parentPaths: ComponentPaths | undefined | null, + type: ComponentPath, +): string { + if (!component) { + return ''; + } + if ((component as any).component) { + component = (component as any).component; + } const compModel = getModelType(component); // Relative paths are only referenced from the current form. const relative = - type === COMPONENT_PATH.LOCAL_FORM || - type === COMPONENT_PATH.FULL_LOCAL_FORM || - type === COMPONENT_PATH.LOCAL_DATA; + type === ComponentPath.localPath || + type === ComponentPath.fullLocalPath || + type === ComponentPath.localDataPath; // Full paths include all layout component ids in the path. - const fullPath = type === COMPONENT_PATH.FULL_FORM || type === COMPONENT_PATH.FULL_LOCAL_FORM; + const fullPath = type === ComponentPath.fullPath || type === ComponentPath.fullLocalPath; // See if this is a data path. - const dataPath = type === COMPONENT_PATH.DATA || type === COMPONENT_PATH.LOCAL_DATA; + const dataPath = type === ComponentPath.dataPath || type === ComponentPath.localDataPath; // Determine if this component should include its key. const includeKey = @@ -405,16 +295,13 @@ export function componentPath(component: Component, type: COMPONENT_PATH): strin } // Return the parent path. - let parentPath = parent.scope ? (parent.scope as any)[type] || '' : ''; + let parentPath = parentPaths?.hasOwnProperty(type) ? parentPaths[type] || '' : ''; // For data paths (where we wish to get the path to the data), we need to ensure we append the parent // paths to the end of the path so that any component within this component properly references their data. if (dataPath && parentPath) { - if ( - parent.scope?.dataIndex !== undefined && - (parentModel === 'nestedArray' || parentModel === 'nestedDataArray') - ) { - parentPath += `[${parent.scope?.dataIndex}]`; + if (parentModel === 'nestedArray' || parentModel === 'nestedDataArray') { + parentPath += `[${parentPaths?.dataIndex || 0}]`; } if (parentModel === 'dataObject' || parentModel === 'nestedDataArray') { parentPath += '.data'; @@ -426,105 +313,166 @@ export function componentPath(component: Component, type: COMPONENT_PATH): strin } /** - * The types of paths that can be set on a component. + * This method determines a components paths provided the component JSON, the parent and the parent paths. + * @param component + * @param parent + * @param parentPaths + * @returns */ -export type ComponentPaths = { - path?: string; - fullPath?: string; - localPath?: string; - fullLocalPath?: string; - dataPath?: string; - localDataPath?: string; +export function getComponentPaths( + component: Component, + parent?: Component, + parentPaths?: ComponentPaths, +): ComponentPaths { + return { + path: componentPath(component, parent, parentPaths, ComponentPath.path), + fullPath: componentPath(component, parent, parentPaths, ComponentPath.fullPath), + localPath: componentPath(component, parent, parentPaths, ComponentPath.localPath), + fullLocalPath: componentPath(component, parent, parentPaths, ComponentPath.fullLocalPath), + dataPath: componentPath(component, parent, parentPaths, ComponentPath.dataPath), + localDataPath: componentPath(component, parent, parentPaths, ComponentPath.localDataPath), + dataIndex: parentPaths?.dataIndex, + }; +} + +export type ComponentMatch = { + component: Component | undefined; + paths: ComponentPaths | undefined; }; -/** - * @param component - The component to establish paths for. - * @param paths - The ComponentPaths object to set the paths on this component. - */ -export function setComponentPaths(component: Component, paths: ComponentPaths = {}) { - Object.defineProperty(component, 'modelType', { - enumerable: false, - writable: true, - value: getModelType(component), - }); - if (paths.hasOwnProperty(COMPONENT_PATH.FORM)) { - // Do not mutate the component path if it is already set. - if (!component.path) { - Object.defineProperty(component, 'path', { - enumerable: false, - writable: true, - value: paths[COMPONENT_PATH.FORM], - }); +export function componentMatches( + component: Component, + paths: ComponentPaths, + formPath: string, + dataIndex?: number, + matches: Record = { + path: undefined, + fullPath: undefined, + localPath: undefined, + dataPath: undefined, + localDataPath: undefined, + fullLocalPath: undefined, + key: undefined, + }, +) { + let dataProperty = ''; + if (component.type === 'selectboxes') { + const valuePath = new RegExp(`(\\.${escapeRegExp(component.key)})(\\.[^\\.]+)$`); + const pathMatches = formPath.match(valuePath); + if (pathMatches?.length === 3) { + dataProperty = pathMatches[2]; + formPath = formPath.replace(valuePath, '$1'); } - setComponentScope(component, COMPONENT_PATH.FORM, paths[COMPONENT_PATH.FORM] || ''); } - if (paths.hasOwnProperty(COMPONENT_PATH.FULL_FORM)) { - setComponentScope(component, COMPONENT_PATH.FULL_FORM, paths[COMPONENT_PATH.FULL_FORM] || ''); + [ + ComponentPath.path, + ComponentPath.fullPath, + ComponentPath.localPath, + ComponentPath.fullLocalPath, + ].forEach((type) => { + if (paths[type as ComponentPath] === formPath) { + if (!matches[type as ComponentPath]) { + matches[type as ComponentPath] = { component, paths }; + } + if (!matches.dataPath || dataIndex === paths.dataIndex) { + const dataPaths = { + dataPath: paths.dataPath || '', + localDataPath: paths.localDataPath || '', + }; + if (dataProperty) { + dataPaths.dataPath += dataProperty; + dataPaths.localDataPath += dataProperty; + } + matches.dataPath = { + component, + paths: { + ...paths, + ...dataPaths, + }, + }; + } + } + }); + if (!matches.key && component.input !== false && component.key === formPath) { + matches.key = { component, paths }; + } +} + +export function getBestMatch( + matches: Record, +): ComponentMatch | undefined { + if (matches.dataPath) { + return matches.dataPath; } - if (paths.hasOwnProperty(COMPONENT_PATH.LOCAL_FORM)) { - setComponentScope(component, COMPONENT_PATH.LOCAL_FORM, paths[COMPONENT_PATH.LOCAL_FORM] || ''); + if (matches.localDataPath) { + return matches.localDataPath; } - if (paths.hasOwnProperty(COMPONENT_PATH.FULL_LOCAL_FORM)) { - setComponentScope( - component, - COMPONENT_PATH.FULL_LOCAL_FORM, - paths[COMPONENT_PATH.FULL_LOCAL_FORM] || '', - ); + if (matches.fullPath) { + return matches.fullPath; } - if (paths.hasOwnProperty(COMPONENT_PATH.DATA)) { - setComponentScope(component, COMPONENT_PATH.DATA, paths[COMPONENT_PATH.DATA] || ''); + if (matches.path) { + return matches.path; } - if (paths.hasOwnProperty(COMPONENT_PATH.LOCAL_DATA)) { - setComponentScope(component, COMPONENT_PATH.LOCAL_DATA, paths[COMPONENT_PATH.LOCAL_DATA] || ''); + if (matches.fullLocalPath) { + return matches.fullLocalPath; } -} - -export function setDefaultComponentPaths(component: Component) { - setComponentPaths(component, { - path: componentPath(component, COMPONENT_PATH.FORM), - fullPath: componentPath(component, COMPONENT_PATH.FULL_FORM), - localPath: componentPath(component, COMPONENT_PATH.LOCAL_FORM), - fullLocalPath: componentPath(component, COMPONENT_PATH.FULL_LOCAL_FORM), - }); + if (matches.localPath) { + return matches.localPath; + } + if (matches.key) { + return matches.key; + } + return undefined; } /** - * Sets the parent reference on a component, and ensures the component paths are set as well - * as removes any circular references. - * @param component - * @param parent - * @returns + * This method performs a fuzzy search for a component within a form provided a number of different + * paths to search. */ -export function setParentReference(component: Component, parent?: Component) { - if (!parent) { - return; +export function getComponentFromPath( + components: Component[], + path: any, + data?: any, + dataIndex?: number, + includeAll: any = false, +): ComponentMatch | undefined { + const matches: Record = { + path: undefined, + fullPath: undefined, + localPath: undefined, + fullLocalPath: undefined, + dataPath: undefined, + localDataPath: undefined, + key: undefined, + }; + if (data) { + eachComponentData( + components, + data, + ( + component: Component, + data: DataObject, + row: any, + compPath: string, + comps, + index, + parent, + paths, + ) => { + componentMatches(component, paths || {}, path, dataIndex, matches); + }, + includeAll, + ); + } else { + eachComponent( + components, + (component: Component, compPath: any, componentComponents, compParent, paths) => { + componentMatches(component, paths || {}, path, dataIndex, matches); + }, + includeAll, + ); } - const parentRef = JSON.parse(JSON.stringify(parent)); - delete parentRef.components; - delete parentRef.componentMap; - delete parentRef.columns; - delete parentRef.rows; - setComponentPaths(parentRef, { - path: parent.path, - localPath: parent.scope?.localPath || '', - fullPath: parent.scope?.fullPath || '', - fullLocalPath: parent.scope?.fullLocalPath || '', - }); - Object.defineProperty(parentRef, 'scope', { - enumerable: false, - writable: true, - value: parent.scope, - }); - Object.defineProperty(parentRef, 'parent', { - enumerable: false, - writable: true, - value: parent.parent, - }); - Object.defineProperty(component, 'parent', { - enumerable: false, - writable: true, - value: parentRef, - }); + return getBestMatch(matches); } /** @@ -535,6 +483,9 @@ export function setParentReference(component: Component, parent?: Component) { * @returns */ export function getComponentKey(component: Component) { + if (!component) { + return ''; + } if ( component.type === 'checkbox' && component.inputType === 'radio' && @@ -545,26 +496,23 @@ export function getComponentKey(component: Component) { return component.key; } -export function getContextualRowPath(component: Component): string { +export function getContextualRowPath(component: Component, paths?: ComponentPaths): string { + if (!paths) { + return ''; + } return ( - component.scope?.dataPath?.replace( - new RegExp(`.?${escapeRegExp(getComponentKey(component))}$`), - '', - ) || '' + paths.dataPath?.replace(new RegExp(`.?${escapeRegExp(getComponentKey(component))}$`), '') || '' ); } -export function getContextualRowData(component: Component, data: any): any { - const rowPath = getContextualRowPath(component); +export function getContextualRowData(component: Component, data: any, paths?: ComponentPaths): any { + const rowPath = getContextualRowPath(component, paths); return rowPath ? get(data, rowPath, null) : data; } -export function getComponentLocalData(component: Component, data: any): string { +export function getComponentLocalData(paths: ComponentPaths, data: any): string { const parentPath = - component.scope?.dataPath?.replace( - new RegExp(`.?${escapeRegExp(component.scope?.localDataPath)}$`), - '', - ) || ''; + paths.dataPath?.replace(new RegExp(`.?${escapeRegExp(paths.localDataPath)}$`), '') || ''; return parentPath ? get(data, parentPath, null) : data; } @@ -584,6 +532,9 @@ export function shouldProcessComponent(component: Component, row: any, value: an } export function componentInfo(component: any) { + if (component.component) { + return componentInfo(component.component); + } const hasColumns = component.columns && Array.isArray(component.columns); const hasRows = component.rows && Array.isArray(component.rows); const hasComps = component.components && Array.isArray(component.components); @@ -616,49 +567,23 @@ export function getComponentData(components: Component[], data: DataObject, path return compData; } -export function getComponentActualValue( - component: Component, - compPath: string, - data: any, - row: any, +export function getComponentValue( + form: Form | undefined, + data: DataObject, + path: string, + dataIndex?: number, ) { - // The compPath here will NOT contain the indexes for DataGrids and EditGrids. - // - // a[0].b[2].c[3].d - // - // Because of this, we will need to determine our parent component path (not data path), - // and find the "row" based comp path. - // - // a[0].b[2].c[3].d => a.b.c.d - // - let parentInputComponent: any = null; - let parent = component; - let rowPath = ''; - - while (parent?.parent?.path && !parentInputComponent) { - parent = parent.parent; - if (parent.input) { - parentInputComponent = parent; - } - } - - if (parentInputComponent) { - const parentCompPath = parentInputComponent.path.replace(/\[[0-9]+\]/g, ''); - rowPath = compPath.replace(parentCompPath, ''); - rowPath = trim(rowPath, '. '); - } - - let value = null; - if (data) { - value = get(data, compPath); - } - if (rowPath && row && isNil(value)) { - value = get(row, rowPath); - } - if (isNil(value) || (isObject(value) && isEmpty(value))) { - value = ''; + const match: ComponentMatch | undefined = getComponentFromPath( + form?.components || [], + path, + data, + dataIndex, + ); + if (!match) { + // Fall back to get the value from the data object. + return get(data, path, undefined); } - return value; + return match?.paths?.dataPath ? get(data, match.paths.dataPath, undefined) : null; } /** @@ -687,11 +612,9 @@ export function isLayoutComponent(component: Component) { * @param query * @return {boolean} */ -export function matchComponent(component: Component, query: any) { +export function matchComponent(component: Component, query: any, paths?: ComponentPaths) { if (isString(query)) { - return ( - component.key === query || component.scope?.localPath === query || component.path === query - ); + return component.key === query || paths?.localPath === query || paths?.path === query; } else { let matches = false; forOwn(query, (value, key) => { @@ -705,35 +628,20 @@ export function matchComponent(component: Component, query: any) { } /** - * Get a component by its key + * Get a component by its path. * * @param {Object} components - The components to iterate. - * @param {String|Object} key - The key of the component to get, or a query of the component to search. + * @param {String|Object} path - The key of the component to get, or a query of the component to search. * @param {boolean} includeAll - Whether or not to include layout components. * @returns {Component} - The component that matches the given key, or undefined if not found. */ export function getComponent( components: Component[], - key: any, + path: any, includeAll: any = false, + dataIndex?: number, // The preferred last data index of the component to find. ): Component | undefined { - let result; - eachComponent( - components, - (component: Component, path: any) => { - if ( - path === key || - component.scope?.localPath === key || - component.path === key || - (component.input !== false && component.key === key) - ) { - result = component; - return true; - } - }, - includeAll, - ); - return result; + return getComponentFromPath(components, path, undefined, dataIndex, includeAll)?.component; } /** @@ -747,8 +655,8 @@ export function searchComponents(components: Component[], query: any): Component const results: Component[] = []; eachComponent( components, - (component: any) => { - if (matchComponent(component, query)) { + (component: any, compPath, components, parent, compPaths) => { + if (matchComponent(component, query, compPaths)) { results.push(component); } }, @@ -1375,4 +1283,23 @@ export function compareSelectResourceWithObjectTypeValues( ); } +export function getComponentErrorField(component: Component, context: ValidationContext) { + const toInterpolate = + component.errorLabel || component.label || component.placeholder || component.key; + return Evaluator.interpolate(toInterpolate, context); +} + +export function normalizeContext(context: any): any { + const { data, paths } = context; + return paths + ? { + ...context, + ...{ + path: paths.localDataPath, + data: getComponentLocalData(paths, data), + }, + } + : context; +} + export { eachComponent, eachComponentData, eachComponentAsync, eachComponentDataAsync }; diff --git a/src/utils/operators/IsEqualTo.js b/src/utils/operators/IsEqualTo.js index c7ad34e9..24a6868e 100644 --- a/src/utils/operators/IsEqualTo.js +++ b/src/utils/operators/IsEqualTo.js @@ -3,7 +3,7 @@ import { isSelectResourceWithObjectValue, } from 'utils/formUtil'; import ConditionOperator from './ConditionOperator'; -import { isString, isEqual, get } from 'lodash'; +import { isString, isEqual, get, isObject } from 'lodash'; export default class IsEqualTo extends ConditionOperator { static get operatorKey() { @@ -16,10 +16,9 @@ export default class IsEqualTo extends ConditionOperator { execute({ value, comparedValue, conditionComponent }) { // special check for select boxes - if (conditionComponent?.type === 'selectboxes') { + if (conditionComponent?.type === 'selectboxes' && isObject(value)) { return get(value, comparedValue, false); } - if ( value && comparedValue && From bf320183af9070f390fd48876513de612a8c6385 Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Wed, 13 Nov 2024 22:18:52 -0600 Subject: [PATCH 05/10] Make it so that you can decorate the match with the componentMatch method. --- src/utils/formUtil/index.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/utils/formUtil/index.ts b/src/utils/formUtil/index.ts index 9f8b8fd4..ea9e4cc9 100644 --- a/src/utils/formUtil/index.ts +++ b/src/utils/formUtil/index.ts @@ -354,6 +354,9 @@ export function componentMatches( fullLocalPath: undefined, key: undefined, }, + addMatch = (type: ComponentPath | 'key', match: ComponentMatch) => { + return match; + }, ) { let dataProperty = ''; if (component.type === 'selectboxes') { @@ -372,7 +375,7 @@ export function componentMatches( ].forEach((type) => { if (paths[type as ComponentPath] === formPath) { if (!matches[type as ComponentPath]) { - matches[type as ComponentPath] = { component, paths }; + matches[type as ComponentPath] = addMatch(type, { component, paths }); } if (!matches.dataPath || dataIndex === paths.dataIndex) { const dataPaths = { @@ -383,18 +386,18 @@ export function componentMatches( dataPaths.dataPath += dataProperty; dataPaths.localDataPath += dataProperty; } - matches.dataPath = { + matches.dataPath = addMatch(ComponentPath.dataPath, { component, paths: { ...paths, ...dataPaths, }, - }; + }); } } }); if (!matches.key && component.input !== false && component.key === formPath) { - matches.key = { component, paths }; + matches.key = addMatch('key', { component, paths }); } } From 0aa0de3ec21708e1fe0bae1e7ec7f7421c876289 Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Thu, 14 Nov 2024 10:21:31 -0600 Subject: [PATCH 06/10] Fixing matchComponent to also allow data path arguments. --- src/utils/formUtil/index.ts | 61 +++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/src/utils/formUtil/index.ts b/src/utils/formUtil/index.ts index ea9e4cc9..310728c3 100644 --- a/src/utils/formUtil/index.ts +++ b/src/utils/formUtil/index.ts @@ -340,10 +340,19 @@ export type ComponentMatch = { paths: ComponentPaths | undefined; }; +/** + * Determines if a component has a match at any of the path types. + * @param component {Component} - The component JSON to check for matches. + * @param paths {ComponentPaths} - The current component paths object. + * @param path {string} - Either the "form" or "data" path to see if a match occurs. + * @param dataIndex {number | undefined} - The data index for the current component to match. + * @param matches {Record} - The current matches object. + * @param addMatch {(type: ComponentPath | 'key', match: ComponentMatch) => ComponentMatch} - A callback function to allow modules to decorate the match object. + */ export function componentMatches( component: Component, paths: ComponentPaths, - formPath: string, + path: string, dataIndex?: number, matches: Record = { path: undefined, @@ -361,42 +370,48 @@ export function componentMatches( let dataProperty = ''; if (component.type === 'selectboxes') { const valuePath = new RegExp(`(\\.${escapeRegExp(component.key)})(\\.[^\\.]+)$`); - const pathMatches = formPath.match(valuePath); + const pathMatches = path.match(valuePath); if (pathMatches?.length === 3) { dataProperty = pathMatches[2]; - formPath = formPath.replace(valuePath, '$1'); + path = path.replace(valuePath, '$1'); } } + [ ComponentPath.path, ComponentPath.fullPath, ComponentPath.localPath, ComponentPath.fullLocalPath, + ComponentPath.dataPath, + ComponentPath.localDataPath, ].forEach((type) => { - if (paths[type as ComponentPath] === formPath) { - if (!matches[type as ComponentPath]) { - matches[type as ComponentPath] = addMatch(type, { component, paths }); - } - if (!matches.dataPath || dataIndex === paths.dataIndex) { - const dataPaths = { - dataPath: paths.dataPath || '', - localDataPath: paths.localDataPath || '', - }; - if (dataProperty) { - dataPaths.dataPath += dataProperty; - dataPaths.localDataPath += dataProperty; + const dataPath = type === ComponentPath.dataPath || type === ComponentPath.localDataPath; + if (paths[type as ComponentPath] === path) { + // Only add a new match if it already doesn't exist OR if the dataIndex is the same (more direct match). + if (!matches[type as ComponentPath] || dataPath || dataIndex === paths.dataIndex) { + if (dataPath) { + const dataPaths = { + dataPath: paths.dataPath || '', + localDataPath: paths.localDataPath || '', + }; + if (dataProperty) { + dataPaths.dataPath += dataProperty; + dataPaths.localDataPath += dataProperty; + } + matches[type as ComponentPath] = addMatch(type, { + component, + paths: { + ...paths, + ...dataPaths, + }, + }); + } else { + matches[type as ComponentPath] = addMatch(type, { component, paths }); } - matches.dataPath = addMatch(ComponentPath.dataPath, { - component, - paths: { - ...paths, - ...dataPaths, - }, - }); } } }); - if (!matches.key && component.input !== false && component.key === formPath) { + if (!matches.key && component.input !== false && component.key === path) { matches.key = addMatch('key', { component, paths }); } } From f16636efa9135fe54093f5953e4cb04356417ab7 Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Fri, 15 Nov 2024 14:00:27 -0600 Subject: [PATCH 07/10] Ensure we allow process call to include the parent and parentPaths so any component can be checked independently. --- src/process/process.ts | 10 ++++++++-- src/types/BaseComponent.ts | 1 - src/types/process/ProcessContext.ts | 3 +++ src/utils/formUtil/index.ts | 16 ++++++++++++++-- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/process/process.ts b/src/process/process.ts index dc3105c2..fa943764 100644 --- a/src/process/process.ts +++ b/src/process/process.ts @@ -28,7 +28,7 @@ import { hideChildrenProcessorInfo } from './hideChildren'; export async function process( context: ProcessContext, ): Promise { - const { instances, components, data, scope, flat, processors } = context; + const { instances, components, data, scope, flat, processors, parent, parentPaths } = context; await eachComponentDataAsync( components, data, @@ -57,6 +57,9 @@ export async function process( return true; } }, + false, + parent, + parentPaths, ); for (let i = 0; i < processors?.length; i++) { const processor = processors[i]; @@ -68,7 +71,7 @@ export async function process( } export function processSync(context: ProcessContext): ProcessScope { - const { instances, components, data, scope, flat, processors } = context; + const { instances, components, data, scope, flat, processors, parent, parentPaths } = context; eachComponentData( components, data, @@ -97,6 +100,9 @@ export function processSync(context: ProcessContext) return true; } }, + false, + parent, + parentPaths, ); for (let i = 0; i < processors?.length; i++) { const processor = processors[i]; diff --git a/src/types/BaseComponent.ts b/src/types/BaseComponent.ts index f8641efe..c1969712 100644 --- a/src/types/BaseComponent.ts +++ b/src/types/BaseComponent.ts @@ -60,7 +60,6 @@ export type BaseComponent = { validateOn?: string; validateWhenHidden?: boolean; modelType?: ReturnType; - parentPath?: string; validate?: { required?: boolean; custom?: string; diff --git a/src/types/process/ProcessContext.ts b/src/types/process/ProcessContext.ts index 5b30dc97..832b293e 100644 --- a/src/types/process/ProcessContext.ts +++ b/src/types/process/ProcessContext.ts @@ -5,6 +5,7 @@ import { ProcessorContext, ProcessType, ProcessorInfo, + ComponentPaths, } from 'types'; export type ComponentInstances = { @@ -21,6 +22,8 @@ export type BaseProcessContext = { form?: any; submission?: any; flat?: boolean; + parent?: Component; + parentPaths?: ComponentPaths; evalContext?: (context: ProcessorContext) => any; }; diff --git a/src/utils/formUtil/index.ts b/src/utils/formUtil/index.ts index 310728c3..89c85e8d 100644 --- a/src/utils/formUtil/index.ts +++ b/src/utils/formUtil/index.ts @@ -377,6 +377,10 @@ export function componentMatches( } } + // Get the current model type. + const modelType = getModelType(component); + const dataModel = modelType !== 'none' && modelType !== 'content'; + [ ComponentPath.path, ComponentPath.fullPath, @@ -387,8 +391,16 @@ export function componentMatches( ].forEach((type) => { const dataPath = type === ComponentPath.dataPath || type === ComponentPath.localDataPath; if (paths[type as ComponentPath] === path) { - // Only add a new match if it already doesn't exist OR if the dataIndex is the same (more direct match). - if (!matches[type as ComponentPath] || dataPath || dataIndex === paths.dataIndex) { + const currentMatch = matches[type as ComponentPath]; + const currentModelType = currentMatch?.component + ? getModelType(currentMatch.component) + : 'none'; + const currentDataModel = currentModelType !== 'none' && currentModelType !== 'content'; + if ( + !currentMatch || + (dataPath && dataModel && currentDataModel) || // Replace the current match if this is a dataPath and both are dataModels. + (!dataPath && dataIndex === paths.dataIndex) // Replace the current match if this is not a dataPath and the indexes are the same. + ) { if (dataPath) { const dataPaths = { dataPath: paths.dataPath || '', From fcfb1287c79fbf8fadc00cb47b51595a0942a28f Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Mon, 18 Nov 2024 09:43:44 -0600 Subject: [PATCH 08/10] Ensure we do NOT process empty rows for DataGrid and EditGrid, but we do process them for forms. --- src/process/process.ts | 8 ------ src/process/processOne.ts | 11 ++------ src/utils/formUtil/eachComponentData.ts | 15 ++++++---- src/utils/formUtil/eachComponentDataAsync.ts | 29 ++++++++++++-------- src/utils/formUtil/index.ts | 14 ++++++---- 5 files changed, 37 insertions(+), 40 deletions(-) diff --git a/src/process/process.ts b/src/process/process.ts index fa943764..48a0c3ce 100644 --- a/src/process/process.ts +++ b/src/process/process.ts @@ -33,10 +33,6 @@ export async function process( components, data, async (component, compData, row, path, components, index, parent, paths) => { - // Skip processing if row is null or undefined - if (!row) { - return; - } await processOne({ ...context, data: compData, @@ -76,10 +72,6 @@ export function processSync(context: ProcessContext) components, data, (component, compData, row, path, components, index, parent, paths) => { - // Skip processing if row is null or undefined - if (!row) { - return; - } processOneSync({ ...context, data: compData, diff --git a/src/process/processOne.ts b/src/process/processOne.ts index cb09f883..f6328506 100644 --- a/src/process/processOne.ts +++ b/src/process/processOne.ts @@ -3,7 +3,7 @@ import { ProcessorsContext, ProcessorType } from 'types'; import { getModelType } from 'utils/formUtil'; export async function processOne(context: ProcessorsContext) { - const { processors, row, component } = context; + const { processors, component } = context; // Create a getter for `value` that is always derived from the current data object if (typeof context.value === 'undefined') { Object.defineProperty(context, 'value', { @@ -26,9 +26,6 @@ export async function processOne(context: ProcessorsContext(context: ProcessorsContext(context: ProcessorsContext) { - const { processors, row, component } = context; + const { processors, component } = context; // Create a getter for `value` that is always derived from the current data object if (typeof context.value === 'undefined') { Object.defineProperty(context, 'value', { @@ -61,10 +58,6 @@ export function processOneSync(context: ProcessorsContext Date: Mon, 18 Nov 2024 20:43:54 -0600 Subject: [PATCH 09/10] Adding ability to provide local form data to processors. --- src/process/process.ts | 8 +++- src/process/processOne.ts | 14 ++++--- src/types/process/ProcessContext.ts | 1 + src/types/process/ProcessorContext.ts | 1 + src/utils/conditions.ts | 8 ++-- src/utils/formUtil/eachComponentData.ts | 41 +++++++++++++++++--- src/utils/formUtil/eachComponentDataAsync.ts | 10 ++++- src/utils/formUtil/index.ts | 33 +++++++++++----- 8 files changed, 87 insertions(+), 29 deletions(-) diff --git a/src/process/process.ts b/src/process/process.ts index 48a0c3ce..92304b55 100644 --- a/src/process/process.ts +++ b/src/process/process.ts @@ -28,7 +28,8 @@ import { hideChildrenProcessorInfo } from './hideChildren'; export async function process( context: ProcessContext, ): Promise { - const { instances, components, data, scope, flat, processors, parent, parentPaths } = context; + const { instances, components, data, scope, flat, processors, local, parent, parentPaths } = + context; await eachComponentDataAsync( components, data, @@ -54,6 +55,7 @@ export async function process( } }, false, + local, parent, parentPaths, ); @@ -67,7 +69,8 @@ export async function process( } export function processSync(context: ProcessContext): ProcessScope { - const { instances, components, data, scope, flat, processors, parent, parentPaths } = context; + const { instances, components, data, scope, flat, processors, local, parent, parentPaths } = + context; eachComponentData( components, data, @@ -93,6 +96,7 @@ export function processSync(context: ProcessContext) } }, false, + local, parent, parentPaths, ); diff --git a/src/process/processOne.ts b/src/process/processOne.ts index f6328506..c919fdb9 100644 --- a/src/process/processOne.ts +++ b/src/process/processOne.ts @@ -3,9 +3,10 @@ import { ProcessorsContext, ProcessorType } from 'types'; import { getModelType } from 'utils/formUtil'; export async function processOne(context: ProcessorsContext) { - const { processors, component } = context; + const { processors, component, paths, local, path } = context; // Create a getter for `value` that is always derived from the current data object if (typeof context.value === 'undefined') { + const dataPath = local ? paths?.localDataPath || path : paths?.dataPath || path; Object.defineProperty(context, 'value', { enumerable: true, get() { @@ -13,7 +14,7 @@ export async function processOne(context: ProcessorsContext(context: ProcessorsContext(context: ProcessorsContext(context: ProcessorsContext) { - const { processors, component } = context; + const { processors, component, paths, local, path } = context; // Create a getter for `value` that is always derived from the current data object if (typeof context.value === 'undefined') { + const dataPath = local ? paths?.localDataPath || path : paths?.dataPath || path; Object.defineProperty(context, 'value', { enumerable: true, get() { @@ -45,7 +47,7 @@ export function processOneSync(context: ProcessorsContext(context: ProcessorsContext = { flat?: boolean; parent?: Component; parentPaths?: ComponentPaths; + local?: boolean; // If the "data" being passed to the processors is local to the nested form. evalContext?: (context: ProcessorContext) => any; }; diff --git a/src/types/process/ProcessorContext.ts b/src/types/process/ProcessorContext.ts index 600912e9..ce9bc2fd 100644 --- a/src/types/process/ProcessorContext.ts +++ b/src/types/process/ProcessorContext.ts @@ -25,6 +25,7 @@ export type ProcessorContext = { processor?: ProcessorType; config?: Record; index?: number; + local?: boolean; // If the "data" being passed to the processors is local to the nested form. scope: ProcessorScope; parent?: Component | null; evalContext?: (context: ProcessorContext) => any; diff --git a/src/utils/conditions.ts b/src/utils/conditions.ts index f703e4ad..1804bd7b 100644 --- a/src/utils/conditions.ts +++ b/src/utils/conditions.ts @@ -64,11 +64,11 @@ export function checkLegacyConditional( conditional: LegacyConditional, context: ConditionsContext, ): boolean | null { - const { data, form } = context; + const { data, form, paths, local } = context; if (!conditional || !isLegacyConditional(conditional) || !conditional.when) { return null; } - const value: any = getComponentValue(form, data, conditional.when); + const value: any = getComponentValue(form, data, conditional.when, paths?.dataIndex, local); const eq = String(conditional.eq); const show = String(conditional.show); if (isObject(value) && has(value, eq)) { @@ -110,7 +110,7 @@ export function checkSimpleConditional( conditional: SimpleConditional, context: ConditionsContext, ): boolean | null { - const { component, data, instance, form, paths } = context; + const { component, data, instance, form, paths, local } = context; if (!conditional || !isSimpleConditional(conditional)) { return null; } @@ -135,7 +135,7 @@ export function checkSimpleConditional( paths?.dataIndex, ); const value = conditionComponent - ? getComponentValue(form, data, conditionComponentPath, paths?.dataIndex) + ? getComponentValue(form, data, conditionComponentPath, paths?.dataIndex, local) : null; const ConditionOperator = ConditionOperators[operator]; return ConditionOperator diff --git a/src/utils/formUtil/eachComponentData.ts b/src/utils/formUtil/eachComponentData.ts index 06a508d7..54aa7d5f 100644 --- a/src/utils/formUtil/eachComponentData.ts +++ b/src/utils/formUtil/eachComponentData.ts @@ -33,6 +33,7 @@ export const eachComponentData = ( data: DataObject, fn: EachComponentDataCallback, includeAll: boolean = false, + local: boolean = false, parent?: Component, parentPaths?: ComponentPaths, ) => { @@ -42,7 +43,7 @@ export const eachComponentData = ( return eachComponent( components, (component, compPath, componentComponents, compParent, compPaths) => { - const row = getContextualRowData(component, data, compPaths); + const row = getContextualRowData(component, data, compPaths, local); if ( fn( component, @@ -59,7 +60,10 @@ export const eachComponentData = ( return true; } if (isComponentNestedDataType(component)) { - const value = get(data, compPaths?.dataPath || '') as DataObject; + const value = get( + data, + local ? compPaths?.localDataPath || '' : compPaths?.dataPath || '', + ) as DataObject; if ( getModelType(component) === 'nestedArray' || getModelType(component) === 'nestedDataArray' @@ -69,7 +73,15 @@ export const eachComponentData = ( if (compPaths) { compPaths.dataIndex = i; } - eachComponentData(component.components, data, fn, includeAll, component, compPaths); + eachComponentData( + component.components, + data, + fn, + includeAll, + local, + component, + compPaths, + ); } } resetComponentScope(component); @@ -79,7 +91,15 @@ export const eachComponentData = ( resetComponentScope(component); return true; } - eachComponentData(component.components, data, fn, includeAll, component, compPaths); + eachComponentData( + component.components, + data, + fn, + includeAll, + local, + component, + compPaths, + ); } resetComponentScope(component); return true; @@ -87,13 +107,21 @@ export const eachComponentData = ( const info = componentInfo(component); if (info.hasColumns) { (component as HasColumns).columns.forEach((column: any) => - eachComponentData(column.components, data, fn, includeAll, component, compPaths), + eachComponentData(column.components, data, fn, includeAll, local, component, compPaths), ); } else if (info.hasRows) { (component as HasRows).rows.forEach((row: any) => { if (Array.isArray(row)) { row.forEach((row) => - eachComponentData(row.components, data, fn, includeAll, component, compPaths), + eachComponentData( + row.components, + data, + fn, + includeAll, + local, + component, + compPaths, + ), ); } }); @@ -103,6 +131,7 @@ export const eachComponentData = ( data, fn, includeAll, + local, component, compPaths, ); diff --git a/src/utils/formUtil/eachComponentDataAsync.ts b/src/utils/formUtil/eachComponentDataAsync.ts index 5516ed74..f81f6f61 100644 --- a/src/utils/formUtil/eachComponentDataAsync.ts +++ b/src/utils/formUtil/eachComponentDataAsync.ts @@ -25,6 +25,7 @@ export const eachComponentDataAsync = async ( data: DataObject, fn: EachComponentDataAsyncCallback, includeAll: boolean = false, + local: boolean = false, parent?: Component, parentPaths?: ComponentPaths, ) => { @@ -40,7 +41,7 @@ export const eachComponentDataAsync = async ( compParent: Component | undefined, compPaths: ComponentPaths | undefined, ) => { - const row = getContextualRowData(component, data, compPaths); + const row = getContextualRowData(component, data, compPaths, local); if ( (await fn( component, @@ -56,7 +57,7 @@ export const eachComponentDataAsync = async ( return true; } if (isComponentNestedDataType(component)) { - const value = get(data, compPaths?.dataPath || ''); + const value = get(data, local ? compPaths?.localDataPath || '' : compPaths?.dataPath || ''); if ( getModelType(component) === 'nestedArray' || getModelType(component) === 'nestedDataArray' @@ -71,6 +72,7 @@ export const eachComponentDataAsync = async ( data, fn, includeAll, + local, component, compPaths, ); @@ -88,6 +90,7 @@ export const eachComponentDataAsync = async ( data, fn, includeAll, + local, component, compPaths, ); @@ -104,6 +107,7 @@ export const eachComponentDataAsync = async ( data, fn, includeAll, + local, component, compPaths, ); @@ -118,6 +122,7 @@ export const eachComponentDataAsync = async ( data, fn, includeAll, + local, component, compPaths, ); @@ -130,6 +135,7 @@ export const eachComponentDataAsync = async ( data, fn, includeAll, + local, component, compPaths, ); diff --git a/src/utils/formUtil/index.ts b/src/utils/formUtil/index.ts index 9014fe0e..62741a2d 100644 --- a/src/utils/formUtil/index.ts +++ b/src/utils/formUtil/index.ts @@ -526,21 +526,32 @@ export function getComponentKey(component: Component) { return component.key; } -export function getContextualRowPath(component: Component, paths?: ComponentPaths): string { +export function getContextualRowPath( + component: Component, + paths?: ComponentPaths, + local?: boolean, +): string { if (!paths) { return ''; } - return ( - paths.dataPath?.replace(new RegExp(`.?${escapeRegExp(getComponentKey(component))}$`), '') || '' - ); + const dataPath = local ? paths.localDataPath : paths.dataPath; + return dataPath?.replace(new RegExp(`.?${escapeRegExp(getComponentKey(component))}$`), '') || ''; } -export function getContextualRowData(component: Component, data: any, paths?: ComponentPaths): any { - const rowPath = getContextualRowPath(component, paths); +export function getContextualRowData( + component: Component, + data: any, + paths?: ComponentPaths, + local?: boolean, +): any { + const rowPath = getContextualRowPath(component, paths, local); return rowPath ? get(data, rowPath, null) : data; } -export function getComponentLocalData(paths: ComponentPaths, data: any): string { +export function getComponentLocalData(paths: ComponentPaths, data: any, local?: boolean): string { + if (local) { + return data; + } const parentPath = paths.dataPath?.replace(new RegExp(`.?${escapeRegExp(paths.localDataPath)}$`), '') || ''; return parentPath ? get(data, parentPath, null) : data; @@ -604,6 +615,7 @@ export function getComponentValue( data: DataObject, path: string, dataIndex?: number, + local?: boolean, ) { const match: ComponentMatch | undefined = getComponentFromPath( form?.components || [], @@ -615,6 +627,9 @@ export function getComponentValue( // Fall back to get the value from the data object. return get(data, path, undefined); } + if (local) { + return match?.paths?.localDataPath ? get(data, match.paths.localDataPath, undefined) : null; + } return match?.paths?.dataPath ? get(data, match.paths.dataPath, undefined) : null; } @@ -1322,13 +1337,13 @@ export function getComponentErrorField(component: Component, context: Validation } export function normalizeContext(context: any): any { - const { data, paths } = context; + const { data, paths, local } = context; return paths ? { ...context, ...{ path: paths.localDataPath, - data: getComponentLocalData(paths, data), + data: getComponentLocalData(paths, data, local), }, } : context; From e38e07a95921ab2a1a423742bebcaeee508163d6 Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Mon, 18 Nov 2024 20:59:10 -0600 Subject: [PATCH 10/10] Update build. --- .../validation/rules/__tests__/validateRequiredDay.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/process/validation/rules/__tests__/validateRequiredDay.test.ts b/src/process/validation/rules/__tests__/validateRequiredDay.test.ts index e32adef5..6e7928ed 100644 --- a/src/process/validation/rules/__tests__/validateRequiredDay.test.ts +++ b/src/process/validation/rules/__tests__/validateRequiredDay.test.ts @@ -59,7 +59,7 @@ describe('validateRequiredDay', function () { fields: { year: { required: true }, month: { required: true }, - day: { hide: true } + day: { hide: true }, }, }; const data = { component: '07/2024' }; @@ -74,7 +74,7 @@ describe('validateRequiredDay', function () { fields: { year: { required: true }, day: { required: true }, - month: { hide: true } + month: { hide: true }, }, }; const data = { component: '24/2024' }; @@ -89,7 +89,7 @@ describe('validateRequiredDay', function () { fields: { month: { required: true }, day: { required: true }, - year: { hide: true } + year: { hide: true }, }, }; const data = { component: '07/24' };