Custom array of enumerations

Hi All,

I have a custom enumeration control that allows me to use the $data reference to populate the an enumeration control with data from another part of the form. This works well if this enumeration property is one of many properties of an object i.e. in an object I have 3 string field properties and one of my custom enumeration property.

My problem is that I want this same functionality for an array of just this enumeration type e.g.:

"elements": {
	"description": "This contains information about the elements.",
	"type": "array",
	"items": {
		"description": "",
		"type": "string",
		"enum": {
			"$data": "/properties/myObject/properties/element/properties/name"
		}
	}
},

Iā€™ve tried to create a custom MaterialArrayControlRenderer by copying and pasting the code from the original Control and increased the rank in the tester but when it uses my custom MaterialArrayControlRenderer it doesnā€™t seem to render anything at all for my arrays, even when they they use more traditionally supported enum: [ā€œitem1ā€, ā€œitem2ā€, ā€œitem3ā€]

Hi @james-morris,

only AJV knows about the custom $data attribute and knows how to handle it. Therefore it can be used in validation-only use cases, for example in if conditionals. JSON Forms itself canā€™t handle the $data property and therefore this will not work out of the box.

To solve this you can write a custom enum renderer which is triggered when enum contains a $data property. In this custom enum renderer you can then resolve the data to your enum values and then re-use the existing enum renderers.

Specifying "enum": ["item1", "item2", "item3"] works with the default renderer set, so when it doesnā€™t work for you then there is probably something wrong with your custom MaterialArrayControlRenderer.

Hi @sdirix,

Initially a slightly off topic question but somewhat linked to the above. I have certain controls that I completely want to replace. So what iā€™ve tried to do is copy the section of the code that sets up the material renderers and then just comment out the ones I donā€™t want and add my custom replacements and use that set of renderers in my JsonForms component. For example I have a custom text control that replaces MaterialTextControl. The tester I use is the same as the default one. The code for my custom text control was copied and pasted from the MaterialTextControl the only change I made was to debounce the input so that the data was only updated after 1second of inactivity. Now iā€™ve commented out the original MaterialTextControl, my application crashed when I try to look at part of the form that contains a text control. It produces the following error:


When I run the custom text control along side the MaterialTextControl with a slightly higher rank it works fine but increasing the rank on the custom control also seems to result in my custom text control rendering for enumerationsā€¦
If itā€™s any help my custom text control code is:

import React, {useCallback, useState} from 'react';
import {
    ControlProps,
    isStringControl,
    RankedTester,
    rankWith, CellProps, WithClassname
} from '@jsonforms/core';
import {withJsonFormsControlProps, areEqual} from '@jsonforms/react';
import merge from 'lodash/merge';
import Input, {InputProps} from "@material-ui/core/Input";
import {JsonFormsTheme} from "@jsonforms/material-renderers/src/util/index";
import {useTheme} from "@material-ui/core/styles";
import InputAdornment from "@material-ui/core/InputAdornment";
import IconButton from "@material-ui/core/IconButton";
import Close from "@material-ui/icons/Close";
import {InputBaseComponentProps} from "@material-ui/core/InputBase";
import {debounce} from 'lodash';
import {MaterialInputControl} from "@jsonforms/material-renderers";

interface CustomMuiTextInputProps {
    muiInputProps?: InputProps['inputProps'];
    inputComponent?: InputProps['inputComponent'];
}

