DEV Community

Mike Cebulski
Mike Cebulski

Posted on

Pt 2: Payload Form Builder Plugin - Custom Array Field

Array Field Component

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],
    },
  ],
}
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,
      datePicker: DatePicker,
      array: ArrayBlock,
    },
    // ...
})
Enter fullscreen mode Exit fullscreen mode

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.

Array field in admin panel

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

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

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

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

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>
  )
}

Enter fullscreen mode Exit fullscreen mode

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

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

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

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

Form Submission

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)