How to update another control?

I am working to create dependent select controls (e.g. state/city, select state, get list of cities). In this example, when the state is changed, I want to clear any selections made on the city control. I have created an onChange handler that uses handleChange to clear the values. When invoked on the primary control, the JSONForms state of the dependent control is properly updated but the control itself continues to display the previously selected values. I have also tried to use dispatch(Actions.update(controlName, () => [])) without success.

In the below example I have two controls, a ‘primary_control’ and a ‘dependent_control’. When the primary is modified, I want to clear the selections of the dependent.

What is the proper way to properly update the other control?

import {
    JsonSchema,
    ControlProps,
    RankedTester,
    rankWith,
    schemaMatches,
    and,
    isEnumControl,
    OwnPropsOfEnum,
    EnumOption,
    Actions,
} from '@jsonforms/core';
import { JsonFormsStateContext, withJsonFormsContext, withJsonFormsEnumProps } from '@jsonforms/react';
import { Autocomplete, Grid, TextField, Typography } from '@mui/material';
import { useEffect, useState } from 'react';
type JsonSchemaWithDependency = JsonSchema & { use_dependent_control: boolean };


interface WithContext {
    ctx: JsonFormsStateContext,
    props: ControlProps & OwnPropsOfEnum
}

export const SimpleControl = (props: WithContext) => {
    const schema = props.props.schema as JsonSchemaWithDependency;
    const coreOptions = props.props.options ? props.props.options : []
    const [options, setOptions] = useState<EnumOption[]>(coreOptions);

    const {
        id,
        label,
        handleChange,
        path,
    } = props.props;

    const {
        dispatch
    } = props.ctx

    useEffect(() => {
        // "dynamically" set the options of this control e.g. from an API.
        setOptions([{ "label": "Foo", "value": "1" }, { "label": "Bar", "value": "2" }])
    }, []);

    const onChange = (_ev: any, newValues: any) => {
        // update this control
        handleChange(path, newValues);

        if ('primary_control' === path) {
            // This updates the JSONForms state but the MUI control is not updated
            handleChange('dependent_control', []);

            // This also updated the JSONForms state but leaves the control unchanged.
            // if (dispatch) {
            //     dispatch(Actions.update('dependent_control', () => []))
            // }
        }
    }

    return (
        <Grid container>
            <Grid item xs={12}>
                <Autocomplete
                    id={id}
                    autoComplete
                    fullWidth
                    disableCloseOnSelect
                    freeSolo={false}
                    isOptionEqualToValue={(option, value) => option.value === value.value}
                    getOptionLabel={(option) => option.value}
                    options={options ? options : []}
                    onChange={onChange}
                    multiple={true}
                    renderInput={(params) => (
                        <TextField {...params} label={label} variant="outlined" />
                    )}
                />
            </Grid>
        </Grid>
    );
};

export const simpleControlTester: RankedTester = rankWith(
    5,
    and(
        isEnumControl,
        schemaMatches((schema) => schema.hasOwnProperty('use_dependent_control')),
    )
);

export default withJsonFormsEnumProps(withJsonFormsContext(SimpleControl));
{
  "type": "object",
  "properties": {
    "primary_control": {
      "title": "Primary Control",
      "type": "string",
      "enum": ["East", "West", "North", "South"],
      "use_dependent_control": true
    },
    "dependent_control": {
      "title": "Dependent Control",
      "type": "string",
      "enum": ["Never", "Daily", "Weekly", "Monthly"],
      "use_dependent_control": true
    }
  }
}
{
  "type": "VerticalLayout",
  "elements": [
    {
      "type": "Control",
      "scope": "#/properties/primary_control"
    },
    {
      "type": "Control",
      "scope": "#/properties/dependent_control"
    }
  ]
}

I was able to simplify my prior code to pass all provided props to a MaterialEnumControl and am able to have the expected dependency work as expected (clear the data & UI component). However, in this simplified version, I have an issue with applying custom options to the control(s). In the below example, when I load the options within useEffect(), the UI is populated correctly but validation fails when any value is selected. I tried dispatching setSchema, update, and updateCore but could not determine the correct way to update JSONForms.

I seem to be missing the proper way to update the schema and to dynamically provide options to the control. Any help would be GREATLY appreciated!

export const SimpleControl2 = (props: ControlProps & TranslateProps & OwnPropsOfEnum & JsonFormsStateContext) => {
    const [options, setOptions] = useState<EnumOption[]>(props.props.options);

    const {
        handleChange,
        path,
    } = props.props;

    const {
        dispatch
    } = props.ctx

    useEffect(() => {
        const enumValues: EnumOption[] = [{ "label": "Foo", "value": "1" }, { "label": "Bar", "value": "2" }, { "label": "East", "value": "East" }, { "label": "Never", "value": "Never" }]

        // "dynamically" set the options of this control e.g. from an API.
        // this populates the control, but the validation fails for all selections.
        setOptions(enumValues)
    }, []);

    const onChange = (_ev: any, newValues: any) => {
        // update this control
        handleChange(path, newValues)

        if ('primary_control' === path) {
            // This now updates the 'dependent_control' data & UI as expected
            handleChange('dependent_control', undefined);
        }
    }

    return (
        <Grid container>
            <Grid item xs={12}>
                <MaterialEnumControl {...props.props} options={options} handleChange={onChange} description='hello there!' />
            </Grid>
        </Grid>
    );
};

export const simpleControlTester2: RankedTester = rankWith(
    5,
    and(
        isEnumControl,
        schemaMatches((schema) => schema.hasOwnProperty('use_dependent_control2')),
    )
);

