DEV Community

Cover image for Custom ArrayLayout with React and JsonForms
Francesco Maida
Francesco Maida

Posted on

Custom ArrayLayout with React and JsonForms

As I started building forms with React and JsonForms library, I found myself in the need of a custom layout for rendering array of items with eventually nested arrays, without all the whistles and bells of the default material-renderers. After some digging in the source code I was able to come out with a custom array layout with my personal style. I will share in this post how to structure your project in order build great custom JsonForms components.

Getting started

let's create a demo project for our needs. Open a terminal and type:

$ npx create-react-app my-app --template typescript
Enter fullscreen mode Exit fullscreen mode

then install needed dependencies:

$ npm install --save @jsonforms/core
$ npm install --save @jsonforms/react
$ npm install --save @jsonforms/material-renderers

$ npm install --save @material-ui/core
$ npm install --save @material-ui/icons
Enter fullscreen mode Exit fullscreen mode

Components

Let's prepare the data to be fed to JsonForms:
src/components/PersonData.ts


const Person = {
  schema: {
    type: "object",
    properties: {
      people: {
        type: "array",
        title: "People",
        items: {
          type: "object",
          properties: {
            name: {
              type: "string",
              minLength: 3,
              description: "Please enter your name",
            },
            vegetarian: {
              type: "boolean",
            },
            birthDate: {
              type: "string",
              format: "date",
            },
            nationality: {
              type: "string",
              oneOf: [
                {
                  const: "DE",
                  title: "German",
                },
                {
                  const: "IT",
                  title: "Italian",
                },
                {
                  const: "JP",
                  title: "Japanese",
                },
                {
                  const: "US",
                  title: "North-American",
                },
                {
                  const: "RU",
                  title: "Russian",
                },
              ],
            },
            personalData: {
              type: "object",
              properties: {
                age: {
                  type: "integer",
                  description: "Please enter your age.",
                },
                height: {
                  type: "number",
                },
                drivingSkill: {
                  type: "number",
                  maximum: 10,
                  minimum: 1,
                  default: 7,
                },
              },
              required: ["age", "height"],
            },
            occupation: {
              type: "string",
            },
            postalCode: {
              type: "string",
              maxLength: 5,
            },
            items: {
              type: "array",
              title: "items",
              uniqueItems: true,
              errorMessage: {
                uniqueItems: "Items must be unique",
              },
              maxItems: 3,
              items: {
                type: "object",
                properties: {
                  name: {
                    type: "string",
                    enum: ["Type 1", "Type 2", "Type 3"],
                  },
                  price: {
                    type: "number",
                    maximum: 10,
                    minimum: 1,
                    default: 1,
                  },
                },
              },
            },
          },
          required: ["occupation", "nationality"],
        },
      },
    },
  },
  uischema: {
    type: "VerticalLayout",
    elements: [
      {
        type: "Control",
        scope: "#/properties/people",
        options: {
          detail: {
            type: "VerticalLayout",
            elements: [
              {
                type: "Label",
                text: "Person Info",
              },
              {
                type: "HorizontalLayout",
                elements: [
                  {
                    type: "Control",
                    scope: "#/properties/name",
                  },
                  {
                    type: "Control",
                    scope: "#/properties/personalData/properties/age",
                  },
                  {
                    type: "Control",
                    scope: "#/properties/birthDate",
                  },
                ],
              },
              {
                type: "Label",
                text: "Additional Information",
              },
              {
                type: "HorizontalLayout",
                elements: [
                  {
                    type: "Control",
                    scope: "#/properties/personalData/properties/height",
                  },
                  {
                    type: "Control",
                    scope: "#/properties/nationality",
                  },
                  {
                    type: "Control",
                    scope: "#/properties/occupation",
                    suggestion: [
                      "Accountant",
                      "Engineer",
                      "Freelancer",
                      "Journalism",
                      "Physician",
                      "Student",
                      "Teacher",
                      "Other",
                    ],
                  },
                ],
              },
              {
                type: "Label",
                text: "Items",
              },
              {
                type: "Control",
                scope: "#/properties/items",
                options: {
                  detail: {
                    type: "VerticalLayout",
                    elements: [
                      {
                        type: "Label",
                        text: "Item Info",
                      },
                      {
                        type: "Control",
                        scope: "#/properties/price",
                      },
                      {
                        type: "Control",
                        scope: "#/properties/name",
                      },
                    ],
                  },
                },
              },
            ],
          },
        },
      },
    ],
  },
  data: {
    people: [
      {
        name: "Dummy Name",
        vegetarian: false,
        birthDate: "1987-01-10",
        personalData: {
          age: 28,
        },
        postalCode: "87100",
        items: [
          {
            price: 1,
            name: "Item 1",
          },
        ],
      },
    ],
  },
};

export default Person;
Enter fullscreen mode Exit fullscreen mode

Notice that this example exports an object with the three items required by JsonForms: data, schema, uischema. Please take a moment to notice that our data will hold an array of people, each one containing a nested array of items.

Customizing the renderers

Now it is time to get our hands dirty with some custom renderers fiddling, and here is where it all came to blood dripping while digging into JsonForms source code, as this is not documented in the docs.

In order to add custom renderers, cells, layouts and so on it is required to provide two items: the component and its tester.
We are ready to build the component, let's prepare the
ArrayLayout.tsx file under src/ui/Layouts/ArrayLayout.tsx

