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