From 3a36d088d19924be11d79d94190822bb71ea5273 Mon Sep 17 00:00:00 2001 From: Brendan Bond Date: Tue, 10 Sep 2024 03:11:03 -0500 Subject: [PATCH] FIO-8912: update validateMultiple to account for model types (#146) * update validateMultiple to account for model types; fix upstream formiojs tests * minor updates and add tests * add known components to model type * update modelType type param --- .../validation/rules/validateMultiple.ts | 41 ++- src/types/BaseComponent.ts | 4 +- src/utils/__tests__/formUtil.test.ts | 270 ++++++++++++++---- src/utils/date.ts | 2 +- src/utils/formUtil.ts | 127 ++++---- 5 files changed, 329 insertions(+), 115 deletions(-) diff --git a/src/process/validation/rules/validateMultiple.ts b/src/process/validation/rules/validateMultiple.ts index 872101e5..7b300768 100644 --- a/src/process/validation/rules/validateMultiple.ts +++ b/src/process/validation/rules/validateMultiple.ts @@ -9,6 +9,7 @@ import { ValidationContext, } from 'types'; import { ProcessorInfo } from 'types/process/ProcessorInfo'; +import { getModelType } from 'utils/formUtil'; export const isEligible = (component: Component) => { // TODO: would be nice if this was type safe @@ -37,6 +38,10 @@ export const isEligible = (component: Component) => { } }; +const isTagsComponent = (component: any): component is TagsComponent => { + return component?.type === 'tags'; +} + export const emptyValueIsArray = (component: Component) => { // TODO: How do we infer the data model of the compoennt given only its JSON? For now, we have to hardcode component types switch (component.type) { @@ -49,11 +54,12 @@ export const emptyValueIsArray = (component: Component) => { case 'file': return true; case 'select': + case 'textfield': return !!component.multiple; case 'tags': return (component as TagsComponent).storeas !== 'string'; default: - return false; + return true; } }; @@ -78,25 +84,36 @@ export const validateMultipleSync: RuleFnSync = ( return null; } - const shouldBeArray = !!component.multiple; + const shouldBeMultipleArray = !!component.multiple; const isRequired = !!component.validate?.required; - const isArray = Array.isArray(value); + const underlyingValueShouldBeArray = getModelType(component) === 'array' || (isTagsComponent(component) && component.storeas === 'array'); + const valueIsArray = Array.isArray(value); + + if (shouldBeMultipleArray) { + if (valueIsArray && underlyingValueShouldBeArray) { + if (value.length === 0) { + return isRequired ? new FieldError('array_nonempty', { ...context, setting: true }) : null; + } + + // TODO: We need to be permissive here for file components, which have an array model type but don't have an underlying array value + // (in other words, a file component's data object will always be a single array regardless of whether or not multiple is set) + // In the future, we could consider checking the underlying value's type to determine if it should be an array + // return Array.isArray(value[0]) ? null : new FieldError('array', { ...context, setting: true }); + return null; + } else if (valueIsArray && !underlyingValueShouldBeArray) { + if (value.length === 0) { + return isRequired ? new FieldError('array_nonempty', { ...context, setting: true }) : null; + } - if (shouldBeArray) { - if (isArray) { - return isRequired - ? value.length > 0 - ? null - : new FieldError('array_nonempty', { ...context, setting: true }) - : null; + return Array.isArray(value[0]) ? new FieldError('nonarray', { ...context, setting: true }) : null; } else { const error = new FieldError('array', { ...context, setting: true }); // Null/undefined is ok if this value isn't required; anything else should fail return isNil(value) ? (isRequired ? error : null) : error; } } else { - const canBeArray = emptyValueIsArray(component); - if (!canBeArray && isArray) { + const canBeArray = emptyValueIsArray(component) || underlyingValueShouldBeArray; + if (!canBeArray && valueIsArray) { return new FieldError('nonarray', { ...context, setting: false }); } return null; diff --git a/src/types/BaseComponent.ts b/src/types/BaseComponent.ts index a5e5924d..fe6b272f 100644 --- a/src/types/BaseComponent.ts +++ b/src/types/BaseComponent.ts @@ -1,6 +1,6 @@ import { RulesLogic } from "json-logic-js"; import { AdvancedLogic } from "./AdvancedLogic"; - +import { getModelType } from "utils/formUtil.js"; export type JSONConditional = { json: RulesLogic; }; export type LegacyConditional = { show: boolean | string | null; when: string | null; eq: boolean | string }; export type SimpleConditionalConditions = { component: string; operator: string; value?: any}[]; @@ -47,7 +47,7 @@ export type BaseComponent = { logic?: AdvancedLogic[]; validateOn?: string; validateWhenHidden?: boolean; - modelType?: "array" | "value" | "object" | "dataObject" | "inherit" | "value"; + modelType?: ReturnType; parentPath?: string; validate?: { required?: boolean; diff --git a/src/utils/__tests__/formUtil.test.ts b/src/utils/__tests__/formUtil.test.ts index 96a189fb..27155d2f 100644 --- a/src/utils/__tests__/formUtil.test.ts +++ b/src/utils/__tests__/formUtil.test.ts @@ -8,7 +8,6 @@ const components2 = JSON.parse(fs.readFileSync(__dirname + '/fixtures/components const components3 = JSON.parse(fs.readFileSync(__dirname + '/fixtures/components3.json').toString()); const components4 = JSON.parse(fs.readFileSync(__dirname + '/fixtures/components4.json').toString()); const components5 = JSON.parse(fs.readFileSync(__dirname + '/fixtures/components5.json').toString()); -const submission1 = JSON.parse(fs.readFileSync(__dirname + '/fixtures/submission1.json').toString()); import { getContextualRowData, eachComponentDataAsync, @@ -21,9 +20,9 @@ import { getComponent, flattenComponents, getComponentActualValue, - hasCondition + hasCondition, + getModelType } from "../formUtil"; -import { fastCloneDeep } from 'utils/fastCloneDeep'; describe('eachComponent', () => { it('should iterate through nested components in the right order', () => { @@ -227,53 +226,53 @@ describe('eachComponent', () => { }); }); - describe('getComponent', () => { - it('should return the correct components', () => { - for (let n = 1; n <= 8; n += 1) { - const component = getComponent(components, writtenNumber(n)); - expect(component).not.to.be.null; - expect(component).not.to.be.undefined; - expect(component).to.be.an('object'); - expect((component as any).order).to.equal(n); - expect(component?.key).to.equal(writtenNumber(n)); - } - }); +describe('getComponent', () => { +it('should return the correct components', () => { + for (let n = 1; n <= 8; n += 1) { + const component = getComponent(components, writtenNumber(n)); + expect(component).not.to.be.null; + expect(component).not.to.be.undefined; + expect(component).to.be.an('object'); + expect((component as any).order).to.equal(n); + expect(component?.key).to.equal(writtenNumber(n)); + } +}); - it('should work with a different this context', () => { - for (let n = 1; n <= 8; n += 1) { - const component = getComponent.call({}, components, writtenNumber(n)); - expect(component).not.to.be.null; - expect(component).not.to.be.undefined; - expect(component).to.be.an('object'); - expect((component as any).order).to.equal(n); - expect(component?.key).to.equal(writtenNumber(n)); - } - }); - }); +it('should work with a different this context', () => { + for (let n = 1; n <= 8; n += 1) { + const component = getComponent.call({}, components, writtenNumber(n)); + expect(component).not.to.be.null; + expect(component).not.to.be.undefined; + expect(component).to.be.an('object'); + expect((component as any).order).to.equal(n); + expect(component?.key).to.equal(writtenNumber(n)); + } +}); +}); - describe('flattenComponents', () => { - it('should return an object of flattened components', () => { - const flattened = flattenComponents(components); - for (let n = 1; n <= 8; n += 1) { - const component = flattened[writtenNumber(n)]; - expect(component).not.to.be.undefined; - expect(component).to.be.an('object'); - expect((component as any).order).to.equal(n); - expect(component.key).to.equal(writtenNumber(n)); - } - }); +describe('flattenComponents', () => { +it('should return an object of flattened components', () => { + const flattened = flattenComponents(components); + for (let n = 1; n <= 8; n += 1) { + const component = flattened[writtenNumber(n)]; + expect(component).not.to.be.undefined; + expect(component).to.be.an('object'); + expect((component as any).order).to.equal(n); + expect(component.key).to.equal(writtenNumber(n)); + } +}); - it('should work with a different this context', () => { - const flattened = flattenComponents.call({}, components); - for (let n = 1; n <= 8; n += 1) { - const component = flattened[writtenNumber(n)]; - expect(component).not.to.be.undefined; - expect(component).to.be.an('object'); - expect(component.order).to.equal(n); - expect(component.key).to.equal(writtenNumber(n)); - } - }); - }); +it('should work with a different this context', () => { + const flattened = flattenComponents.call({}, components); + for (let n = 1; n <= 8; n += 1) { + const component = flattened[writtenNumber(n)]; + expect(component).not.to.be.undefined; + expect(component).to.be.an('object'); + expect(component.order).to.equal(n); + expect(component.key).to.equal(writtenNumber(n)); + } +}); +}); describe('getContextualRowData', () => { it('Should return the data at path without the last element given nested containers', () => { @@ -1791,11 +1790,11 @@ describe('getComponentActualValue', () => { submit: true, }; const row = { radio: 'yes', textArea: 'test' }; - + const value = getComponentActualValue(component, compPath, data, row); expect(value).to.equal('yes'); }); - }); +}); describe('hasCondition', () => { it('Should return false if conditions is saved in invalid state', () => { @@ -1813,5 +1812,174 @@ describe('hasCondition', () => { const result = hasCondition(component as Component); expect(result).to.equal(false); }) -}) - \ No newline at end of file +}); + +describe('getModelType', () => { + it('Should return the correct model type for a component', () => { + const component = { + type: 'textfield', + input: true, + key: 'textField', + }; + const actual = getModelType(component); + const expected = 'string'; + expect(actual).to.equal(expected); + }); + + it('Should return the correct model type for a component with a number input type', () => { + const component = { + type: 'number', + input: true, + key: 'number', + }; + const actual = getModelType(component); + const expected = 'number'; + expect(actual).to.equal(expected); + }); + + it('Should return the correct model type for a component with a boolean input type', () => { + const component = { + type: 'checkbox', + input: true, + key: 'checkbox', + }; + const actual = getModelType(component); + const expected = 'boolean'; + expect(actual).to.equal(expected); + }); + + it('Should return the correct model type for a component with a datetime input type', () => { + const component = { + type: 'datetime', + input: true, + key: 'datetime', + }; + const actual = getModelType(component); + const expected = 'string'; + expect(actual).to.equal(expected); + }); + + it('Should return the correct model type for a component with a date input type', () => { + const component = { + type: 'datetime', + input: true, + key: 'date', + format: 'yyyy-MM-dd', + }; + const actual = getModelType(component); + const expected = 'string'; + expect(actual).to.equal(expected); + }); + + it('Should return the correct model type for a component with a time input type', () => { + const component = { + type: 'datetime', + input: true, + key: 'time', + format: 'HH:mm:ss', + }; + const actual = getModelType(component); + const expected = 'string'; + expect(actual).to.equal(expected); + }); + + it('Should return the correct model type for a component with a currency input type', () => { + const component = { + type: 'currency', + input: true, + key: 'currency', + }; + const actual = getModelType(component); + const expected = 'number'; + expect(actual).to.equal(expected); + }); + + it('Should return the correct model type for a component with a email input type', () => { + const component = { + type: 'email', + input: true, + key: 'email', + }; + const actual = getModelType(component); + const expected = 'string'; + expect(actual).to.equal(expected); + }); + + it('Should return the correct model type for a component with a phoneNumber input type', () => { + const component = { + type: 'phoneNumber', + input: true, + key: 'phoneNumber', + }; + const actual = getModelType(component); + const expected = 'string'; + expect(actual).to.equal(expected); + }); + + it('Should return the correct model type for a component with a url input type', () => { + const component = { + type: 'url', + input: true, + key: 'url', + }; + const actual = getModelType(component); + const expected = 'string'; + expect(actual).to.equal(expected); + }); + + it('Should return the correct model type for a component with a textarea input type', () => { + const component = { + type: 'textarea', + input: true, + key: 'textarea', + }; + const actual = getModelType(component); + const expected = 'string'; + expect(actual).to.equal(expected); + }); + + it('Should return the correct model type for a component with a signature input type', () => { + const component = { + type: 'signature', + input: true, + key: 'signature', + }; + const actual = getModelType(component); + const expected = 'string'; + expect(actual).to.equal(expected); + }); + + it('Should return the correct model type for a component with a select input type', () => { + const component = { + type: 'select', + input: true, + key: 'select', + data: { + values: [ + { label: 'foo', value: 'foo' }, + { label: 'bar', value: 'bar' }, + ], + }, + }; + const actual = getModelType(component); + const expected = 'any'; + expect(actual).to.equal(expected); + }); + + it('Should return the correct model type for a component with a selectboxes input type', () => { + const component = { + type: 'selectboxes', + input: true, + key: 'selectboxes', + data: { + values: [ + { label: 'foo', value: 'foo' }, + { label: 'bar', value: 'bar' }, + ], + }, + }; + const actual = getModelType(component); + const expected = 'any'; + expect(actual).to.equal(expected); + }); +}); diff --git a/src/utils/date.ts b/src/utils/date.ts index f119bb40..bb3d69e9 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -138,7 +138,7 @@ export const getDateValidationFormat = (component: DayComponent) => { export const isPartialDay = (component: DayComponent, value: string | undefined) => { if (!value) { - return false; + return true; } const [DAY, MONTH, YEAR] = component.dayFirst ? [0, 1, 2] : [1, 0, 2]; const values = value.split('/'); diff --git a/src/utils/formUtil.ts b/src/utils/formUtil.ts index 364814a5..a07dbeb1 100644 --- a/src/utils/formUtil.ts +++ b/src/utils/formUtil.ts @@ -104,59 +104,92 @@ export function uniqueName(name: string, template?: string, evalContext?: any) { return uniqueName; } -export const MODEL_TYPES: Record = { +// TODO: bring in all component types (don't forget premium components) +export const MODEL_TYPES_OF_KNOWN_COMPONENTS: Record = { array: [ 'datagrid', 'editgrid', 'datatable', 'dynamicWizard', - 'tagpad' + 'tagpad', + 'file', ], dataObject: [ 'form' ], object: [ 'container', - 'address' + 'address', ], map: [ - 'datamap' + 'datamap', ], content: [ 'htmlelement', 'content' ], - layout: [ + string: [ + 'textfield', + 'textarea', + 'password', + 'email', + 'url', + 'phoneNumber', + 'day', + 'datetime', + 'time', + 'signature', + ], + number: [ + 'number', + 'currency' + ], + boolean: [ + 'checkbox', + 'radio', + ], + none: [ 'table', - 'tabs', 'well', 'columns', 'fieldset', 'panel', 'tabs' ], + any: [ + 'survey', + 'captcha', + 'selectboxes', + 'tags', + 'select', + 'hidden', + 'button', + 'datasource', + 'sketchpad', + 'reviewpage' + ], }; -export function getModelType(component: Component) { - if (isComponentNestedDataType(component)) { - if (isComponentModelType(component, 'dataObject')) { - return 'dataObject'; - } - if (isComponentModelType(component, 'array')) { - return 'array'; - } - if (isComponentModelType(component, 'map')) { - return 'map'; - } - return 'object'; +export function getModelType(component: Component): keyof typeof MODEL_TYPES_OF_KNOWN_COMPONENTS { + // If the component JSON asserts a model type, use that. + if (component.modelType) { + return component.modelType; } - if ((component.input === false) || isComponentModelType(component, 'layout')) { - return 'inherit'; + + // Otherwise, check for known component types. + for (const type in MODEL_TYPES_OF_KNOWN_COMPONENTS) { + if (MODEL_TYPES_OF_KNOWN_COMPONENTS[type].includes(component.type)) { + return type as keyof typeof MODEL_TYPES_OF_KNOWN_COMPONENTS; + } } - if (getComponentKey(component)) { - return 'value'; + + // Otherwise check for components that assert no value. + if ((component.input === false)) { + return 'none'; } - return 'inherit'; + + // Otherwise default to any. + return 'any'; } export function getComponentAbsolutePath(component: Component) { @@ -164,7 +197,7 @@ export function getComponentAbsolutePath(component: Component) { 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 (isComponentModelType(component, 'dataObject')) { + if (getModelType(component) === 'dataObject') { paths[paths.length - 1] = `data.${paths[paths.length - 1]}`; paths.push(component.path); } @@ -183,18 +216,14 @@ export function getComponentPath(component: Component, path: string) { if (path.match(new RegExp(`${key}$`))) { return path; } - return (getModelType(component) === 'inherit') ? `${path}.${key}` : path; -} - -export function isComponentModelType(component: Component, modelType: string) { - return component.modelType === modelType || MODEL_TYPES[modelType].includes(component.type); + return (getModelType(component) === 'layout') ? `${path}.${key}` : path; } export function isComponentNestedDataType(component: any) { - return component.tree || isComponentModelType(component, 'array') || - isComponentModelType(component, 'dataObject') || - isComponentModelType(component, 'object') || - isComponentModelType(component, 'map'); + return component.tree || getModelType(component) === 'array' || + getModelType(component) === 'dataObject' || + getModelType(component) === 'object' || + getModelType(component) === 'map'; } export function componentPath(component: Component, parentPath?: string): string { @@ -212,10 +241,10 @@ export const componentDataPath = (component: any, parentPath: string, path: stri path = path || componentPath(component, parentPath); // See if we are a nested component. if (component.components && Array.isArray(component.components)) { - if (isComponentModelType(component, 'dataObject')) { + if (getModelType(component) === 'dataObject') { return `${path}.data`; } - if (isComponentModelType(component, 'array')) { + if (getModelType(component) === 'array') { return `${path}[0]`; } if (isComponentNestedDataType(component)) { @@ -229,7 +258,7 @@ export const componentDataPath = (component: any, parentPath: string, path: stri export const componentFormPath = (component: any, parentPath: string, path: string): string => { parentPath = component.parentPath || parentPath; path = path || componentPath(component, parentPath); - if (isComponentModelType(component, 'dataObject')) { + if (getModelType(component) === 'dataObject') { return `${path}.data`; } if (isComponentNestedDataType(component)) { @@ -277,7 +306,7 @@ export const eachComponentDataAsync = async ( // 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 (isComponentModelType(component, 'dataObject')) { + if (getModelType(component) === 'dataObject') { // No need to bother processing all the children data if there is no data for this form or the reference value has not been loaded. const nestedFormValue: any = get(data, component.path); const noReferenceAttached = nestedFormValue?._id && isEmpty(nestedFormValue.data) && !has(nestedFormValue, 'form'); @@ -333,7 +362,7 @@ export const eachComponentData = ( // 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 (isComponentModelType(component, 'dataObject')) { + if (getModelType(component) === 'dataObject') { // No need to bother processing all the children data if there is no data for this form or the reference value has not been loaded. const nestedFormValue: any = get(data, component.path); const noReferenceAttached = nestedFormValue?._id && isEmpty(nestedFormValue.data) && !has(nestedFormValue, 'form'); @@ -385,8 +414,8 @@ export function componentInfo(component: any) { const hasColumns = component.columns && Array.isArray(component.columns); const hasRows = component.rows && Array.isArray(component.rows); const hasComps = component.components && Array.isArray(component.components); - const isContent = isComponentModelType(component, 'content'); - const isLayout = isComponentModelType(component, 'layout'); + const isContent = getModelType(component) === 'content'; + const isLayout = getModelType(component) === 'layout'; const isInput = !component.hasOwnProperty('input') || !!component.input; return { hasColumns, @@ -1129,15 +1158,15 @@ export function findComponent(components: any, key: any, path: any, fn: any) { }); } -const isCheckboxComponent = (component: Component): component is CheckboxComponent => component.type === 'checkbox'; -const isDataGridComponent = (component: Component): component is DataGridComponent => component.type === 'datagrid'; -const isEditGridComponent = (component: Component): component is EditGridComponent => component.type === 'editgrid'; -const isDataTableComponent = (component: Component): component is DataTableComponent => component.type === 'datatable'; -const hasChildComponents = (component: any): component is HasChildComponents => component.components != null; -const isDateTimeComponent = (component: Component): component is DateTimeComponent => component.type === 'datetime'; -const isSelectBoxesComponent = (component: Component): component is SelectBoxesComponent => component.type === 'selectboxes'; -const isTextAreaComponent = (component: Component): component is TextAreaComponent => component.type === 'textarea'; -const isTextFieldComponent = (component: Component): component is TextFieldComponent => component.type === 'textfield'; +const isCheckboxComponent = (component: any): component is CheckboxComponent => component?.type === 'checkbox'; +const isDataGridComponent = (component: any): component is DataGridComponent => component?.type === 'datagrid'; +const isEditGridComponent = (component: any): component is EditGridComponent => component?.type === 'editgrid'; +const isDataTableComponent = (component: any): component is DataTableComponent => component?.type === 'datatable'; +const hasChildComponents = (component: any): component is HasChildComponents => component?.components != null; +const isDateTimeComponent = (component: any): component is DateTimeComponent => component?.type === 'datetime'; +const isSelectBoxesComponent = (component: any): component is SelectBoxesComponent => component?.type === 'selectboxes'; +const isTextAreaComponent = (component: any): component is TextAreaComponent => component?.type === 'textarea'; +const isTextFieldComponent = (component: any): component is TextFieldComponent => component?.type === 'textfield'; export function getEmptyValue(component: Component) { switch (component.type) {