diff --git a/packages/react-form-renderer/src/field-spy/index.js b/packages/react-form-renderer/src/field-spy/index.js new file mode 100644 index 000000000..92078f917 --- /dev/null +++ b/packages/react-form-renderer/src/field-spy/index.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { useFormState } from 'react-final-form'; + +const FieldSpy = ({ fields, field, children }) => { + const previousValues = React.useRef(Object.fromEntries(fields.map((field) => [field, null]))); + const [renderCounter, setRenderCounter] = React.useState(0); + const memoizedChildren = React.useMemo(() => children(), [renderCounter, field]); + const getChangedFields = React.useCallback((prev, next, arr) => + arr.filter((field) => { + const nextVal = field.split('.').reduce((o, i) => (o ? o[i] : null), next); + if (!prev[field] && !nextVal) { + return false; + } + + if (prev[field] !== nextVal) { + return true; + } + + return false; + }) + ); + + useFormState({ + subscription: { values: true }, + onChange: ({ values }) => { + const changedFields = getChangedFields(previousValues.current, values, fields); + if (changedFields.length) { + setRenderCounter(renderCounter + 1); + previousValues.current = { ...values }; + } + }, + }); + + return memoizedChildren; +}; + +export default FieldSpy; 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 efde3950f..e70972356 100644 --- a/packages/react-form-renderer/src/form-renderer/render-form.js +++ b/packages/react-form-renderer/src/form-renderer/render-form.js @@ -1,12 +1,10 @@ import React, { useContext } from 'react'; import PropTypes from 'prop-types'; -import setWith from 'lodash/setWith'; -import cloneDeep from 'lodash/cloneDeep'; -import { Field } from 'react-final-form'; import RendererContext from '../renderer-context'; import Condition from '../condition'; import getConditionTriggers from '../get-condition-triggers'; import prepareComponentProps from '../prepare-component-props'; +import FieldSpy from '../field-spy'; const FormFieldHideWrapper = ({ hideField, children }) => (hideField ? : children); @@ -19,8 +17,8 @@ FormFieldHideWrapper.defaultProps = { hideField: false, }; -const ConditionTriggerWrapper = ({ condition, values, children, field }) => ( - +const ConditionTriggerWrapper = ({ condition, children, field }) => ( + {children} ); @@ -29,38 +27,21 @@ ConditionTriggerWrapper.propTypes = { condition: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), children: PropTypes.node.isRequired, field: PropTypes.object, - values: PropTypes.object.isRequired, }; -const ConditionTriggerDetector = ({ values = {}, triggers = [], children, condition, field }) => { - const internalTriggers = [...triggers]; - if (internalTriggers.length === 0) { - return ( - - {children} - - ); - } - - const name = internalTriggers.shift(); +const ConditionTriggerDetector = ({ triggers = [], children, condition, field }) => { return ( - - {({ input: { value } }) => ( - + + {() => ( + {children} - + )} - + ); }; ConditionTriggerDetector.propTypes = { - values: PropTypes.object, triggers: PropTypes.arrayOf(PropTypes.string), children: PropTypes.node, condition: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), diff --git a/packages/react-form-renderer/src/tests/form-renderer/field-spy.test.js b/packages/react-form-renderer/src/tests/form-renderer/field-spy.test.js new file mode 100644 index 000000000..a09049f3e --- /dev/null +++ b/packages/react-form-renderer/src/tests/form-renderer/field-spy.test.js @@ -0,0 +1,111 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import FormTemplate from '../../../../../__mocks__/mock-form-template'; +import componentTypes from '../../component-types'; +import useFieldApi from '../../use-field-api'; +import FormRenderer from '../../form-renderer'; + +import FieldSpy from '../../field-spy'; + +const TextField = (props) => { + const { input, placeholder } = useFieldApi(props); + return ; +}; + +describe('field-spy test', () => { + let initialProps; + + beforeEach(() => { + initialProps = { + FormTemplate, + componentMapper: { + [componentTypes.TEXT_FIELD]: TextField, + }, + onSubmit: () => {}, + }; + }); + + it('should re-render child on eligible field change', async () => { + const onChangeFn = jest.fn(); + const schema = { + fields: [ + { + component: componentTypes.TEXT_FIELD, + name: 'field-1', + }, + { + component: componentTypes.TEXT_FIELD, + name: 'field-2', + }, + { + component: 'listener', + name: 'listener', + }, + ], + }; + + render( + ( + + {() => { + onChangeFn(); + return <>; + }} + + ), + }} + /> + ); + expect(onChangeFn).toBeCalledTimes(1); + await userEvent.type(screen.getByLabelText('field-1'), 's'); + expect(onChangeFn).toBeCalledTimes(2); + }); + + it('should not re-render child on ineligible field change', async () => { + const onChangeFn = jest.fn(); + const schema = { + fields: [ + { + component: componentTypes.TEXT_FIELD, + name: 'field-1', + }, + { + component: componentTypes.TEXT_FIELD, + name: 'field-2', + }, + { + component: 'listener', + name: 'listener', + }, + ], + }; + + render( + ( + + {() => { + onChangeFn(); + return <>; + }} + + ), + }} + /> + ); + expect(onChangeFn).toBeCalledTimes(1); + await userEvent.type(screen.getByLabelText('field-1'), 's'); + expect(onChangeFn).toBeCalledTimes(1); + }); +});