Custom ArrayLayout with React and JsonForms
Tags: react, jsonforms, javascript, typescript
Published: 2021-07-06 13:38:36

As I started building forms with [React](https://it.reactjs.org/) and [JsonForms](https://jsonforms.io/) 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:

```bash
$ npx create-react-app my-app --template typescript
```
then install needed dependencies:

```bash
$ 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
```

###Components

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

```typescript

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;
```

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`


```typescript
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)
);

```

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`:

```typescript
export const arrayLayoutTester: RankedTester = rankWith(
  5,
  isObjectArrayWithNesting
);
```
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:


```typescript
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);

```

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`

```typescript
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;

```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0wx0yakic93dbpi7gatr.png)


That's it! Now you are ready to build your own layouts and renderers. You can find this example on [this repository](https://github.com/edge33/jsonforms-custom-array-demo)