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
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
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;
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)
);
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
);
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);
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;
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:
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)
Thanks for the great post Francesco - really useful for our react native project!