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)