From c08fcdcdfbb15edb0550c7340dfd351e6e014bbb Mon Sep 17 00:00:00 2001 From: Stefan Dirix Date: Tue, 7 Nov 2023 13:36:49 +0100 Subject: [PATCH 1/2] feat: middleware support in React bindings This update introduces optional middleware support for our React bindings, enabling advanced features like controlled mode in JSON Forms and on-the-fly handling of derived attributes. --- packages/react/src/JsonForms.tsx | 4 ++ packages/react/src/JsonFormsContext.tsx | 58 +++++++++++++++++++------ 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/packages/react/src/JsonForms.tsx b/packages/react/src/JsonForms.tsx index 7d06bcd8c1..1d821a9beb 100644 --- a/packages/react/src/JsonForms.tsx +++ b/packages/react/src/JsonForms.tsx @@ -45,6 +45,7 @@ import { } from '@jsonforms/core'; import { JsonFormsStateProvider, + Middleware, withJsonFormsRendererProps, } from './JsonFormsContext'; @@ -54,6 +55,7 @@ interface JsonFormsRendererState { export interface JsonFormsReactProps { onChange?(state: Pick): void; + middleware?: Middleware; } export class JsonFormsDispatchRenderer extends React.Component< @@ -203,6 +205,7 @@ export const JsonForms = ( validationMode, i18n, additionalErrors, + middleware, } = props; const schemaToUse = useMemo( () => (schema !== undefined ? schema : Generate.jsonSchema(data)), @@ -233,6 +236,7 @@ export const JsonForms = ( i18n, }} onChange={onChange} + middleware={middleware} > diff --git a/packages/react/src/JsonFormsContext.tsx b/packages/react/src/JsonFormsContext.tsx index b860f6a915..5162f922d7 100644 --- a/packages/react/src/JsonFormsContext.tsx +++ b/packages/react/src/JsonFormsContext.tsx @@ -75,6 +75,7 @@ import { OwnPropsOfLabel, LabelProps, mapStateToLabelProps, + CoreActions, } from '@jsonforms/core'; import debounce from 'lodash/debounce'; import React, { @@ -87,6 +88,7 @@ import React, { useMemo, useReducer, useRef, + useState, } from 'react'; const initialCoreState: JsonFormsCore = { @@ -126,33 +128,56 @@ const useEffectAfterFirstRender = ( }, dependencies); }; +export interface Middleware { + ( + state: JsonFormsCore, + action: CoreActions, + defaultReducer: (state: JsonFormsCore, action: CoreActions) => JsonFormsCore + ): JsonFormsCore; +} + +const defaultMiddleware: Middleware = (state, action, defaultReducer) => + defaultReducer(state, action); + export const JsonFormsStateProvider = ({ children, initState, onChange, + middleware, }: any) => { const { data, schema, uischema, ajv, validationMode, additionalErrors } = initState.core; - const [core, coreDispatch] = useReducer(coreReducer, undefined, () => - coreReducer( + const middlewareRef = useRef(middleware ?? defaultMiddleware); + middlewareRef.current = middleware ?? defaultMiddleware; + + const [core, setCore] = useState(() => + middlewareRef.current( initState.core, Actions.init(data, schema, uischema, { ajv, validationMode, additionalErrors, - }) + }), + coreReducer ) ); - useEffect(() => { - coreDispatch( - Actions.updateCore(data, schema, uischema, { - ajv, - validationMode, - additionalErrors, - }) - ); - }, [data, schema, uischema, ajv, validationMode, additionalErrors]); + + useEffect( + () => + setCore((currentCore) => + middlewareRef.current( + currentCore, + Actions.updateCore(data, schema, uischema, { + ajv, + validationMode, + additionalErrors, + }), + coreReducer + ) + ), + [data, schema, uischema, ajv, validationMode, additionalErrors] + ); const [config, configDispatch] = useReducer(configReducer, undefined, () => configReducer(undefined, Actions.setConfig(initState.config)) @@ -185,6 +210,12 @@ export const JsonFormsStateProvider = ({ initState.i18n?.translateError, ]); + const dispatch = useCallback((action: CoreActions) => { + setCore((currentCore) => + middlewareRef.current(currentCore, action, coreReducer) + ); + }, []); + const contextValue = useMemo( () => ({ core, @@ -194,8 +225,7 @@ export const JsonFormsStateProvider = ({ uischemas: initState.uischemas, readonly: initState.readonly, i18n: i18n, - // only core dispatch available - dispatch: coreDispatch, + dispatch: dispatch, }), [ core, From 67e77ffd01540e04b20a22833c77bf4f2b1c921d Mon Sep 17 00:00:00 2001 From: Dan Holmes Date: Fri, 24 Nov 2023 14:45:59 +0000 Subject: [PATCH 2/2] test: unit tests for React middleware support --- .../react/test/renderers/JsonForms.test.tsx | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/packages/react/test/renderers/JsonForms.test.tsx b/packages/react/test/renderers/JsonForms.test.tsx index a26c077875..7098ae4115 100644 --- a/packages/react/test/renderers/JsonForms.test.tsx +++ b/packages/react/test/renderers/JsonForms.test.tsx @@ -58,6 +58,7 @@ import { } from '../../src/JsonForms'; import { JsonFormsStateProvider, + Middleware, useJsonForms, withJsonFormsControlProps, } from '../../src/JsonFormsContext'; @@ -1100,3 +1101,144 @@ test('JsonForms should use react to additionalErrors update', () => { expect(wrapper.find('h5').text()).toBe('Foobar'); wrapper.unmount(); }); + +test('JsonForms middleware should be called if provided', async () => { + // given + const onChangeHandler = jest.fn(); + const customMiddleware = jest.fn(); + const TestInputRenderer = withJsonFormsControlProps((props) => ( + props.handleChange('foo', ev.target.value)} + value={props.data} + /> + )); + const renderers = [ + { + tester: () => 10, + renderer: TestInputRenderer, + }, + ]; + const controlledMiddleware: Middleware = (state, action, reducer) => { + if (action.type === 'jsonforms/UPDATE') { + customMiddleware(); + return state; + } else { + return reducer(state, action); + } + }; + const wrapper = mount( + + ); + + // wait for 50 ms for the change handler invocation + await new Promise((resolve) => setTimeout(resolve, 50)); + + // initial rendering should call onChange 1 time + expect(onChangeHandler).toHaveBeenCalledTimes(1); + const calls = onChangeHandler.mock.calls; + const lastCallParameter = calls[calls.length - 1][0]; + expect(lastCallParameter.data).toEqual({ foo: 'John Doe' }); + expect(lastCallParameter.errors).toEqual([]); + + // adapt test input + wrapper.find('input').simulate('change', { + target: { + value: 'Test Value', + }, + }); + + expect(customMiddleware).toHaveBeenCalledTimes(1); + + // wait for 50 ms for the change handler invocation + await new Promise((resolve) => setTimeout(resolve, 50)); + // change handler should not have been called another time as we blocked the update in the middleware + expect(onChangeHandler).toHaveBeenCalledTimes(1); + // The rendered field should also not have been updated + expect(wrapper.find('input').getDOMNode().value).toBe( + 'John Doe' + ); + + wrapper.unmount(); +}); + +test('JsonForms middleware should update state if modified', async () => { + // given + const onChangeHandler = jest.fn(); + const customMiddleware = jest.fn(); + const TestInputRenderer = withJsonFormsControlProps((props) => ( + props.handleChange('foo', ev.target.value)} + value={props.data} + /> + )); + const renderers = [ + { + tester: () => 10, + renderer: TestInputRenderer, + }, + ]; + const controlledMiddleware: Middleware = (state, action, reducer) => { + if (action.type === 'jsonforms/UPDATE') { + customMiddleware(); + const newState = reducer(state, action); + return { ...newState, data: { foo: `${newState.data.foo} Test` } }; + } else { + return reducer(state, action); + } + }; + const wrapper = mount( + + ); + + // wait for 50 ms for the change handler invocation + await new Promise((resolve) => setTimeout(resolve, 50)); + + // initial rendering should call onChange 1 time + expect(onChangeHandler).toHaveBeenCalledTimes(1); + { + const calls = onChangeHandler.mock.calls; + const lastCallParameter = calls[calls.length - 1][0]; + expect(lastCallParameter.data).toEqual({ foo: 'John Doe' }); + expect(lastCallParameter.errors).toEqual([]); + } + + // adapt input + wrapper.find('input').simulate('change', { + target: { + value: 'Test Value', + }, + }); + + // then + expect(customMiddleware).toHaveBeenCalledTimes(1); + expect(wrapper.find('input').getDOMNode().value).toBe( + 'Test Value Test' + ); + + // wait for 50 ms for the change handler invocation + await new Promise((resolve) => setTimeout(resolve, 50)); + // onChangeHandler should have been called after the state update + expect(onChangeHandler).toHaveBeenCalledTimes(2); + { + const calls = onChangeHandler.mock.calls; + const lastCallParameter = calls[calls.length - 1][0]; + expect(lastCallParameter.data).toEqual({ foo: 'Test Value Test' }); + expect(lastCallParameter.errors).toEqual([]); + } + + wrapper.unmount(); +});