DEV Community

Mike Cebulski
Mike Cebulski

Posted on

Payload Form Builder Plugin - Custom Fields

Date of birth picker component light mode

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',
  },
}
Enter fullscreen mode Exit fullscreen mode

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,
    },
    // ...
})
Enter fullscreen mode Exit fullscreen mode

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.

date of birth form field

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 }
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

Date of birth picker component dark mode

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)