Making CRUD feel Euphoric with Dynamic Forms - even in GraphQL

Intro

When I first launched

ecoeats

back in February 2020, I chose GraphQL on NestJS as our primary API language. GraphQL was and still is an excellent choice. It gave me the power to quickly build API clients across seven different apps and get them running the latest schema with a single command thanks to

graphql-zeus

. Now, it lets my team quickly build and iterate on front-end features while exposing our large data graph for easy use.

There's one part of using GraphQL, or any other REST API, that isn't talked about enough when it comes to startups - the forms.

We Begin with a Dashboard

It was a requirement from day 1 to be able to see an overview of what went on inside the ecoeats Platform. We needed to know how many orders we had, how many riders were online and whether our merchants had their tablets turned on.

One of the earliest problems I had was the absolute tedium of creating GraphQL endpoints for all of these tasks. Repetitive, uninteresting tables. Create object forms. Edit forms for Rider's birthdays. Each and every one with custom client input validation

and

server side validation. By the start of 2021 we had a shedload of ObjectTypes, InputTypes and Mutations. So much busywork getting in the way of actually solving the hard problems I wanted to get on with.

You want everything to be configurable when needed, but exposing your (highly trained) support and operations staff to your actual database is never quite the right choice.

Ideas for Forms

One option for building forms in a system where a sizeable chunk of the API surface is private is just to use HTML forms loaded from the server, submitting them is easy as they work with a standard <form> tag.

I like this approach, but it would have brought its own problems in terms of theming, infinite lists, interactivity etc.

There just had to be a better way to do this. Then I found

JSON Schema

, and I was in meta-descriptor heaven.

So, What is a Dynamic Form?

The dynamic form I designed is composed of a few key parts. In GraphQL, the specification looks like this:

Many of the forms attributes are specified in GraphQL like a regular object, but the pair of JSON keys,

initialValues

and

schema

are what give Dynamic Forms their magic sauce.

Having a JSONSchema that describes an entity allows you to construct an arbitrary display / input method for any instance of that entity. As a JSONSchema property might define something like the below simple name entry;

You can see how this would map on to describe a single piece of JSON such as;

Using the schema as a building block for entity representation, I set out to create forms and tables out of this schema and some values alone.

Building Dynamic Forms in NestJS

Architecture

Following the principles of NestJS, I went with a decorator based solution for handling and loading forms.

The form loader looks up the initial values for the form given an EntityID, if provided. It also handles some basic authentication checks, like whether the requesting user has certain permissions.

Adding these decorators to a NestJS service causes them to be hooked in to a global

DynamicFormRegistry

(we can discuss this another time) at application bootstrap, allowing NestJS to lookup the correct handler for a given FormID when requested from the client side.

Here's an example of a loader/handler pair in our Charity Handling service:

It may seem risky to save input directly to the database, but don't worry! Because of...

This package has made creating JSON Schemas for various forms super easy. The entities used in the above service had their JSON Schema generated directly from the class, like below:

A bit decorator heavy, eh? Well, this exact class is used for publicly showing customers which Charity they are supporting via GraphQL, so it includes a bunch of @Field decorators alongside the others. If you are looking for a cleaner implementation, an alternative to bogging down your single entity with these fields is map the fields you care about onto a DTO class using

implements Pick<Charity, 'name' | 'logoId'>

and so on.

When we receive a response to the form (via

submitDynamicForm

) class-validator and class-transformer spring into action, converting the input into validated objects. If the validation fails, the user is notified and our handlers never see anything.

Now we have validation rules on all our important class fields, how do we turn this into a really useful form? Well, the folks over at react-jsonschema-form have taken care of the hard work for us. Using a JSON Schema, this library will construct a fully interactive, validated, batteries-included form that users can easily understand.

Even better than that, react-jsonschema-form works with your UI library. We use a skinned version of antd on the backend, which makes the forms look just

gorgeous

.

What's Missing?

At the start, I complained about forms in GraphQL APIs. One other, massive inconvenience when building GraphQL APIs is generic pagination.

GraphQL does not like the idea of you having generic types. You must be completely explicit in your declarations of list types, leading to thousands of copies of:

The equivalent problem exists

A Full Dynamic Form is Born

Make a request to to our API:

Now we have a gorgeous form generated entirely from our DynamicForm endpoint, we pass it in to RJSF and

bam!

This is a screenshot of our Menu Offer creation form that merchants use to create offers for their customers.

Nesting Forms

A challenge we often faced was building more complex forms that included references to other object types. The naive way is to embed a list of options in a form is to abuse the JSON Schema 'Enum' type and shoehorn everything in.

In the above form, clicking on the 'Restrict to specific items' button opens

another nested form

, that allows the user to select up to X number of items using the

same JSONSchema information from the parent form

, including validation options like 'maxItems'.

The dynamic table, along with full pagination, is constructed simply using a

DynamicFormLoader

function that accepts generic pagination paramters. The rendering logic is maintained alongside the form code on the client side. Images, for example, are auto-detected and displayed alongside copyable UUIDs.

Other Reasons to use Dynamic Forms

Sometimes you just don't want parts of your API exposed to your GraphQL schema. For example, we run a service which exports a range of orders with configurable options. Without a system like this, adding such a service to our dashboards would require building custom GraphQL Mutations and Queries and Objects to handle what could be a pair of functions in a dynamic form.

Open Source?

I'm not completely happy with the final API design of the forms, especially the nesting part. If anyone has demand for some

☕️ Made with Intenso