export const ArrayLayoutRenderer = ({
  visible,
  enabled,
  id,
  uischema,
  schema,
  label,
  rootSchema,
  renderers,
  cells,
  data,
  path,
  errors,
  uischemas,
  addItem,
  removeItems,
}: ArrayLayoutProps) => {
  const addItemCb = useCallback(
    (p: string, value: any) => {
      return addItem(p, value);
    },
    [addItem]
  );

  const removeItemsCb = useCallback(
    (path: string, toDelete: number[]) => {
      return removeItems ? removeItems(path, toDelete) : () => {};
    },
    [removeItems]
  );

  const toRender = Array(data)
    .fill(0)
    .map((_, i) => {
      return (
        <CardRenderer
          key={i}
          index={i}
          schema={schema}
          uischema={uischema}
          path={composePaths(path, `${i}`)}
          renderers={renderers}
          cells={cells}
          onRemove={removeItemsCb(path, [i])}
        />
      );
    });
  return (
    <div>
      <button onClick={addItemCb(path, createDefaultValue(schema))}>Add</button>
      {toRender}
      <div></div>
    </div>
  );
};

export default React.memo(
  withJsonFormsArrayLayoutProps(ArrayLayoutRenderer),
  (prevProps, props) => areEqual(prevProps, props)
);

Enter fullscreen mode Exit fullscreen mode

Let's check this component. Using the Higher order component withJsonFormsArrayLayoutProps we wrap our custom layout component with JsonForms props like data, schema, ui schema and so on. Speaking of data, this prop contains the number of items in the array, hence we can use this information to create an array fill it with zeros and iterate over it to create a list of CardRenderer components. In the end, we are simply rendering an array of items and a button with the handler to add a new empty item to the collection.

Before diving into CardRenderer component, we need to provide a tester for our ArrayLayoutRenderer, this gives a priority value to JsonForms and it will be used to choose the correct renderer to use. So, let's add the following to ArrayLayout.tsx:

export const arrayLayoutTester: RankedTester = rankWith(
  5,
  isObjectArrayWithNesting
);
Enter fullscreen mode Exit fullscreen mode

In this example I use 5 as rank value which is fairly sure to have higher priority over built-in renderers. Consider to use different ranks if you have multiple custom renderers or layouts.

I am using my ArrayLayout.tsx to render a list of CardRenderer Items which are defined as follow in the CardRenderer.tsx file:

interface DispatchPropsOfCardRenderer {
  onRemove(): () => void;
}

interface CardRendererProps extends LayoutProps, DispatchPropsOfCardRenderer {
  index: number;
}

export const CardRenderer = (props: CardRendererProps) => {
  const { uischema, schema, path, renderers, cells, onRemove } = props;
  const elements = uischema.options?.["detail"]["elements"];
  const itemsToRender = elements.map((element: any, index: number) => {
    return (
      <ResolvedJsonFormsDispatch
        schema={schema}
        uischema={element}
        path={path}
        enabled={true}
        renderers={renderers}
        cells={cells}
        key={index}
      />
    );
  });
  return (
    <Card>
      {itemsToRender}
      <button onClick={onRemove}>Remove</button>
    </Card>
  );
};

const withContextToCardRenderd =
  (
    Component: ComponentType<CardRendererProps>
  ): ComponentType<CardRendererProps> =>
  ({ ctx, props }: JsonFormsStateContext & CardRendererProps) => {
    return <Component {...props}/>;
  };

const withCustomProps = (Component: ComponentType<CardRendererProps>) => {
  return withJsonFormsContext(
    withContextToCardRenderd(
      React.memo(Component, (prevProps, props) => areEqual(prevProps, props))
    )
  );
};

export default withCustomProps(CardRenderer);

Enter fullscreen mode Exit fullscreen mode

Again we use some higher order components to provide JsonForms props to our component along with the onRemove handler that can be used to remove items from the array.
In the end, what this component does is to simply render the form items according to the schema and uischema delegating the actual rendering to ResolvedJsonFormsDispatch. Before doing this, I wrap the component with a Card component which simply provides some styling to it (I won't discuss this as this is out of the scope of this post), and I add the Remove button which fires the onRemove handler.

Wrapping up

We are ready to use our custom layout, let's prepare a container component to host our JsonForm. Let's create the file:
src/components/FormContainer/FormContainer.tsx

import {
  materialCells,
  materialRenderers,
} from "@jsonforms/material-renderers";
import { JsonForms } from "@jsonforms/react";
import { useState } from "react";

import Person from "../PersonData";
import ArrayLayout, {
  arrayLayoutTester,
} from "../UI/Layouts/PeopleControl/ArrayLayout";
import classes from "./FormContainer.module.css";

const renderers = [
  ...materialRenderers,
  //register custom renderers
  { tester: arrayLayoutTester, renderer: ArrayLayout },
];

const FormContainer = () => {
  const [data, setData] = useState(Person.data);
  const value = JSON.stringify(data, null, 2);
  return (
    <div className={classes.Container}>
      <div className={classes.Box}>
        <pre style={{ textAlign: "left" }}>{value}</pre>
      </div>
      <div className={classes.Side}>
        <JsonForms
          schema={Person.schema}
          uischema={Person.uischema}
          data={data}
          renderers={renderers}
          cells={materialCells}
          onChange={({ data }) => setData(data)}
        />
      </div>
    </div>
  );
};

export default FormContainer;

Enter fullscreen mode Exit fullscreen mode

We register the Material Renderers along with our custom Renderers and then render the form.

This FormContainer component will display the form panel side-by-side with a preview of the actual-data gathered:

image

That's it! Now you are ready to build your own layouts and renderers. You can find this example on this repository

Top comments (1)

Collapse
 
oliechan profile image
Olie

Thanks for the great post Francesco - really useful for our react native project!