/* Copyright 2026 Marimo. All rights reserved. */ /* oxlint-disable typescript/no-explicit-any */ import { PlusIcon, RefreshCcw, Trash2Icon } from "lucide-react"; import React from "react"; import { type FieldValues, FormProvider, type Path, type UseFormReturn, useFieldArray, } from "react-hook-form"; import { z } from "zod"; import { FieldOptions, randomNumber } from "@/components/forms/options"; import { Combobox, ComboboxItem } from "@/components/ui/combobox"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { cn } from "@/utils/cn"; import { Events } from "@/utils/events"; import { Strings } from "@/utils/strings"; import { isZodPipe } from "@/utils/zod-utils"; import { Objects } from "../../utils/objects"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "../ui/form"; import { DebouncedInput, DebouncedNumberInput } from "../ui/input"; import { Textarea } from "../ui/textarea"; import { getDefaults, getUnionLiteral, maybeUnwrap } from "./form-utils"; import { ensureStringArray, SwitchableMultiSelect, TextAreaMultiSelect, } from "./switchable-multi-select"; export interface FormRenderer { isMatch: (schema: z.ZodType) => schema is z.ZodType; Component: React.ComponentType<{ schema: z.ZodType; form: UseFormReturn; path: Path; }>; } interface Props { form: UseFormReturn; schema: z.ZodType; path?: Path; renderers: FormRenderer[] | undefined; children?: React.ReactNode; } export const ZodForm = ({ schema, form, path = "" as Path, renderers = [], children, }: Props) => { return ( {children} {renderZodSchema(schema, form, path, renderers)} ); }; export function renderZodSchema( schema: z.ZodType, form: UseFormReturn, path: Path, renderers: FormRenderer[], ) { // Try custom renderers first for (const renderer of renderers) { const { isMatch, Component } = renderer; if (isMatch(schema)) { return ; } } const { label, description, special, direction = "column", minLength, } = FieldOptions.parse(schema.description || ""); if (schema instanceof z.ZodDefault) { let inner = schema.unwrap() as z.ZodType; inner = !inner.description && schema.description ? inner.describe(schema.description) : inner; return renderZodSchema(inner, form, path, renderers); } if (schema instanceof z.ZodOptional) { let inner = schema.unwrap() as z.ZodType; inner = !inner.description && schema.description ? inner.describe(schema.description) : inner; return renderZodSchema(inner, form, path, renderers); } if (schema instanceof z.ZodObject) { return (
{label} {Objects.entries(schema.shape).map(([key, value]) => { const isLiteral = value instanceof z.ZodLiteral; const childForm = renderZodSchema( value as z.ZodType, form, joinPath(path, key), renderers, ); if (isLiteral) { return {childForm}; } return (
{childForm}
); })}
); } if (schema instanceof z.ZodString) { if (special === "time") { return ( ( {label} {description} )} /> ); } return ; } if (schema instanceof z.ZodBoolean) { return ( (
{label} {description}
)} /> ); } if (schema instanceof z.ZodNumber) { if (special === "random_number_button") { return ( ( )} /> ); } return ( ( {label} {description} )} /> ); } if (schema instanceof z.ZodDate) { const inputType = special === "datetime" ? "datetime-local" : special === "time" ? "time" : "date"; return ( ( {label} {description} )} /> ); } if (schema instanceof z.ZodAny) { return ; } if (schema instanceof z.ZodEnum) { const values = schema.options.map((option) => option.toString()); return ( ); } if (schema instanceof z.ZodArray) { if (special === "text_area_multiline") { return ; } // Inspect child type for a better input const childType = schema.element; // Show multi-select for enum array if (childType instanceof z.ZodEnum) { const childOptions: string[] = childType.options.map((option) => option.toString(), ); return ( ); } // Fallback to generic array return (
); } if (schema instanceof z.ZodDiscriminatedUnion) { const def = schema.def; const options = def.options as z.ZodType[]; const discriminator = def.discriminator; const getSchemaValue = (value: string) => { return options.find((option) => { return getUnionLiteral(option).value === value; }); }; return ( { const value = field.value; const types = options.map((option) => { return getUnionLiteral(option).value; }); const unionTypeValue: string = value && typeof value === "object" && discriminator in value ? value[discriminator] : types[0]; const selectedOption = getSchemaValue(unionTypeValue); return (
{label}
{types.map((type: string) => ( ))}
{selectedOption && renderZodSchema(selectedOption, form, path, renderers)}
); }} /> ); } if (schema instanceof z.ZodUnion) { return ( { const options = schema.options as z.ZodType[]; let value: string = field.value; const types = options.map((option) => { return getUnionLiteral(option).value; }); if (!value) { // Default to first value = types[0]; } const selectedOption = options.find((option) => { return getUnionLiteral(option).value === value; }); return (
{label} {types.map((type: string) => { return ( ); })} {selectedOption && renderZodSchema(selectedOption, form, path, renderers)}
); }} /> ); } if (schema instanceof z.ZodLiteral) { return ( ( )} /> ); } if ("unwrap" in schema) { // Handle ZodEffects (transforms/refinements) return renderZodSchema(maybeUnwrap(schema), form, path, renderers); } if (isZodPipe(schema)) { return renderZodSchema(schema.in, form, path, renderers); } return (
Unknown schema type{" "} {schema == null ? path : JSON.stringify(schema.type ?? schema)}
); } /** * Type: T[] */ const FormArray = ({ schema, form, path, minLength, renderers, }: { schema: z.ZodType; form: UseFormReturn; path: Path; renderers: FormRenderer[]; minLength?: number; }) => { const { label, description } = FieldOptions.parse(schema.description || ""); const control = form.control; // prepend, remove, swap, move, insert, replace const { fields, append, remove } = useFieldArray({ control, name: path, }); const isBelowMinLength = minLength != null && fields.length < minLength; const canRemove = minLength == null || fields.length > minLength; return (
{label} {description} {fields.map((field, index) => { return (
e.preventDefault())} > {renderZodSchema(schema, form, `${path}[${index}]`, renderers)} {canRemove && ( { remove(index); }} /> )}
); })} {isBelowMinLength && (
At least {minLength} required.
)}
); }; /** * Type: string */ const StringFormField = ({ schema, form, path, }: { schema: z.ZodAny | z.ZodString; form: UseFormReturn; path: Path; }) => { const { label, description, placeholder, disabled, inputType } = FieldOptions.parse(schema.description); if (inputType === "textarea") { return ( ( {label} {description}