export const CustomMuiInputText = React.memo((props: CellProps & WithClassname & CustomMuiTextInputProps) => {
    const [showAdornment, setShowAdornment] = useState(false);
    const {
        data,
        config,
        className,
        id,
        enabled,
        uischema,
        isValid,
        path,
        handleChange,
        schema,
        muiInputProps,
        inputComponent
    } = props;
    const [inputText, setInputText] = useState(data || '')
    const maxLength = schema.maxLength;
    const appliedUiSchemaOptions = merge({}, config, uischema.options);
    let inputProps: InputBaseComponentProps;
    if (appliedUiSchemaOptions.restrict) {
        inputProps = { maxLength: maxLength };
    } else {
        inputProps = {};
    }

    inputProps = merge(inputProps, muiInputProps);

    if (appliedUiSchemaOptions.trim && maxLength !== undefined) {
        inputProps.size = maxLength;
    }

    //This was inspired by https://www.freecodecamp.org/news/debounce-and-throttle-in-react-with-hooks/
    const debouncedChange = useCallback(
        debounce((nextInputText: any)=>{handleChange(path, nextInputText)}, 1000),
        []
    )
    const onChange = (ev: any) => {
        setInputText(ev.target.value);
        debouncedChange(ev.target.value);
    }

    const theme: JsonFormsTheme = useTheme();
    const inputDeleteBackgroundColor = theme.jsonforms?.input?.delete?.background || theme.palette.background.default;

    return (
        <Input
            type={
                appliedUiSchemaOptions.format === 'password' ? 'password' : 'text'
            }
            value={inputText}
            onChange={onChange}
            className={className}
            id={id}
            disabled={!enabled}
            autoFocus={appliedUiSchemaOptions.focus}
            multiline={appliedUiSchemaOptions.multi}
            fullWidth={!appliedUiSchemaOptions.trim || maxLength === undefined}
            inputProps={inputProps}
            error={!isValid}
            onPointerEnter={() => setShowAdornment(true) }
            onPointerLeave={() => setShowAdornment(false) }
            endAdornment={
                <InputAdornment
                    position='end'
                    style={{
                        display:
                            !showAdornment || !enabled || data === undefined ? 'none' : 'flex',
                        position: 'absolute',
                        right: 0
                    }}
                >
                    <IconButton
                        aria-label='Clear input field'
                        onClick={() => handleChange(path, undefined)}
                    >
                        <Close style={{background: inputDeleteBackgroundColor, borderRadius: '50%'}}/>
                    </IconButton>
                </InputAdornment>
            }
            inputComponent={inputComponent}
        />
    );
}, areEqual);

export const CustomTextControl = (props: ControlProps) => (
    <MaterialInputControl {...props} input={CustomMuiInputText} />
);

export const customTextControlTester: RankedTester = rankWith(
    1,
    isStringControl
);
export default withJsonFormsControlProps(CustomTextControl);

The reason iā€™ve put this comment in this thread is because I believe whateverā€™s causing my custom text control to error may be linked to what was causing my custom ArrayControlRenderer to fail.

Hmm length is called on the errors prop. Usually this is guaranteed to be a string so the error should not happen. Especially itā€™s strange that it only happens when you remove the normal text control from the renderers. Can you debug into the withJsonFormsControlProps, especially into the mapStateToControlProps and check why errors is not properly calculated?

There is definitely something off here.

How do you construct the renderer set? I donā€™t understand how you ā€œcommented outā€ the original MaterialTextControl. Do you have a full copy of the source code in your build? Do you use a custom build of JSON Forms?

So I created my custom list of renderers doing the following:


import ReferenceControl, {referenceControlTester} from "../controls/ReferenceControl";
import CustomListWithDetailRenderer, {customListWithDetailTester} from "../layouts/CustomListWithDetailRenderer";
import {CustomTextControl, customTextControlTester} from "../controls/CustomTextControl";
import {
    materialAllOfControlTester,
    MaterialAllOfRenderer,
    materialAnyOfControlTester,
    MaterialAnyOfRenderer,
    MaterialAnyOfStringOrEnumControl,
    materialAnyOfStringOrEnumControlTester,
    MaterialArrayControlRenderer,
    materialArrayControlTester,
    MaterialArrayLayout,
    materialArrayLayoutTester,
    MaterialBooleanControl,
    materialBooleanControlTester,
    MaterialBooleanToggleControl,
    materialBooleanToggleControlTester,
    MaterialCategorizationLayout,
    materialCategorizationTester,
    MaterialDateControl,
    materialDateControlTester,
    MaterialDateTimeControl,
    materialDateTimeControlTester, MaterialEnumArrayRenderer,
    materialEnumArrayRendererTester,
    MaterialEnumControl,
    materialEnumControlTester,
    MaterialGroupLayout,
    materialGroupTester,
    MaterialHorizontalLayout,
    materialHorizontalLayoutTester,
    MaterialIntegerControl,
    materialIntegerControlTester,
    MaterialNativeControl,
    materialNativeControlTester,
    MaterialNumberControl,
    materialNumberControlTester,
    materialObjectControlTester,
    MaterialObjectRenderer,
    materialOneOfControlTester,
    MaterialOneOfEnumControl,
    materialOneOfEnumControlTester,
    MaterialOneOfRadioGroupControl,
    materialOneOfRadioGroupControlTester,
    MaterialOneOfRenderer,
    MaterialRadioGroupControl,
    materialRadioGroupControlTester,
    MaterialSliderControl,
    materialSliderControlTester, MaterialTextControl, materialTextControlTester,
    MaterialVerticalLayout,
    materialVerticalLayoutTester
} from "@jsonforms/material-renderers";

