Dynamic Form

Installation

Add the following Soul components

The dynamic-form component uses the button, button-radio-group, card-radio-group, checkbox, checkbox-group, date-picker, form-status, input, number-input, radio-group, select, swatch-radio-group and textarea components. Make sure you have added them to your project.

Install the following dependencies

npm install @conform-to/react @conform-to/zod zod

Copy and paste the following code into your project

form/dynamic-form/index.tsx

/* eslint-disable complexity */'use client';import {  FieldMetadata,  FormProvider,  getFormProps,  getInputProps,  SubmissionResult,  useForm,  useInputControl,} from '@conform-to/react';import { getZodConstraint, parseWithZod } from '@conform-to/zod';import { MouseEvent, ReactNode, startTransition, useActionState } from 'react';import { useFormStatus } from 'react-dom';import { z } from 'zod';import { ButtonRadioGroup } from '@/vibes/soul/form/button-radio-group';import { CardRadioGroup } from '@/vibes/soul/form/card-radio-group';import { Checkbox } from '@/vibes/soul/form/checkbox';import { CheckboxGroup } from '@/vibes/soul/form/checkbox-group';import { DatePicker } from '@/vibes/soul/form/date-picker';import { FormStatus } from '@/vibes/soul/form/form-status';import { Input } from '@/vibes/soul/form/input';import { NumberInput } from '@/vibes/soul/form/number-input';import { RadioGroup } from '@/vibes/soul/form/radio-group';import { Select } from '@/vibes/soul/form/select';import { SwatchRadioGroup } from '@/vibes/soul/form/swatch-radio-group';import { Textarea } from '@/vibes/soul/form/textarea';import { Button, ButtonProps } from '@/vibes/soul/primitives/button';import { Field, FieldGroup, schema } from './schema';type Action<S, P> = (state: Awaited<S>, payload: P) => S | Promise<S>;interface State<F extends Field> {  fields: Array<F | FieldGroup<F>>;  lastResult: SubmissionResult | null;}export type DynamicFormAction<F extends Field> = Action<State<F>, FormData>;export interface DynamicFormProps<F extends Field> {  fields: Array<F | FieldGroup<F>>;  action: DynamicFormAction<F>;  buttonSize?: ButtonProps['size'];  cancelLabel?: string;  submitLabel?: string;  submitName?: string;  submitValue?: string;  onCancel?: (e: MouseEvent<HTMLButtonElement>) => void;}export function DynamicForm<F extends Field>({  action,  fields: defaultFields,  buttonSize = 'medium',  cancelLabel = 'Cancel',  submitLabel = 'Submit',  submitName,  submitValue,  onCancel,}: DynamicFormProps<F>) {  const [{ lastResult, fields }, formAction] = useActionState(action, {    fields: defaultFields,    lastResult: null,  });  const dynamicSchema = schema(fields);  const defaultValue = fields    .flatMap((f) => (Array.isArray(f) ? f : [f]))    .reduce<z.infer<typeof dynamicSchema>>(      (acc, field) => ({        ...acc,        [field.name]: 'defaultValue' in field ? field.defaultValue : '',      }),      {},    );  const [form, formFields] = useForm({    lastResult,    constraint: getZodConstraint(dynamicSchema),    onValidate({ formData }) {      return parseWithZod(formData, { schema: dynamicSchema });    },    defaultValue,    shouldValidate: 'onSubmit',    shouldRevalidate: 'onInput',    onSubmit(event, { formData }) {      event.preventDefault();      startTransition(() => {        formAction(formData);      });    },  });  return (    <FormProvider context={form.context}>      <form {...getFormProps(form)} action={formAction}>        <div className="space-y-6">          {fields.map((field, index) => {            if (Array.isArray(field)) {              return (                <div className="flex gap-4" key={index}>                  {field.map((f) => {                    const groupFormField = formFields[f.name];                    if (!groupFormField) return null;                    return (                      <DynamicFormField                        field={f}                        formField={groupFormField}                        key={groupFormField.id}                      />                    );                  })}                </div>              );            }            const formField = formFields[field.name];            if (formField == null) return null;            return <DynamicFormField field={field} formField={formField} key={formField.id} />;          })}          <div className="flex gap-1 pt-3">            {onCancel && (              <Button                aria-label={`${cancelLabel} ${submitLabel}`}                onClick={onCancel}                size={buttonSize}                variant="tertiary"              >                {cancelLabel}              </Button>            )}            <SubmitButton name={submitName} size={buttonSize} value={submitValue}>              {submitLabel}            </SubmitButton>          </div>          {form.errors?.map((error, index) => (            <FormStatus key={index} type="error">              {error}            </FormStatus>          ))}        </div>      </form>    </FormProvider>  );}function SubmitButton({  children,  name,  value,  size,}: {  children: ReactNode;  name?: string;  value?: string;  size: ButtonProps['size'];}) {  const { pending } = useFormStatus();  return (    <Button loading={pending} name={name} size={size} type="submit" value={value}>      {children}    </Button>  );}function DynamicFormField({  field,  formField,}: {  field: Field;  formField: FieldMetadata<string | string[] | number | boolean | Date | undefined>;}) {  const controls = useInputControl(formField);  switch (field.type) {    case 'number':      return (        <NumberInput          {...getInputProps(formField, { type: 'number' })}          decrementLabel={field.decrementLabel}          errors={formField.errors}          incrementLabel={field.incrementLabel}          key={field.name}          label={field.label}        />      );    case 'text':      return (        <Input          {...getInputProps(formField, { type: 'text' })}          errors={formField.errors}          key={field.name}          label={field.label}        />      );    case 'textarea':      return (        <Textarea          {...getInputProps(formField, { type: 'text' })}          errors={formField.errors}          key={field.name}          label={field.label}        />      );    case 'password':    case 'confirm-password':      return (        <Input          {...getInputProps(formField, { type: 'password' })}          errors={formField.errors}          key={field.name}          label={field.label}        />      );    case 'email':      return (        <Input          {...getInputProps(formField, { type: 'email' })}          errors={formField.errors}          key={field.name}          label={field.label}        />      );    case 'checkbox':      return (        <Checkbox          errors={formField.errors}          key={field.name}          label={field.label}          name={formField.name}          onBlur={controls.blur}          onCheckedChange={(value) => controls.change(String(value))}          onFocus={controls.focus}          required={formField.required}          value={controls.value}        />      );    case 'checkbox-group':      return (        <CheckboxGroup          errors={formField.errors}          key={field.name}          label={field.label}          name={formField.name}          onValueChange={controls.change}          options={field.options}          value={Array.isArray(controls.value) ? controls.value : []}        />      );    case 'select':      return (        <Select          errors={formField.errors}          key={field.name}          label={field.label}          name={formField.name}          onBlur={controls.blur}          onFocus={controls.focus}          onValueChange={controls.change}          options={field.options}          required={formField.required}          value={typeof controls.value === 'string' ? controls.value : ''}        />      );    case 'radio-group':      return (        <RadioGroup          errors={formField.errors}          key={field.name}          label={field.label}          name={formField.name}          onBlur={controls.blur}          onFocus={controls.focus}          onValueChange={controls.change}          options={field.options}          required={formField.required}          value={typeof controls.value === 'string' ? controls.value : ''}        />      );    case 'swatch-radio-group':      return (        <SwatchRadioGroup          errors={formField.errors}          id={formField.id}          key={field.name}          label={field.label}          name={formField.name}          onBlur={controls.blur}          onFocus={controls.focus}          onValueChange={controls.change}          options={field.options}          required={formField.required}          value={typeof controls.value === 'string' ? controls.value : ''}        />      );    case 'card-radio-group':      return (        <CardRadioGroup          errors={formField.errors}          id={formField.id}          key={field.name}          label={field.label}          name={formField.name}          onBlur={controls.blur}          onFocus={controls.focus}          onValueChange={controls.change}          options={field.options}          required={formField.required}          value={typeof controls.value === 'string' ? controls.value : ''}        />      );    case 'button-radio-group':      return (        <ButtonRadioGroup          errors={formField.errors}          id={formField.id}          key={field.name}          label={field.label}          name={formField.name}          onBlur={controls.blur}          onFocus={controls.focus}          onValueChange={controls.change}          options={field.options}          required={formField.required}          value={typeof controls.value === 'string' ? controls.value : ''}        />      );    case 'date':      return (        <DatePicker          disabledDays={            field.minDate != null && field.maxDate != null              ? {                  before: new Date(field.minDate),                  after: new Date(field.maxDate),                }              : undefined          }          errors={formField.errors}          key={field.name}          label={field.label}          name={formField.name}          onBlur={controls.blur}          onFocus={controls.focus}          onSelect={(date) =>            controls.change(date ? Intl.DateTimeFormat().format(date) : undefined)          }          required={formField.required}          selected={typeof controls.value === 'string' ? new Date(controls.value) : undefined}        />      );  }}

