diff --git a/src/components/auto-form/common/label.tsx b/src/components/auto-form/common/label.tsx new file mode 100644 index 0000000..df4cdec --- /dev/null +++ b/src/components/auto-form/common/label.tsx @@ -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 ( + <> + + {label} + {isRequired && *} + + + ); +} + +export default AutoFormLabel; diff --git a/src/components/auto-form/common/tooltip.tsx b/src/components/auto-form/common/tooltip.tsx new file mode 100644 index 0000000..8ffb047 --- /dev/null +++ b/src/components/auto-form/common/tooltip.tsx @@ -0,0 +1,13 @@ +function AutoFormTooltip({ fieldConfigItem }: { fieldConfigItem: any }) { + return ( + <> + {fieldConfigItem?.description && ( +

+ {fieldConfigItem.description} +

+ )} + + ); +} + +export default AutoFormTooltip; diff --git a/src/components/auto-form/config.ts b/src/components/auto-form/config.ts new file mode 100644 index 0000000..b98361a --- /dev/null +++ b/src/components/auto-form/config.ts @@ -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", +}; diff --git a/src/components/auto-form/dependencies.ts b/src/components/auto-form/dependencies.ts new file mode 100644 index 0000000..0e28c09 --- /dev/null +++ b/src/components/auto-form/dependencies.ts @@ -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>, +>( + dependencies: Dependency[], + currentFieldName: keyof SchemaType, + watch: UseFormWatch, +) { + 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, + }; +} diff --git a/src/components/auto-form/fields/array.tsx b/src/components/auto-form/fields/array.tsx new file mode 100644 index 0000000..5391dae --- /dev/null +++ b/src/components/auto-form/fields/array.tsx @@ -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 | z.ZodDefault): item is z.ZodArray { + return item instanceof z.ZodArray; +} + +function isZodDefault( + item: z.ZodArray | z.ZodDefault, +): item is z.ZodDefault { + return item instanceof z.ZodDefault; +} + +export default function AutoFormArray({ + name, + item, + form, + path = [], + fieldConfig, +}: { + name: string; + item: z.ZodArray | z.ZodDefault; + form: ReturnType; + 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 ( + + {title} + + {fields.map((_field, index) => { + const key = _field.id; + return ( +
+ } + form={form} + fieldConfig={fieldConfig} + path={[...path, index.toString()]} + /> +
+ +
+ + +
+ ); + })} + +
+
+ ); +} diff --git a/src/components/auto-form/fields/checkbox.tsx b/src/components/auto-form/fields/checkbox.tsx new file mode 100644 index 0000000..3f20a77 --- /dev/null +++ b/src/components/auto-form/fields/checkbox.tsx @@ -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 ( +
+ +
+ + + + +
+
+ +
+ ); +} diff --git a/src/components/auto-form/fields/date.tsx b/src/components/auto-form/fields/date.tsx new file mode 100644 index 0000000..b88c92d --- /dev/null +++ b/src/components/auto-form/fields/date.tsx @@ -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 ( + + + + + + + + + + ); +} diff --git a/src/components/auto-form/fields/enum.tsx b/src/components/auto-form/fields/enum.tsx new file mode 100644 index 0000000..24702bf --- /dev/null +++ b/src/components/auto-form/fields/enum.tsx @@ -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)._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 ( + + + + + + + + + ); +} diff --git a/src/components/auto-form/fields/file.tsx b/src/components/auto-form/fields/file.tsx new file mode 100644 index 0000000..d56020e --- /dev/null +++ b/src/components/auto-form/fields/file.tsx @@ -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(null); + const [fileName, setFileName] = useState(null); + const handleFileChange = (e: ChangeEvent) => { + 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 ( + + {showLabel && ( + + )} + {!file && ( + + + + )} + {file && ( +
+

{fileName}

+ +
+ )} + + +
+ ); +} diff --git a/src/components/auto-form/fields/input.tsx b/src/components/auto-form/fields/input.tsx new file mode 100644 index 0000000..0ed0c24 --- /dev/null +++ b/src/components/auto-form/fields/input.tsx @@ -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 ( +
+ + {showLabel && ( + + )} + + + + + + +
+ ); +} diff --git a/src/components/auto-form/fields/number.tsx b/src/components/auto-form/fields/number.tsx new file mode 100644 index 0000000..cb1d86b --- /dev/null +++ b/src/components/auto-form/fields/number.tsx @@ -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 ( + + {showLabel && ( + + )} + + + + + + + ); +} diff --git a/src/components/auto-form/fields/object.tsx b/src/components/auto-form/fields/object.tsx new file mode 100644 index 0000000..bf6f3e3 --- /dev/null +++ b/src/components/auto-form/fields/object.tsx @@ -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>({ + schema, + form, + fieldConfig, + path = [], + dependencies = [], +}: { + schema: SchemaType | z.ZodEffects; + form: ReturnType; + fieldConfig?: FieldConfig>; + path?: string[]; + dependencies?: Dependency>[]; +}) { + const { watch } = useFormContext(); // Use useFormContext to access the watch function + + if (!schema) { + return null; + } + const { shape } = getBaseSchema(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 ( + + {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 ( + + {itemName} + + } + form={form} + fieldConfig={ + (fieldConfig?.[name] ?? {}) as FieldConfig> + } + path={[...path, name]} + /> + + + ); + } + if (zodBaseType === "ZodArray") { + return ( + } + 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 ( + { + 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 ( + + + + ); + }} + /> + ); + })} + + ); +} diff --git a/src/components/auto-form/fields/radio-group.tsx b/src/components/auto-form/fields/radio-group.tsx new file mode 100644 index 0000000..4d4ee05 --- /dev/null +++ b/src/components/auto-form/fields/radio-group.tsx @@ -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)._def.values; + + let values: string[] = []; + if (!Array.isArray(baseValues)) { + values = Object.entries(baseValues).map((item) => item[0]); + } else { + values = baseValues; + } + + return ( +
+ + + + + {values?.map((value: any) => ( + + + + + {value} + + ))} + + + + + +
+ ); +} diff --git a/src/components/auto-form/fields/switch.tsx b/src/components/auto-form/fields/switch.tsx new file mode 100644 index 0000000..5a10dfc --- /dev/null +++ b/src/components/auto-form/fields/switch.tsx @@ -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 ( +
+ +
+ + + + +
+
+ +
+ ); +} diff --git a/src/components/auto-form/fields/textarea.tsx b/src/components/auto-form/fields/textarea.tsx new file mode 100644 index 0000000..904c5df --- /dev/null +++ b/src/components/auto-form/fields/textarea.tsx @@ -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 ( + + {showLabel && ( + + )} + +