import {JsonFormsRendererRegistryEntry} from "@jsonforms/core";
import MaterialCategorizationStepperLayout, {materialCategorizationStepperTester} from "@jsonforms/material-renderers/lib/layouts/MaterialCategorizationStepperLayout";
import {
    MaterialLabelRenderer,
    materialLabelRendererTester, MaterialListWithDetailRenderer,
    materialListWithDetailTester
} from "@jsonforms/material-renderers/lib/additional";

export const renderers: JsonFormsRendererRegistryEntry[] = [
    // controls
    {
        tester: materialArrayControlTester,
        renderer: MaterialArrayControlRenderer
    },
    { tester: materialBooleanControlTester, renderer: MaterialBooleanControl },
    { tester: materialBooleanToggleControlTester, renderer: MaterialBooleanToggleControl },
    { tester: materialNativeControlTester, renderer: MaterialNativeControl },
    { tester: materialEnumControlTester, renderer: MaterialEnumControl },
    { tester: materialIntegerControlTester, renderer: MaterialIntegerControl },
    { tester: materialNumberControlTester, renderer: MaterialNumberControl },
    // { tester: materialTextControlTester, renderer: MaterialTextControl },
    { tester: materialDateTimeControlTester, renderer: MaterialDateTimeControl },
    { tester: materialDateControlTester, renderer: MaterialDateControl },
    { tester: materialSliderControlTester, renderer: MaterialSliderControl },
    { tester: materialObjectControlTester, renderer: MaterialObjectRenderer },
    { tester: materialAllOfControlTester, renderer: MaterialAllOfRenderer },
    { tester: materialAnyOfControlTester, renderer: MaterialAnyOfRenderer },
    { tester: materialOneOfControlTester, renderer: MaterialOneOfRenderer },
    {
        tester: materialRadioGroupControlTester,
        renderer: MaterialRadioGroupControl
    },
    {
        tester: materialOneOfRadioGroupControlTester,
        renderer: MaterialOneOfRadioGroupControl
    },
    { tester: materialOneOfEnumControlTester, renderer: MaterialOneOfEnumControl },
    // layouts
    { tester: materialGroupTester, renderer: MaterialGroupLayout },
    {
        tester: materialHorizontalLayoutTester,
        renderer: MaterialHorizontalLayout
    },
    { tester: materialVerticalLayoutTester, renderer: MaterialVerticalLayout },
    {
        tester: materialCategorizationTester,
        renderer: MaterialCategorizationLayout
    },
    {
        tester: materialCategorizationStepperTester,
        renderer: MaterialCategorizationStepperLayout
    },
    { tester: materialArrayLayoutTester, renderer: MaterialArrayLayout },
    // additional
    { tester: materialLabelRendererTester, renderer: MaterialLabelRenderer },
    {
        tester: materialListWithDetailTester,
        renderer: MaterialListWithDetailRenderer
    },
    {
        tester: materialAnyOfStringOrEnumControlTester,
        renderer: MaterialAnyOfStringOrEnumControl
    },
    {
        tester: materialEnumArrayRendererTester,
        renderer: MaterialEnumArrayRenderer
    },
    // Custom renderers
    { tester: referenceControlTester, renderer: ReferenceControl },
    { tester: customTextControlTester, renderer: CustomTextControl}
]

I donā€™t use a custom build of JSON forms, iā€™m using the following:

    "@jsonforms/core": "^2.5.2",
    "@jsonforms/material-renderers": "^2.5.2",
    "@jsonforms/react": "^2.5.2",
    "@material-ui/core": "^4.7.0",
    "@material-ui/icons": "^4.5.1",
    "@material-ui/lab": "^4.0.0-alpha.56",

Iā€™ll try debug into the two props you mentioned and report back any findings.

Your suggestion of looking into the withJsonFormsControlProps solved my issue!

In my custom set of renderers I wasnā€™t importing the withJsonFormsControlProps(CustomTextControl), I was accidentally importing the CustomTextControl. Iā€™ve since removed the export of the CustomTextControl on itā€™s own and imported the default withJsonFormsControlProps(CustomTextControl).

1 Like

Nice to hear that the issue is now solved :wink:

Thatā€™s definitely getting me a step closer but my original issue still remains unfortunately :frowning: .

