Recently, I was tasked with rendering a form dynamically based on input fields gotten from backend. Rendering the form was no problem but integrating the dynamic form with material ui, validation with formik, making it reusable in other components and also formatting the input field which is an amount field was a bit of a conundrum for me. 3 stack overflow searches and a Ben award's video later, I was able to render the form dynamically and add validation to it. I will be outlining the process in this article and hope you enjoy it.
Not to bore you with the superficial details, let's assume i have already installed @material-ui/core, yup, formik to my react app
npm i @material-ui/core yup formik @material-ui/lab
Setting up App.js
I will create a new component named AddFeeModal to encapsulate my form
import AddFeeModal from './AddFeeModal'
export default function App() {
return (
<div className="App">
<AddFeeModal />
</div>
);
}
Making it Reusable
In order to make it reusable, I decided to set up a separate component for it and pass form down to it as props to another component called SetFeeModal . Take note that we can add other form values at the top level here, but I want to keep the logic for the dynamic form separate.
import { useState} from 'react'
import { Button, Modal } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles';
import { useFormik } from 'formik'
import * as yup from 'yup'
import NewSetFeeModal from './NewSetFeeModal'
const useStyles = makeStyles((theme) => ({
modal: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}
}));
const AddFeeModal = () => {
const classes = useStyles();
const [feeModal, setFeeModal] = useState(false);
const openFeeModal = () => setFeeModal(true);
const closeFeeModal = () => setFeeModal(false);
const submitHandler = async val => {
console.log(val);
};
const validationSchema = yup.object({
feeFields: yup.array().of(
yup.object().shape({
value: yup
.number()
.required("The amount is required")
.min(1, "Please enter an amount"),
})
),
});
const formik = useFormik({
initialValues: {
feeFields: [],
},
validationSchema,
onSubmit: submitHandler,
});
return (
<>
<form onSubmit={formik.handleSubmit}>
<Button
color="secondary"
variant="contained"
onClick={openFeeModal} >
Set Fee
</Button>
<pre>
{JSON.stringify(formik.values, null, 2)}
</pre>
</form>
<Modal
open={feeModal}
className={classes.modal}
onClose={closeFeeModal}
width={700}
>
<NewSetFeeModal formik={formik} />
</Modal>
</>
)
}
export default AddFeeModal
Breaking down the code
I imported the need component at the top
- makeStyles to style my material ui modal
- useFormik to instantiate formik
- yup to validate the formik
- NewSetFeeModal to encapsulate the dynamic form
- useStyles to create the styles i need for the modal
- feeModal and setFeeModal to open the modal
- submitHandler to submit our formik form if it is valid
- valdiationSchema: this is where i validate each field of the dynamic array to be rendered
- formik: this is where i instantiate my useFormik with initial values, validationSchema(yup) and submit handler
- Notice the
tag after the button: This is to show the formik values onChange
Setting up the NewSetFeeModal
The next step is to set up the component itself. This is where i encountered another problem. I could have used the push props provided by formik FieldArray, but since the field is dynamic, the title/label will always be different and the I want the user to to be able to search, select and deselect as many field as they want. I decided to use material-ui autocomplete from @material-ui/lab.
Also to mimic the type of data that will be sent from the backend I will create a file for dynamic data called dynamicData.js
const dynamicData = [{id: 1, name: "Legal Fee"}, {id: 2, name: "Agent Commission"}, {id: 3, name: "Caution Deposit"}]
export default dynamicData
Now for setFeeModal Component
import { useEffect, useState } from "react";
import {
FieldArray,
getIn,
FormikProvider,
} from "formik";
import { Autocomplete } from "@material-ui/lab";
import {
Grid,
TextField,
Checkbox,
MenuItem
} from "@material-ui/core";
import { makeStyles } from '@material-ui/core/styles';
import { MdCheckBoxOutlineBlank, MdCheckBox } from "react-icons/md";
import dynamicData from './dynamicData'
const icon = <MdCheckBoxOutlineBlank fontSize="small" />;
const checkedIcon = <MdCheckBox fontSize="small" />;
const useStyles = makeStyles((theme) => ({
paper: {
position: 'absolute',
width: 600,
backgroundColor: theme.palette.background.paper,
border: '2px solid #000',
boxShadow: theme.shadows[5],
padding: theme.spacing(2, 4, 3),
},
}));
const NewSetFeeModal = ({formik }) => {
const classes = useStyles();
let currency = [{code: "1", id: 1, major_symbol: "₦", name: "Naira"}]
const [fields, setFields] = useState([]);
const fetchServiceFees = () => {
let newFields = [...dynamicData].map(item => {
item.value = "";
item.formattedValue = "";
item.currencyField = 1;
return item;
});
setFields(newFields);
};
useEffect(() => {
fetchServiceFees();
}, []);
return (
<>
<div className={classes.paper}>
<h2>Set Fee</h2>
<FormikProvider
value={formik}>
<Grid container spacing={2}>
<FieldArray name="feeFields">
{() => {
return (
<>
<Grid item xs={12}>
<Autocomplete
id="feeFields"
name="feeFields"
multiple
options={fields}
disableCloseOnSelect
size="small"
getOptionLabel={option => option.name}
onChange={(e, value) => {
let oldFields = {};
let newFields = {};
let feeFields = formik.values.feeFields;
feeFields.forEach(item => {
oldFields[item.id] = item;
});
value.forEach(item => {
if (oldFields[item.id]) {
newFields[item.id] = oldFields[item.id];
} else {
newFields[item.id] = item;
}
});
let newArray = Object.values(newFields);
formik.setFieldValue("feeFields", newArray);
}}
renderOption={(option, { selected }) => (
<>
<Checkbox
icon={icon}
checkedIcon={checkedIcon}
style={{ marginRight: 8 }}
checked={selected}
/>
{option.name}
</>
)}
renderInput={params => (
<TextField
{...params}
variant="outlined"
label="Pick Fee"
onChange={formik.handleChange}
onBlur={formik.handleBlur}
value={formik.values.feeFields}
/>
)}
/>
</Grid>
{Array.isArray(formik.values.feeFields) &&
formik.values.feeFields.map((item, id) => {
let name = `feeFields[${id}].name`;
let value = `feeFields[${id}].value`;
let errorMessage = getIn(formik.errors, value);
return (
<>
<Grid key={id} item container spacing={2}>
<Grid item xs={3} sm={3}>
<TextField
name={`feeFields[${id}].currencyField`}
label="Currency"
select
variant="outlined"
size="small"
fullWidth
value={item.currencyField}
>
{currency.map(({ code, major_symbol }) => (
<MenuItem value={code}>{major_symbol}</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={9}>
<TextField
name={name}
label={item.name}
variant="outlined"
size="small"
fullWidth
value={
item.formattedValue.length > 1
? item.formattedValue
: item.value
}
error={errorMessage}
helperText={errorMessage}
onChange={(e , value)=> {
if (
isNaN(parseInt(e.nativeEvent.data)) &&
e.nativeEvent.inputType !==
"deleteContentBackward"
)
return;
let format = formatNumbers(
parseInt(
Number(
e.target.value.replace(/[^0-9.-]+/g, "")
)
)
)
formik.setFieldValue(
`feeFields[${id}].formattedValue`,
format
);
formik.setFieldValue(
`feeFields[${id}].value`,
Number(
e.target.value.replace(/[^0-9.-]+/g, "")
)
);
}}
onBlur={formik.handleBlur}
/>
</Grid>
</Grid>
</>
);
})}
</>
);
}}
</FieldArray>
</Grid>
</FormikProvider>
</div>
</>
);
};
export default NewSetFeeModal;
export const formatNumbers = num => {
return num.toFixed(0).replace(/(\d)(?=(\d{3})+(?!\d))/g, "$1,");
};
Code Breakdown
- I imported the needed component
- useStyles to style the body of the modal
- currency to set the currency field
- setting fields and setFields as an empty array to store the data fetched from the API
- fetchServiceFees as the fake API call function to fetch our data and set it in the fields array
- useEffect with empty array dependency to make the code run once when the component loads
- The Autocomplete to search for fields and select multiple tags
- onChange: Since the value of the autocomplete multiple is an array, it was difficult for me to get the last value added or removed, therefore, I decided to memoize the old value and return it. You may be surprised about the reason for this long function. Because I am not using FieldArray push and remove, using the autocomplete mode will cause a new array of objects to be created each time the user decides to add or remove a field. Consequently, this will clear the field/fields data the user has already entered. To prevent this, I set up that process to store former data in the old fields and return it when adding or removing field(s).
- Name, value, errorMessage: this is to get the value, name, and errors from formik since it is nested
- onChange for value: this is where I format the input field to currency
Our component is ready to be tested.
You can check the working example here
https://codesandbox.io/s/formik-dynamic-form-rendering-forked-tbs0j?file=/src/NewSetFeeModal.js
Thank you for the read.