diff --git a/packages/react-form-renderer/demo/index.js b/packages/react-form-renderer/demo/index.js index 5f7a8dc44..4b3083389 100644 --- a/packages/react-form-renderer/demo/index.js +++ b/packages/react-form-renderer/demo/index.js @@ -8,6 +8,22 @@ import mapper from './form-fields-mapper'; const schema = { fields: [ + { + name: 'field1', + label: 'Field 1', + component: 'text-field', + }, + { + name: 'mapped-condition', + label: 'Mapped Condition', + component: 'text-field', + condition: { + mappedAttributes: { + is: ['nameFn', 'John', 'Doe'], + }, + when: 'field1', + }, + }, { name: 'formRadio', label: 'SelectSubForm', @@ -107,12 +123,26 @@ const initialValues = { formRadio: 'form2', radioBtn2: 'stu', txtField3: 'data', + field1: 'John', }; const App = () => { return (
- + { + return (value, _conditionConfig) => { + return value === name; + }; + }, + }} + initialValues={initialValues} + componentMapper={mapper} + onSubmit={console.log} + FormTemplate={FormTemplate} + schema={schema} + />
); }; diff --git a/packages/react-form-renderer/src/condition/condition.d.ts b/packages/react-form-renderer/src/condition/condition.d.ts index 4213959cb..735a9f742 100644 --- a/packages/react-form-renderer/src/condition/condition.d.ts +++ b/packages/react-form-renderer/src/condition/condition.d.ts @@ -24,6 +24,11 @@ export interface ConditionProp { } export interface ConditionDefinition extends ConditionProp { + mappedAttributes?: { + is?: string; + when?: string; + set?: string; + }, or?: ConditionProp | ConditionProp[]; and?: ConditionProp | ConditionProp[]; not?: ConditionProp | ConditionProp[]; diff --git a/packages/react-form-renderer/src/condition/condition.js b/packages/react-form-renderer/src/condition/condition.js index e40349826..437860595 100644 --- a/packages/react-form-renderer/src/condition/condition.js +++ b/packages/react-form-renderer/src/condition/condition.js @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useMemo, useReducer } from 'react'; +import { useCallback, useContext, useEffect, useMemo, useReducer } from 'react'; import PropTypes from 'prop-types'; import isEqual from 'lodash/isEqual'; import useFormApi from '../use-form-api'; import parseCondition from '../parse-condition'; +import RendererContext from '../renderer-context/renderer-context'; const setterValueCheck = (setterValue) => { if (setterValue === null || Array.isArray(setterValue)) { @@ -36,7 +37,7 @@ export const reducer = (state, { type, sets }) => { const Condition = ({ condition, children, field }) => { const formOptions = useFormApi(); const formState = formOptions.getState(); - + const { conditionMapper } = useContext(RendererContext); const [state, dispatch] = useReducer(reducer, { sets: [], initial: true, @@ -44,7 +45,10 @@ const Condition = ({ condition, children, field }) => { // It is required to get the context state values from in order to get the latest state. // Using the trigger values can cause issues with the radio field as each input is registered separately to state and does not yield the actual field value. - const conditionResult = useMemo(() => parseCondition(condition, formState.values, field), [formState.values, condition, field]); + const conditionResult = useMemo( + () => parseCondition(condition, formState.values, field, conditionMapper), + [formState.values, condition, field, conditionMapper] + ); const setters = conditionResult.set ? [conditionResult.set] : conditionResult.sets; diff --git a/packages/react-form-renderer/src/default-schema-validator/default-schema-validator.js b/packages/react-form-renderer/src/default-schema-validator/default-schema-validator.js index 4bb3de37e..011cc9065 100644 --- a/packages/react-form-renderer/src/default-schema-validator/default-schema-validator.js +++ b/packages/react-form-renderer/src/default-schema-validator/default-schema-validator.js @@ -32,6 +32,24 @@ const checkConditionalAction = (type, action, fieldName) => { } }; +const requiredOneOf = ['is', 'isEmpty', 'isNotEmpty', 'pattern', 'greaterThan', 'greaterThanOrEqualTo', 'lessThan', 'lessThanOrEqualTo']; + +const checkMappedAttributes = (condition) => { + const hasStaticAttribute = requiredOneOf.some((key) => condition.hasOwnProperty(key)); + if (hasStaticAttribute) { + return true; + } + + if ( + condition.hasOwnProperty('mappedAttributes') && + typeof condition.mappedAttributes === 'object' && + !Array.isArray(condition.mappedAttributes) && + condition.mappedAttributes !== null + ) { + return requiredOneOf.some((key) => condition.mappedAttributes.hasOwnProperty(key)); + } +}; + const checkCondition = (condition, fieldName, isRoot) => { /** * validate array condition @@ -96,30 +114,22 @@ const checkCondition = (condition, fieldName, isRoot) => { !condition.hasOwnProperty('not') && !condition.hasOwnProperty('sequence') ) { - if (!condition.hasOwnProperty('when')) { + const isWhenMapped = condition.hasOwnProperty('mappedAttributes') && condition.mappedAttributes?.hasOwnProperty('when'); + if (!condition.hasOwnProperty('when') && !isWhenMapped) { throw new DefaultSchemaError(` Error occured in field definition with "name" property: "${fieldName}". Field condition must have "when" property! Properties received: [${Object.keys(condition)}]. `); } - if (!(typeof condition.when === 'string' || typeof condition.when === 'function' || Array.isArray(condition.when))) { + if (!isWhenMapped && !(typeof condition.when === 'string' || typeof condition.when === 'function' || Array.isArray(condition.when))) { throw new DefaultSchemaError(` Error occured in field definition with name: "${fieldName}". Field condition property "when" must be of type "string", "function" or "array", ${typeof condition.when} received!]. `); } - if ( - !condition.hasOwnProperty('is') && - !condition.hasOwnProperty('isEmpty') && - !condition.hasOwnProperty('isNotEmpty') && - !condition.hasOwnProperty('pattern') && - !condition.hasOwnProperty('greaterThan') && - !condition.hasOwnProperty('greaterThanOrEqualTo') && - !condition.hasOwnProperty('lessThan') && - !condition.hasOwnProperty('lessThanOrEqualTo') - ) { + if (!checkMappedAttributes(condition)) { throw new DefaultSchemaError(` Error occured in field definition with name: "${fieldName}". Field condition must have one of "is", "isEmpty", "isNotEmpty", "pattern", "greaterThan", "greaterThanOrEqualTo", "lessThan", "lessThanOrEqualTo" property! Properties received: [${Object.keys( diff --git a/packages/react-form-renderer/src/form-renderer/condition-mapper.d.ts b/packages/react-form-renderer/src/form-renderer/condition-mapper.d.ts new file mode 100644 index 000000000..304df3b0e --- /dev/null +++ b/packages/react-form-renderer/src/form-renderer/condition-mapper.d.ts @@ -0,0 +1,5 @@ +import { ConditionDefinition } from "../condition"; + +export interface ConditionMapper { + [key: string]: (...args: any[]) => (value: any, conditionConfig: ConditionDefinition) => boolean; +} diff --git a/packages/react-form-renderer/src/form-renderer/form-renderer.d.ts b/packages/react-form-renderer/src/form-renderer/form-renderer.d.ts index 1200666f4..27c73375c 100644 --- a/packages/react-form-renderer/src/form-renderer/form-renderer.d.ts +++ b/packages/react-form-renderer/src/form-renderer/form-renderer.d.ts @@ -7,6 +7,7 @@ import { ActionMapper } from './action-mapper'; import SchemaValidatorMapper from '../common-types/schema-validator-mapper'; import { FormTemplateRenderProps } from '../common-types/form-template-render-props'; import { NoIndex } from '../common-types/no-index'; +import { ConditionMapper } from './condition-mapper'; export interface FormRendererProps< FormValues = Record, @@ -25,6 +26,7 @@ export interface FormRendererProps< FormTemplate?: ComponentType | FunctionComponent; validatorMapper?: ValidatorMapper; actionMapper?: ActionMapper; + conditionMapper?: ConditionMapper; schemaValidatorMapper?: SchemaValidatorMapper; FormTemplateProps?: Partial; children?: ReactNode | ((props: FormTemplateRenderProps) => ReactNode); diff --git a/packages/react-form-renderer/src/form-renderer/form-renderer.js b/packages/react-form-renderer/src/form-renderer/form-renderer.js index 827fc4953..2d8479af9 100644 --- a/packages/react-form-renderer/src/form-renderer/form-renderer.js +++ b/packages/react-form-renderer/src/form-renderer/form-renderer.js @@ -45,6 +45,7 @@ const FormRenderer = ({ clearedValue, clearOnUnmount, componentMapper, + conditionMapper = {}, decorators, FormTemplate, FormTemplateProps, @@ -148,6 +149,7 @@ const FormRenderer = ({ componentMapper, validatorMapper: validatorMapperMerged, actionMapper, + conditionMapper, formOptions: { registerInputFile, unRegisterInputFile, @@ -220,6 +222,9 @@ FormRenderer.propTypes = { initialValues: PropTypes.object, decorators: PropTypes.array, mutators: PropTypes.object, + conditionMapper: PropTypes.shape({ + [PropTypes.string]: PropTypes.func, + }), }; FormRenderer.defaultProps = { diff --git a/packages/react-form-renderer/src/parse-condition/parse-condition.d.ts b/packages/react-form-renderer/src/parse-condition/parse-condition.d.ts index 0dbcb293d..ba75492e9 100644 --- a/packages/react-form-renderer/src/parse-condition/parse-condition.d.ts +++ b/packages/react-form-renderer/src/parse-condition/parse-condition.d.ts @@ -1,7 +1,8 @@ import { AnyObject } from "../common-types/any-object"; import { ConditionDefinition } from "../condition"; import Field from "../common-types/field"; +import { ConditionMapper } from "../form-renderer/condition-mapper"; -export type ParseCondition = (condition: ConditionDefinition, values: AnyObject, Field: Field) => void; +export type ParseCondition = (condition: ConditionDefinition, values: AnyObject, Field: Field, conditionMapper?: ConditionMapper) => void; declare const parseCondition: ParseCondition export default parseCondition; diff --git a/packages/react-form-renderer/src/parse-condition/parse-condition.js b/packages/react-form-renderer/src/parse-condition/parse-condition.js index b834cc675..93cdd2bc5 100644 --- a/packages/react-form-renderer/src/parse-condition/parse-condition.js +++ b/packages/react-form-renderer/src/parse-condition/parse-condition.js @@ -43,7 +43,39 @@ const fieldCondition = (value, config) => { return config.notMatch ? !isMatched : isMatched; }; -export const parseCondition = (condition, values, field) => { +const allowedMappedAttributes = ['when', 'is']; + +export const unpackMappedCondition = (condition, conditionMapper) => { + if (typeof condition.mappedAttributes !== 'object') { + return condition; + } + + const { mappedAttributes } = condition; + + const internalCondition = { + ...condition, + mappedAttributes: undefined, + }; + + Object.entries(mappedAttributes).forEach(([key, value]) => { + if (!allowedMappedAttributes.includes(key)) { + console.error(`Mapped condition attribute ${key} is not allowed! Allowed attributes are: ${allowedMappedAttributes.join(', ')}`); + return; + } + + if (conditionMapper[value?.[0]]) { + const [fnName, ...args] = value; + const fn = conditionMapper[fnName]; + internalCondition[key] = fn(...args); + } else { + console.error(`Missing conditionMapper entry for ${value}!`); + } + }); + + return internalCondition; +}; + +export const parseCondition = (condition, values, field, conditionMapper = {}) => { let positiveResult = { visible: true, ...condition.then, @@ -62,14 +94,16 @@ export const parseCondition = (condition, values, field) => { : negativeResult; } - if (condition.and) { - return !condition.and.map((condition) => parseCondition(condition, values, field)).some(({ result }) => result === false) + const conditionInternal = unpackMappedCondition(condition, conditionMapper); + + if (conditionInternal.and) { + return !conditionInternal.and.map((condition) => parseCondition(condition, values, field)).some(({ result }) => result === false) ? positiveResult : negativeResult; } - if (condition.sequence) { - return condition.sequence.reduce( + if (conditionInternal.sequence) { + return conditionInternal.sequence.reduce( (acc, curr) => { const result = parseCondition(curr, values, field); @@ -83,25 +117,25 @@ export const parseCondition = (condition, values, field) => { ); } - if (condition.or) { - return condition.or.map((condition) => parseCondition(condition, values, field)).some(({ result }) => result === true) + if (conditionInternal.or) { + return conditionInternal.or.map((condition) => parseCondition(condition, values, field)).some(({ result }) => result === true) ? positiveResult : negativeResult; } - if (condition.not) { - return !parseCondition(condition.not, values, field).result ? positiveResult : negativeResult; + if (conditionInternal.not) { + return !parseCondition(conditionInternal.not, values, field).result ? positiveResult : negativeResult; } - const finalWhen = typeof condition.when === 'function' ? condition.when(field) : condition.when; + const finalWhen = typeof conditionInternal.when === 'function' ? conditionInternal.when(field) : conditionInternal.when; if (typeof finalWhen === 'string') { - return fieldCondition(get(values, finalWhen), condition) ? positiveResult : negativeResult; + return fieldCondition(get(values, finalWhen), conditionInternal) ? positiveResult : negativeResult; } if (Array.isArray(finalWhen)) { return finalWhen - .map((fieldName) => fieldCondition(get(values, typeof fieldName === 'function' ? fieldName(field) : fieldName), condition)) + .map((fieldName) => fieldCondition(get(values, typeof fieldName === 'function' ? fieldName(field) : fieldName), conditionInternal)) .find((condition) => !!condition) ? positiveResult : negativeResult; diff --git a/packages/react-form-renderer/src/renderer-context/renderer-context.d.ts b/packages/react-form-renderer/src/renderer-context/renderer-context.d.ts index 9b1042bd9..9116fe969 100644 --- a/packages/react-form-renderer/src/renderer-context/renderer-context.d.ts +++ b/packages/react-form-renderer/src/renderer-context/renderer-context.d.ts @@ -6,6 +6,7 @@ import { ActionMapper } from '../form-renderer'; import Field from '../common-types/field'; import { AnyObject } from '../common-types/any-object'; import Schema from '../common-types/schema'; +import { ConditionMapper } from '../form-renderer/condition-mapper'; export interface FormOptions, InitialFormValues = Partial> extends FormApi { @@ -29,6 +30,7 @@ export interface RendererContextValue { validatorMapper: ValidatorMapper; actionMapper: ActionMapper; formOptions: FormOptions; + conditionMapper: ConditionMapper; } declare const RendererContext: React.Context; diff --git a/packages/react-form-renderer/src/tests/form-renderer/parse-condition.test.js b/packages/react-form-renderer/src/tests/form-renderer/parse-condition.test.js index ae5230ff6..4d89c5ea4 100644 --- a/packages/react-form-renderer/src/tests/form-renderer/parse-condition.test.js +++ b/packages/react-form-renderer/src/tests/form-renderer/parse-condition.test.js @@ -684,4 +684,70 @@ describe('parseCondition', () => { expect(parseCondition(condition, values)).toEqual(negativeResult); }); }); + + describe('mapped attributes', () => { + const conditionMapper = { + whenFn: () => jest.fn().mockImplementation(() => 'x'), + isFn: (config) => jest.fn().mockImplementation((value) => value === config), + setFn: () => jest.fn().mockImplementation((value) => ({ y: value === true ? 'yes' : 'no' })), + }; + + positiveResult = { visible: true, result: true }; + negativeResult = { visible: false, result: false }; + + [positiveResult, negativeResult].forEach((conditionResult) => { + const values = { + x: true, + }; + it(`maps attribute - when - ${conditionResult.result ? 'positive' : 'negative'}`, () => { + const condition = { + mappedAttributes: { + when: ['whenFn'], + }, + is: conditionResult.result, + }; + + expect(parseCondition(condition, values, undefined, conditionMapper)).toEqual(conditionResult); + }); + + it(`maps attribute - is - ${conditionResult.result ? 'positive' : 'negative'}`, () => { + const condition = { + mappedAttributes: { + is: ['isFn', true], + }, + when: 'x', + }; + expect(parseCondition(condition, { x: conditionResult.result }, undefined, conditionMapper)).toEqual(conditionResult); + }); + }); + + it('should log an error if conditionMapper is missing mapped attribute', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const conditionMapper = {}; + const condition = { + mappedAttributes: { + when: ['whenFn'], + }, + is: true, + }; + expect(parseCondition(condition, { x: true }, undefined, conditionMapper)).toEqual(negativeResult); + expect(errorSpy).toHaveBeenCalledWith('Missing conditionMapper entry for whenFn!'); + errorSpy.mockRestore(); + }); + + it('should log an error if mapped attribute is not allowed', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const conditionMapper = {}; + const condition = { + mappedAttributes: { + when: ['whenFn'], + is: ['isFn', true], + not: ['notFn'], + }, + }; + expect(parseCondition(condition, { x: true }, undefined, conditionMapper)).toEqual(negativeResult); + expect(errorSpy).toHaveBeenCalledWith('Mapped condition attribute not is not allowed! Allowed attributes are: when, is'); + errorSpy.mockRestore(); + }); + }); }); diff --git a/packages/react-form-renderer/src/tests/parsers/default-schema-validator.test.js b/packages/react-form-renderer/src/tests/parsers/default-schema-validator.test.js index c102883a7..00c7f39a3 100644 --- a/packages/react-form-renderer/src/tests/parsers/default-schema-validator.test.js +++ b/packages/react-form-renderer/src/tests/parsers/default-schema-validator.test.js @@ -126,6 +126,28 @@ describe('Default schema validator', () => { ).toThrowErrorMatchingSnapshot(); }); + it('should not fail if field condition is missing when key but has mapped attribute when.', () => { + expect(() => + defaultSchemaValidator( + { + fields: [ + { + component: 'foo', + name: 'foo', + condition: { + mappedAttributes: { + when: ['whenMapped'], + }, + is: 'bar', + }, + }, + ], + }, + componentMapper + ) + ).not.toThrow(); + }); + it('should fail if field condition is missing is key.', () => { expect(() => defaultSchemaValidator( diff --git a/packages/react-renderer-demo/src/components/navigation/schemas/mappers.schema.js b/packages/react-renderer-demo/src/components/navigation/schemas/mappers.schema.js index d7f07133f..6294bcd12 100644 --- a/packages/react-renderer-demo/src/components/navigation/schemas/mappers.schema.js +++ b/packages/react-renderer-demo/src/components/navigation/schemas/mappers.schema.js @@ -25,6 +25,10 @@ const mappersSchema = [ link: 'action-mapper', linkText: 'Action mapper', }, + { + link: 'condition-mapper', + linkText: 'Condition mapper', + }, { link: 'schema-validator-mapper ', linkText: 'Schema validator mapper', diff --git a/packages/react-renderer-demo/src/examples/components/condition-mapper.js b/packages/react-renderer-demo/src/examples/components/condition-mapper.js new file mode 100644 index 000000000..992a4d933 --- /dev/null +++ b/packages/react-renderer-demo/src/examples/components/condition-mapper.js @@ -0,0 +1,49 @@ +import React from 'react'; +import FormRenderer from '@data-driven-forms/react-form-renderer/form-renderer'; +import componentTypes from '@data-driven-forms/react-form-renderer/component-types'; + +import TextField from '@data-driven-forms/mui-component-mapper/text-field'; +import FormTemplate from '@data-driven-forms/mui-component-mapper/form-template'; + +const conditionMapper = { + resolveCondition: (requiredValue) => (value, _conditionConfig) => requiredValue === value, +}; + +const schema = { + title: 'Condition Mapper example', + fields: [ + { + name: 'first-name', + label: 'First name', + component: componentTypes.TEXT_FIELD, + helperText: 'Type John to see mapped condition', + }, + { + name: 'mapped-condition', + label: 'Mapped Condition', + component: componentTypes.TEXT_FIELD, + condition: { + mappedAttributes: { + is: ['resolveCondition', 'John'], + }, + when: 'first-name', + }, + }, + ], +}; + +const componentMapper = { + [componentTypes.TEXT_FIELD]: TextField, +}; +const ConditionMapper = () => ( + +); +ConditionMapper.displayName = 'Condition mapper'; + +export default ConditionMapper; diff --git a/packages/react-renderer-demo/src/pages/components/renderer.md b/packages/react-renderer-demo/src/pages/components/renderer.md index 1167b23cc..955642e54 100644 --- a/packages/react-renderer-demo/src/pages/components/renderer.md +++ b/packages/react-renderer-demo/src/pages/components/renderer.md @@ -117,6 +117,14 @@ Value that will be set to field with **initialValue** after deleting it. Useful --- +### conditionMapper + +*object* + +Condition mapper allows to map condition attributes to functions. + +[Read more](/mappers/condition-mapper). + ### onReset *func* diff --git a/packages/react-renderer-demo/src/pages/mappers/condition-mapper.md b/packages/react-renderer-demo/src/pages/mappers/condition-mapper.md new file mode 100644 index 000000000..900ff803b --- /dev/null +++ b/packages/react-renderer-demo/src/pages/mappers/condition-mapper.md @@ -0,0 +1,97 @@ +import CodeExample from '@docs/code-example'; +import DocPage from '@docs/doc-page'; + + + +# Condition mapper + +The [ConditionMapper](/components/renderer#conditionmapper) allows to map condition attributes to a functions defined on the form renderer. This is useful when your schema is not written in JavaScript source files and functions can't be defined inside schema. For example for schemas stored in databases. + +The functions defined in condition mapper are higher order functions. This allows configuration of the function arguments via schema. + +## Mapper + +```TS +type ConditionMapper = { + [functionName: string]: (...args: any[]) => (value: any, conditionConfig: ConditionDefinition) => boolean; +} +``` + +## Schema + +``` +[ + // Field that is a condition dependency + { + name: 'field1', + label: 'Field 1', + component: 'text-field', + }, + { + name: 'mapped-condition', + label: 'Mapped Condition', + component: 'text-field', + condition: { + mappedAttributes: { + // first array entry is the function name in the mapper, rest are the higher order function arguments + is: ['nameFn', 'John', 'Doe'], + }, + when: 'field1', + }, + }, +] +``` + +## Example + +Lets image a simple scenario. We have a form, with a `name` text field. If the `name` field has a certain value, a second field will appear in the form. The value is a variable filed from database and we can't statically define it in the schema. + +Firstly, a condition mapper must be defined. + +```js +const conditionMapper = { + resolveCondition: (requiredValue) => (value, _conditionConfig) => requiredValue === value +} +``` + +Add this object as a prop to the `FormRenderer`. + +```JS + +``` + +In your schema, map the condition attribute a function. + +```JSON +{ + "fields": [ + { + "name": "first-name", + "label": "First name", + "component": "text-field", + }, + { + "name": "mapped-condition", + "label": "Mapped Condition", + "component": "text-field", + "condition": { + "mappedAttributes": { + "is": ["resolveCondition", "John"], + }, + "when": "first-name", + }, + }, + ] +} +``` + + +## Mappable attributes + +Currently, mapping the `when` and `is` condition attributes is allowed. + + \ No newline at end of file