feat(auto-form): add new fields and form files to AutoForm component
Added several files to the AutoForm component for better form functionality including a file for each type of input field (checkbox, date, enum, file, number, radio-group, switch, etc). Managed the configurations in a separate file and handled dependencies to control form behaviours. This commit enhances form handling capabilities and simplifies the process for creating versatile forms.
This commit is contained in:
parent
6976e3a317
commit
c51e4fcc77
23
src/components/auto-form/common/label.tsx
Normal file
23
src/components/auto-form/common/label.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function AutoFormLabel({
|
||||
label,
|
||||
isRequired,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
isRequired: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<FormLabel className={cn(className)}>
|
||||
{label}
|
||||
{isRequired && <span className="text-destructive"> *</span>}
|
||||
</FormLabel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AutoFormLabel;
|
13
src/components/auto-form/common/tooltip.tsx
Normal file
13
src/components/auto-form/common/tooltip.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
function AutoFormTooltip({ fieldConfigItem }: { fieldConfigItem: any }) {
|
||||
return (
|
||||
<>
|
||||
{fieldConfigItem?.description && (
|
||||
<p className="text-sm text-gray-500 dark:text-white">
|
||||
{fieldConfigItem.description}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AutoFormTooltip;
|
35
src/components/auto-form/config.ts
Normal file
35
src/components/auto-form/config.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import AutoFormCheckbox from "./fields/checkbox";
|
||||
import AutoFormDate from "./fields/date";
|
||||
import AutoFormEnum from "./fields/enum";
|
||||
import AutoFormFile from "./fields/file";
|
||||
import AutoFormInput from "./fields/input";
|
||||
import AutoFormNumber from "./fields/number";
|
||||
import AutoFormRadioGroup from "./fields/radio-group";
|
||||
import AutoFormSwitch from "./fields/switch";
|
||||
import AutoFormTextarea from "./fields/textarea";
|
||||
|
||||
export const INPUT_COMPONENTS = {
|
||||
checkbox: AutoFormCheckbox,
|
||||
date: AutoFormDate,
|
||||
select: AutoFormEnum,
|
||||
radio: AutoFormRadioGroup,
|
||||
switch: AutoFormSwitch,
|
||||
textarea: AutoFormTextarea,
|
||||
number: AutoFormNumber,
|
||||
file: AutoFormFile,
|
||||
fallback: AutoFormInput,
|
||||
};
|
||||
|
||||
/**
|
||||
* Define handlers for specific Zod types.
|
||||
* You can expand this object to support more types.
|
||||
*/
|
||||
export const DEFAULT_ZOD_HANDLERS: {
|
||||
[key: string]: keyof typeof INPUT_COMPONENTS;
|
||||
} = {
|
||||
ZodBoolean: "checkbox",
|
||||
ZodDate: "date",
|
||||
ZodEnum: "select",
|
||||
ZodNativeEnum: "select",
|
||||
ZodNumber: "number",
|
||||
};
|
57
src/components/auto-form/dependencies.ts
Normal file
57
src/components/auto-form/dependencies.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import type { FieldValues, UseFormWatch } from "react-hook-form";
|
||||
import type * as z from "zod";
|
||||
import { type Dependency, DependencyType, type EnumValues } from "./types";
|
||||
|
||||
export default function resolveDependencies<
|
||||
SchemaType extends z.infer<z.ZodObject<any, any>>,
|
||||
>(
|
||||
dependencies: Dependency<SchemaType>[],
|
||||
currentFieldName: keyof SchemaType,
|
||||
watch: UseFormWatch<FieldValues>,
|
||||
) {
|
||||
let isDisabled = false;
|
||||
let isHidden = false;
|
||||
let isRequired = false;
|
||||
let overrideOptions: EnumValues | undefined;
|
||||
|
||||
const currentFieldValue = watch(currentFieldName as string);
|
||||
|
||||
const currentFieldDependencies = dependencies.filter(
|
||||
(dependency) => dependency.targetField === currentFieldName,
|
||||
);
|
||||
for (const dependency of currentFieldDependencies) {
|
||||
const watchedValue = watch(dependency.sourceField as string);
|
||||
|
||||
const conditionMet = dependency.when(watchedValue, currentFieldValue);
|
||||
|
||||
switch (dependency.type) {
|
||||
case DependencyType.DISABLES:
|
||||
if (conditionMet) {
|
||||
isDisabled = true;
|
||||
}
|
||||
break;
|
||||
case DependencyType.REQUIRES:
|
||||
if (conditionMet) {
|
||||
isRequired = true;
|
||||
}
|
||||
break;
|
||||
case DependencyType.HIDES:
|
||||
if (conditionMet) {
|
||||
isHidden = true;
|
||||
}
|
||||
break;
|
||||
case DependencyType.SETS_OPTIONS:
|
||||
if (conditionMet) {
|
||||
overrideOptions = dependency.options;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isDisabled,
|
||||
isHidden,
|
||||
isRequired,
|
||||
overrideOptions,
|
||||
};
|
||||
}
|
91
src/components/auto-form/fields/array.tsx
Normal file
91
src/components/auto-form/fields/array.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import {
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Plus, Trash } from "lucide-react";
|
||||
import { useFieldArray, type useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { beautifyObjectName } from "../utils";
|
||||
import AutoFormObject from "./object";
|
||||
|
||||
function isZodArray(item: z.ZodArray<any> | z.ZodDefault<any>): item is z.ZodArray<any> {
|
||||
return item instanceof z.ZodArray;
|
||||
}
|
||||
|
||||
function isZodDefault(
|
||||
item: z.ZodArray<any> | z.ZodDefault<any>,
|
||||
): item is z.ZodDefault<any> {
|
||||
return item instanceof z.ZodDefault;
|
||||
}
|
||||
|
||||
export default function AutoFormArray({
|
||||
name,
|
||||
item,
|
||||
form,
|
||||
path = [],
|
||||
fieldConfig,
|
||||
}: {
|
||||
name: string;
|
||||
item: z.ZodArray<any> | z.ZodDefault<any>;
|
||||
form: ReturnType<typeof useForm>;
|
||||
path?: string[];
|
||||
fieldConfig?: any;
|
||||
}) {
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name,
|
||||
});
|
||||
const title = item._def.description ?? beautifyObjectName(name);
|
||||
|
||||
const itemDefType = isZodArray(item)
|
||||
? item._def.type
|
||||
: isZodDefault(item)
|
||||
? item._def.innerType._def.type
|
||||
: null;
|
||||
|
||||
return (
|
||||
<AccordionItem value={name} className="border-none">
|
||||
<AccordionTrigger>{title}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{fields.map((_field, index) => {
|
||||
const key = _field.id;
|
||||
return (
|
||||
<div className="mt-4 flex flex-col" key={`${key}`}>
|
||||
<AutoFormObject
|
||||
schema={itemDefType as z.ZodObject<any, any>}
|
||||
form={form}
|
||||
fieldConfig={fieldConfig}
|
||||
path={[...path, index.toString()]}
|
||||
/>
|
||||
<div className="my-4 flex justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
type="button"
|
||||
className="hover:bg-zinc-300 hover:text-black focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-white dark:text-black dark:hover:bg-zinc-300 dark:hover:text-black dark:hover:ring-0 dark:hover:ring-offset-0 dark:focus-visible:ring-0 dark:focus-visible:ring-offset-0"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash className="size-4 " />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => append({})}
|
||||
className="mt-4 flex items-center"
|
||||
>
|
||||
<Plus className="mr-2" size={16} />
|
||||
Add
|
||||
</Button>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
34
src/components/auto-form/fields/checkbox.tsx
Normal file
34
src/components/auto-form/fields/checkbox.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { FormControl, FormItem } from "@/components/ui/form";
|
||||
import AutoFormLabel from "../common/label";
|
||||
import AutoFormTooltip from "../common/tooltip";
|
||||
import type { AutoFormInputComponentProps } from "../types";
|
||||
|
||||
export default function AutoFormCheckbox({
|
||||
label,
|
||||
isRequired,
|
||||
field,
|
||||
fieldConfigItem,
|
||||
fieldProps,
|
||||
}: AutoFormInputComponentProps) {
|
||||
return (
|
||||
<div>
|
||||
<FormItem>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
{...fieldProps}
|
||||
/>
|
||||
</FormControl>
|
||||
<AutoFormLabel
|
||||
label={fieldConfigItem?.label || label}
|
||||
isRequired={isRequired}
|
||||
/>
|
||||
</div>
|
||||
</FormItem>
|
||||
<AutoFormTooltip fieldConfigItem={fieldConfigItem} />
|
||||
</div>
|
||||
);
|
||||
}
|
25
src/components/auto-form/fields/date.tsx
Normal file
25
src/components/auto-form/fields/date.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { DatePicker } from "@/components/ui/date-picker";
|
||||
import { FormControl, FormItem, FormMessage } from "@/components/ui/form";
|
||||
import AutoFormLabel from "../common/label";
|
||||
import AutoFormTooltip from "../common/tooltip";
|
||||
import type { AutoFormInputComponentProps } from "../types";
|
||||
|
||||
export default function AutoFormDate({
|
||||
label,
|
||||
isRequired,
|
||||
field,
|
||||
fieldConfigItem,
|
||||
fieldProps,
|
||||
}: AutoFormInputComponentProps) {
|
||||
return (
|
||||
<FormItem>
|
||||
<AutoFormLabel label={fieldConfigItem?.label || label} isRequired={isRequired} />
|
||||
<FormControl>
|
||||
<DatePicker date={field.value} setDate={field.onChange} {...fieldProps} />
|
||||
</FormControl>
|
||||
<AutoFormTooltip fieldConfigItem={fieldConfigItem} />
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
59
src/components/auto-form/fields/enum.tsx
Normal file
59
src/components/auto-form/fields/enum.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { FormControl, FormItem, FormMessage } from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type * as z from "zod";
|
||||
import AutoFormLabel from "../common/label";
|
||||
import AutoFormTooltip from "../common/tooltip";
|
||||
import type { AutoFormInputComponentProps } from "../types";
|
||||
import { getBaseSchema } from "../utils";
|
||||
|
||||
export default function AutoFormEnum({
|
||||
label,
|
||||
isRequired,
|
||||
field,
|
||||
fieldConfigItem,
|
||||
zodItem,
|
||||
fieldProps,
|
||||
}: 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>
|
||||
<AutoFormLabel label={fieldConfigItem?.label || label} isRequired={isRequired} />
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value} {...fieldProps}>
|
||||
<SelectTrigger className={fieldProps.className}>
|
||||
<SelectValue placeholder={fieldConfigItem.inputProps?.placeholder}>
|
||||
{field.value ? findItem(field.value)?.[1] : "Select an option"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{values.map(([value, label]) => (
|
||||
<SelectItem value={label} key={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<AutoFormTooltip fieldConfigItem={fieldConfigItem} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
64
src/components/auto-form/fields/file.tsx
Normal file
64
src/components/auto-form/fields/file.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { FormControl, FormItem, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { type ChangeEvent, useState } from "react";
|
||||
import AutoFormLabel from "../common/label";
|
||||
import AutoFormTooltip from "../common/tooltip";
|
||||
import type { AutoFormInputComponentProps } from "../types";
|
||||
export default function AutoFormFile({
|
||||
label,
|
||||
isRequired,
|
||||
fieldConfigItem,
|
||||
fieldProps,
|
||||
field,
|
||||
}: AutoFormInputComponentProps) {
|
||||
const { showLabel: _showLabel, ...fieldPropsWithoutShowLabel } = fieldProps;
|
||||
const showLabel = _showLabel === undefined ? true : _showLabel;
|
||||
const [file, setFile] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setFile(reader.result as string);
|
||||
setFileName(file.name);
|
||||
field.onChange(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveClick = () => {
|
||||
setFile(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
{showLabel && (
|
||||
<AutoFormLabel label={fieldConfigItem?.label || label} isRequired={isRequired} />
|
||||
)}
|
||||
{!file && (
|
||||
<FormControl>
|
||||
<Input
|
||||
type="file"
|
||||
{...fieldPropsWithoutShowLabel}
|
||||
onChange={handleFileChange}
|
||||
value={""}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
{file && (
|
||||
<div className="flex h-[40px] w-full flex-row items-center justify-between space-x-2 rounded-sm border p-2 text-black focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-white dark:text-black dark:focus-visible:ring-0 dark:focus-visible:ring-offset-0">
|
||||
<p>{fileName}</p>
|
||||
<button onClick={handleRemoveClick} aria-label="Remove image">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<AutoFormTooltip fieldConfigItem={fieldConfigItem} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
34
src/components/auto-form/fields/input.tsx
Normal file
34
src/components/auto-form/fields/input.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { FormControl, FormItem, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import AutoFormLabel from "../common/label";
|
||||
import AutoFormTooltip from "../common/tooltip";
|
||||
import type { AutoFormInputComponentProps } from "../types";
|
||||
|
||||
export default function AutoFormInput({
|
||||
label,
|
||||
isRequired,
|
||||
fieldConfigItem,
|
||||
fieldProps,
|
||||
}: AutoFormInputComponentProps) {
|
||||
const { showLabel: _showLabel, ...fieldPropsWithoutShowLabel } = fieldProps;
|
||||
const showLabel = _showLabel === undefined ? true : _showLabel;
|
||||
const type = fieldProps.type || "text";
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<FormItem className="flex w-full flex-col justify-start">
|
||||
{showLabel && (
|
||||
<AutoFormLabel
|
||||
label={fieldConfigItem?.label || label}
|
||||
isRequired={isRequired}
|
||||
/>
|
||||
)}
|
||||
<FormControl>
|
||||
<Input type={type} {...fieldPropsWithoutShowLabel} />
|
||||
</FormControl>
|
||||
<AutoFormTooltip fieldConfigItem={fieldConfigItem} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</div>
|
||||
);
|
||||
}
|
28
src/components/auto-form/fields/number.tsx
Normal file
28
src/components/auto-form/fields/number.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { FormControl, FormItem, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import AutoFormLabel from "../common/label";
|
||||
import AutoFormTooltip from "../common/tooltip";
|
||||
import type { AutoFormInputComponentProps } from "../types";
|
||||
|
||||
export default function AutoFormNumber({
|
||||
label,
|
||||
isRequired,
|
||||
fieldConfigItem,
|
||||
fieldProps,
|
||||
}: AutoFormInputComponentProps) {
|
||||
const { showLabel: _showLabel, ...fieldPropsWithoutShowLabel } = fieldProps;
|
||||
const showLabel = _showLabel === undefined ? true : _showLabel;
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
{showLabel && (
|
||||
<AutoFormLabel label={fieldConfigItem?.label || label} isRequired={isRequired} />
|
||||
)}
|
||||
<FormControl>
|
||||
<Input type="number" {...fieldPropsWithoutShowLabel} />
|
||||
</FormControl>
|
||||
<AutoFormTooltip fieldConfigItem={fieldConfigItem} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
175
src/components/auto-form/fields/object.tsx
Normal file
175
src/components/auto-form/fields/object.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { FormField } from "@/components/ui/form";
|
||||
import { type useForm, useFormContext } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { DEFAULT_ZOD_HANDLERS, INPUT_COMPONENTS } from "../config";
|
||||
import resolveDependencies from "../dependencies";
|
||||
import type { Dependency, FieldConfig, FieldConfigItem } from "../types";
|
||||
import {
|
||||
beautifyObjectName,
|
||||
getBaseSchema,
|
||||
getBaseType,
|
||||
zodToHtmlInputProps,
|
||||
} from "../utils";
|
||||
import AutoFormArray from "./array";
|
||||
|
||||
function DefaultParent({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function AutoFormObject<SchemaType extends z.ZodObject<any, any>>({
|
||||
schema,
|
||||
form,
|
||||
fieldConfig,
|
||||
path = [],
|
||||
dependencies = [],
|
||||
}: {
|
||||
schema: SchemaType | z.ZodEffects<SchemaType>;
|
||||
form: ReturnType<typeof useForm>;
|
||||
fieldConfig?: FieldConfig<z.infer<SchemaType>>;
|
||||
path?: string[];
|
||||
dependencies?: Dependency<z.infer<SchemaType>>[];
|
||||
}) {
|
||||
const { watch } = useFormContext(); // Use useFormContext to access the watch function
|
||||
|
||||
if (!schema) {
|
||||
return null;
|
||||
}
|
||||
const { shape } = getBaseSchema<SchemaType>(schema) || {};
|
||||
|
||||
if (!shape) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleIfZodNumber = (item: z.ZodAny) => {
|
||||
const isZodNumber = (item as any)._def.typeName === "ZodNumber";
|
||||
const isInnerZodNumber = (item._def as any).innerType?._def?.typeName === "ZodNumber";
|
||||
|
||||
if (isZodNumber) {
|
||||
(item as any)._def.coerce = true;
|
||||
} else if (isInnerZodNumber) {
|
||||
(item._def as any).innerType._def.coerce = true;
|
||||
}
|
||||
|
||||
return item;
|
||||
};
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" className="space-y-5 border-none">
|
||||
{Object.keys(shape).map((name) => {
|
||||
let item = shape[name] as z.ZodAny;
|
||||
item = handleIfZodNumber(item) as z.ZodAny;
|
||||
const zodBaseType = getBaseType(item);
|
||||
const itemName = item._def.description ?? beautifyObjectName(name);
|
||||
const key = [...path, name].join(".");
|
||||
|
||||
const {
|
||||
isHidden,
|
||||
isDisabled,
|
||||
isRequired: isRequiredByDependency,
|
||||
overrideOptions,
|
||||
} = resolveDependencies(dependencies, name, watch);
|
||||
if (isHidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (zodBaseType === "ZodObject") {
|
||||
return (
|
||||
<AccordionItem value={name} key={key} className="border-none">
|
||||
<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}
|
||||
fieldConfig={fieldConfig?.[name] ?? {}}
|
||||
path={[...path, name]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldConfigItem: FieldConfigItem = fieldConfig?.[name] ?? {};
|
||||
const zodInputProps = zodToHtmlInputProps(item);
|
||||
const isRequired =
|
||||
isRequiredByDependency ||
|
||||
zodInputProps.required ||
|
||||
fieldConfigItem.inputProps?.required ||
|
||||
false;
|
||||
|
||||
if (overrideOptions) {
|
||||
item = z.enum(overrideOptions) as unknown as z.ZodAny;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const defaultValue = fieldConfigItem.inputProps?.defaultValue;
|
||||
const value = field.value ?? defaultValue ?? "";
|
||||
|
||||
const fieldProps = {
|
||||
...zodToHtmlInputProps(item),
|
||||
...field,
|
||||
...fieldConfigItem.inputProps,
|
||||
disabled: fieldConfigItem.inputProps?.disabled || isDisabled,
|
||||
ref: undefined,
|
||||
value: value,
|
||||
};
|
||||
|
||||
if (InputComponent === undefined) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ParentElement key={`${key}.parent`}>
|
||||
<InputComponent
|
||||
zodInputProps={zodInputProps}
|
||||
field={field}
|
||||
fieldConfigItem={fieldConfigItem}
|
||||
label={itemName}
|
||||
isRequired={isRequired}
|
||||
zodItem={item}
|
||||
fieldProps={fieldProps}
|
||||
className={fieldProps.className}
|
||||
/>
|
||||
</ParentElement>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
);
|
||||
}
|
51
src/components/auto-form/fields/radio-group.tsx
Normal file
51
src/components/auto-form/fields/radio-group.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import type * as z from "zod";
|
||||
import AutoFormLabel from "../common/label";
|
||||
import AutoFormTooltip from "../common/tooltip";
|
||||
import type { AutoFormInputComponentProps } from "../types";
|
||||
import { getBaseSchema } from "../utils";
|
||||
|
||||
export default function AutoFormRadioGroup({
|
||||
label,
|
||||
isRequired,
|
||||
field,
|
||||
zodItem,
|
||||
fieldProps,
|
||||
fieldConfigItem,
|
||||
}: AutoFormInputComponentProps) {
|
||||
const baseValues = (getBaseSchema(zodItem) as unknown as z.ZodEnum<any>)._def.values;
|
||||
|
||||
let values: string[] = [];
|
||||
if (!Array.isArray(baseValues)) {
|
||||
values = Object.entries(baseValues).map((item) => item[0]);
|
||||
} else {
|
||||
values = baseValues;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormItem>
|
||||
<AutoFormLabel label={fieldConfigItem?.label || label} isRequired={isRequired} />
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
{...fieldProps}
|
||||
>
|
||||
{values?.map((value: any) => (
|
||||
<FormItem key={value} className="mb-2 flex items-center gap-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value={value} />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">{value}</FormLabel>
|
||||
</FormItem>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<AutoFormTooltip fieldConfigItem={fieldConfigItem} />
|
||||
</div>
|
||||
);
|
||||
}
|
34
src/components/auto-form/fields/switch.tsx
Normal file
34
src/components/auto-form/fields/switch.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { FormControl, FormItem } from "@/components/ui/form";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import AutoFormLabel from "../common/label";
|
||||
import AutoFormTooltip from "../common/tooltip";
|
||||
import type { AutoFormInputComponentProps } from "../types";
|
||||
|
||||
export default function AutoFormSwitch({
|
||||
label,
|
||||
isRequired,
|
||||
field,
|
||||
fieldConfigItem,
|
||||
fieldProps,
|
||||
}: AutoFormInputComponentProps) {
|
||||
return (
|
||||
<div>
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-3">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
{...fieldProps}
|
||||
/>
|
||||
</FormControl>
|
||||
<AutoFormLabel
|
||||
label={fieldConfigItem?.label || label}
|
||||
isRequired={isRequired}
|
||||
/>
|
||||
</div>
|
||||
</FormItem>
|
||||
<AutoFormTooltip fieldConfigItem={fieldConfigItem} />
|
||||
</div>
|
||||
);
|
||||
}
|
27
src/components/auto-form/fields/textarea.tsx
Normal file
27
src/components/auto-form/fields/textarea.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { FormControl, FormItem, FormMessage } from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import AutoFormLabel from "../common/label";
|
||||
import AutoFormTooltip from "../common/tooltip";
|
||||
import type { AutoFormInputComponentProps } from "../types";
|
||||
|
||||
export default function AutoFormTextarea({
|
||||
label,
|
||||
isRequired,
|
||||
fieldConfigItem,
|
||||
fieldProps,
|
||||
}: AutoFormInputComponentProps) {
|
||||
const { showLabel: _showLabel, ...fieldPropsWithoutShowLabel } = fieldProps;
|
||||
const showLabel = _showLabel === undefined ? true : _showLabel;
|
||||
return (
|
||||
<FormItem>
|
||||
{showLabel && (
|
||||
<AutoFormLabel label={fieldConfigItem?.label || label} isRequired={isRequired} />
|
||||
)}
|
||||
<FormControl>
|
||||
<Textarea {...fieldPropsWithoutShowLabel} />
|
||||
</FormControl>
|
||||
<AutoFormTooltip fieldConfigItem={fieldConfigItem} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
117
src/components/auto-form/index.tsx
Normal file
117
src/components/auto-form/index.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { type DefaultValues, type FormState, useForm } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import AutoFormObject from "@/components/auto-form/fields/object";
|
||||
import type { Dependency, FieldConfig } from "@/components/auto-form/types";
|
||||
import {
|
||||
type ZodObjectOrWrapped,
|
||||
getDefaultValues,
|
||||
getObjectFormSchema,
|
||||
} from "@/components/auto-form/utils";
|
||||
|
||||
export function AutoFormSubmit({
|
||||
children,
|
||||
className,
|
||||
disabled,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Button type="submit" disabled={disabled} className={className}>
|
||||
{children ?? "Submit"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function AutoForm<SchemaType extends ZodObjectOrWrapped>({
|
||||
formSchema,
|
||||
values: valuesProp,
|
||||
onValuesChange: onValuesChangeProp,
|
||||
onParsedValuesChange,
|
||||
onSubmit: onSubmitProp,
|
||||
fieldConfig,
|
||||
children,
|
||||
className,
|
||||
dependencies,
|
||||
}: {
|
||||
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
|
||||
| ((formState: FormState<z.infer<SchemaType>>) => React.ReactNode);
|
||||
className?: string;
|
||||
dependencies?: Dependency<z.infer<SchemaType>>[];
|
||||
}) {
|
||||
const objectFormSchema = getObjectFormSchema(formSchema);
|
||||
const defaultValues: DefaultValues<z.infer<typeof objectFormSchema>> | null =
|
||||
getDefaultValues(objectFormSchema, fieldConfig);
|
||||
|
||||
const form = useForm<z.infer<typeof objectFormSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: defaultValues ?? undefined,
|
||||
values: valuesProp,
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
const parsedValues = formSchema.safeParse(values);
|
||||
if (parsedValues.success) {
|
||||
onSubmitProp?.(parsedValues.data);
|
||||
}
|
||||
}
|
||||
|
||||
const values = form.watch();
|
||||
// valuesString is needed because form.watch() returns a new object every time
|
||||
const valuesString = JSON.stringify(values);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
|
||||
useEffect(() => {
|
||||
onValuesChangeProp?.(values);
|
||||
const parsedValues = formSchema.safeParse(values);
|
||||
if (parsedValues.success) {
|
||||
onParsedValuesChange?.(parsedValues.data);
|
||||
}
|
||||
}, [valuesString]);
|
||||
|
||||
const renderChildren =
|
||||
typeof children === "function"
|
||||
? children(form.formState as FormState<z.infer<SchemaType>>)
|
||||
: children;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
form.handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
className={cn("space-y-5", className)}
|
||||
>
|
||||
<AutoFormObject
|
||||
schema={objectFormSchema}
|
||||
form={form}
|
||||
dependencies={dependencies}
|
||||
fieldConfig={fieldConfig}
|
||||
/>
|
||||
|
||||
{renderChildren}
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AutoForm;
|
71
src/components/auto-form/types.ts
Normal file
71
src/components/auto-form/types.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import type React from "react";
|
||||
import type { ControllerRenderProps, FieldValues } from "react-hook-form";
|
||||
import type * as z from "zod";
|
||||
import type { INPUT_COMPONENTS } from "./config";
|
||||
|
||||
export type FieldConfigItem = {
|
||||
description?: React.ReactNode;
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
showLabel?: boolean;
|
||||
};
|
||||
label?: 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;
|
||||
};
|
||||
|
||||
export enum DependencyType {
|
||||
DISABLES = 0,
|
||||
REQUIRES = 1,
|
||||
HIDES = 2,
|
||||
SETS_OPTIONS = 3,
|
||||
}
|
||||
|
||||
type BaseDependency<SchemaType extends z.infer<z.ZodObject<any, any>>> = {
|
||||
sourceField: keyof SchemaType;
|
||||
type: DependencyType;
|
||||
targetField: keyof SchemaType;
|
||||
when: (sourceFieldValue: any, targetFieldValue: any) => boolean;
|
||||
};
|
||||
|
||||
export type ValueDependency<SchemaType extends z.infer<z.ZodObject<any, any>>> =
|
||||
BaseDependency<SchemaType> & {
|
||||
type: DependencyType.DISABLES | DependencyType.REQUIRES | DependencyType.HIDES;
|
||||
};
|
||||
|
||||
export type EnumValues = readonly [string, ...string[]];
|
||||
|
||||
export type OptionsDependency<SchemaType extends z.infer<z.ZodObject<any, any>>> =
|
||||
BaseDependency<SchemaType> & {
|
||||
type: DependencyType.SETS_OPTIONS;
|
||||
|
||||
// Partial array of values from sourceField that will trigger the dependency
|
||||
options: EnumValues;
|
||||
};
|
||||
|
||||
export type Dependency<SchemaType extends z.infer<z.ZodObject<any, any>>> =
|
||||
| ValueDependency<SchemaType>
|
||||
| OptionsDependency<SchemaType>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
className?: string;
|
||||
};
|
167
src/components/auto-form/utils.ts
Normal file
167
src/components/auto-form/utils.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import type React from "react";
|
||||
import type { DefaultValues } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
import type { FieldConfig } from "./types";
|
||||
|
||||
// TODO: This should support recursive ZodEffects but TypeScript doesn't allow circular type definitions.
|
||||
export type ZodObjectOrWrapped =
|
||||
| z.ZodObject<any, any>
|
||||
| z.ZodEffects<z.ZodObject<any, any>>;
|
||||
|
||||
/**
|
||||
* Beautify a camelCase string.
|
||||
* e.g. "myString" -> "My String"
|
||||
*/
|
||||
export function beautifyObjectName(string: string) {
|
||||
// if numbers only return the 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.
|
||||
*/
|
||||
export function getBaseSchema<ChildType extends z.ZodAny | z.AnyZodObject = z.ZodAny>(
|
||||
schema: ChildType | z.ZodEffects<ChildType>,
|
||||
): ChildType | null {
|
||||
if (!schema) return null;
|
||||
if ("innerType" in schema._def) {
|
||||
return getBaseSchema(schema._def.innerType as ChildType);
|
||||
}
|
||||
if ("schema" in schema._def) {
|
||||
return getBaseSchema(schema._def.schema as ChildType);
|
||||
}
|
||||
|
||||
return schema as ChildType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type name of the lowest level Zod type.
|
||||
* This will unpack optionals, refinements, etc.
|
||||
*/
|
||||
export function getBaseType(schema: z.ZodAny): string {
|
||||
const baseSchema = getBaseSchema(schema);
|
||||
return baseSchema ? baseSchema._def.typeName : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a "ZodDefult" in the Zod stack and return its value.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export function getDefaultValues<Schema extends z.ZodObject<any, any>>(
|
||||
schema: Schema,
|
||||
fieldConfig?: FieldConfig<z.infer<Schema>>,
|
||||
) {
|
||||
if (!schema) return null;
|
||||
const { shape } = schema;
|
||||
type DefaultValuesType = DefaultValues<Partial<z.infer<Schema>>>;
|
||||
const defaultValues = {} as DefaultValuesType;
|
||||
if (!shape) return defaultValues;
|
||||
|
||||
for (const key of Object.keys(shape)) {
|
||||
const item = shape[key] as z.ZodAny;
|
||||
|
||||
if (getBaseType(item) === "ZodObject") {
|
||||
const defaultItems = getDefaultValues(
|
||||
getBaseSchema(item) as unknown as z.ZodObject<any, any>,
|
||||
fieldConfig?.[key] as FieldConfig<z.infer<Schema>>,
|
||||
);
|
||||
|
||||
if (defaultItems !== null) {
|
||||
for (const defaultItemKey of Object.keys(defaultItems)) {
|
||||
const pathKey = `${key}.${defaultItemKey}` as keyof DefaultValuesType;
|
||||
defaultValues[pathKey] = defaultItems[defaultItemKey];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let defaultValue = getDefaultValueInZodStack(item);
|
||||
if (
|
||||
(defaultValue === null || defaultValue === "") &&
|
||||
fieldConfig?.[key]?.inputProps
|
||||
) {
|
||||
defaultValue = (fieldConfig?.[key]?.inputProps as unknown as any).defaultValue;
|
||||
}
|
||||
if (defaultValue !== undefined) {
|
||||
defaultValues[key as keyof DefaultValuesType] = defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValues;
|
||||
}
|
||||
|
||||
export 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.
|
||||
*/
|
||||
export 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 {
|
||||
required: true,
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user