Hi @richkroll,

The setSchema is no longer used internally in JSON Forms and is only there for legacy reasons. If you want to dynamically update the schema then you have to hand over an updated schema to the JsonForms component.


However I would advise against dynamically updating the JSON Schema for dynamic enums. You already manage the options manually, so there really is no need to rely on the JSON Schema for the enum entries. Also there is no validation benefit as you manage the enum entries anyway.

Often dynamic enums are just modeled as type: 'string' without specifying any option. Instead usually some kind of endpoint discovery is added there. If you want to encode initial options to already render before your endpoint returns, then you can add them via a custom attribute so that they are not picked up by AJV.

The SimpleControl2 should be wrapped with withJsonFormsControlProps and use the unwrapped variant of the MaterialEnumControl, i.e.

import { Unwrapped } from '@jsonforms/material-renderers';
const { MaterialEnumControl } = Unwrapped;

Thanks @sdirix! Changing to a string (or array of strings) was the key. I made the faulty assumption that AJV also drove the rules, which we require in our implementation, so I was focused on modifying the underlying schema so AJV would work as expected.

In the hopes that it can help someone else, below is the final version of my demo which includes dynamically populating the select list (e.g. from an API).

schema.json

{
  "type": "object",
  "properties": {
    "primary_control": {
      "title": "Primary Control",
      "type": "string",
      "use_dependent_control": true
    },
    "dependent_control": {
      "title": "Dependent Control",
      "type": "string",
      "use_dependent_control": true
    }
  }
}

uischema.json

{
  "type": "VerticalLayout",
  "elements": [
    {
      "type": "HorizontalLayout",
      "elements": [
        {
          "type": "Control",
          "scope": "#/properties/primary_control"
        },
        {
          "type": "Control",
          "scope": "#/properties/dependent_control",
          "rule": {
            "effect": "SHOW",
            "condition": {
              "scope": "#",
              "schema": {
                "properties": {
                  "const": true
                },
                "required": ["primary_control"]
              }
            }
          }
        }
      ]
    }
  ]
}

SimpleControl.tsx

import {
    ControlProps,
    RankedTester,
    rankWith,
    schemaMatches,
    and,
    OwnPropsOfEnum,
    EnumOption,
} from '@jsonforms/core';
import { Unwrapped } from '@jsonforms/material-renderers';
import { TranslateProps, withJsonFormsControlProps, withTranslateProps } from '@jsonforms/react';
import { Grid } from '@mui/material';
import { useEffect, useState } from 'react';

const { MaterialEnumControl } = Unwrapped;

export const SimpleControl = (props: ControlProps & TranslateProps & OwnPropsOfEnum) => {
    const [options, setOptions] = useState<EnumOption[]>([]);

    const {
        handleChange,
        path,
    } = props;


    useEffect(() => {
        // "dynamically" load the set of options for this control e.g. from an API.
        const enumValues: EnumOption[] = [{ "label": "Foo", "value": "1" }, { "label": "Bar", "value": "2" }, { "label": "East", "value": "East" }, { "label": "Never", "value": "Never" }]
        setOptions(enumValues)
    }, []);

    const onChange = (_ev: any, newValues: any) => {
        // update this control
        handleChange(path, newValues)

        // The dependent path needs to be defined in the schema and dynamically resolved via core data in the context.
        if ('primary_control' === path) {
            // clear the dependent control if the primary control changes
            handleChange('dependent_control', undefined);
        }
    }

    return (
        <Grid container>
            <Grid item xs={12}>
                <MaterialEnumControl {...props} options={options} handleChange={onChange} />
            </Grid>
        </Grid>
    );
};

export const simpleControlTester: RankedTester = rankWith(
    5,
    and(
        schemaMatches((schema) => schema.hasOwnProperty('use_dependent_control')),
    )
);

export default withJsonFormsControlProps(
    withTranslateProps(SimpleControl),
    false
);
1 Like

@sdirix In further testing this approach, I discovered that the call to handleChange('dependent_control',undefined) does not trigger the controls change handler. Is there a recommended way to trigger that a changed occurred?

What I am looking to achieve is to have the dependent control be aware that it needs to reload its data. An example would be a state control and a dependent city control. When the state control changes, I would like to clear the city control and load the cites based on the current value of the state control. I know that I can use ctx.core.data to look up the current value of the state control, but without the change handler being triggered, I am at a loss for how to trigger the query for the dependent data.

Hi @richkroll,

Correct, all this does is to update the data of dependent_control to undefined. In case that the data was not already undefined before, the dependent_control will rerender with props.data being undefined.

The implementation design here depends on your exact requirements and what to optimize for. A typical design looks like this:

  • Each control which relies on external API gets a corresponding API injected. This can be done by creating a corresponding context outside of JSON Forms and consuming it in your custom renderers
  • Each control which requires data from other fields resolves that data from the form-wide data store via Resolve.data(formWideData, pathToData). The formWideData can be accessed via the useJsonForms() hook, while the pathToData is typically specified via the JSON Schema
    • Because the data is resolved, the control will automatically rerender when one of the required upon data changes. Therefore the control knows when its API requests are outdated and can perform new ones, either immediately or once needed, e.g. when the user clicks on the drop down.
  • A control on which other controls depends, often has to delete or set a new value for all dependent data. This should all be handled directly in this control, just like you already implemented.
    • When a new value has to be set then often a new API request is needed. The same API request has to be performed in the dependent controls itself to resolve the options. In case optimization is needed here, the API needs to be stateful and just return immediately on second invocation or the result of past queries are also stored in the React context

Thanks for the pointers in the right direction!