Intro
Welcome to part 2 of building custom form fields. If you haven't completed part 1 I recommend going through that first before diving into this, but I've supplied all the code required to get this working. You can also check out a working repo from my Github.
Now let's create an array field for our form. The example is going to be a contact form to submit an issue. But what if they had multiple issues to submit? Let's improve the UX by allowing the customer to submit up to 4 issues at a time in a single (dynamic) form submission.
Create a Payload Website
To follow along you can load up a brand new payload website template.
pnpm dlx create-payload-app
Follow the prompts to create a website project. I use mongodb because I find it the simplest when making changes to the schema. Log into the admin panel, seed the dashboard, go to the pages collection, and add a form block to the top of the home page. This form comes with a text, email, number, and textarea field. Let's add a date picker and an array field.
Update admin config
Array Block
Lets start by creating a new Block. Navigate to src/blocks/Form
where the he pre-made form fields are. Create a file called blocks.ts
. In this file we need to create the config for the Array block. We are going to reuse the same name, label, required, text, textArea, number, and width fields from the Payload repo. I just copy/pasted from there to maintain consistency in our custom field. I also used our dateOfBirth field from part 1 and renamed it to datePicker for our contact form. This is a giant wall of code but it's rather simple Payload config.
import type { Field, Block } from 'payload' export const name: Field = { name: 'name', type: 'text', label: 'Name (lowercase, no special characters)', required: true, } export const label: Field = { name: 'label', type: 'text', label: 'Label', localized: true, } export const required: Field = { name: 'required', type: 'checkbox', label: 'Required', } export const width: Field = { name: 'width', type: 'number', label: 'Field Width (percentage)', } export const TextArea: Block = { slug: 'textarea', fields: [ { type: 'row', fields: [ { ...name, admin: { width: '50%', }, }, { ...label, admin: { width: '50%', }, }, ], }, { type: 'row', fields: [ { ...width, admin: { width: '50%', }, }, { name: 'defaultValue', type: 'text', admin: { width: '50%', }, label: 'Default Value', localized: true, }, ], }, required, ], labels: { plural: 'Text Area Fields', singular: 'Text Area', }, } const Number: Block = { slug: 'number', fields: [ { type: 'row', fields: [ { ...name, admin: { width: '50%', }, }, { ...label, admin: { width: '50%', }, }, ], }, { type: 'row', fields: [ { ...width, admin: { width: '50%', }, }, { name: 'defaultValue', type: 'number', admin: { width: '50%', }, label: 'Default Value', }, ], }, required, ], labels: { plural: 'Number Fields', singular: 'Number', }, } const Text: Block = { slug: 'text', fields: [ { type: 'row', fields: [ { ...name, admin: { width: '50%', }, }, { ...label, admin: { width: '50%', }, }, ], }, { type: 'row', fields: [ { ...width, admin: { width: '50%', }, }, { name: 'defaultValue', type: 'text', admin: { width: '50%', }, label: 'Default Value', localized: true, }, ], }, required, ], labels: { plural: 'Text Fields', singular: 'Text', }, } const Email: Block = { slug: 'email', fields: [ { type: 'row', fields: [ { ...name, admin: { width: '50%', }, }, { ...label, admin: { width: '50%', }, }, ], }, width, required, ], labels: { plural: 'Email Fields', singular: 'Email', }, } export const DatePicker: Block = { slug: 'datePicker', fields: [ { type: 'row', fields: [ { ...name, admin: { width: '50%', }, }, { ...label, admin: { width: '50%', }, }, ], }, { type: 'row', fields: [ { ...width, admin: { width: '50%', }, }, { name: 'defaultValue', type: 'text', label: 'Default Value', admin: { width: '50%', }, }, ], }, required, ], labels: { plural: 'Date pickers', singular: 'Date picker', }, } export const ArrayBlock: Block = { slug: 'array', fields: [ { type: 'row', fields: [ { ...name, admin: { width: '33%', }, }, { name: 'label', type: 'text', label: 'Label Plural', required: true, admin: { width: '33%', }, }, { name: 'labelSingular', type: 'text', label: 'Label Singular', required: true, admin: { width: '33%', }, }, ], }, { type: 'row', fields: [], }, { type: 'row', fields: [ { ...width, defaultValue: 100, admin: { width: '33%', }, }, { name: 'minRows', type: 'number', label: 'Minimum Rows', required: true, defaultValue: 1, admin: { width: '33%', }, }, { name: 'maxRows', type: 'number', label: 'Maximum Rows', required: true, defaultValue: 4, admin: { width: '33%', }, }, ], }, { type: 'blocks', name: 'fields', label: 'Fields', blocks: [Text, TextArea, Number, Email, DatePicker], }, ], }
Add Block to Plugin Config
Paul has the plugin configs in src/plugins/index
. Simply add the block to the fields like this:
formBuilderPlugin({ fields: { payment: false, datePicker: DatePicker, array: ArrayBlock, }, // ... })
Test out the field in Admin Panel
It's going feel like we're already halfway there because we can now go to the form in the admin panel and add our new custom fields. Fill out the name and label, I'm using date and issues as the names. We can save but we don't see anything on the frontend yet.
Frontend Field Components
Date Picker
Type for DatePicker
Just need to make some small changes to our custom component from part 1. First we update the type.
// src/blocks/Form/DatePicker/type.ts export interface DatePickerField { blockName?: string blockType: 'datePicker' defaultValue?: string label?: string name: string required?: boolean width?: number }
Shadcn ui components
Make sure you have the calendar and popover shadcn components.
pnpm dlx shadcn@latest add calendar popover
Remember we also updated our Calendar component for select fields for month and year. This isn't required for this form, but we'll reuse it anyway. So make sure your calendar component matches mine.
// src/components/ui/calendar.tsx "use client" import * as React from "react" import { ChevronLeft, ChevronRight } from "lucide-react" import { DayPicker } from "react-day-picker" import { cn } from "@/utilities/ui" import { buttonVariants } from "@/components/ui/button" export type CalendarProps = React.ComponentProps<typeof DayPicker> function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) { return ( <DayPicker showOutsideDays={showOutsideDays} className={cn("p-3", className)} classNames={{ months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", month: "space-y-4", caption: "flex justify-center pt-1 relative items-center", caption_label: "text-sm font-medium", nav: "space-x-1 flex items-center", nav_button: cn( buttonVariants({ variant: "outline" }), "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" ), nav_button_previous: "absolute left-1", nav_button_next: "absolute right-1", table: "w-full border-collapse space-y-1", head_row: "flex", head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]", row: "flex w-full mt-2", cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20", day: cn( buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100" ), day_range_end: "day-range-end", day_selected: "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", day_today: "bg-accent text-accent-foreground", day_outside: "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", day_disabled: "text-muted-foreground opacity-50", day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground", day_hidden: "invisible", ...classNames, }} components={{ IconLeft: ({ className, ...props }) => ( <ChevronLeft className={cn("h-4 w-4", className)} {...props} /> ), IconRight: ({ className, ...props }) => ( <ChevronRight className={cn("h-4 w-4", className)} {...props} /> ), }} {...props} /> ) } Calendar.displayName = "Calendar" export { Calendar }
DatePicker component
Make sure this is reflected in the component.
// src/blocks/Form/DatePicker/index.tsx import type { DatePickerField } from './type' import type { Control, FieldErrorsImpl, FieldValues } from 'react-hook-form' import { Label } from '@/components/ui/label' import React from 'react' import { Controller } from 'react-hook-form' import { CalendarIcon } from 'lucide-react' import { format } from 'date-fns' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Button } from '@/components/ui/button' import { Calendar } from '@/components/ui/calendar' import { cn } from '@/utilities/ui' import { Error } from '../Error' import { Width } from '../Width' export const DatePicker: React.FC< DatePickerField & { control: Control<FieldValues, any> errors: Partial< FieldErrorsImpl<{ [x: string]: any }> > } > = ({ name, control, errors, label, required, width, defaultValue }) => { const [open, setOpen] = React.useState(false) return ( <Width width={width}> <Label htmlFor={name}>{label}</Label> <Controller control={control} defaultValue={defaultValue} name={name} shouldUnregister // this is very important for state consistency when used in an array render={({ field }) => ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <Button variant="outline" className={cn( 'w-full pl-3 text-left font-normal', !field.value && 'text-muted-foreground', )} > {field.value ? format(field.value, 'dd/MM/yyyy') : <span>Pick a date</span>} <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> </Button> </PopoverTrigger> <PopoverContent className="w-auto p-0"> <Calendar mode="single" selected={field.value} onSelect={(date) => { field.onChange(date) setOpen(false) }} disabled={(date) => date > new Date() || date < new Date('1900-01-01')} initialFocus captionLayout="dropdown-buttons" fromYear={1950} toYear={new Date().getFullYear()} classNames={{ dropdown: 'rdp-dropdown bg-card rounded-md border !px-2', dropdown_icon: 'ml-2', dropdown_year: 'rdp-dropdown_year ml-3', dropdown_month: '', }} /> </PopoverContent> </Popover> )} rules={{ required }} /> <div className="min-h-[24px]">{required && errors[name] && <Error />}</div> </Width> ) }
Array
Types for Array Component
Let's create the types.
// src/blocks/Form/Array/types.ts import { BlockConfig } from '@payloadcms/plugin-form-builder/types' export type ArrayEntryField = { blockType: 'datePicker' | 'textArea' name: string label: string required?: boolean width?: number } export interface ArrayBlockConfig extends BlockConfig { blockType: 'array' name: string label: string labelSingular: string minRows: number maxRows: number width?: number fields: ArrayEntryField[] }
Array Component
This is our main Array component. Inside it we are using the ArrayField components. We are also using motion for smooth animations.
// src/blocks/Form/Array/index.tsx 'use client' import React, { useEffect } from 'react' import { useFieldArray, useFormContext } from 'react-hook-form' import { ArrayField } from './ArrayField' import { Button } from '@/components/ui/button' import { CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Plus } from 'lucide-react' import { ArrayBlockConfig } from './types' import { motion, AnimatePresence } from 'motion/react' import { cn } from '@/utilities/ui' export const Array: React.FC<ArrayBlockConfig> = (props) => { const { label, maxRows = 10, minRows = 0, name } = props const { register, control, formState: { errors }, } = useFormContext() const { fields, append, remove } = useFieldArray({ control, name: name, shouldUnregister: true, }) useEffect(() => { if (minRows > 0 && fields.length === 0) { append({}) } }, [append, fields.length, minRows]) return ( <div> <CardHeader className="flex flex-row items-center justify-between px-0"> <CardTitle>{label}</CardTitle> </CardHeader> <CardContent className="flex flex-col gap-4 px-0"> <AnimatePresence initial={false} mode="sync"> {fields.map((field, index) => ( <motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0, transition: { duration: 0.3 }, }} layout transition={{ duration: 0.3 }} key={field.id} className="rounded-lg border p-4" > <ArrayField index={index} register={register} errors={errors} {...props} control={control} remove={remove} currentRows={fields.length} /> </motion.div> ))} </AnimatePresence> <Button type="button" size="icon" className={cn( 'size-7 rounded-full bg-gray-400 transition-opacity duration-300 hover:bg-gray-500', { 'pointer-events-none opacity-0': fields.length >= maxRows, 'opacity-100': fields.length < maxRows, }, )} onClick={() => append({})} > <Plus className="h-4 w-4 text-black" /> </Button> </CardContent> </div> ) }
Install motion
We should make this a smooth animation when adding and removing fields, so lets install the motion package
pnpm i motion
Array field
Each input field in the array field needs to be handled in this component.
// src/blocks/Form/Array/ArrayField.tsx import React from 'react' import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form' import { CardDescriptionDiv } from '@/components/ui/card' import type { ArrayEntryField } from './types' import { Button } from '@/components/ui/button' import { Trash2 } from 'lucide-react' import { cn } from '@/utilities/ui' import { DatePicker } from '../DatePicker' import { Textarea } from '../Textarea' interface ArrayFieldsProps { index: number name: string fields: ArrayEntryField[] labelSingular: string label: string errors: Partial<FieldErrorsImpl<{ [x: string]: any }>> register: UseFormRegister<FieldValues> control: any remove: (index: number) => void minRows: number currentRows: number } type FieldComponentType = ArrayEntryField['blockType'] const fieldComponents: Record<FieldComponentType, React.ComponentType<any>> = { datePicker: DatePicker, textArea: Textarea, } as const export const ArrayField: React.FC<ArrayFieldsProps> = ({ index, fields, register, name, errors, labelSingular, control, remove, minRows, currentRows, }) => { const renderField = (fieldItem: ArrayEntryField, fieldIndex: number) => { const Field = fieldComponents[fieldItem.blockType] if (Field) { const fieldName = `${name}[${index}].${fieldItem.name}` const wrappedErrors = { [fieldName]: (errors?.[name] as any)?.[index]?.[fieldItem.name], } return ( <Field {...(fieldItem as any)} name={fieldName} control={control} errors={wrappedErrors} register={register} /> ) } return null } return ( <div className=""> <CardDescriptionDiv className="flex items-center justify-between"> {labelSingular} {index + 1} <Button type="button" variant="ghost" size="icon" className={cn('size-7 rounded-full transition-opacity hover:bg-red-100', { 'pointer-events-none opacity-0': currentRows <= minRows, 'opacity-100': currentRows > minRows, })} onClick={() => remove(index)} > <Trash2 className="size-4 text-red-700 hover:text-red-900" /> </Button> </CardDescriptionDiv> <div className="flex flex-wrap gap-x-4 gap-y-2"> {fields.map((fieldItem, fieldIndex) => ( <React.Fragment key={fieldIndex}>{renderField(fieldItem, fieldIndex)}</React.Fragment> ))} </div> </div> ) }
fields.tsx
We need to update the map function to include our DatePicker and Array components.
// src blocks/Form/fields.tsx import { Checkbox } from './Checkbox' import { Country } from './Country' import { DatePicker } from './DatePicker' import { Email } from './Email' import { Message } from './Message' import { Number } from './Number' import { Select } from './Select' import { State } from './State' import { Text } from './Text' import { Textarea } from './Textarea' import { Array } from './Array' export const fields = { checkbox: Checkbox, country: Country, email: Email, message: Message, number: Number, select: Select, state: State, text: Text, textarea: Textarea, datePicker: DatePicker, array: Array }
Form Submission
We are going to make our lives easier by using a json field for our data. There will be a couple steps, but this also makes it incredibly easy to use a custom component for form submissions if we want later.
onSubmit()
in the main form component we need to update the onSubmit() to post are data to submissionData. Also use the email field for our title.
// src/blocks/Component.tsx // ...beginning of file const onSubmit = useCallback( (data: FormFieldBlock[]) => { let loadingTimerID: ReturnType<typeof setTimeout> const submitForm = async () => { setError(undefined) // delay loading indicator by 1s loadingTimerID = setTimeout(() => { setIsLoading(true) }, 1000) try { // @ts-expect-error const title = data.email ? data.email : new Date().toISOString() const req = await fetch(`${getClientSideURL()}/api/form-submissions`, { body: JSON.stringify({ title, form: formID, submissionData: data, }), headers: { 'Content-Type': 'application/json', }, method: 'POST', }) const res = await req.json() // ...rest of function void submitForm() }, [router, formID, redirect, confirmationType], ) // ...rest of file
formS
Last we need to update the config for the form submission collection. We are grabbing the name field and adding a title and json data.
formSubmissionOverrides: { admin: { useAsTitle: 'title', }, fields: ({ defaultFields }) => { const formField = defaultFields.find((field) => 'name' in field && field.name === 'form') return [ ...(formField ? [formField] : []), { name: 'title', type: 'text', }, { name: 'submissionData', type: 'json', }, ] }, },
Conclusion
There is a lot of code and files to get this working. On top of that it required a good understanding of react-hook-forms and the motion library. Quite a few gotchas along the way. Maybe someone can help with he month/year drop down text color being too light in dark mode.
Super easy to add a custom component from here for the form submissions. What I want to improve is the fact I don't know the names of the fields a head of time to make it truly dynamic. Let me know your thoughts.
Top comments (0)