While working on a current project, I was tasked with the following, build a form that can render an infinite number of inputs and an enumerable type of inputs. You should have the freedom to stack these however you like.

I chose to write the template for how the form is built separate from what the form will be made up of. The template builds the same way everytime and expects a configuration file.

The configuration file looks like this,


Basic Configuration

export default [
  [
    {
      key: 'quantity', // used by react to identity the element
      type: 'textField', // enumerable to render a specific input
      display: 'Number:', // the label for the input
      inputProps: { // props that get drilled down to the wrapped component
        variant: 'outlined',
        style: {
          width: '200px',
        },
        placeholder: 'number',
        type: 'number',
      },
      labelProps: { // props that get drilled down to the wrapped component's label
        style: {
          color: 'blue'
        }
      },
    },
  ]
];
    

Result -

As outlined above, the configuration file exports an array by default. Each index in that array represents a new row for the form. But what if I want to stack more than one input in a single row?


Extended Configuration

[
  {
    ...
    type: 'textField', // enumerable to render a specific input
    ...
  },
  {
    ...
    type: 'textField', // enumerable to render a specific input,
    ...
  },
],
    

Result -

NBD! Simply stack objects one after another. But what if the designer gets adventurous and wireframes something that your nested for loop cant handle?


Advanced Configuration

[
  {
    ...
    type: 'textField', // enumerable to render a specific input
    placeholder: "Test1",
    ...
  },
  [
    {
      ...
      type: 'textField', // enumerable to render a specific input,
      placeholder: "Test2",
      ...
    },
    [
      {
        ...
        type: 'textField', // enumerable to render a specific input,
        placeholder: "Test3",
        ...
      },
      [
        {
          ...
          type: 'textField', // enumerable to render a specific input,
          placeholder: "Test4",
          ...
        },
        [
          {
            ...
            type: 'textField', // enumerable to render a specific input,
            placeholder: "Test5",
            ...
          },
          {
            ...
            type: 'textField', // enumerable to render a specific input,
            placeholder: "Test5",
            ...
          },
          {
            ...
            type: 'textField', // enumerable to render a specific input,
            placeholder: "Test5",
            ...
          },
          {
            ...
            type: 'textField', // enumerable to render a specific input,
            placeholder: "Test5",
            ...
          },
        ],
      ],
    ]
  ],
],
    

Result -

Here we have a nightmare of a form, but it's still feasable. The designer wanted 2 columns of inputs. On the right hand side, she requested two inputs. For the input underneath, we had to have 2 more inputs next to it, in a column. Lastly, we were specifically instructed to make sure the input on the bottom of that column has 4 inputs in a row.

If you compared the snippet to the output, you can see that anytime you want to swtich the way the inputs are stacked, you wrap it in a fresh array. Everytime you do this it switches the direction from row to column and vice versa.


The Code

Now that you have an idea on what the function expects, we can see the work in action.

const buildForm = (data, index, jsx, tmp, direction, depth) => {
  if (data[index] === undefined) {
    if (depth === 0) return jsx;
    return tmp;
  }
  else if (Array.isArray(data[index])) {
    const items = buildForm(data[index], 0, jsx, [], switchDirection(direction), depth + 1);
    if (direction === "row") {
      if (depth === 0) {
        jsx.push(<Row key={`${index}-${direction}`}>{[...tmp, ...items]}</Row>);
        tmp = [];
      } else tmp.push(<Row key={`${index}-${direction}`}>{items}</Row>);
    } else {
      if (depth === 0) {
        jsx.push(<Column key={`${index}-${direction}`}>{[...tmp, ...items]}</Column>);
        tmp = [];
      } else tmp.push(<Column key={`${index}-${direction}`}>{items}</Column>);
    }
    return buildForm(data, index + 1, jsx, tmp, direction, depth);
  }
  const newElement = item(data[index]);
  tmp.push(newElement);
  return buildForm(data, index + 1, jsx, tmp, direction, depth);
}
    

Let's look at this piece by piece.


The Parameters

The function accepts 6 parameters.

const buildForm = (data, index, jsx, tmp, direction, depth) => {

data is the configuration array.

index is the current index within data.

jsx is an array of jsx elements that gets built throughout the process. It is what gets ultimately returned.

tmp is an array of jsx elements that gets held so that the order of elements does not get messed up.

direction is which way the elements should be rendered.

depth is a counter that adds 1 everytime an array is found so it knows when its at the root level.


The Base Case

if (data[index] === undefined) {
  if (depth === 0) return jsx;
  return tmp;
}
    

This case is checking to see if the current index within data is undefined. If it is, we do 1 of 2 things. We either return the jsx array or the tmp array. When depth is at the root level (typically 0), we know we are done looking at every item in the array and can finally return jsx. Every other situation, we return tmp so that we can continue to build the form.


Handling Array Logic

else if (Array.isArray(data[index])) {

There are two data types that data[index] can be, an object or an array. This else if block is checking if it's an array.

  const items = renderItems(data[index], 0, jsx, [], switchDirection(direction), depth + 1);
    

The first thing we do to the children arrays is set a variable equal to the return value of calling the function recursively. data[index] is our working array, jsx is our master array containing all of our elements that we're building and depth + 1 is a variable we keep track of to know whether or not we're done traversing our data.

switchDirection(direction) is a function that accepts a string that's either row or column and returns the opposite value. As explained in the base case, items will be an array.


  if (direction === "row") {
    if (depth === 0) {
      jsx.push(<Row key={`${index}-${direction}`}>{[...tmp, ...items]}</Row>);
      tmp = [];
    } else tmp.push(<Row key={`${index}-${direction}`}>{items}</Row>);
  } else {
    if (depth === 0) {
      jsx.push(<Column key={`${index}-${direction}`}>{[...tmp, ...items]}</Column>);
      tmp = [];
    } else tmp.push(<Column key={`${index}-${direction}`}>{items}</Column>);
  }

After items is set, we push it to an array in either a row or a column. The JSX tags for row and column are styled components that contain the CSS property flex-direction of their respective value.


There is a check for each direction to see if the depth is equal to 0 or not. When the depth is equal to 0, we can assume that it's safe to record this row in the master array jsx. When depth isn't equal to 0, we can assume that there might be more elements to draw, so we keep track of it in the tmp array. Agnostic of the depth, we wrap the items returned from the recursive call and the elements within the tmp array and spread them within their appropriate direction.


  return renderItems(data, index + 1, jsx, tmp, direction, depth);
}
    

Now we recursively call the function again and return its value. We keep everything the same except for index. We increment it by 1 so that we can look at the adjacent element, if it exists.


This Is Where The Magic Happens

  const newElement = item(data[index]);
  tmp.push(newElement);
  return renderItems(data, index + 1, jsx, tmp, direction, depth);
}
    

Sorry, no magic. We set the variable newElement equal to be the return value of an item function that we've wrote elsewhere. The return value is an input element based on the configuration passed by the user. Specifically referencing the type field they passed.

We take this value, push it into the tmp array and return it recursively, in the exact same fashion we did above.

That's it! If this reaches you and helps, let me know!