form/dynamic-form/schema.ts

import { z } from 'zod';interface FormField {  name: string;  label?: string;  errors?: string[];  required?: boolean;  id?: string;}type RadioField = {  type: 'radio-group';  options: Array<{ label: string; value: string }>;  defaultValue?: string;} & FormField;type SelectField = {  type: 'select';  options: Array<{ label: string; value: string }>;  defaultValue?: string;} & FormField;type CheckboxField = {  type: 'checkbox';  defaultValue?: string;} & FormField;type CheckboxGroupField = {  type: 'checkbox-group';  options: Array<{ label: string; value: string }>;  defaultValue?: string[];} & FormField;type NumberInputField = {  type: 'number';  defaultValue?: string;  min?: number;  max?: number;  step?: number;  incrementLabel?: string;  decrementLabel?: string;} & FormField;type TextInputField = {  type: 'text';  defaultValue?: string;} & FormField;type EmailInputField = {  type: 'email';  defaultValue?: string;} & FormField;type TextAreaField = {  type: 'textarea';  defaultValue?: string;} & FormField;type DateField = {  type: 'date';  defaultValue?: string;  minDate?: string;  maxDate?: string;} & FormField;type SwatchRadioFieldOption =  | {      type: 'color';      value: string;      label: string;      color: string;      disabled?: boolean;    }  | {      type: 'image';      value: string;      label: string;      image: { src: string; alt: string };      disabled?: boolean;    };type SwatchRadioField = {  type: 'swatch-radio-group';  defaultValue?: string;  options: SwatchRadioFieldOption[];} & FormField;type CardRadioField = {  type: 'card-radio-group';  defaultValue?: string;  options: Array<{    value: string;    label: string;    image: { src: string; alt: string };    disabled?: boolean;  }>;} & FormField;type ButtonRadioField = {  type: 'button-radio-group';  defaultValue?: string;  pattern?: string;  options: Array<{    value: string;    label: string;    disabled?: boolean;  }>;} & FormField;type PasswordField = {  type: 'password';} & FormField;type ConfirmPasswordField = {  type: 'confirm-password';} & FormField;type HiddenInputField = {  type: 'hidden';  defaultValue?: string;} & FormField;export type Field =  | RadioField  | CheckboxField  | CheckboxGroupField  | NumberInputField  | TextInputField  | TextAreaField  | DateField  | SwatchRadioField  | CardRadioField  | ButtonRadioField  | SelectField  | PasswordField  | ConfirmPasswordField  | EmailInputField  | HiddenInputField;export type FieldGroup<F> = F[];export type SchemaRawShape = Record<  string,  | z.ZodString  | z.ZodOptional<z.ZodString>  | z.ZodNumber  | z.ZodOptional<z.ZodNumber>  | z.ZodArray<z.ZodString>  | z.ZodOptional<z.ZodArray<z.ZodString>>>;function getFieldSchema(field: Field) {  let fieldSchema:    | z.ZodString    | z.ZodNumber    | z.ZodOptional<z.ZodString>    | z.ZodOptional<z.ZodNumber>    | z.ZodArray<z.ZodString, 'atleastone' | 'many'>    | z.ZodOptional<z.ZodArray<z.ZodString, 'atleastone' | 'many'>>;  switch (field.type) {    case 'number':      fieldSchema = z.number();      if (field.min != null) fieldSchema = fieldSchema.min(field.min);      if (field.max != null) fieldSchema = fieldSchema.max(field.max);      if (field.required !== true) fieldSchema = fieldSchema.optional();      break;    case 'password':      fieldSchema = z        .string()        .min(8, { message: 'Be at least 8 characters long' })        .regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' })        .regex(/[0-9]/, { message: 'Contain at least one number.' })        .regex(/[^a-zA-Z0-9]/, {          message: 'Contain at least one special character.',        })        .trim();      if (field.required !== true) fieldSchema = fieldSchema.optional();      break;    case 'email':      fieldSchema = z.string().email({ message: 'Please enter a valid email.' }).trim();      if (field.required !== true) fieldSchema = fieldSchema.optional();      break;    case 'checkbox-group':      fieldSchema = z.string().array();      if (field.required === true) fieldSchema = fieldSchema.nonempty();      break;    default:      fieldSchema = z.string();      if (field.required !== true) fieldSchema = fieldSchema.optional();  }  return fieldSchema;}export function schema(fields: Array<Field | FieldGroup<Field>>) {  const shape: SchemaRawShape = {};  let passwordFieldName: string | undefined;  let confirmPasswordFieldName: string | undefined;  fields.forEach((field) => {    if (Array.isArray(field)) {      field.forEach((f) => {        shape[f.name] = getFieldSchema(f);        if (f.type === 'password') passwordFieldName = f.name;        if (f.type === 'confirm-password') confirmPasswordFieldName = f.name;      });    } else {      shape[field.name] = getFieldSchema(field);      if (field.type === 'password') passwordFieldName = field.name;      if (field.type === 'confirm-password') confirmPasswordFieldName = field.name;    }  });  return z.object(shape).superRefine((data, ctx) => {    if (      passwordFieldName != null &&      confirmPasswordFieldName != null &&      data[passwordFieldName] !== data[confirmPasswordFieldName]    ) {      ctx.addIssue({        code: 'custom',        message: 'The passwords did not match',        path: [confirmPasswordFieldName],      });    }  });}