Intro
Many people have asked how to create a new custom field with the form builder plugin with Payload CMS. I've made a couple and wanted to share the steps because there's a lot of them. I made a simple Birthdate field, and a complex Array field. Let's start simple.
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, goto 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 of birth field.
Update admin config
DateOfBirth Block
Lets start by creating a new Block. The pre-made form fields are in src/blocks/Form
. Create a file called blocks.ts
. In this file we need to create the config for the DateOfBirth block. We are going to reuse the same name, label, required, and width fields from the Payload repo. I just copy/pasted from there to maintain consistency in our custom field.
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 DateOfBirth: Block = {
slug: 'dateOfBirth',
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 of Birth Fields',
singular: 'Date of Birth',
},
}
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,
dateOfBirth: DateOfBirth,
},
// ...
})
Use 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 field. Fill out the name and label, I use dob
and Birthdate
. We can save but we don't see anything on the frontend yet.
Frontend Field Component
React Component
Lets add the directory and file src/blocks/Form/DateOfBirth/index.tsx
. The shadcn Date Picker doesn't come with a select for month and year, but I found some mods people came up with from here. First install the calendar and popover from shadcn.
pnpm dlx shadcn@latest add calendar popover
There are some updates to the Calendar component that's used in the date picker.
// src/components/ui/calender.tsx
"use client"
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import "react-day-picker/dist/style.css"
import { cn } from "@/utilities/cn"
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: "flex items-center text-sm font-medium",
dropdown: "rdp-dropdown bg-card p-2 rounded-lg border",
dropdown_icon: "ml-2",
dropdown_year: "rdp-dropdown_year ml-3",
button: "",
button_reset: "",
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: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }
Now the actual date picker component for the date of birth field.
// src/blocks/Form/DateOfBirth/index.tsx
import type { DateOfBirthField } 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 DateOfBirth: React.FC<
DateOfBirthField & {
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}
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, 'MM/dd/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>
)
}
I also put the type in its own file
// src/blocks/Form/DateOfBirth/type.ts
export interface DateOfBirthField {
blockName?: string
blockType: 'dateOfBirth'
defaultValue?: string
label?: string
name: string
required?: boolean
width?: number
}
Update fields.ts
The map function needs to include our new component.
import { Checkbox } from './Checkbox'
import { Country } from './Country'
import { DateOfBirth } from './DateOfBirth'
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'
export const fields = {
checkbox: Checkbox,
country: Country,
email: Email,
message: Message,
number: Number,
select: Select,
state: State,
text: Text,
textarea: Textarea,
dateOfBirth: DateOfBirth,
}
Conclusion
And that's it. We use to do a bunch of typescript gymnastics with buildInitialFormState.tsx
to type the default values of the form, but it looks like Paul opted to just type them as any
. Not sure if that will matter with a more complex array field, but I'll check that out when I write part 2.
Top comments (0)