diff --git a/packages/react-form-renderer/demo/index.js b/packages/react-form-renderer/demo/index.js index b5844eac0..d909709ed 100644 --- a/packages/react-form-renderer/demo/index.js +++ b/packages/react-form-renderer/demo/index.js @@ -9,17 +9,78 @@ const fileSchema = { fields: [ { component: 'text-field', - name: 'file-upload', - type: 'file', - label: 'file upload' - } - ] + name: 'field1', + label: 'Field1 (try "abc")', + }, + { + component: 'text-field', + name: 'field2', + label: 'Field2 (try "xyz")', + hideField: true, + }, + { + component: 'text-field', + name: 'field3', + label: 'Field3 (try "123")', + }, + { + component: 'text-field', + name: 'field4', + label: 'Field4', + }, + + { + component: 'text-field', + name: 'field5', + label: 'Field5', + condition: { + when: 'field1', + is: 'cba', + }, + }, + ], + conditions: { + cond1: { + when: 'fieldx', + is: 'abc', + then: { + field4: { + disabled: true, + set: 'New value for field4', + }, + field3: { + disabled: true, + }, + field2: { + visible: true, + }, + }, + }, + cond2: { + when: 'field3', + is: '123', + then: { + field3: { + visible: false, + }, + }, + }, + cond3: { + when: 'field2', + is: 'xyz', + then: { + field3: { + visible: false, + }, + }, + }, + }, }; const App = () => { // const [values, setValues] = useState({}); return ( -
+
console.log(values, args)} diff --git a/packages/react-form-renderer/src/files/conditions-mapper.js b/packages/react-form-renderer/src/files/conditions-mapper.js new file mode 100644 index 000000000..a4448dd0d --- /dev/null +++ b/packages/react-form-renderer/src/files/conditions-mapper.js @@ -0,0 +1,85 @@ +/* + conditionsMapper will remap a conditions object and create an object with each depending fieldName as a key. + + Since one field can be involed in more than one condition, an array of condition references will be created under each fieldName key + + Since more than one field can be involved in the same condition, the same condition might be referenced from + several condition arrays. +*/ + +function isObject(obj) { + return obj !== null && typeof obj === 'object' && !Array.isArray(obj); +} + +function isArray(obj) { + return Array.isArray(obj); +} + +export const conditionsMapper = ({conditions}) => { + if (!conditions) return {}; + + function traverse({obj, fnc, key}) { + fnc && fnc({obj, key}); + + if (isArray(obj)) { + traverseArray({ + obj, + fnc, + key, + }); + } else if (isObject(obj)) { + traverseObject({ + obj, + fnc, + key, + }); + } + } + + function traverseArray({obj, fnc, key}) { + obj.forEach(([key, item]) => { + traverse({ + obj: item, + fnc, + key, + }); + }); + } + + function traverseObject({obj, fnc, key}) { + Object.entries(obj).forEach(([key, item]) => { + traverse({ + obj: item, + fnc, + key, + }); + }); + } + + const indexedConditions = {}; + const conditionArray = Object.entries(conditions); + + conditionArray + .map(([key, condition]) => { + return { + key: key, + ...condition, + }; + }) + .forEach(condition => { + traverse({ + obj: condition, + fnc: ({obj, key}) => { + if (key === 'when') { + const fieldNames = isArray(obj) ? obj : [obj]; + fieldNames.map(fieldName => { + indexedConditions[fieldName] = indexedConditions[fieldName] || []; + indexedConditions[fieldName].push(condition); + }); + } + }, + }); + }); + + return indexedConditions; +}; diff --git a/packages/react-form-renderer/src/files/form-renderer.js b/packages/react-form-renderer/src/files/form-renderer.js index 878cb62c0..d100f3742 100644 --- a/packages/react-form-renderer/src/files/form-renderer.js +++ b/packages/react-form-renderer/src/files/form-renderer.js @@ -1,4 +1,4 @@ -import React, { useState, useRef } from 'react'; +import React, {useState, useRef, useReducer} from 'react'; import Form from './form'; import arrayMutators from 'final-form-arrays'; import PropTypes from 'prop-types'; @@ -9,6 +9,9 @@ import renderForm from '../form-renderer/render-form'; import defaultSchemaValidator from './default-schema-validator'; import SchemaErrorComponent from '../form-renderer/schema-error-component'; import defaultValidatorMapper from './validator-mapper'; +import RegisterConditions from './register-conditions'; +import SetFieldValues from './set-field-values'; +import uiStateReducer from './ui-state-reducer'; const FormRenderer = ({ componentMapper, @@ -26,15 +29,25 @@ const FormRenderer = ({ ...props }) => { const [fileInputs, setFileInputs] = useState([]); + const [uiState, dispatchUIState] = useReducer(uiStateReducer, { + fields: {}, + setFieldValues: {}, + }); const focusDecorator = useRef(createFocusDecorator()); let schemaError; - const validatorMapperMerged = { ...defaultValidatorMapper, ...validatorMapper }; + const validatorMapperMerged = {...defaultValidatorMapper, ...validatorMapper}; try { const validatorTypes = Object.keys(validatorMapperMerged); const actionTypes = actionMapper ? Object.keys(actionMapper) : []; - defaultSchemaValidator(schema, componentMapper, validatorTypes, actionTypes, schemaValidatorMapper); + defaultSchemaValidator( + schema, + componentMapper, + validatorTypes, + actionTypes, + schemaValidatorMapper + ); } catch (error) { schemaError = error; console.error(error); @@ -45,18 +58,24 @@ const FormRenderer = ({ return ; } - const registerInputFile = (name) => setFileInputs((prevFiles) => [...prevFiles, name]); + const registerInputFile = name => setFileInputs(prevFiles => [...prevFiles, name]); - const unRegisterInputFile = (name) => setFileInputs((prevFiles) => [...prevFiles.splice(prevFiles.indexOf(name))]); + const unRegisterInputFile = name => + setFileInputs(prevFiles => [...prevFiles.splice(prevFiles.indexOf(name))]); return (
onSubmit(values, { ...formApi, fileInputs }, ...args)} - mutators={{ ...arrayMutators }} + onSubmit={(values, formApi, ...args) => onSubmit(values, {...formApi, fileInputs}, ...args)} + mutators={{...arrayMutators}} decorators={[focusDecorator.current]} - subscription={{ pristine: true, submitting: true, valid: true, ...subscription }} - render={({ handleSubmit, pristine, valid, form: { reset, mutators, getState, submit, ...form } }) => ( + subscription={{pristine: true, submitting: true, valid: true, ...subscription}} + render={({ + handleSubmit, + pristine, + valid, + form: {reset, mutators, getState, submit, registerField, ...form}, + }) => ( + + +
{JSON.stringify(uiState, null, 2)}
)} /> @@ -98,34 +123,34 @@ FormRenderer.propTypes = { onReset: PropTypes.func, schema: PropTypes.object.isRequired, clearOnUnmount: PropTypes.bool, - subscription: PropTypes.shape({ [PropTypes.string]: PropTypes.bool }), + subscription: PropTypes.shape({[PropTypes.string]: PropTypes.bool}), clearedValue: PropTypes.any, componentMapper: PropTypes.shape({ - [PropTypes.string]: PropTypes.oneOfType([PropTypes.node, PropTypes.element, PropTypes.func]) + [PropTypes.string]: PropTypes.oneOfType([PropTypes.node, PropTypes.element, PropTypes.func]), }).isRequired, FormTemplate: PropTypes.func.isRequired, validatorMapper: PropTypes.shape({ - [PropTypes.string]: PropTypes.func + [PropTypes.string]: PropTypes.func, }), actionMapper: PropTypes.shape({ - [PropTypes.string]: PropTypes.func + [PropTypes.string]: PropTypes.func, }), schemaValidatorMapper: PropTypes.shape({ components: PropTypes.shape({ - [PropTypes.string]: PropTypes.func + [PropTypes.string]: PropTypes.func, }), validators: PropTypes.shape({ - [PropTypes.string]: PropTypes.func + [PropTypes.string]: PropTypes.func, }), actions: PropTypes.shape({ - [PropTypes.string]: PropTypes.func - }) - }) + [PropTypes.string]: PropTypes.func, + }), + }), }; FormRenderer.defaultProps = { initialValues: {}, - clearOnUnmount: false + clearOnUnmount: false, }; export default FormRenderer; diff --git a/packages/react-form-renderer/src/files/legacy-conditions.js b/packages/react-form-renderer/src/files/legacy-conditions.js new file mode 100644 index 000000000..5a5d78b3a --- /dev/null +++ b/packages/react-form-renderer/src/files/legacy-conditions.js @@ -0,0 +1,90 @@ +/* +We need to collect all field-level conditions (legacy conditions) and remap those to the new +conditions structure. + +Legacy example: +fields: [ + { + name: 'Foo', // controlled field + component: 'text-field', + }, { + name: 'BarFoo', + label: 'Foo is Bar!', + component: 'text-field', + condition: { + when: 'Foo', // name of controlled field + is: 'Bar', // condition + }, + }, + ] + +Remapped condition +- unique key for each condition needs to be generated +- fieldName needed as key under then/else: + +"legacy-BarFoo-0": { + when: 'Foo', + is: 'Bar', + then: { + BarFoo: { + visible: true + } + } + else: { + BarFoo: { + visible: false + } + } +}, + + +*/ + +export const collectLegacyConditions = ({fields}) => { + const conditions = {}; + let counter = 0; + let prevName; + + fields.forEach(field => { + const {name, condition} = field; + if (name !== prevName) counter = 0; + if (!condition) return; + + const key = `legacy-${name}-${counter}`; + const {when, and, is, not, or, isEmpty, isNotEmpty, notMatch, pattern} = condition; + + let thenClause; + let elseClause; + if (condition.then) { + } else { + thenClause = { + [name]: {visible: false}, + }; + } + + if (condition.else) { + } else { + elseClause = { + [name]: {visible: true}, + }; + } + + conditions[key] = { + when, + is, + and, + or, + isEmpty, + isNotEmpty, + notMatch, + pattern, + then: thenClause, + else: elseClause, + }; + + prevName = name; + counter++; + }); + + return conditions; +}; diff --git a/packages/react-form-renderer/src/files/register-conditions.js b/packages/react-form-renderer/src/files/register-conditions.js new file mode 100644 index 000000000..29055824b --- /dev/null +++ b/packages/react-form-renderer/src/files/register-conditions.js @@ -0,0 +1,76 @@ +import React, {useEffect} from 'react'; +import {useFormApi} from '../'; +import {Field} from 'react-final-form'; + +import {conditionsMapper} from './conditions-mapper'; +import {parseCondition} from '../form-renderer/condition'; +import {collectLegacyConditions} from './legacy-conditions'; + +const RegisterConditions = ({schema}) => { + const {getState, registerField, dispatchUIState} = useFormApi(); + + useEffect(() => { + const legacyConditions = collectLegacyConditions({fields: schema.fields}); + const mergedConditions = {...legacyConditions, ...schema.conditions}; + const indexedConditions = conditionsMapper({conditions: mergedConditions}); + + //We need an array of conditions, including the fieldName + const unsubscribeFields = Object.entries(indexedConditions) + .map(([fieldName, fieldValue]) => { + return { + fieldName, + ...fieldValue, + }; + }) + .map(field => { + console.log('creating field-listener for condition parsing: ' + field.fieldName); + + return registerField( + field.fieldName, + fieldState => { + if (!fieldState || !fieldState.data || !fieldState.data.conditions) return; + + console.log('Parsing conditions for field ' + field.fieldName); + + const values = getState().values; + fieldState.data.conditions.map(condition => { + const conditionResult = parseCondition(condition, values); + const { + uiState: {add, remove}, + } = conditionResult; + + //remove needs to happen before add. Otherwise an added "then" will be overwritten by a removed "else" + if (remove) { + dispatchUIState({ + type: 'removeUIState', + source: condition.key, + uiState: remove, + }); + } + if (add) { + dispatchUIState({ + type: 'addUIState', + source: condition.key, + uiState: add, + }); + } + }); + }, + {value: true, data: true}, + { + data: { + conditions: indexedConditions[field.fieldName] + ? indexedConditions[field.fieldName] + : null, + }, + } + ); + }); + + return () => unsubscribeFields.map(unsubscribeField => unsubscribeField()); + }, [schema]); + + return null; +}; + +export default RegisterConditions; diff --git a/packages/react-form-renderer/src/files/set-field-values.js b/packages/react-form-renderer/src/files/set-field-values.js new file mode 100644 index 000000000..ccedd58ef --- /dev/null +++ b/packages/react-form-renderer/src/files/set-field-values.js @@ -0,0 +1,25 @@ +import React, {useEffect} from 'react'; +import lodashIsEmpty from 'lodash/isEmpty'; + +import {useFormApi} from '../'; + +const SetFieldValues = () => { + const {batch, change, uiState, dispatchUIState} = useFormApi(); + useEffect(() => { + if (lodashIsEmpty(uiState.setFieldValues)) return; + + setTimeout(() => { + batch(() => { + Object.entries(uiState.setFieldValues).forEach(([name, value]) => { + console.log('Setting new value for field ' + name); + change(name, value); + }); + dispatchUIState({type: 'fieldValuesUpdated'}); + }); + }); + }, [uiState.setFieldValues]); + + return null; +}; + +export default SetFieldValues; diff --git a/packages/react-form-renderer/src/files/ui-state-reducer.js b/packages/react-form-renderer/src/files/ui-state-reducer.js new file mode 100644 index 000000000..e966803dc --- /dev/null +++ b/packages/react-form-renderer/src/files/ui-state-reducer.js @@ -0,0 +1,105 @@ +const validAttributes = [ + 'visible', + 'disabled', + 'hidden', + 'enabled', + 'icon', + 'help', + 'colorBar', + 'required', + 'max', + 'min', +]; + +//enabled and hidden are not used internally, but accepted as input from the conditions object +const inversedTypes = { + enabled: 'disabled', + hidden: 'visible', +}; + +const uiStateReducer = (state, action) => { + switch (action.type) { + case 'addUIState': { + const {source, uiState} = action; + const newState = {...state}; + + Object.entries(uiState).forEach(([key, item]) => { + //Create the item object for this item if it doesn't exist + if (!newState.fields[key]) newState.fields[key] = {}; + + validAttributes.forEach(type => { + //Don't add uiTypes if they don't exist in the dispatched message + if (item[type] === undefined) return; + + //Handle inversed types (disabled/enabled, visible/hidden) + const inversedType = inversedTypes[type]; + const value = inversedType ? !item[type] : item[type]; + type = inversedType || type; + + if (!newState.fields[key][type]) { + //If this type doesn't exists for this item, we create a new array with only this source. No need to search fot the source + newState.fields[key] = {...newState.fields[key], [type]: [{source, value}]}; + } else { + newState.fields[key] = {...newState.fields[key]}; + const index = newState.fields[key][type].findIndex(item => item.source === source); + if (index !== -1) { + //If this type for this item from this source existed, update the state (value could change if condition went from "then" to "else") + newState.fields[key][type] = [...newState.fields[key][type]]; + newState.fields[key][type][index].value = value; + } else { + //Otherwise, add the state from this source at the begining of the array (i.e. this will supress result from other sources) + newState.fields[key][type] = [{source, value}, ...newState.fields[key][type]]; + } + } + }); + + // Set-instructions are ephemeral and goes into a separate list which is emptied when processed + if (item.set) { + newState.setFieldValues = {...newState.setFieldValues, [key]: item.set}; + } + }); + return {...newState}; + } + + case 'removeUIState': { + const {source, uiState} = action; + const newState = {...state}; + + Object.entries(uiState).forEach(([key, item]) => { + //If the field/section doesn't exist, we don't need to do anymore + if (!newState.fields[key]) return; + + Object.entries(item).forEach(([type, value]) => { + if (!newState.fields[key][type]) return; + + const index = newState.fields[key][type].findIndex(item => item.source === source); + if (index !== -1) { + newState.fields[key][type] = [...newState.fields[key][type]]; + newState.fields[key][type].splice(index, 1); + } + if (newState.fields[key][type].length === 0) { + newState.fields[key] = {...newState.fields[key]}; + delete newState.fields[key][type]; + } + }); + + //If no more uiStateType keys exists for this field, remove the field + if (Object.keys(newState.fields[key]).length === 0) { + newState.fields = {...newState.fields[key]}; + delete newState.fields[key]; + } + }); + + return newState; + } + + case 'fieldValuesUpdated': { + return {...state, setFieldValues: {}}; + } + + default: + return state; + } +}; + +export default uiStateReducer; diff --git a/packages/react-form-renderer/src/form-renderer/condition.js b/packages/react-form-renderer/src/form-renderer/condition.js index 5cb092a41..0414440da 100644 --- a/packages/react-form-renderer/src/form-renderer/condition.js +++ b/packages/react-form-renderer/src/form-renderer/condition.js @@ -1,14 +1,13 @@ -import React, { useEffect, useReducer } from 'react'; +import React, {useEffect, useReducer} from 'react'; import PropTypes from 'prop-types'; import lodashIsEmpty from 'lodash/isEmpty'; import get from 'lodash/get'; import isEqual from 'lodash/isEqual'; -import useFormApi from '../files/use-form-api'; +const isEmptyValue = value => + typeof value === 'number' || value === true ? false : lodashIsEmpty(value); -const isEmptyValue = (value) => (typeof value === 'number' || value === true ? false : lodashIsEmpty(value)); - -const fieldCondition = (value, { is, isNotEmpty, isEmpty, pattern, notMatch, flags }) => { +const fieldCondition = (value, {is, isNotEmpty, isEmpty, pattern, notMatch, flags}) => { if (isNotEmpty) { return !isEmptyValue(value); } @@ -29,49 +28,52 @@ const fieldCondition = (value, { is, isNotEmpty, isEmpty, pattern, notMatch, fla }; export const parseCondition = (condition, values) => { + //Positive result is always a triggering condition + //since a then clause always exists let positiveResult = { - visible: true, - ...condition.then, - result: true + uiState: { + add: condition.then, + remove: condition.else, + }, + triggered: true, }; + //if else clause exists, this is a triggered condition + //if no else clause exists, this is a non-triggering condition let negativeResult = { - visible: false, - ...condition.else, - result: false + uiState: { + add: condition.else, + remove: condition.then, + }, + triggered: condition.else ? true : false, }; if (Array.isArray(condition)) { - return !condition.map((condition) => parseCondition(condition, values)).some(({ result }) => result === false) ? positiveResult : negativeResult; + return !condition + .map(condition => parseCondition(condition, values)) + .some(({triggered}) => triggered === false) + ? positiveResult + : negativeResult; } if (condition.and) { - return !condition.and.map((condition) => parseCondition(condition, values)).some(({ result }) => result === false) + return !condition.and + .map(condition => parseCondition(condition, values)) + .some(({triggered}) => triggered === false) ? positiveResult : negativeResult; } - if (condition.sequence) { - return condition.sequence.reduce( - (acc, curr) => { - const result = parseCondition(curr, values); - - return { - sets: [...acc.sets, ...(result.set ? [result.set] : [])], - visible: acc.visible || result.visible, - result: acc.result || result.result - }; - }, - { ...negativeResult, sets: [] } - ); - } - if (condition.or) { - return condition.or.map((condition) => parseCondition(condition, values)).some(({ result }) => result === true) ? positiveResult : negativeResult; + return condition.or + .map(condition => parseCondition(condition, values)) + .some(({triggered}) => triggered === true) + ? positiveResult + : negativeResult; } if (condition.not) { - return !parseCondition(condition.not, values).result ? positiveResult : negativeResult; + return !parseCondition(condition.not, values).triggered ? positiveResult : negativeResult; } if (typeof condition.when === 'string') { @@ -79,7 +81,9 @@ export const parseCondition = (condition, values) => { } if (Array.isArray(condition.when)) { - return condition.when.map((fieldName) => fieldCondition(get(values, fieldName), condition)).find((condition) => !!condition) + return condition.when + .map(fieldName => fieldCondition(get(values, fieldName), condition)) + .find(condition => !!condition) ? positiveResult : negativeResult; } @@ -87,68 +91,15 @@ export const parseCondition = (condition, values) => { return negativeResult; }; -export const reducer = (state, { type, sets }) => { - switch (type) { - case 'formResetted': - return { - ...state, - initial: true - }; - case 'rememberSets': - return { - ...state, - initial: false, - sets - }; - default: - return state; - } -}; - -const Condition = React.memo( - ({ condition, children, values }) => { - const formOptions = useFormApi(); - const dirty = formOptions.getState().dirty; - - const [state, dispatch] = useReducer(reducer, { - sets: [], - initial: true - }); - - const conditionResult = parseCondition(condition, values, formOptions); - const setters = conditionResult.set ? [conditionResult.set] : conditionResult.sets; - - useEffect(() => { - if (!dirty) { - dispatch({ type: 'formResetted' }); - } - }, [dirty]); - - useEffect(() => { - if (setters && setters.length > 0 && (state.initial || !isEqual(setters, state.sets))) { - setters.forEach((setter, index) => { - if (setter && (state.initial || !isEqual(setter, state.sets[index]))) { - setTimeout(() => { - formOptions.batch(() => { - Object.entries(setter).forEach(([name, value]) => { - formOptions.change(name, value); - }); - }); - }); - } - }); - dispatch({ type: 'rememberSets', sets: setters }); - } - }, [setters, state.initial]); - - return conditionResult.visible ? children : null; - }, - (a, b) => isEqual(a.values, b.values) && isEqual(a.condition, b.condition) -); - const conditionProps = { when: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), - is: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.object, PropTypes.number, PropTypes.bool]), + is: PropTypes.oneOfType([ + PropTypes.array, + PropTypes.string, + PropTypes.object, + PropTypes.number, + PropTypes.bool, + ]), isNotEmpty: PropTypes.bool, isEmpty: PropTypes.bool, pattern: (props, name, componentName) => { @@ -166,30 +117,31 @@ const conditionProps = { notMatch: PropTypes.any, then: PropTypes.shape({ visible: PropTypes.bool, - set: PropTypes.object + set: PropTypes.object, }), else: PropTypes.shape({ visible: PropTypes.bool, - set: PropTypes.object - }) + set: PropTypes.object, + }), }; const nestedConditions = { - or: PropTypes.oneOfType([PropTypes.shape(conditionProps), PropTypes.arrayOf(PropTypes.shape(conditionProps))]), - and: PropTypes.oneOfType([PropTypes.shape(conditionProps), PropTypes.arrayOf(PropTypes.shape(conditionProps))]), - not: PropTypes.oneOfType([PropTypes.shape(conditionProps), PropTypes.arrayOf(PropTypes.shape(conditionProps))]), - sequence: PropTypes.arrayOf(PropTypes.shape(conditionProps)) + or: PropTypes.oneOfType([ + PropTypes.shape(conditionProps), + PropTypes.arrayOf(PropTypes.shape(conditionProps)), + ]), + and: PropTypes.oneOfType([ + PropTypes.shape(conditionProps), + PropTypes.arrayOf(PropTypes.shape(conditionProps)), + ]), + not: PropTypes.oneOfType([ + PropTypes.shape(conditionProps), + PropTypes.arrayOf(PropTypes.shape(conditionProps)), + ]), + sequence: PropTypes.arrayOf(PropTypes.shape(conditionProps)), }; const conditionsProps = { ...conditionProps, - ...nestedConditions -}; - -Condition.propTypes = { - condition: PropTypes.oneOfType([PropTypes.shape(conditionsProps), PropTypes.arrayOf(PropTypes.shape(conditionsProps))]), - children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired, - values: PropTypes.object.isRequired + ...nestedConditions, }; - -export default Condition; diff --git a/packages/react-form-renderer/src/form-renderer/render-form.js b/packages/react-form-renderer/src/form-renderer/render-form.js index 19a4b581b..0de668306 100644 --- a/packages/react-form-renderer/src/form-renderer/render-form.js +++ b/packages/react-form-renderer/src/form-renderer/render-form.js @@ -1,66 +1,73 @@ -import React, { useContext } from 'react'; +import React, {useContext} from 'react'; import PropTypes from 'prop-types'; -import { childrenPropTypes } from '@data-driven-forms/common/src/prop-types-templates'; +import {childrenPropTypes} from '@data-driven-forms/common/src/prop-types-templates'; import RendererContext from '../files/renderer-context'; -import Condition from './condition'; import FormSpy from '../files/form-spy'; -const FormFieldHideWrapper = ({ hideField, children }) => (hideField ? : children); +const FormFieldHideWrapper = ({hideField, children}) => + hideField ? : children; FormFieldHideWrapper.propTypes = { hideField: PropTypes.bool, - children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired + children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]).isRequired, }; FormFieldHideWrapper.defaultProps = { - hideField: false + hideField: false, }; -const FormConditionWrapper = ({ condition, children }) => - condition ? ( - - {({ values }) => ( - - {children} - - )} - - ) : ( - children - ); +//Helper function to read the top uiState from the uiState stack of the specified field +//undefined means that no explicit uiState is set by a condition. +const checkUIState = ({fieldName, uiState}) => { + const fieldState = uiState.fields[fieldName]; + if (!fieldState) return {visible: undefined, disabled: undefined}; -FormConditionWrapper.propTypes = { - condition: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), - children: childrenPropTypes.isRequired + const visible = !fieldState.visible ? undefined : fieldState.visible[0].value; + const disabled = !fieldState.disabled ? undefined : fieldState.disabled[0].value; + + return {visible, disabled}; }; -const SingleField = ({ component, condition, hideField, ...rest }) => { - const { actionMapper, componentMapper } = useContext(RendererContext); +const SingleField = ({component, hideField, name, ...rest}) => { + const { + actionMapper, + componentMapper, + formOptions: {uiState}, + } = useContext(RendererContext); + + const fieldState = checkUIState({fieldName: name, uiState}); let componentProps = { component, - ...rest + name, + disabled: fieldState.disabled, + ...rest, }; const componentBinding = componentMapper[component]; let Component; - if (typeof componentBinding === 'object' && Object.prototype.hasOwnProperty.call(componentBinding, 'component')) { - const { component, ...mapperProps } = componentBinding; + if ( + typeof componentBinding === 'object' && + Object.prototype.hasOwnProperty.call(componentBinding, 'component') + ) { + const {component, ...mapperProps} = componentBinding; Component = component; componentProps = { ...mapperProps, ...componentProps, // merge mapper and field actions - ...(mapperProps.actions && rest.actions ? { actions: { ...mapperProps.actions, ...rest.actions } } : {}), + ...(mapperProps.actions && rest.actions + ? {actions: {...mapperProps.actions, ...rest.actions}} + : {}), // merge mapper and field resolveProps ...(mapperProps.resolveProps && rest.resolveProps ? { resolveProps: (...args) => ({ ...mapperProps.resolveProps(...args), - ...rest.resolveProps(...args) - }) + ...rest.resolveProps(...args), + }), } - : {}) + : {}), }; } else { Component = componentBinding; @@ -72,7 +79,7 @@ const SingleField = ({ component, condition, hideField, ...rest }) => { let overrideProps = {}; let mergedResolveProps; // new object has to be created because of references if (componentProps.actions) { - Object.keys(componentProps.actions).forEach((prop) => { + Object.keys(componentProps.actions).forEach(prop => { const [action, ...args] = componentProps.actions[prop]; overrideProps[prop] = actionMapper[action](...args); }); @@ -81,7 +88,7 @@ const SingleField = ({ component, condition, hideField, ...rest }) => { if (componentProps.resolveProps && overrideProps.resolveProps) { mergedResolveProps = (...args) => ({ ...componentProps.resolveProps(...args), - ...overrideProps.resolveProps(...args) + ...overrideProps.resolveProps(...args), }); } @@ -90,11 +97,15 @@ const SingleField = ({ component, condition, hideField, ...rest }) => { } return ( - - - - - + + + ); }; @@ -106,11 +117,14 @@ SingleField.propTypes = { validate: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object])), initialValue: PropTypes.any, actions: PropTypes.shape({ - [PropTypes.string]: PropTypes.func + [PropTypes.string]: PropTypes.func, }), - resolveProps: PropTypes.func + resolveProps: PropTypes.func, }; -const renderForm = (fields) => fields.map((field) => (Array.isArray(field) ? renderForm(field) : )); +const renderForm = fields => + fields.map(field => + Array.isArray(field) ? renderForm(field) : + ); export default renderForm; diff --git a/packages/react-form-renderer/src/form-renderer/validator-helpers.js b/packages/react-form-renderer/src/form-renderer/validator-helpers.js index 114138234..e5ee9d8ab 100644 --- a/packages/react-form-renderer/src/form-renderer/validator-helpers.js +++ b/packages/react-form-renderer/src/form-renderer/validator-helpers.js @@ -1,16 +1,16 @@ -import { memoize } from '../validators/helpers'; -import { dataTypeValidator } from '../validators'; +import {memoize} from '../validators/helpers'; +import {dataTypeValidator} from '../validators'; import composeValidators from '../files/compose-validators'; export const prepareValidator = (validator, mapper) => - typeof validator === 'function' ? memoize(validator) : mapper[validator.type]({ ...validator }); + typeof validator === 'function' ? memoize(validator) : mapper[validator.type]({...validator}); export const getValidate = (validate, dataType, mapper = {}) => [ - ...(validate ? validate.map((validator) => prepareValidator(validator, mapper)) : []), - ...(dataType ? [dataTypeValidator(dataType)()] : []) + ...(validate ? validate.map(validator => prepareValidator(validator, mapper)) : []), + ...(dataType ? [dataTypeValidator(dataType)()] : []), ]; -export const prepareArrayValidator = (validation) => (value = []) => { +export const prepareArrayValidator = validation => (value = []) => { if (!Array.isArray(value)) { return; } diff --git a/packages/react-form-renderer/src/tests/form-renderer/condition.test.js b/packages/react-form-renderer/src/tests/form-renderer/condition.test.js index 1c242ca61..ab6806578 100644 --- a/packages/react-form-renderer/src/tests/form-renderer/condition.test.js +++ b/packages/react-form-renderer/src/tests/form-renderer/condition.test.js @@ -1,16 +1,16 @@ import React from 'react'; -import { mount } from 'enzyme'; -import { act } from 'react-dom/test-utils'; +import {mount} from 'enzyme'; +import {act} from 'react-dom/test-utils'; import FormTemplate from '../../../../../__mocks__/mock-form-template'; import componentTypes from '../../../dist/cjs/component-types'; import useFieldApi from '../../files/use-field-api'; import FormRenderer from '../../files/form-renderer'; -import { reducer } from '../../form-renderer/condition'; +import {reducer} from '../../form-renderer/condition'; -const TextField = (props) => { - const { input } = useFieldApi(props); +const TextField = props => { + const {input} = useFieldApi(props); return ; }; @@ -28,18 +28,19 @@ describe('condition test', () => { initialProps = { FormTemplate, componentMapper: { - [componentTypes.TEXT_FIELD]: TextField + [componentTypes.TEXT_FIELD]: TextField, }, - onSubmit: (values) => onSubmit(values) + onSubmit: values => onSubmit(values), }; }); it('should render when condition is fulfill', () => { + //Testing both legacy condition and new condition style schema = { fields: [ { component: componentTypes.TEXT_FIELD, - name: 'field-1' + name: 'field-1', }, { component: componentTypes.TEXT_FIELD, @@ -47,26 +48,40 @@ describe('condition test', () => { condition: [ { when: 'field-1', - is: 'show' - } - ] - } - ] + is: 'show', + }, + ], + }, + { + component: componentTypes.TEXT_FIELD, + name: 'field-3', + hideField: true, + }, + ], + conditions: { + cond1: { + when: 'field-1', + is: 'show', + then: { + 'field-3': {visible: true}, + }, + }, + }, }; wrapper = mount(); expect(wrapper.find('input')).toHaveLength(1); - wrapper.find('input').simulate('change', { target: { value: 'show' } }); + wrapper.find('input').simulate('change', {target: {value: 'show'}}); wrapper.update(); - expect(wrapper.find('input')).toHaveLength(2); + expect(wrapper.find('input')).toHaveLength(3); wrapper .find('input') .first() - .simulate('change', { target: { value: 'dontshow' } }); + .simulate('change', {target: {value: 'dontshow'}}); wrapper.update(); expect(wrapper.find('input')).toHaveLength(1); @@ -77,22 +92,37 @@ describe('condition test', () => { fields: [ { component: componentTypes.TEXT_FIELD, - name: 'field-1' + name: 'field-1', }, { component: componentTypes.TEXT_FIELD, name: 'field-2', condition: { when: 'field-1', - is: 'show', + is: 'legacy', then: { set: { - 'field-2': 'someValue' - } - } - } - } - ] + 'field-2': 'someValue', + }, + }, + }, + }, + { + component: componentTypes.TEXT_FIELD, + name: 'field-3', + }, + ], + conditions: { + cond1: { + when: 'field-1', + is: 'show', + then: { + 'field-3': { + set: 'otherValue', + }, + }, + }, + }, }; await act(async () => { @@ -100,15 +130,15 @@ describe('condition test', () => { }); wrapper.update(); - expect(wrapper.find('input')).toHaveLength(1); + expect(wrapper.find('input')).toHaveLength(2); await act(async () => { - wrapper.find('input').simulate('change', { target: { value: 'show' } }); + wrapper.find('input').simulate('change', {target: {value: 'show'}}); jest.advanceTimersByTime(1); }); wrapper.update(); - expect(wrapper.find('input')).toHaveLength(2); + expect(wrapper.find('input')).toHaveLength(3); await act(async () => { wrapper.find('form').simulate('submit'); @@ -118,7 +148,8 @@ describe('condition test', () => { expect(onSubmit).toHaveBeenCalledWith({ 'field-1': 'show', - 'field-2': 'someValue' + 'field-2': 'someValue', + 'field-3': 'otherValue', }); }); @@ -127,7 +158,7 @@ describe('condition test', () => { fields: [ { component: componentTypes.TEXT_FIELD, - name: 'field-1' + name: 'field-1', }, { component: componentTypes.TEXT_FIELD, @@ -137,16 +168,18 @@ describe('condition test', () => { is: 'show', then: { set: { - 'field-2': 'someValue' - } - } - } - } - ] + 'field-2': 'someValue', + }, + }, + }, + }, + ], }; await act(async () => { - wrapper = mount(); + wrapper = mount( + + ); jest.advanceTimersByTime(1); }); wrapper.update(); @@ -160,7 +193,7 @@ describe('condition test', () => { expect(onSubmit).toHaveBeenCalledWith({ 'field-1': 'show', - 'field-2': 'someValue' + 'field-2': 'someValue', }); }); @@ -169,7 +202,7 @@ describe('condition test', () => { fields: [ { component: componentTypes.TEXT_FIELD, - name: 'field-1' + name: 'field-1', }, { component: componentTypes.TEXT_FIELD, @@ -179,16 +212,18 @@ describe('condition test', () => { is: 'show', then: { set: { - 'field-2': 'someValue' - } - } - } - } - ] + 'field-2': 'someValue', + }, + }, + }, + }, + ], }; await act(async () => { - wrapper = mount(); + wrapper = mount( + + ); jest.advanceTimersByTime(1); }); wrapper.update(); @@ -199,7 +234,7 @@ describe('condition test', () => { wrapper .find('input') .first() - .simulate('change', { target: { value: 'dontshow' } }); + .simulate('change', {target: {value: 'dontshow'}}); jest.advanceTimersByTime(1); }); wrapper.update(); @@ -213,7 +248,7 @@ describe('condition test', () => { expect(onSubmit).toHaveBeenCalledWith({ 'field-1': 'dontshow', - 'field-2': 'someValue' + 'field-2': 'someValue', }); onSubmit.mockClear(); @@ -232,90 +267,90 @@ describe('condition test', () => { }); wrapper.update(); - expect(onSubmit).toHaveBeenCalledWith({ - 'field-1': 'show', - 'field-2': 'someValue' - }); - }); - - it('sets value when condition is fulfill - sequence', async () => { - schema = { - fields: [ - { - component: componentTypes.TEXT_FIELD, - name: 'field-1' - }, - { - component: componentTypes.TEXT_FIELD, - name: 'field-2', - condition: { - sequence: [ - { - when: 'field-1', - is: 'show', - then: { - set: { - 'field-2': 'someValue', - 'field-3': 'someValue3' - } - } - }, - { - when: 'field-1', - is: 'not', - then: { - set: { - 'field-4': 'someValue4' - } - } - }, - { - when: 'field-1', - is: 'show', - then: { - set: { - 'field-5': 'someValuu5' - } - } - } - ] - } - } - ] - }; - - await act(async () => { - wrapper = mount(); - }); - - expect(wrapper.find('input')).toHaveLength(1); - - await act(async () => { - wrapper.find('input').simulate('change', { target: { value: 'show' } }); - jest.advanceTimersByTime(1); - }); - wrapper.update(); - - await act(async () => { - wrapper.find('form').simulate('submit'); - }); - wrapper.update(); - expect(onSubmit).toHaveBeenCalledWith({ 'field-1': 'show', 'field-2': 'someValue', - 'field-3': 'someValue3', - 'field-5': 'someValuu5' }); }); - describe('reducer', () => { - it('returns default', () => { - const initialState = { - a: 'bb' - }; - - expect(reducer(initialState, { type: 'nonsesne' })).toEqual(initialState); - }); - }); + // it('sets value when condition is fulfill - sequence', async () => { + // schema = { + // fields: [ + // { + // component: componentTypes.TEXT_FIELD, + // name: 'field-1', + // }, + // { + // component: componentTypes.TEXT_FIELD, + // name: 'field-2', + // condition: { + // sequence: [ + // { + // when: 'field-1', + // is: 'show', + // then: { + // set: { + // 'field-2': 'someValue', + // 'field-3': 'someValue3', + // }, + // }, + // }, + // { + // when: 'field-1', + // is: 'not', + // then: { + // set: { + // 'field-4': 'someValue4', + // }, + // }, + // }, + // { + // when: 'field-1', + // is: 'show', + // then: { + // set: { + // 'field-5': 'someValuu5', + // }, + // }, + // }, + // ], + // }, + // }, + // ], + // }; + + // await act(async () => { + // wrapper = mount(); + // }); + + // expect(wrapper.find('input')).toHaveLength(1); + + // await act(async () => { + // wrapper.find('input').simulate('change', {target: {value: 'show'}}); + // jest.advanceTimersByTime(1); + // }); + // wrapper.update(); + + // await act(async () => { + // wrapper.find('form').simulate('submit'); + // }); + // wrapper.update(); + + // expect(onSubmit).toHaveBeenCalledWith({ + // 'field-1': 'show', + // 'field-2': 'someValue', + // 'field-3': 'someValue3', + // 'field-5': 'someValuu5', + // }); + // }); + + // describe('reducer', () => { + // it('returns default', () => { + // const initialState = { + // a: 'bb', + // }; + + // expect(reducer(initialState, {type: 'nonsesne'})).toEqual(initialState); + // }); + // }); }); diff --git a/{ b/{ new file mode 100644 index 000000000..e69de29bb