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 14b76cc8..94a8f653 100644 --- a/src/modules/jsonlogic/index.ts +++ b/src/modules/jsonlogic/index.ts @@ -1,5 +1,6 @@ -import { BaseEvaluator, EvaluatorOptions } from 'utils'; +import { normalizeContext } from 'utils/formUtil'; import { jsonLogic } from './jsonLogic'; +import { BaseEvaluator, EvaluatorOptions, EvaluatorContext } 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 55d04237..d80df88b 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..b708ea9c 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/formUtil'; 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/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..37d88ed8 100644 --- a/src/process/defaultValue/index.ts +++ b/src/process/defaultValue/index.ts @@ -7,7 +7,7 @@ import { DefaultValueContext, } from 'types'; import { set, has } from 'lodash'; -import { getComponentKey } from 'utils/formUtil'; +import { getComponentKey, normalizeContext } from 'utils/formUtil'; export const hasCustomDefaultValue = (context: DefaultValueContext): boolean => { const { component } = context; @@ -48,7 +48,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..92c5eeaa 100644 --- a/src/process/fetch/index.ts +++ b/src/process/fetch/index.ts @@ -9,7 +9,7 @@ import { } from 'types'; import { get, set } from 'lodash'; import { Evaluator } from 'utils'; -import { getComponentKey } from 'utils/formUtil'; +import { getComponentKey, normalizeContext } from 'utils/formUtil'; export const shouldFetch = (context: FetchContext): boolean => { const { component, config } = context; @@ -38,7 +38,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/__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/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..96b4ceef 100644 --- a/src/process/populate/index.ts +++ b/src/process/populate/index.ts @@ -1,33 +1,31 @@ -import { get, set } from 'lodash'; +import { set } from 'lodash'; import { PopulateContext, PopulateScope, ProcessorFnSync } from 'types'; -import { componentPath, getContextualRowPath, getModelType } from 'utils/formUtil'; +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 = componentPath(component, getContextualRowPath(component, path)); - const compData: any = get(data, compDataPath); if (!scope.populated) scope.populated = []; 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/process.ts b/src/process/process.ts index cab0e157..92304b55 100644 --- a/src/process/process.ts +++ b/src/process/process.ts @@ -28,22 +28,19 @@ 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, local, parent, parentPaths } = + context; await eachComponentDataAsync( components, data, - async (component, compData, row, path, components, index, parent) => { - // Skip processing if row is null or undefined - if (!row) { - return; - } + async (component, compData, row, path, components, index, parent, paths) => { await processOne({ ...context, data: compData, component, components, path, + paths, row, index, instance: instances ? instances[path] : undefined, @@ -57,6 +54,10 @@ export async function process( return true; } }, + false, + local, + parent, + parentPaths, ); for (let i = 0; i < processors?.length; i++) { const processor = processors[i]; @@ -68,22 +69,19 @@ 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, local, parent, parentPaths } = + context; eachComponentData( components, data, - (component, compData, row, path, components, index, parent) => { - // Skip processing if row is null or undefined - if (!row) { - return; - } + (component, compData, row, path, components, index, parent, paths) => { processOneSync({ ...context, data: compData, component, components, path, + paths, row, index, instance: instances ? instances[path] : undefined, @@ -97,6 +95,10 @@ export function processSync(context: ProcessContext) return true; } }, + false, + local, + parent, + parentPaths, ); for (let i = 0; i < processors?.length; i++) { const processor = processors[i]; diff --git a/src/process/processOne.ts b/src/process/processOne.ts index 50d2ad83..c919fdb9 100644 --- a/src/process/processOne.ts +++ b/src/process/processOne.ts @@ -1,42 +1,32 @@ 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'; +import { getModelType } from 'utils/formUtil'; export async function processOne(context: ProcessorsContext) { - const { processors, component, path } = 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() { - return get(context.data, context.path); + const modelType = getModelType(component); + if (!component.type || modelType === 'none' || modelType === 'content') { + return undefined; + } + return get(context.data, dataPath); }, set(newValue: any) { - set(context.data, context.path, newValue); + const modelType = getModelType(component); + if (!component.type || modelType === 'none' || modelType === 'content') { + // Do not set the value if the model type is 'none' or 'content' + return; + } + set(context.data, dataPath, 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) { - return; - } context.processor = ProcessorType.Custom; for (const processor of processors) { if (processor?.process) { @@ -46,33 +36,31 @@ export async function processOne(context: ProcessorsContext(context: ProcessorsContext) { - const { processors, component, path } = 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() { - return get(context.data, context.path); + const modelType = getModelType(component); + if (!component.type || modelType === 'none' || modelType === 'content') { + return undefined; + } + return get(context.data, dataPath); }, set(newValue: any) { - set(context.data, context.path, newValue); + const modelType = getModelType(component); + if (!component.type || modelType === 'none' || modelType === 'content') { + // Do not set the value if the model type is 'none' or 'content' + return; + } + set(context.data, dataPath, 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) { - 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 98feed93..9548259a 100644 --- a/src/process/validation/index.ts +++ b/src/process/validation/index.ts @@ -14,7 +14,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 { @@ -92,10 +91,7 @@ export function isForcedHidden( isConditionallyHidden: ConditionallyHidden, ): boolean { const { component } = context; - if ( - component.ephemeralState?.conditionallyHidden || - isConditionallyHidden(context as ConditionsContext) - ) { + if (component.scope?.conditionallyHidden || isConditionallyHidden(context as ConditionsContext)) { return true; } if (component.hasOwnProperty('hidden')) { @@ -160,23 +156,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__/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/__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' }; 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..e480bac7 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/formUtil'; 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..d713556d 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/formUtil'; 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/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/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/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..c1969712 100644 --- a/src/types/BaseComponent.ts +++ b/src/types/BaseComponent.ts @@ -14,12 +14,15 @@ export type SimpleConditional = { conditions: SimpleConditionalConditions; }; +export type ComponentScope = { + conditionallyHidden?: boolean; +}; + export type BaseComponent = { input: boolean; type: string; key: string; - path?: string; - parent?: BaseComponent; + path?: string; // The "form" path to the component including non-layout parent components. tableView?: boolean; placeholder?: string; prefix?: string; @@ -31,9 +34,7 @@ export type BaseComponent = { unique?: boolean; persistent?: boolean | string; hidden?: boolean; - ephemeralState?: { - conditionallyHidden?: boolean; - }; + scope?: ComponentScope; clearOnHide?: boolean; refreshOn?: string; redrawOn?: string; @@ -59,7 +60,6 @@ export type BaseComponent = { validateOn?: string; validateWhenHidden?: boolean; modelType?: ReturnType; - parentPath?: string; validate?: { required?: boolean; custom?: 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/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/ProcessContext.ts b/src/types/process/ProcessContext.ts index 5b30dc97..36740a3f 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,9 @@ export type BaseProcessContext = { form?: any; submission?: any; 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 b4a28d51..ce9bc2fd 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; @@ -23,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/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/Evaluator.ts b/src/utils/Evaluator.ts index 3eb8da2e..55c6cc6f 100644 --- a/src/utils/Evaluator.ts +++ b/src/utils/Evaluator.ts @@ -5,6 +5,12 @@ export interface EvaluatorOptions { data?: any; } +export type EvaluatorContext = { + evalContext?: (context: any) => any; + instance?: any; + [key: string]: any; +}; + // 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..426ac010 100644 --- a/src/utils/__tests__/formUtil.test.ts +++ b/src/utils/__tests__/formUtil.test.ts @@ -12,7 +12,7 @@ import { findComponents, getComponent, flattenComponents, - getComponentActualValue, + getComponentValue, hasCondition, getModelType, } from '../formUtil'; @@ -109,16 +109,14 @@ 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', + }; + const actual = getContextualRowData(component, data, { + dataPath: 'a.b.c', + }); const expected = { c: 'hello' }; expect(actual).to.deep.equal(expected); }); @@ -131,16 +129,14 @@ 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', + }; + const actual = getContextualRowData(component, data, { + dataPath: 'a.b', + }); const expected = { b: { c: 'hello' } }; expect(actual).to.deep.equal(expected); }); @@ -153,16 +149,14 @@ 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', + }; + const actual = getContextualRowData(component, data, { + dataPath: 'a', + }); const expected = { a: { b: { c: 'hello' } } }; expect(actual).to.deep.equal(expected); }); @@ -176,16 +170,12 @@ 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', + }; + const actual = getContextualRowData(component, data); const expected = { a: { b: { c: 'hello' } }, d: 'there' }; expect(actual).to.deep.equal(expected); }); @@ -197,16 +187,14 @@ 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', + }; + const actual = getContextualRowData(component, data, { + dataPath: 'a[0].b', + }); const expected = { b: 'hello', c: 'world' }; expect(actual).to.deep.equal(expected); }); @@ -218,16 +206,14 @@ 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', + }; + const actual = getContextualRowData(component, data, { + dataPath: 'a[1].b', + }); const expected = { b: 'foo', c: 'bar' }; expect(actual).to.deep.equal(expected); }); @@ -239,16 +225,14 @@ 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', + }; + const actual = getContextualRowData(component, data, { + dataPath: 'a', + }); const expected = { a: [ { b: 'hello', c: 'world' }, @@ -265,16 +249,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 +273,14 @@ 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', + }; + const actual = getContextualRowData(component, data, { + dataPath: 'a.b[0].c', + }); const expected = { c: 'hello', d: 'world' }; expect(actual).to.deep.equal(expected); }); @@ -316,16 +294,14 @@ 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', + }; + const actual = getContextualRowData(component, data, { + dataPath: 'a.b[1].c', + }); const expected = { c: 'foo', d: 'bar' }; expect(actual).to.deep.equal(expected); }); @@ -339,16 +315,14 @@ 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', + }; + const actual = getContextualRowData(component, data, { + dataPath: 'a.b', + }); const expected = { b: [ { c: 'hello', d: 'world' }, @@ -367,16 +341,14 @@ 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', + }; + const actual = getContextualRowData(component, data, { + dataPath: 'a', + }); const expected = { a: { b: [ @@ -397,16 +369,12 @@ describe('formUtil', function () { ], }, }; - 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: [ @@ -427,16 +395,14 @@ 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', + }; + const actual = getContextualRowData(component, data, { + dataPath: 'a.b[0].c.e', + }); const expected = { c: { e: 'zed' }, d: 'world' }; expect(actual).to.deep.equal(expected); }); @@ -711,9 +677,6 @@ describe('formUtil', function () { const value = get(data, path); rowResults.set(path, [component, value]); }, - undefined, - undefined, - undefined, true, ); expect(rowResults.size).to.equal(2); @@ -822,7 +785,7 @@ describe('formUtil', function () { tableView: true, }, { - type: 'editGrid', + type: 'editgrid', key: 'nestedEditGrid', input: true, tableView: true, @@ -1478,9 +1441,6 @@ describe('formUtil', function () { const value = get(data, path); rowResults.set(path, [component, value]); }, - undefined, - undefined, - undefined, true, ); expect(rowResults.size).to.equal(2); @@ -1505,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 b0b13bbe..1804bd7b 100644 --- a/src/utils/conditions.ts +++ b/src/utils/conditions.ts @@ -1,12 +1,7 @@ import { ConditionsContext, JSONConditional, LegacyConditional, SimpleConditional } from 'types'; import { EvaluatorFn, evaluate, JSONLogicEvaluator } from 'modules/jsonlogic'; -import { - flattenComponents, - getComponent, - getComponentAbsolutePath, - 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'; export const isJSONConditional = (conditional: any): conditional is JSONConditional => { @@ -22,11 +17,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; } @@ -70,11 +64,11 @@ export function checkLegacyConditional( conditional: LegacyConditional, context: ConditionsContext, ): boolean | null { - const { row, data, component } = context; + const { data, form, paths, local } = 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, paths?.dataIndex, local); const eq = String(conditional.eq); const show = String(conditional.show); if (isObject(value) && has(value, eq)) { @@ -100,29 +94,12 @@ 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); } -/** - * 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 @@ -133,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, local } = context; if (!conditional || !isSimpleConditional(conditional)) { return null; } @@ -145,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, local) : 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 94d9beaa..7bb3df1e 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', @@ -1042,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 3034149f..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, componentPath, componentFormPath } from './index'; +import { Component, EachComponentCallback, ComponentPaths } from 'types'; +import { componentInfo, getComponentPaths } 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,67 +17,41 @@ export function eachComponent( components: Component[], fn: EachComponentCallback, includeAll?: boolean, - path: string = '', + 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; - // 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); + const compPaths = getComponentPaths(component, parent, parentPaths); if (includeAll || component.tree || !info.layout) { - noRecurse = !!fn(component, compPath, 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, path, parent ? component : null), + 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, path, parent ? component : null), + eachComponent(column.components, fn, includeAll, compPaths, component), ); } }); } else if (info.hasComps) { - eachComponent( - component.components, - fn, - includeAll, - componentFormPath(component, path, compPath), - parent ? component : null, - ); + eachComponent(component.components, fn, includeAll, compPaths, component); } } }); diff --git a/src/utils/formUtil/eachComponentAsync.ts b/src/utils/formUtil/eachComponentAsync.ts index 36985a32..f035c922 100644 --- a/src/utils/formUtil/eachComponentAsync.ts +++ b/src/utils/formUtil/eachComponentAsync.ts @@ -1,47 +1,28 @@ -import { componentInfo, componentPath, componentFormPath } from './index'; +import { Component, EachComponentAsyncCallback, ComponentPaths } from 'types'; +import { componentInfo, getComponentPaths } from './index'; // Async each component. export async function eachComponentAsync( - components: any[], - fn: any, + components: Component[], + fn: EachComponentAsyncCallback, includeAll = false, - path = '', + 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 = 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); - + const compPaths = getComponentPaths(component, parent, parentPaths); if (includeAll || component.tree || !info.layout) { - if (await fn(component, compPath, components, parent)) { + const path = includeAll ? compPaths.fullPath : compPaths.path; + if (await fn(component, path || '', components, parent, compPaths)) { continue; } } @@ -51,8 +32,8 @@ export async function eachComponentAsync( component.columns[j]?.components, fn, includeAll, - path, - parent ? component : null, + compPaths, + component, ); } } else if (info.hasRows) { @@ -60,24 +41,12 @@ export async function eachComponentAsync( 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, compPaths, component); } } } } else if (info.hasComps) { - await eachComponentAsync( - component.components, - fn, - includeAll, - componentFormPath(component, path, compPath), - parent ? component : null, - ); + await eachComponentAsync(component.components, fn, includeAll, compPaths, component); } } } diff --git a/src/utils/formUtil/eachComponentData.ts b/src/utils/formUtil/eachComponentData.ts index c7309161..54aa7d5f 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, @@ -7,143 +6,144 @@ import { HasChildComponents, HasColumns, HasRows, + ComponentPaths, } from 'types'; import { - getContextualRowData, isComponentNestedDataType, - getModelType, - componentDataPath, componentInfo, - componentFormPath, + getContextualRowData, + shouldProcessComponent, + resetComponentScope, + getModelType, } 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, + local: boolean = false, + parent?: Component, + parentPaths?: ComponentPaths, ) => { 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) { + (component, compPath, componentComponents, compParent, compPaths) => { + const row = getContextualRowData(component, data, compPaths, local); + if ( + fn( + component, + data, + row, + compPaths?.dataPath || '', + componentComponents, + compPaths?.dataIndex, + compParent, + compPaths, + ) === true + ) { + resetComponentScope(component); return true; } if (isComponentNestedDataType(component)) { - const value = get(data, compPath, data) 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, - ); + const value = get( + data, + local ? compPaths?.localDataPath || '' : compPaths?.dataPath || '', + ) as DataObject; + if ( + getModelType(component) === 'nestedArray' || + getModelType(component) === 'nestedDataArray' + ) { + if (Array.isArray(value) && value.length) { + for (let i = 0; i < value.length; i++) { + if (compPaths) { + compPaths.dataIndex = i; + } + eachComponentData( + component.components, + data, + fn, + includeAll, + local, + component, + compPaths, + ); + } } + 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') { - const nestedFormValue: any = get(data, component.path); - const noReferenceAttached = nestedFormValue?._id - ? isEmpty(nestedFormValue.data) && !has(nestedFormValue, 'form') - : false; - const shouldBeCleared = - (!component.hasOwnProperty('clearOnHide') || component.clearOnHide) && - (component.hidden || component.ephemeralState?.conditionallyHidden); - // Skip all the nested components processing if nested form is hidden and should be cleared on hide or if submission is saved as reference and not loaded - const shouldSkipProcessingNestedFormData = noReferenceAttached || shouldBeCleared; - if (!shouldSkipProcessingNestedFormData) { - // 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 { + if (!includeAll && !shouldProcessComponent(component, row, value)) { + resetComponentScope(component); + return true; + } eachComponentData( component.components, data, fn, - componentDataPath(component, path, compPath), - index, - component, includeAll, + local, + component, + compPaths, ); } + resetComponentScope(component); return true; - } else if (getModelType(component) === 'none') { + } else if (!component.type || getModelType(component) === '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, includeAll, local, component, compPaths), ); } 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, + includeAll, + local, component, + compPaths, ), ); } }); } else if (info.hasComps) { - const componentWithChildren = component as HasChildComponents; eachComponentData( - componentWithChildren.components, + (component as HasChildComponents).components, data, fn, - componentFormPath(componentWithChildren, path, componentWithChildren.path), - index, + includeAll, + local, component, + compPaths, ); } + resetComponentScope(component); return true; } + resetComponentScope(component); return false; }, true, - path, + parentPaths, parent, ); }; diff --git a/src/utils/formUtil/eachComponentDataAsync.ts b/src/utils/formUtil/eachComponentDataAsync.ts index 314d7190..f81f6f61 100644 --- a/src/utils/formUtil/eachComponentDataAsync.ts +++ b/src/utils/formUtil/eachComponentDataAsync.ts @@ -1,20 +1,21 @@ -import { get, set, isEmpty, has } from 'lodash'; +import { get } from 'lodash'; import { Component, DataObject, EachComponentDataAsyncCallback, - HasChildComponents, HasColumns, HasRows, + ComponentPaths, + HasChildComponents, } from 'types'; import { - getContextualRowData, isComponentNestedDataType, - getModelType, - componentDataPath, componentInfo, - componentFormPath, + getContextualRowData, + shouldProcessComponent, + resetComponentScope, + getModelType, } from './index'; import { eachComponentAsync } from './eachComponentAsync'; @@ -23,116 +24,130 @@ export const eachComponentDataAsync = async ( components: Component[], data: DataObject, fn: EachComponentDataAsyncCallback, - path = '', - index?: number, - parent?: Component, includeAll: boolean = false, + local: boolean = false, + parent?: Component, + parentPaths?: ComponentPaths, ) => { if (!components) { return; } return await eachComponentAsync( components, - async (component: any, compPath: string, componentComponents: any, compParent: any) => { - const row = getContextualRowData(component, compPath, data); + async ( + component: Component, + compPath: string, + componentComponents: Component[] | undefined, + compParent: Component | undefined, + compPaths: ComponentPaths | undefined, + ) => { + const row = getContextualRowData(component, data, compPaths, local); if ( - (await fn(component, data, row, compPath, componentComponents, index, compParent)) === true + (await fn( + component, + data, + row, + compPaths?.dataPath || '', + componentComponents, + compPaths?.dataIndex, + compParent, + )) === true ) { + resetComponentScope(component); return true; } if (isComponentNestedDataType(component)) { - const value = get(data, compPath, data); - 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, - ); + const value = get(data, local ? compPaths?.localDataPath || '' : compPaths?.dataPath || ''); + if ( + getModelType(component) === 'nestedArray' || + getModelType(component) === 'nestedDataArray' + ) { + if (Array.isArray(value) && value.length) { + for (let i = 0; i < value.length; i++) { + if (compPaths) { + compPaths.dataIndex = i; + } + await eachComponentDataAsync( + component.components, + data, + fn, + includeAll, + local, + component, + compPaths, + ); + } } + 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') { - const nestedFormValue: any = get(data, component.path); - const noReferenceAttached = nestedFormValue?._id - ? isEmpty(nestedFormValue.data) && !has(nestedFormValue, 'form') - : false; - const shouldBeCleared = - (!component.hasOwnProperty('clearOnHide') || component.clearOnHide) && - (component.hidden || component.ephemeralState?.conditionallyHidden); - // Skip all the nested components processing if nested form is hidden and should be cleared on hide or if submission is saved as reference and not loaded - const shouldSkipProcessingNestedFormData = noReferenceAttached || shouldBeCleared; - if (!shouldSkipProcessingNestedFormData) { - // 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 { + if (!includeAll && !shouldProcessComponent(component, row, value)) { + resetComponentScope(component); + return true; + } await eachComponentDataAsync( component.components, data, fn, - componentDataPath(component, path, compPath), - index, - component, includeAll, + local, + component, + compPaths, ); } + resetComponentScope(component); return true; - } else if (getModelType(component) === 'none') { + } else if (!component.type || getModelType(component) === '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, + includeAll, + local, + 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, path, index, component); + await eachComponentDataAsync( + row.components, + data, + fn, + includeAll, + local, + component, + compPaths, + ); } } } } else if (info.hasComps) { - const componentWithChildren = component as HasChildComponents; await eachComponentDataAsync( - componentWithChildren.components, + (component as HasChildComponents).components, data, fn, - componentFormPath(componentWithChildren, path, componentWithChildren.path), - index, + includeAll, + local, component, + compPaths, ); } + resetComponentScope(component); return true; } + resetComponentScope(component); return false; }, true, - path, + parentPaths, parent, ); }; diff --git a/src/utils/formUtil/index.ts b/src/utils/formUtil/index.ts index 0350a54f..62741a2d 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,10 +13,10 @@ import { isPlainObject, isArray, isEqual, - trim, isBoolean, omit, every, + escapeRegExp, } from 'lodash'; import { compare, applyPatch } from 'fast-json-patch'; @@ -40,6 +39,11 @@ import { SimpleConditional, AddressComponent, SelectComponent, + ComponentScope, + ComponentPaths, + ComponentPath, + Form, + ValidationContext, } from 'types'; import { Evaluator } from '../Evaluator'; import { eachComponent } from './eachComponent'; @@ -169,49 +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'; -} - -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('.'); -} + // 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(`${key}$`))) { - return path; - } - return getModelType(component) === 'none' ? `${path}.${key}` : path; + // Otherwise default to any. + return modelType; } export function isComponentNestedDataType(component: any): component is HasChildComponents { @@ -225,51 +212,310 @@ 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) { + return; } - return parentPath ? `${parentPath}.${key}` : key; + if (!component.scope) { + Object.defineProperty(component, 'scope', { + enumerable: false, + configurable: true, + writable: true, + value: {}, + }); + } + 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`; +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, + 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 === ComponentPath.localPath || + type === ComponentPath.fullLocalPath || + type === ComponentPath.localDataPath; + + // Full paths include all layout component ids in the path. + const fullPath = type === ComponentPath.fullPath || type === ComponentPath.fullLocalPath; + + // See if this is a data path. + const dataPath = type === ComponentPath.dataPath || type === ComponentPath.localDataPath; + + // 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 = 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 (parentModel === 'nestedArray' || parentModel === 'nestedDataArray') { + parentPath += `[${parentPaths?.dataIndex || 0}]`; } - if (getModelType(component) === 'nestedArray') { - return `${path}[0]`; + if (parentModel === 'dataObject' || parentModel === 'nestedDataArray') { + parentPath += '.data'; } - if (getModelType(component) === 'nestedDataArray') { - return `${path}[0].data`; + } + + // Return the parent path with its relative component path (if applicable). + return parentPath ? (key ? `${parentPath}.${key}` : parentPath) : key; +} + +/** + * This method determines a components paths provided the component JSON, the parent and the parent paths. + * @param component + * @param parent + * @param parentPaths + * @returns + */ +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; +}; + +/** + * 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, + path: string, + dataIndex?: number, + matches: Record = { + path: undefined, + fullPath: undefined, + localPath: undefined, + dataPath: undefined, + localDataPath: undefined, + fullLocalPath: undefined, + key: undefined, + }, + addMatch = (type: ComponentPath | 'key', match: ComponentMatch) => { + return match; + }, +) { + let dataProperty = ''; + if (component.type === 'selectboxes') { + const valuePath = new RegExp(`(\\.${escapeRegExp(component.key)})(\\.[^\\.]+)$`); + const pathMatches = path.match(valuePath); + if (pathMatches?.length === 3) { + dataProperty = pathMatches[2]; + path = path.replace(valuePath, '$1'); } - if (isComponentNestedDataType(component)) { - return path; + } + + // Get the current model type. + const modelType = getModelType(component); + const dataModel = modelType !== 'none' && modelType !== 'content'; + + [ + ComponentPath.path, + ComponentPath.fullPath, + ComponentPath.localPath, + ComponentPath.fullLocalPath, + ComponentPath.dataPath, + ComponentPath.localDataPath, + ].forEach((type) => { + const dataPath = type === ComponentPath.dataPath || type === ComponentPath.localDataPath; + if (paths[type as ComponentPath] === path) { + 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 || '', + 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 }); + } + } } - return parentPath; + }); + if (!matches.key && component.input !== false && component.key === path) { + matches.key = addMatch('key', { component, paths }); } - return path; -}; +} -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`; +export function getBestMatch( + matches: Record, +): ComponentMatch | undefined { + if (matches.dataPath) { + return matches.dataPath; } - if (isComponentNestedDataType(component)) { - return path; + if (matches.localDataPath) { + return matches.localDataPath; } - return parentPath; -}; + if (matches.fullPath) { + return matches.fullPath; + } + if (matches.path) { + return matches.path; + } + if (matches.fullLocalPath) { + return matches.fullLocalPath; + } + if (matches.localPath) { + return matches.localPath; + } + if (matches.key) { + return matches.key; + } + return undefined; +} +/** + * This method performs a fuzzy search for a component within a form provided a number of different + * paths to search. + */ +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, + ); + } + return getBestMatch(matches); +} + +/** + * 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) { + return ''; + } if ( component.type === 'checkbox' && component.inputType === 'radio' && @@ -280,16 +526,58 @@ 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, + paths?: ComponentPaths, + local?: boolean, +): string { + if (!paths) { + return ''; + } + const dataPath = local ? paths.localDataPath : paths.dataPath; + return 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, + 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, local?: boolean): string { + if (local) { + return data; + } + const parentPath = + paths.dataPath?.replace(new RegExp(`.?${escapeRegExp(paths.localDataPath)}$`), '') || ''; + return parentPath ? get(data, parentPath, null) : data; +} + +export function shouldProcessComponent(comp: Component, row: any, value: any): boolean { + if (isEmpty(row)) { + return false; + } + if (getModelType(comp) === 'dataObject') { + const noReferenceAttached = value?._id ? isEmpty(value.data) && !has(value, 'form') : false; + const shouldBeCleared = + (!comp.hasOwnProperty('clearOnHide') || comp.clearOnHide) && + (comp.hidden || comp.scope?.conditionallyHidden); + const shouldSkipProcessingNestedFormData = noReferenceAttached || shouldBeCleared; + if (shouldSkipProcessingNestedFormData) { + return false; + } + } + return true; +} + 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); @@ -322,49 +610,27 @@ 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, + local?: boolean, ) { - // 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); + 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); } - if (isNil(value) || (isObject(value) && isEmpty(value))) { - value = ''; + if (local) { + return match?.paths?.localDataPath ? get(data, match.paths.localDataPath, undefined) : null; } - return value; + return match?.paths?.dataPath ? get(data, match.paths.dataPath, undefined) : null; } /** @@ -393,9 +659,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.path === query; + return component.key === query || paths?.localPath === query || paths?.path === query; } else { let matches = false; forOwn(query, (value, key) => { @@ -409,30 +675,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.input && component.key === key)) { - result = component; - return true; - } - }, - includeAll, - ); - return result; + return getComponentFromPath(components, path, undefined, dataIndex, includeAll)?.component; } /** @@ -446,8 +702,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); } }, @@ -1074,4 +1330,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, local } = context; + return paths + ? { + ...context, + ...{ + path: paths.localDataPath, + data: getComponentLocalData(paths, data, local), + }, + } + : context; +} + export { eachComponent, eachComponentData, eachComponentAsync, eachComponentDataAsync }; 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/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 && 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; - } -}