coding

Building a dynamic form from a JSON schema

Written in May 21, 2021 - 🕒 5 min. read

Around 2 months ago, Gatsby v3 was released, and I was already super hyped to upgrade this blog to v3 and start using incremental builds, but “sadly” this blog has way too many customizations, so upgrading it to Gatsby v3 is not an easy feat.

Luckily, my open source project, Resume Builder, didn’t have many customizations, so I was able to upgrade it to Gatsby v3 by simply running ncu -u, a command by npm-check-updates. But then after upgrading it to Gatsby v3 I kind of got carried away and decided to implement some new features, like the cover letter editor that was on my backlog since 2018.

What is Resume Builder?

Resume Builder is a free open-source project that allows anyone to easily maintain and build any kind of resume using a spreadsheet or a JSON file as a data source. Thanks to this project I was hired to live abroad, as I already explained in another blog post.

One of the biggest flaws of this project was that you must already have a data source for your resume, there was no way to create your own resume from scratch.

Formik

A couple of days ago I was watching a talk by Jared Palmer and I was convinced I should try Formik, and I have the perfect feature for it.

JSON schema

I needed the form to be dynamic, so users could add as much work experience as they wanted it to, and since I’m already using the JSON schema from the json-resume project, why not use it to build the form programmatically for me?

Before coding I decided to use my UML knowledge to make a diagram of how this is going to work:

JSON schema to HTML form
JSON schema to HTML form

For this blog post let’s use a simpler version of the JSON schema from the json-resume project.

{
  "work": {
    "type": "array",
    "additionalItems": false,
    "items": {
      "type": "object",
      "additionalProperties": true,
      "properties": {
        "name": {
          "type": "string",
          "description": "e.g. Facebook"
        },
        "location": {
          "type": "string",
          "description": "e.g. Menlo Park, CA"
        },
        "description": {
          "type": "string",
          "description": "e.g. Social Media Company"
        },
        "position": {
          "type": "string",
          "description": "e.g. Software Engineer"
        },
        "url": {
          "type": "string",
          "description": "e.g. http://facebook.example.com",
          "format": "uri"
        },
        "startDate": {
          "$ref": "#/definitions/iso8601"
        },
        "endDate": {
          "$ref": "#/definitions/iso8601"
        },
        "summary": {
          "type": "string",
          "description": "Give an overview of your responsibilities at the company"
        },
        "highlights": {
          "type": "array",
          "description": "Specify multiple accomplishments",
          "additionalItems": false,
          "items": {
            "type": "string",
            "description": "e.g. Increased profits by 20% from 2011-2012 through viral advertising"
          }
        }
      }
    }
  }
}

Traversing the JSON

There are 3 different data types in the JSON schema that I’m using, object, array, and string, so it should be fairly simple to traverse.

const formik = useFormik({});
const getForm = (jsonSchema) => {
  Object.entries(jsonSchema).map(([key, value], index) => {
    switch (value.type) {
      case 'object': {
        return (
          <div>
            <h1>{key}</h1>
            {getForm(value.properties)}
          </div>
        );
      }

      case 'array': {
        return (
          <div>
            {getForm({
              [key]: value.items,
            })}
          </div>
        );
      }

      case 'string': 
      default: {
        return (
          <div>
            <TextField
              key={key}
              fullWidth
              id={key}
              name={key}
              label={key}
              value={formik.values[key]}
              onChange={formik.handleChange}
            />
          </div>
        );
      }
    }
  });
};

const form = getForm(jsonSchema);

That should be enough for most cases when there are not repeating keys in the JSON neither the need for the user to input unlimited data.

Using unique keys

Now things start to get a bit hacky, to make sure every field has a unique key, I will accumulate the keys in the node, for example for the object { foo: { bar: '' } }, the key for the bar node would be foo-bar. I’m not super proud of this, but I can always refactor it later.

const getForm = (jsonSchema, accKey = '') => {
  Object.entries(jsonSchema).map(([key, value], index) => {
    const newAccKey = `${accKey}-${key}`;
    switch (value.type) {
      case 'object': {
        return (
          <div>
            <h1>{key}</h1>
            {getForm(value.properties, newAccKey)}
          </div>
        );
      }

      case 'array': {
        return (
          <div>
            {getForm({
              [key]: value.items,
            }, newAccKey)}
          </div>
        );
      }

      case 'string': 
      default: {
        return (
          <div>
            <TextField
              key={newAccKey}
              fullWidth
              id={newAccKey}
              name={key}
              label={key}
              value={formik.values[newAccKey]}
              onChange={formik.handleChange}
            />
          </div>
        );
      }
    }
  });
};

Adding more fields dynamically

To add fields dynamically I decided to create a local variable that would hold how many times an input should be displayed based on the input unique key.

const [quantitiesObject, setQuantitiesObject] = useState({});
const getForm = (jsonSchema, accKey = '', quantity = 1) =>
  Object.entries(jsonSchema).map(([key, value], index) => {
    let newAccKey = key;
    if (accKey) {
      newAccKey = `${accKey}-${key}`;
    }

    switch (value.type) {
      case 'object': {
        return (
          <div key={key}>
            <h1>{key}</h1>
            {(new Array(quantity).fill(null).map(
              (v, i) => (
                <div key={i}>
                  {getForm(value.properties, `${newAccKey}-${i}`)}
                </div>
              )
            ))}
          </div>
        );
      }

      case 'array': {
        const currQuantity = quantitiesObject[newAccKey] || 1;
        return (
          <div key={key}>
            {(new Array(quantity).fill(null).map((v, i) => (
              <div key={i}>
                {getForm({
                  [key]: value.items,
                }, `${newAccKey}-${i}`, currQuantity)}
              </div>
            )))}
            <div>
              <Button
                onClick={() => {
                  setQuantitiesObject({
                    ...quantitiesObject,
                    [newAccKey]: currQuantity + 1,
                  });
                }}
                color="primary"
                variant="contained"
              >
                {`+ ${key}`}
              </Button>
              {currQuantity > 1 && (
                <Button
                  onClick={() => {
                    setQuantitiesObject({
                      ...quantitiesObject,
                      [newAccKey]: currQuantity - 1,
                    });
                  }}
                  color="secondary"
                  variant="contained"
                >
                  {`- ${key}`}
                </Button>
              )}
            </div>
          </div>
        );
      }

      case 'string':
      default: {
        return (
          <div key={key}>
            {(new Array(quantity).fill(null).map(
              (v, i) => {
                const newKey = `${newAccKey}-${i}`;

                return (
                  <TextField
                    key={newKey}
                    fullWidth
                    id={newKey}
                    name={newKey}
                    label={key}
                    value={formik.values[newKey]}
                    onChange={formik.handleChange}
                  />
                );
              }
            ))}
          </div>
        );
      }
    }
  });

After wrapping this code into a React component, I can use it like this:

<DynamicForm
    schema={jsonSchema}
    formik={formik}
/>

The result of this messy code looks like the following:

Dynamic form with Formik

You can check the actual code for the form on GitHub, and the nice thing is that next time the json-resume schema changes, I don’t have to do anything.

Try the form yourself on the Resume Builder website.

Tags:


Post a comment

Comments

No comments yet.