Building out forms ranks as one of the highest priorities in web development. It facilitates user interaction for the broader application. It is your gateway to providing the rest of your application clean data.
It can also be very tedious.
Over the weekend, I began playing with the idea about how I could build forms without needing to write any JSX. I wanted to take a format (like JSON) that could be produced by anyone and use that to generate out the form with validation.
What are the benefits of doing this?
Re-useability: The ability to take from one project to another and reproduce the form output.
Compose-ability: It enables configurations to be combined with ease ie multiple config files being merged for more complex forms.
Upgradeability: It centralizes the point where I can make adjustments in the future.
Flexibility: It is easy to provide JSON format. The endgame is to be very meta and build a form that itself builds forms.
As a fair warning, this project uses my personal (but currently private) design system to create the files.
While the output itself will be custom components, the approach to doing so is translatable to anything thing you want. This includes using the general HTML tags of form, input, etc.
This tutorial is a lot more rough-around-the-edges and scrappy than my others. There is much I could do to clean this up, but I hope that this will show you how I begin projects by being scrappy and validating my goals. I have made adjustments to the code since.
Prerequisites
We are going to be using Deno and Snowpack. I wanted an excuse to try both out as I've heard so many good things! Yes, this is my first time playing around with both. I wasn't so quick on adopting these technologies.
Spoiler alert: both great.
Check the installation guide for Deno to set up for this project as it required, but Mac users can use the trusty Brew install.
Setting up a TypeScript React project with Snowpack
We will set up a basic project for this to run in, but we won't be using it for much other than running our app and seeing the results.
npx create-snowpack-app forms --template @snowpack/app-template-react-typescript
cd forms
yarn start
Once installed, if we run yarn start then it will boot up a dev server with the familiar React starter page!
Perfect. Let's move onto generating the template.
The game plan
You may have seen my previous post on building your own code generator in JavaScript. Generally, I do use that approach or use Hygen, but when quickly mocking up "will this work?" scenarios, I opted to use the power of template strings.
So the plan is this:
Learn how to read and write files with Deno.
Write a reuseable template string that I can test out (for building the forms).
Have this template string build out using my personal Design System components.
Come up with a re-useable JSON schema (which can be subject to change).
Let's get started with a short "Hello, World!" CLI with Deno.
Hello, Deno
At this point, it required a bunch of running through Deno's documentation to find the packages that I needed.
For the sake of brevity, I will leave all the different Deno documentation resources for the end.
I knew what I wanted to be able to do:
Parse arguments given from the command line to provide the config path.
Read that config and use it.
From prior experience, I had an inkling on what to Google for (file readers, command line parsing, etc in Deno) so I went to work and found the parse module and built-in readFileSync from the std library were what I needed.
Let's make a script file and see it in action:
# Where we will keep our script
mkdir bin
touch bin/generate-form.ts
# Where we will search for data
mkdir data
touch data/basic-form.json
echo '{ "hello": "world" }' > bin/data.json
Now we can add six simple lines of code to bin/generate-form.ts:
import { parse } from "https://deno.land/std/flags/mod.ts";
const argv = parse(Deno.args);
const decoder = new TextDecoder("utf-8");
const jsonFile = Deno.readFileSync(argv._[0] as string);
const json = JSON.parse(decoder.decode(jsonFile));
console.log(json);
In this code, we are telling Deno to:
Parse the arguments given when we run the program.
Use a TextDecoder to read the file path that we will give as the first argument (indexed at 0).
Read that file synchronously.
Decode the file contents and assign it to JSON.
Log the JSON.
Now we can run the following:
> deno run --allow-read bin/generate-form.ts data/basic-form.json
{ "hello": "world" }
The --allow-read flag gives Deno read permissions during runtime to allow us to read data/basic-form.json.
As for argv, the values of _ begin after running the program with deno run --allow-read bin/generate-form.ts, so our argument data/basic-form.json becomes the first index of _, hence the argv._[0] being parse to the Deno.readFileSync line.
Let's now move onto the more complex work.
Designing the form schema
A lot of this came from what props I provide my components, but I opted to update data/generate-form.json with something like this:
Here, I decided that I would have a top-level name property for the form and then elements as an array of what I want in the form. I decided to go this way to enable the top-level room for more metadata as I may need it down the track.
As for the structure of each element, I decided that they need an id, name, type and required, but anything else there can be optionally used to help create the validations and other metadata.
The types here relate directly to a component I have in my design system and map the following:
text - <TextInput />
textarea - <TextArea />
date - <DatePicker />
checkbox - <CheckboxGroup />
select - <Select />
radio - <RadioGroup />
Between these six, I have most of what I need for my forms.
Building out the simple form
The following relates to how I ended up writing out the form. As there can be a lot of code, I will paste them in order of what is in the final file and explain a little on each.
If you would like, follow along as I add the code bit-by-bit, but I will leave the final code at the end too.
The imports
import { parse } from "https://deno.land/std/flags/mod.ts";
import { camelCase } from "https://deno.land/x/case/mod.ts";
import {
prettier,
prettierPlugins,
} from "https://denolib.com/denolib/prettier/prettier.ts";
I want to use the name field to help generate components, so I added in camelCase from Deno's case module to help alter the casing when required.
I also adding in the prettier modules to help with formatting the files afterward. We are going to use string interpolation, so it will never be that pretty.
Parsing the file
const argv = parse(Deno.args);
const decoder = new TextDecoder("utf-8");
const jsonFile = Deno.readFileSync(argv._[0] as string);
const json = JSON.parse(decoder.decode(jsonFile));
console.log(json);
As mentioned, I wanted to start the form with string interpolation. A lot of this was identifying the points where I could repeat code (ie the elements, the component name, etc) and abstracted them so I could generate the strings and interpolate them all into one.
It's worth noting that you don't need to copy .replace(/\s/g, "" everywhere like I did. Again, it was simply to get things up and going. All this code is before refinement.
You can read through, but the gist of it is string manipulation based on the JSON files. The types I have above for them Config, FormElement should help understand the properties available to each argument.
These generators create a custom name for my component, add in the components within the JSX, add validation, and are prepped to set up things that work outside of React Hook Form.
I'm not going to delve too deep into React Hook Form, but it was the library that I opted to use. Why? I just went looking through the different options and felt it was the simplest to implement and had performance perfs (controlled input sometimes causes me headaches).
Finally, the last part is essentially calling a "main" function buildConfig that generates a config on the fly by using the helper generator functions, then will finish up by using Prettier to format the code and then write the code out to a path that I desired.
That path "./src/NewForm.tsx" was changed later to become an --output flag.
So it looks like a lot is happening since there is a lot of code, but really it is just one big interpolated string.
The form in action
We can now run our final code using the following:
deno run --allow-read --allow-write bin/generate-form.ts data/basic-form.json
We've added --allow-write to our previous run for permissions.
If successful, we will have a new file src/NewForm.tsx!
Let's update our App.tsx file. Mine looked like so:
import React from "react";
// @ts-ignore
import { alpha as theme } from "@okeeffed/design-system";
import NewForm from "./NewForm";
import "./App.css";
interface AppProps {}
function App({}: AppProps) {
return <NewForm onSubmit={(data: never) => alert(JSON.stringify(data))} />;
}
export default App;
If we hit save, our local environment will reload (incredibly fast thanks to Snowpack) and show us our form!
The basic form output from the generator
The form in action
The form after submitting
If you stopped the Snowpack server before, just run yarn start again from the CLI.
With the auto-generated form, validations, and the onSubmit prop to show use the data, we can now play around with it and see that our code is working as expected.
Victory!
Next steps
This was just a quick recount of how I got things up and running yesterday.
I made some changes later to introduce more complex JSON files that started adding in my other types.
More complex example
Unlike my other tutorials, this is a rough-around-the-edges example of getting things up and going and how you can too! I'll leave you with the final code and resources so I can begin my work week!