So now that I have my CustomTextControl I want this to be the control that is used when I have an array of strings i.e. for my schema object:

				"elements": {
					"description": "This contains information about the elements that form the compound.",
					"type": "array",
					"items": {
						"description": "",
						"type": "string"
					}
				},

What do I need to do to make that happen? Iā€™ve started making a custom MaterialArrayControlRenderer and some custom objects that are used within that but iā€™m not sure if this is the right approach. The most recent custom object iā€™m looking to create is a custom DispatchCell but iā€™m worried iā€™ve gone too far?

Ultimately I want to be able to have my custom control that handles enum: {$data: ā€˜path/to/my/dataā€™} but I think solving the problem for the CustomTextControl will give me an insight into how to solve for the other case.

Letā€™s take a step back: You want to render a list of enum controls. The list is modeled as an array.

By default JSON Forms will render an array in of two ways:

  • It will render a table in case the items is either a primitive or a shallow object
  • It will render a list of sub views in case the items is a more complex object

As your items are modeled as string primitives, the table renderer will be used by default.

In case you like the default table renderer (how it looks, where it places the add button, remove buttons etc.) then you donā€™t need to add a custom renderer for the array. If you then want to customize the string inputs which are rendered inside the table then you need to provide a custom text cell. Cells work very similar to regular renderers, so that should be straightforward.

Now if you donā€™t like the table renderer, then you have two options:

If you use the array layout renderer, each string will be rendered in a separate layout with a regular string control. So to customize the string inputs here, you need to register a normal custom text renderer.

If you want to use a custom array renderer all bets are off as you can customize the items rendering in any way you want:

  • Dispatch to a renderer, or
  • Dispatch to a cell, or
  • Handle all children yourself

Now my guess is that what you want to actually do is to reuse the table renderer and just register a custom special enum cell renderer. Of course if your whole array is special you might want to go the custom array control route. The array layout renderer with a custom text control is most probably not the way to go.

Hi @sdirix

apologies for the confusion. Youā€™re right, I like the table renderer. I have a custom control for my special enum that works when the enum property is one of many properties on a more complex object. Would I need to create a special enum cell renderer that has similar logic for my use case? If so, how do I go about adding additional cell renderers?

Thanks again for all your help! itā€™s much appreciated

I managed to figure it out. I added a custom cell for my special enum case:

import React from 'react';
import {
    CellProps,
    EnumCellProps,
    isEnumControl,
    RankedTester,
    rankWith, schemaMatches,
    WithClassname
} from '@jsonforms/core';
import {useJsonForms, withJsonFormsCellProps, withJsonFormsEnumCellProps} from '@jsonforms/react';
import {MuiSelect} from "@jsonforms/material-renderers";
import {getDataEnumOptions, isDataEnum} from "../utils/dataEnum";

function DataEnumCell(props: CellProps) {
    const ctx = useJsonForms();
    const wholeDataObject = ctx.core!.data;
    const dataEnumOptions = getDataEnumOptions((props.schema.enum as any).$data, wholeDataObject);
    const newProps: EnumCellProps & WithClassname = {...props, options: dataEnumOptions};
    return (
        <MuiSelect {...newProps} />
    );
}

/**
 * Default tester for enum controls.
 * @type {RankedTester}
 */
export const dataEnumCellTester: RankedTester = rankWith(
    1000,
    schemaMatches(isDataEnum)
);

export default withJsonFormsCellProps(DataEnumCell);

and added it to the set of cells:

export const cells: JsonFormsCellRendererRegistryEntry[] = [
    { tester: materialBooleanCellTester, cell: MaterialBooleanCell },
    { tester: materialBooleanToggleCellTester, cell: MaterialBooleanToggleCell },
    { tester: materialDateCellTester, cell: MaterialDateCell },
    { tester: materialEnumCellTester, cell: MaterialEnumCell },
    { tester: materialIntegerCellTester, cell: MaterialIntegerCell },
    { tester: materialNumberCellTester, cell: MaterialNumberCell },
    { tester: materialNumberFormatCellTester, cell: MaterialNumberFormatCell },
    { tester: materialTextCellTester, cell: MaterialTextCell },
    { tester: materialTimeCellTester, cell: MaterialTimeCell },
    // Custom Cells
    { tester: dataEnumCellTester, cell: DataEnumCell},
];

thanks again for your help!

1 Like

Perfect, thatā€™s exactly what I meant!

1 Like