From dc17e4a8f755ce045b461858ffcd56f567c5658f Mon Sep 17 00:00:00 2001 From: Mathis Date: Mon, 10 Jun 2024 11:51:23 +0200 Subject: [PATCH] feat: Implement new auto-form components and fields This commit includes the creation of new react components and fields for auto-form functionality. The components include AutoFormLabel, AutoFormTooltip, and specific field components for data types such as AutoFormObject, AutoFormArray, AutoFormDate, AutoFormCheckbox, and others. Added tests to ensure the correct rendering of fields, labels generation, among other behaviors. --- src/components/form.tsx | 0 src/components/ui/auto-form/common/label.tsx | 23 ++ .../ui/auto-form/common/tooltip.tsx | 13 + src/components/ui/auto-form/config.ts | 35 +++ src/components/ui/auto-form/dependencies.ts | 57 ++++ src/components/ui/auto-form/fields/array.tsx | 93 +++++++ .../ui/auto-form/fields/checkbox.tsx | 34 +++ src/components/ui/auto-form/fields/date.tsx | 32 +++ src/components/ui/auto-form/fields/enum.tsx | 67 +++++ src/components/ui/auto-form/fields/file.tsx | 67 +++++ src/components/ui/auto-form/fields/input.tsx | 34 +++ src/components/ui/auto-form/fields/number.tsx | 31 +++ src/components/ui/auto-form/fields/object.tsx | 183 +++++++++++++ .../ui/auto-form/fields/radio-group.tsx | 63 +++++ src/components/ui/auto-form/fields/switch.tsx | 34 +++ .../ui/auto-form/fields/textarea.tsx | 30 +++ src/components/ui/auto-form/index.tsx | 115 +++++++++ .../ui/auto-form/tests/basics.cy.tsx | 244 ++++++++++++++++++ src/components/ui/auto-form/types.ts | 76 ++++++ src/components/ui/auto-form/utils.ts | 180 +++++++++++++ src/components/ui/date-picker.tsx | 46 ++++ 21 files changed, 1457 insertions(+) create mode 100644 src/components/form.tsx create mode 100644 src/components/ui/auto-form/common/label.tsx create mode 100644 src/components/ui/auto-form/common/tooltip.tsx create mode 100644 src/components/ui/auto-form/config.ts create mode 100644 src/components/ui/auto-form/dependencies.ts create mode 100644 src/components/ui/auto-form/fields/array.tsx create mode 100644 src/components/ui/auto-form/fields/checkbox.tsx create mode 100644 src/components/ui/auto-form/fields/date.tsx create mode 100644 src/components/ui/auto-form/fields/enum.tsx create mode 100644 src/components/ui/auto-form/fields/file.tsx create mode 100644 src/components/ui/auto-form/fields/input.tsx create mode 100644 src/components/ui/auto-form/fields/number.tsx create mode 100644 src/components/ui/auto-form/fields/object.tsx create mode 100644 src/components/ui/auto-form/fields/radio-group.tsx create mode 100644 src/components/ui/auto-form/fields/switch.tsx create mode 100644 src/components/ui/auto-form/fields/textarea.tsx create mode 100644 src/components/ui/auto-form/index.tsx create mode 100644 src/components/ui/auto-form/tests/basics.cy.tsx create mode 100644 src/components/ui/auto-form/types.ts create mode 100644 src/components/ui/auto-form/utils.ts create mode 100644 src/components/ui/date-picker.tsx diff --git a/src/components/form.tsx b/src/components/form.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/components/ui/auto-form/common/label.tsx b/src/components/ui/auto-form/common/label.tsx new file mode 100644 index 0000000..d570830 --- /dev/null +++ b/src/components/ui/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/ui/auto-form/common/tooltip.tsx b/src/components/ui/auto-form/common/tooltip.tsx new file mode 100644 index 0000000..cafdb79 --- /dev/null +++ b/src/components/ui/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/ui/auto-form/config.ts b/src/components/ui/auto-form/config.ts new file mode 100644 index 0000000..be416ba --- /dev/null +++ b/src/components/ui/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/ui/auto-form/dependencies.ts b/src/components/ui/auto-form/dependencies.ts new file mode 100644 index 0000000..4d9cada --- /dev/null +++ b/src/components/ui/auto-form/dependencies.ts @@ -0,0 +1,57 @@ +import { FieldValues, UseFormWatch } from "react-hook-form"; +import { Dependency, DependencyType, EnumValues } from "./types"; +import * as z from "zod"; + +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/ui/auto-form/fields/array.tsx b/src/components/ui/auto-form/fields/array.tsx new file mode 100644 index 0000000..c66e972 --- /dev/null +++ b/src/components/ui/auto-form/fields/array.tsx @@ -0,0 +1,93 @@ +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, 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/ui/auto-form/fields/checkbox.tsx b/src/components/ui/auto-form/fields/checkbox.tsx new file mode 100644 index 0000000..2b0121c --- /dev/null +++ b/src/components/ui/auto-form/fields/checkbox.tsx @@ -0,0 +1,34 @@ +import { Checkbox } from "@/components/ui/checkbox"; +import { FormControl, FormItem } from "@/components/ui/form"; +import AutoFormTooltip from "../common/tooltip"; +import { AutoFormInputComponentProps } from "../types"; +import AutoFormLabel from "../common/label"; + +export default function AutoFormCheckbox({ + label, + isRequired, + field, + fieldConfigItem, + fieldProps, +}: AutoFormInputComponentProps) { + return ( +
+ +
+ + + + +
+
+ +
+ ); +} diff --git a/src/components/ui/auto-form/fields/date.tsx b/src/components/ui/auto-form/fields/date.tsx new file mode 100644 index 0000000..9c06cdc --- /dev/null +++ b/src/components/ui/auto-form/fields/date.tsx @@ -0,0 +1,32 @@ +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 { AutoFormInputComponentProps } from "../types"; + +export default function AutoFormDate({ + label, + isRequired, + field, + fieldConfigItem, + fieldProps, +}: AutoFormInputComponentProps) { + return ( + + + + + + + + + + ); +} diff --git a/src/components/ui/auto-form/fields/enum.tsx b/src/components/ui/auto-form/fields/enum.tsx new file mode 100644 index 0000000..d5a68db --- /dev/null +++ b/src/components/ui/auto-form/fields/enum.tsx @@ -0,0 +1,67 @@ +import { FormControl, FormItem, FormMessage } from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import * as z from "zod"; +import AutoFormLabel from "../common/label"; +import AutoFormTooltip from "../common/tooltip"; +import { 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/ui/auto-form/fields/file.tsx b/src/components/ui/auto-form/fields/file.tsx new file mode 100644 index 0000000..058aab7 --- /dev/null +++ b/src/components/ui/auto-form/fields/file.tsx @@ -0,0 +1,67 @@ +import { FormControl, FormItem, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Trash2 } from "lucide-react"; +import { ChangeEvent, useState } from "react"; +import AutoFormLabel from "../common/label"; +import AutoFormTooltip from "../common/tooltip"; +import { 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/ui/auto-form/fields/input.tsx b/src/components/ui/auto-form/fields/input.tsx new file mode 100644 index 0000000..33fcd36 --- /dev/null +++ b/src/components/ui/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 { 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/ui/auto-form/fields/number.tsx b/src/components/ui/auto-form/fields/number.tsx new file mode 100644 index 0000000..8637732 --- /dev/null +++ b/src/components/ui/auto-form/fields/number.tsx @@ -0,0 +1,31 @@ +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 { 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/ui/auto-form/fields/object.tsx b/src/components/ui/auto-form/fields/object.tsx new file mode 100644 index 0000000..5ec122d --- /dev/null +++ b/src/components/ui/auto-form/fields/object.tsx @@ -0,0 +1,183 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { FormField } from "@/components/ui/form"; +import { useForm, useFormContext } from "react-hook-form"; +import * as z from "zod"; +import { DEFAULT_ZOD_HANDLERS, INPUT_COMPONENTS } from "../config"; +import { Dependency, FieldConfig, FieldConfigItem } from "../types"; +import { + beautifyObjectName, + getBaseSchema, + getBaseType, + zodToHtmlInputProps, +} from "../utils"; +import AutoFormArray from "./array"; +import resolveDependencies from "../dependencies"; + +function DefaultParent({ children }: { children: React.ReactNode }) { + return <>{children}; +} + +export default function AutoFormObject< + SchemaType extends z.ZodObject, +>({ + 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< + z.infer + > + } + 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/ui/auto-form/fields/radio-group.tsx b/src/components/ui/auto-form/fields/radio-group.tsx new file mode 100644 index 0000000..fd192f0 --- /dev/null +++ b/src/components/ui/auto-form/fields/radio-group.tsx @@ -0,0 +1,63 @@ +import { + FormControl, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import * as z from "zod"; +import AutoFormLabel from "../common/label"; +import AutoFormTooltip from "../common/tooltip"; +import { 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/ui/auto-form/fields/switch.tsx b/src/components/ui/auto-form/fields/switch.tsx new file mode 100644 index 0000000..24ea9ff --- /dev/null +++ b/src/components/ui/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 { AutoFormInputComponentProps } from "../types"; + +export default function AutoFormSwitch({ + label, + isRequired, + field, + fieldConfigItem, + fieldProps, +}: AutoFormInputComponentProps) { + return ( +
+ +
+ + + + +
+
+ +
+ ); +} diff --git a/src/components/ui/auto-form/fields/textarea.tsx b/src/components/ui/auto-form/fields/textarea.tsx new file mode 100644 index 0000000..2064551 --- /dev/null +++ b/src/components/ui/auto-form/fields/textarea.tsx @@ -0,0 +1,30 @@ +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 { 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 && ( + + )} + +