Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(renderer): allow condition mapping #1424

Merged
merged 1 commit into from
Nov 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion packages/react-form-renderer/demo/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -107,12 +123,26 @@ const initialValues = {
formRadio: 'form2',
radioBtn2: 'stu',
txtField3: 'data',
field1: 'John',
};

const App = () => {
return (
<div style={{ padding: 20 }}>
<FormRenderer initialValues={initialValues} componentMapper={mapper} onSubmit={console.log} FormTemplate={FormTemplate} schema={schema} />
<FormRenderer
conditionMapper={{
nameFn: (name, _surname) => {
return (value, _conditionConfig) => {
return value === name;
};
},
}}
initialValues={initialValues}
componentMapper={mapper}
onSubmit={console.log}
FormTemplate={FormTemplate}
schema={schema}
/>
</div>
);
};
Expand Down
5 changes: 5 additions & 0 deletions packages/react-form-renderer/src/condition/condition.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
10 changes: 7 additions & 3 deletions packages/react-form-renderer/src/condition/condition.js
Original file line number Diff line number Diff line change
@@ -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)) {
Expand Down Expand Up @@ -36,15 +37,18 @@ 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,
});

// 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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,24 @@
}
};

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

Check warning on line 47 in packages/react-form-renderer/src/default-schema-validator/default-schema-validator.js

View check run for this annotation

Codecov / codecov/patch

packages/react-form-renderer/src/default-schema-validator/default-schema-validator.js#L45-L47

Added lines #L45 - L47 were not covered by tests
) {
return requiredOneOf.some((key) => condition.mappedAttributes.hasOwnProperty(key));

Check warning on line 49 in packages/react-form-renderer/src/default-schema-validator/default-schema-validator.js

View check run for this annotation

Codecov / codecov/patch

packages/react-form-renderer/src/default-schema-validator/default-schema-validator.js#L49

Added line #L49 was not covered by tests
}
};

const checkCondition = (condition, fieldName, isRoot) => {
/**
* validate array condition
Expand Down Expand Up @@ -96,30 +114,22 @@
!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(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ConditionDefinition } from "../condition";

export interface ConditionMapper {
[key: string]: (...args: any[]) => (value: any, conditionConfig: ConditionDefinition) => boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>,
Expand All @@ -25,6 +26,7 @@ export interface FormRendererProps<
FormTemplate?: ComponentType<FormTemplateProps> | FunctionComponent<FormTemplateProps>;
validatorMapper?: ValidatorMapper;
actionMapper?: ActionMapper;
conditionMapper?: ConditionMapper;
schemaValidatorMapper?: SchemaValidatorMapper;
FormTemplateProps?: Partial<FormTemplateProps>;
children?: ReactNode | ((props: FormTemplateRenderProps) => ReactNode);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const FormRenderer = ({
clearedValue,
clearOnUnmount,
componentMapper,
conditionMapper = {},
decorators,
FormTemplate,
FormTemplateProps,
Expand Down Expand Up @@ -148,6 +149,7 @@ const FormRenderer = ({
componentMapper,
validatorMapper: validatorMapperMerged,
actionMapper,
conditionMapper,
formOptions: {
registerInputFile,
unRegisterInputFile,
Expand Down Expand Up @@ -220,6 +222,9 @@ FormRenderer.propTypes = {
initialValues: PropTypes.object,
decorators: PropTypes.array,
mutators: PropTypes.object,
conditionMapper: PropTypes.shape({
[PropTypes.string]: PropTypes.func,
}),
};

FormRenderer.defaultProps = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
58 changes: 46 additions & 12 deletions packages/react-form-renderer/src/parse-condition/parse-condition.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);

Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<FormValues = Record<string, any>, InitialFormValues = Partial<FormValues>>
extends FormApi<FormValues, InitialFormValues> {
Expand All @@ -29,6 +30,7 @@ export interface RendererContextValue {
validatorMapper: ValidatorMapper;
actionMapper: ActionMapper;
formOptions: FormOptions;
conditionMapper: ConditionMapper;
}

declare const RendererContext: React.Context<RendererContextValue>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Loading
Loading