Criando um formulário dinâmico a partir de um JSON schema

Escrito em 21 de maio de 2021 - 🕒 5 min. de leitura

Há cerca de 2 meses atrás, o Gatsby v3 foi lançado, e eu estava super ansioso para atualizar este blog para v3 e começar a usar os incremental builds, mas “infelizmente” este blog tem muita customização, então fazer a atualização para v3 não vai ser fácil 😢.

Porém o meu projeto open source, Resume Builder, não tinha muitas customizações, então foi tranquilo atualizá-lo para o Gatsby v3 com o comando ncu -u, usando o package npm-check-updates. Então, depois de atualizá-lo para v3, eu meio que me empolguei e decidi implementar alguns novos recursos do meu backlog desde 2018, como o editor de cover letter.

O que é o Resume Builder?

O Resume Builder é um projeto open source gratuito que permite a qualquer pessoa manter e construir facilmente qualquer tipo de currículo usando planilhas ou um JSON como a fonte de dados. Graças a este projeto eu fui contratado para morar no exterior, como já expliquei em outro post desse blog.

Uma das maiores falhas desse projeto é que você já deve ter o arquivo com os dados para o seu currículo, não havia como criar seu próprio currículo do zero.

Formik

Alguns dias atrás, eu estava assistindo uma palestra do Jared Palmer e me convenci totalmente de que deveria testar o Formik, e eu tenho a feature perfeita para isso.

JSON schema

Eu precisava que o formulário fosse dinâmico, para que os usuários pudessem adicionar quantos dados quisessem, e já estou usando o JSON schema do projeto json-resume, por que não usá-lo para criar o formulário programaticamente para mim?

Antes de começar a programar, eu decidi usar meu conhecimento de UML para fazer um diagrama de como isso vai funcionar:

De JSON schema para um formulário
De JSON schema para um formulário

Para o propósito desse post, vamos usar uma versão mais simples do JSON schema do projeto json-resume.

{
  "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"
          }
        }
      }
    }
  }
}

Percorrendo o JSON

Existem 3 tipos de dados diferentes no JSON schema que estou usando, object, array e string, então ele vai ser bem fácil de percorrer.

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

Isso deve ser suficiente para a maioria dos casos em que não há keys repetidas no JSON e nem a necessidade do usuário inserir dados ilimitados.

Usando keys únicas

Agora as coisas começam a ficar um pouco gambiarradas, para garantir que cada campo tenha uma key única, vou acumular as keys de todos os nodes, por exemplo para o objeto {foo: {bar: ''}}, a key do node bar seria foo-bar. Não estou muito orgulhoso disso, mas sempre posso refatorá-lo mais tarde.

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>
        );
      }
    }
  });
};

Adicionando mais campos dinamicamente

Para adicionar campos dinamicamente, decidi criar uma variável local que controlaria quantas vezes cada input deveria ser exibido baseado na key única de cada input.

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>
        );
      }
    }
  });

Depois de colocar esse código em um componente React, eu posso usar-lo assim:

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

O resultado desse código meio confuso fica assim:

Dynamic form with Formik

Você pode ver o código real do formulário no GitHub, e o bom é que da próxima vez que o schema do json-resume mudar, eu não preciso fazer nada yay.

Acesse o site do Resume Builder e veja como o formulário ficou.

Tags:


Publicar um comentário

Comentários

Nenhum comentário.