Created
September 29, 2023 16:12
-
-
Save WangLarry/1b528c2f8433dbd1d5eb048335854e98 to your computer and use it in GitHub Desktop.
auto-form: support hidden and grid layout
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable @typescript-eslint/no-unsafe-argument */ | |
import React from "react"; | |
import { type z } from "zod"; | |
import { | |
Form, | |
FormControl, | |
FormDescription, | |
FormField, | |
FormItem, | |
FormLabel, | |
FormMessage, | |
} from "./form"; | |
import { | |
type ControllerRenderProps, | |
type DefaultValues, | |
type FieldValues, | |
useFieldArray, | |
useForm, | |
} from "react-hook-form"; | |
import { | |
Select, | |
SelectContent, | |
SelectItem, | |
SelectTrigger, | |
SelectValue, | |
} from "./select"; | |
import { zodResolver } from "@hookform/resolvers/zod"; | |
import { Button } from "./button"; | |
import { Input } from "./input"; | |
import { Checkbox } from "./checkbox"; | |
import { DatePicker } from "./date-picker"; | |
import { cn } from "../lib/utils"; | |
import { Switch } from "./switch"; | |
import { Textarea } from "./textarea"; | |
import { | |
Accordion, | |
AccordionContent, | |
AccordionItem, | |
AccordionTrigger, | |
} from "./accordion"; | |
import { RadioGroup, RadioGroupItem } from "./radio-group"; | |
import { Separator } from "./separator"; | |
import { Plus, Trash } from "lucide-react"; | |
/** | |
* Beautify a camelCase string. | |
* e.g. "myString" -> "My String" | |
*/ | |
function beautifyObjectName(string: string) { | |
let output = string.replace(/([A-Z])/g, " $1"); | |
output = output.charAt(0).toUpperCase() + output.slice(1); | |
return output; | |
} | |
/** | |
* Get the lowest level Zod type. | |
* This will unpack optionals, refinements, etc. | |
*/ | |
function getBaseSchema(schema: z.ZodAny | z.ZodEffects<z.ZodAny>): z.ZodAny { | |
if ("innerType" in schema._def) { | |
return getBaseSchema(schema._def.innerType as z.ZodAny); | |
} | |
if ("schema" in schema._def) { | |
return getBaseSchema(schema._def.schema as z.ZodAny); | |
} | |
return schema as z.ZodAny; | |
} | |
/** | |
* Get the type name of the lowest level Zod type. | |
* This will unpack optionals, refinements, etc. | |
*/ | |
function getBaseType(schema: z.ZodAny): string { | |
return getBaseSchema(schema)._def.typeName; | |
} | |
/** | |
* Search for a "ZodDefult" in the Zod stack and return its value. | |
*/ | |
function getDefaultValueInZodStack(schema: z.ZodAny): any { | |
const typedSchema = schema as unknown as z.ZodDefault< | |
z.ZodNumber | z.ZodString | |
>; | |
if (typedSchema._def.typeName === "ZodDefault") { | |
return typedSchema._def.defaultValue(); | |
} | |
if ("innerType" in typedSchema._def) { | |
return getDefaultValueInZodStack( | |
typedSchema._def.innerType as unknown as z.ZodAny, | |
); | |
} | |
if ("schema" in typedSchema._def) { | |
return getDefaultValueInZodStack( | |
(typedSchema._def as any).schema as z.ZodAny, | |
); | |
} | |
return undefined; | |
} | |
/** | |
* Get all default values from a Zod schema. | |
*/ | |
function getDefaultValues<Schema extends z.ZodObject<any, any>>( | |
schema: Schema, | |
) { | |
const { shape } = schema; | |
type DefaultValuesType = DefaultValues<Partial<z.infer<Schema>>>; | |
const defaultValues = {} as DefaultValuesType; | |
for (const key of Object.keys(shape)) { | |
const item = shape[key] as z.ZodAny; | |
if (getBaseType(item) === "ZodObject") { | |
const defaultItems = getDefaultValues( | |
item as unknown as z.ZodObject<any, any>, | |
); | |
for (const defaultItemKey of Object.keys(defaultItems)) { | |
const pathKey = `${key}.${defaultItemKey}` as keyof DefaultValuesType; | |
defaultValues[pathKey] = defaultItems[defaultItemKey]; | |
} | |
} else { | |
const defaultValue = getDefaultValueInZodStack(item); | |
if (defaultValue !== undefined) { | |
defaultValues[key as keyof DefaultValuesType] = defaultValue; | |
} | |
} | |
} | |
return defaultValues; | |
} | |
function getObjectFormSchema( | |
schema: ZodObjectOrWrapped, | |
): z.ZodObject<any, any> { | |
if (schema._def.typeName === "ZodEffects") { | |
const typedSchema = schema as z.ZodEffects<z.ZodObject<any, any>>; | |
return getObjectFormSchema(typedSchema._def.schema); | |
} | |
return schema as z.ZodObject<any, any>; | |
} | |
/** | |
* Convert a Zod schema to HTML input props to give direct feedback to the user. | |
* Once submitted, the schema will be validated completely. | |
*/ | |
function zodToHtmlInputProps( | |
schema: | |
| z.ZodNumber | |
| z.ZodString | |
| z.ZodOptional<z.ZodNumber | z.ZodString> | |
| any, | |
): React.InputHTMLAttributes<HTMLInputElement> { | |
if (["ZodOptional", "ZodNullable"].includes(schema._def.typeName)) { | |
const typedSchema = schema as z.ZodOptional<z.ZodNumber | z.ZodString>; | |
return { | |
...zodToHtmlInputProps(typedSchema._def.innerType), | |
required: false, | |
}; | |
} | |
const typedSchema = schema as z.ZodNumber | z.ZodString; | |
if (!("checks" in typedSchema._def)) return {}; | |
const { checks } = typedSchema._def; | |
const inputProps: React.InputHTMLAttributes<HTMLInputElement> = { | |
required: true, | |
}; | |
const type = getBaseType(schema); | |
for (const check of checks) { | |
if (check.kind === "min") { | |
if (type === "ZodString") { | |
inputProps.minLength = check.value; | |
} else { | |
inputProps.min = check.value; | |
} | |
} | |
if (check.kind === "max") { | |
if (type === "ZodString") { | |
inputProps.maxLength = check.value; | |
} else { | |
inputProps.max = check.value; | |
} | |
} | |
} | |
return inputProps; | |
} | |
export type FieldConfigItem = { | |
description?: React.ReactNode; | |
inputProps?: React.InputHTMLAttributes<HTMLInputElement> & { | |
showLabel?: boolean; | |
}; | |
hidden?: boolean; | |
className?: string; | |
fieldType?: | |
| keyof typeof INPUT_COMPONENTS | |
| React.FC<AutoFormInputComponentProps>; | |
renderParent?: (props: { | |
children: React.ReactNode; | |
}) => React.ReactElement | null; | |
}; | |
export type FieldConfig<SchemaType extends z.infer<z.ZodObject<any, any>>> = { | |
// If SchemaType.key is an object, create a nested FieldConfig, otherwise FieldConfigItem | |
[Key in keyof SchemaType]?: SchemaType[Key] extends object | |
? FieldConfig<z.infer<SchemaType[Key]>> | |
: FieldConfigItem; | |
}; | |
/** | |
* A FormInput component can handle a specific Zod type (e.g. "ZodBoolean") | |
*/ | |
export type AutoFormInputComponentProps = { | |
zodInputProps: React.InputHTMLAttributes<HTMLInputElement>; | |
field: ControllerRenderProps<FieldValues, any>; | |
fieldConfigItem: FieldConfigItem; | |
label: string; | |
isRequired: boolean; | |
fieldProps: any; | |
zodItem: z.ZodAny; | |
}; | |
function AutoFormInput({ | |
label, | |
isRequired, | |
fieldConfigItem, | |
fieldProps, | |
}: AutoFormInputComponentProps) { | |
const { showLabel: _showLabel, ...fieldPropsWithoutShowLabel } = fieldProps; | |
const showLabel = _showLabel === undefined ? true : _showLabel; | |
return ( | |
<FormItem> | |
{showLabel && ( | |
<FormLabel> | |
{label} | |
{isRequired && <span className="text-destructive"> *</span>} | |
</FormLabel> | |
)} | |
<FormControl> | |
<Input type="text" {...fieldPropsWithoutShowLabel} /> | |
</FormControl> | |
{fieldConfigItem.description && ( | |
<FormDescription>{fieldConfigItem.description}</FormDescription> | |
)} | |
<FormMessage /> | |
</FormItem> | |
); | |
} | |
function AutoFormNumber({ fieldProps, ...props }: AutoFormInputComponentProps) { | |
return ( | |
<AutoFormInput | |
fieldProps={{ | |
type: "number", | |
...fieldProps, | |
}} | |
{...props} | |
/> | |
); | |
} | |
function AutoFormTextarea({ | |
label, | |
isRequired, | |
fieldConfigItem, | |
fieldProps, | |
}: AutoFormInputComponentProps) { | |
return ( | |
<FormItem> | |
<FormLabel> | |
{label} | |
{isRequired && <span className="text-destructive"> *</span>} | |
</FormLabel> | |
<FormControl> | |
<Textarea {...fieldProps} /> | |
</FormControl> | |
{fieldConfigItem.description && ( | |
<FormDescription>{fieldConfigItem.description}</FormDescription> | |
)} | |
<FormMessage /> | |
</FormItem> | |
); | |
} | |
function AutoFormCheckbox({ | |
label, | |
isRequired, | |
field, | |
fieldConfigItem, | |
fieldProps, | |
}: AutoFormInputComponentProps) { | |
return ( | |
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> | |
<FormControl> | |
<Checkbox | |
checked={field.value} | |
onCheckedChange={field.onChange} | |
{...fieldProps} | |
/> | |
</FormControl> | |
<div className="space-y-1 leading-none"> | |
<FormLabel> | |
{label} | |
{isRequired && <span className="text-destructive"> *</span>} | |
</FormLabel> | |
{fieldConfigItem.description && ( | |
<FormDescription>{fieldConfigItem.description}</FormDescription> | |
)} | |
</div> | |
</FormItem> | |
); | |
} | |
function AutoFormSwitch({ | |
label, | |
isRequired, | |
field, | |
fieldConfigItem, | |
fieldProps, | |
}: AutoFormInputComponentProps) { | |
return ( | |
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> | |
<FormControl> | |
<Switch | |
checked={field.value} | |
onCheckedChange={field.onChange} | |
{...fieldProps} | |
/> | |
</FormControl> | |
<div className="space-y-1 leading-none"> | |
<FormLabel> | |
{label} | |
{isRequired && <span className="text-destructive"> *</span>} | |
</FormLabel> | |
{fieldConfigItem.description && ( | |
<FormDescription>{fieldConfigItem.description}</FormDescription> | |
)} | |
</div> | |
</FormItem> | |
); | |
} | |
function AutoFormRadioGroup({ | |
label, | |
isRequired, | |
field, | |
zodItem, | |
fieldProps, | |
}: AutoFormInputComponentProps) { | |
const values = (zodItem as unknown as z.ZodEnum<any>)._def.values; | |
return ( | |
<FormItem className="space-y-3"> | |
<FormLabel> | |
{label} | |
{isRequired && <span className="text-destructive"> *</span>} | |
</FormLabel> | |
<FormControl> | |
<RadioGroup | |
onValueChange={field.onChange} | |
defaultValue={field.value} | |
className="flex flex-col space-y-1" | |
{...fieldProps} | |
> | |
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-call */} | |
{values.map((value: any) => ( | |
<FormItem | |
className="flex items-center space-x-3 space-y-0" | |
key={value} | |
> | |
<FormControl> | |
<RadioGroupItem value={value} /> | |
</FormControl> | |
<FormLabel className="font-normal">{value}</FormLabel> | |
</FormItem> | |
))} | |
</RadioGroup> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
); | |
} | |
function AutoFormDate({ | |
label, | |
isRequired, | |
field, | |
fieldConfigItem, | |
fieldProps, | |
}: AutoFormInputComponentProps) { | |
return ( | |
<FormItem> | |
<FormLabel> | |
{label} | |
{isRequired && <span className="text-destructive"> *</span>} | |
</FormLabel> | |
<FormControl> | |
<DatePicker | |
date={field.value} | |
setDate={field.onChange} | |
{...fieldProps} | |
/> | |
</FormControl> | |
{fieldConfigItem.description && ( | |
<FormDescription>{fieldConfigItem.description}</FormDescription> | |
)} | |
<FormMessage /> | |
</FormItem> | |
); | |
} | |
function AutoFormEnum({ | |
label, | |
isRequired, | |
field, | |
fieldConfigItem, | |
zodItem, | |
}: AutoFormInputComponentProps) { | |
const baseValues = (getBaseSchema(zodItem) as unknown as z.ZodEnum<any>)._def | |
.values; | |
let values: [string, string][] = []; | |
if (!Array.isArray(baseValues)) { | |
values = Object.entries(baseValues); | |
} else { | |
values = baseValues.map((value) => [value, value]); | |
} | |
function findItem(value: any) { | |
return values.find((item) => item[0] === value); | |
} | |
return ( | |
<FormItem> | |
<FormLabel> | |
{label} | |
{isRequired && <span className="text-destructive"> *</span>} | |
</FormLabel> | |
<FormControl> | |
<Select onValueChange={field.onChange} defaultValue={field.value}> | |
<SelectTrigger> | |
<SelectValue | |
className="w-full" | |
placeholder={fieldConfigItem.inputProps?.placeholder} | |
> | |
{field.value ? findItem(field.value)?.[1] : "Select an option"} | |
</SelectValue> | |
</SelectTrigger> | |
<SelectContent> | |
{values.map(([value, label]) => ( | |
<SelectItem value={value} key={value}> | |
{label} | |
</SelectItem> | |
))} | |
</SelectContent> | |
</Select> | |
</FormControl> | |
{fieldConfigItem.description && ( | |
<FormDescription>{fieldConfigItem.description}</FormDescription> | |
)} | |
<FormMessage /> | |
</FormItem> | |
); | |
} | |
const INPUT_COMPONENTS = { | |
checkbox: AutoFormCheckbox, | |
date: AutoFormDate, | |
select: AutoFormEnum, | |
radio: AutoFormRadioGroup, | |
switch: AutoFormSwitch, | |
textarea: AutoFormTextarea, | |
number: AutoFormNumber, | |
fallback: AutoFormInput, | |
}; | |
/** | |
* Define handlers for specific Zod types. | |
* You can expand this object to support more types. | |
*/ | |
const DEFAULT_ZOD_HANDLERS: { | |
[key: string]: keyof typeof INPUT_COMPONENTS; | |
} = { | |
ZodBoolean: "checkbox", | |
ZodDate: "date", | |
ZodEnum: "select", | |
ZodNativeEnum: "select", | |
ZodNumber: "number", | |
}; | |
function DefaultParent({ children, id, fieldConfig }: { children: React.ReactNode, id: string, fieldConfig: FieldConfigItem }) { | |
return <div id={id} className={cn(fieldConfig.className, fieldConfig.hidden && "hidden")}>{children}</div>; | |
} | |
function AutoFormObject<SchemaType extends z.ZodObject<any, any>>({ | |
schema, | |
form, | |
fieldConfig, | |
path = [], | |
}: { | |
schema: SchemaType; | |
form: ReturnType<typeof useForm>; | |
fieldConfig?: FieldConfig<z.infer<SchemaType>>; | |
path?: string[]; | |
}) { | |
const { shape } = schema; | |
return ( | |
<Accordion type="multiple" className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8"> | |
{Object.keys(shape).map((name) => { | |
const item = shape[name] as z.ZodAny; | |
const zodBaseType = getBaseType(item); | |
const itemName = item._def.description ?? beautifyObjectName(name); | |
const key = [...path, name].join("."); | |
if (zodBaseType === "ZodObject") { | |
const itemClassName = fieldConfig && fieldConfig[key]?.className as string; | |
return ( | |
<AccordionItem value={name} key={key} className={itemClassName}> | |
<AccordionTrigger>{itemName}</AccordionTrigger> | |
<AccordionContent className="p-2"> | |
<AutoFormObject | |
schema={item as unknown as z.ZodObject<any, any>} | |
form={form} | |
fieldConfig={ | |
(fieldConfig?.[name] ?? {}) as FieldConfig< | |
z.infer<typeof item> | |
> | |
} | |
path={[...path, name]} | |
/> | |
</AccordionContent> | |
</AccordionItem> | |
); | |
} | |
if (zodBaseType === "ZodArray") { | |
return ( | |
<AutoFormArray | |
key={key} | |
name={name} | |
item={item as unknown as z.ZodArray<any>} | |
form={form} | |
path={[...path, name]} | |
/> | |
); | |
} | |
const fieldConfigItem: FieldConfigItem = fieldConfig?.[name] ?? {}; | |
const zodInputProps = zodToHtmlInputProps(item); | |
const isRequired = | |
zodInputProps.required || | |
fieldConfigItem.inputProps?.required || | |
false; | |
return ( | |
<FormField | |
control={form.control} | |
name={key} | |
key={key} | |
render={({ field }) => { | |
const inputType = | |
fieldConfigItem.fieldType ?? | |
DEFAULT_ZOD_HANDLERS[zodBaseType] ?? | |
"fallback"; | |
const InputComponent = | |
typeof inputType === "function" | |
? inputType | |
: INPUT_COMPONENTS[inputType]; | |
const ParentElement = | |
fieldConfigItem.renderParent ?? DefaultParent; | |
return ( | |
<ParentElement key={`${key}.parent`} id={`${key}.parent`} fieldConfig={fieldConfigItem}> | |
<InputComponent | |
zodInputProps={zodInputProps} | |
field={field} | |
fieldConfigItem={fieldConfigItem} | |
label={itemName} | |
isRequired={isRequired} | |
zodItem={item} | |
fieldProps={{ | |
...zodInputProps, | |
...field, | |
...fieldConfigItem.inputProps, | |
value: !fieldConfigItem.inputProps?.defaultValue | |
? field.value ?? "" | |
: undefined, | |
}} | |
/> | |
</ParentElement> | |
); | |
}} | |
/> | |
); | |
})} | |
</Accordion> | |
); | |
} | |
function AutoFormArray<SchemaType extends z.ZodObject<any, any>>({ | |
name, | |
item, | |
form, | |
fieldConfig, | |
path = [], | |
}: { | |
name: string; | |
item: z.ZodArray<any>; | |
form: ReturnType<typeof useForm>; | |
fieldConfig?: FieldConfig<z.infer<SchemaType>>; | |
path?: string[]; | |
}) { | |
const { fields, append, remove } = useFieldArray({ | |
control: form.control, | |
name, | |
}); | |
const title = item._def.description ?? beautifyObjectName(name); | |
const className = fieldConfig && fieldConfig[path.join(".")]?.className as string; | |
return ( | |
<AccordionItem value={name} className={className}> | |
<AccordionTrigger>{title}</AccordionTrigger> | |
<AccordionContent className="border-l p-3 pl-6"> | |
{fields.map((_field, index) => { | |
const key = [...path, index.toString()].join("."); | |
return ( | |
<div className="mb-4 grid gap-6" key={`${key}`}> | |
<AutoFormObject | |
schema={item._def.type as z.ZodObject<any, any>} | |
form={form} | |
path={[...path, index.toString()]} | |
/> | |
<Button | |
variant="secondary" | |
size="sm" | |
type="button" | |
onClick={() => remove(index)} | |
> | |
<Trash className="h-4 w-4" /> | |
</Button> | |
<Separator /> | |
</div> | |
); | |
})} | |
<Button | |
type="button" | |
onClick={() => append({})} | |
className="flex items-center" | |
> | |
<Plus className="mr-2" size={16} /> | |
Add | |
</Button> | |
</AccordionContent> | |
</AccordionItem> | |
); | |
} | |
export function AutoFormSubmit({ children }: { children?: React.ReactNode }) { | |
return <Button type="submit">{children ?? "Submit"}</Button>; | |
} | |
// TODO: This should support recursive ZodEffects but TypeScript doesn't allow circular type definitions. | |
type ZodObjectOrWrapped = | |
| z.ZodObject<any, any> | |
| z.ZodEffects<z.ZodObject<any, any>>; | |
function AutoForm<SchemaType extends ZodObjectOrWrapped>({ | |
formSchema, | |
values: valuesProp, | |
onValuesChange: onValuesChangeProp, | |
onParsedValuesChange, | |
onSubmit: onSubmitProp, | |
fieldConfig, | |
children, | |
className, | |
}: { | |
formSchema: SchemaType; | |
values?: Partial<z.infer<SchemaType>>; | |
onValuesChange?: (values: Partial<z.infer<SchemaType>>) => void; | |
onParsedValuesChange?: (values: Partial<z.infer<SchemaType>>) => void; | |
onSubmit?: (values: z.infer<SchemaType>) => void; | |
fieldConfig?: FieldConfig<z.infer<SchemaType>>; | |
children?: React.ReactNode; | |
className?: string; | |
}) { | |
const objectFormSchema = getObjectFormSchema(formSchema); | |
const defaultValues: DefaultValues<z.infer<typeof objectFormSchema>> = | |
getDefaultValues(objectFormSchema); | |
const form = useForm<z.infer<typeof objectFormSchema>>({ | |
resolver: zodResolver(formSchema), | |
defaultValues, | |
values: valuesProp, | |
}); | |
function onSubmit(values: z.infer<typeof formSchema>) { | |
const parsedValues = formSchema.safeParse(values); | |
if (parsedValues.success) { | |
onSubmitProp?.(parsedValues.data); | |
} | |
} | |
return ( | |
<Form {...form}> | |
<form | |
onSubmit={(e) => { | |
void form.handleSubmit(onSubmit)(e); | |
}} | |
onChange={() => { | |
const values = form.getValues(); | |
onValuesChangeProp?.(values); | |
const parsedValues = formSchema.safeParse(values); | |
if (parsedValues.success) { | |
onParsedValuesChange?.(parsedValues.data); | |
} | |
}} | |
className={cn("space-y-5", className)} | |
> | |
<AutoFormObject | |
schema={objectFormSchema} | |
form={form} | |
fieldConfig={fieldConfig} | |
/> | |
{children} | |
</form> | |
</Form> | |
); | |
} | |
export { AutoForm }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment