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.
This commit is contained in:
parent
036acfce23
commit
dc17e4a8f7
0
src/components/form.tsx
Normal file
0
src/components/form.tsx
Normal file
23
src/components/ui/auto-form/common/label.tsx
Normal file
23
src/components/ui/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/ui/auto-form/common/tooltip.tsx
Normal file
13
src/components/ui/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/ui/auto-form/config.ts
Normal file
35
src/components/ui/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/ui/auto-form/dependencies.ts
Normal file
57
src/components/ui/auto-form/dependencies.ts
Normal file
@ -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<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,
|
||||
};
|
||||
}
|
93
src/components/ui/auto-form/fields/array.tsx
Normal file
93
src/components/ui/auto-form/fields/array.tsx
Normal file
@ -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<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/ui/auto-form/fields/checkbox.tsx
Normal file
34
src/components/ui/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 AutoFormTooltip from "../common/tooltip";
|
||||
import { AutoFormInputComponentProps } from "../types";
|
||||
import AutoFormLabel from "../common/label";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
32
src/components/ui/auto-form/fields/date.tsx
Normal file
32
src/components/ui/auto-form/fields/date.tsx
Normal file
@ -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 (
|
||||
<FormItem>
|
||||
<AutoFormLabel
|
||||
label={fieldConfigItem?.label || label}
|
||||
isRequired={isRequired}
|
||||
/>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
date={field.value}
|
||||
setDate={field.onChange}
|
||||
{...fieldProps}
|
||||
/>
|
||||
</FormControl>
|
||||
<AutoFormTooltip fieldConfigItem={fieldConfigItem} />
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
67
src/components/ui/auto-form/fields/enum.tsx
Normal file
67
src/components/ui/auto-form/fields/enum.tsx
Normal file
@ -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<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>
|
||||
);
|
||||
}
|
67
src/components/ui/auto-form/fields/file.tsx
Normal file
67
src/components/ui/auto-form/fields/file.tsx
Normal file
@ -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<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/ui/auto-form/fields/input.tsx
Normal file
34
src/components/ui/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 { 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>
|
||||
);
|
||||
}
|
31
src/components/ui/auto-form/fields/number.tsx
Normal file
31
src/components/ui/auto-form/fields/number.tsx
Normal file
@ -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 (
|
||||
<FormItem>
|
||||
{showLabel && (
|
||||
<AutoFormLabel
|
||||
label={fieldConfigItem?.label || label}
|
||||
isRequired={isRequired}
|
||||
/>
|
||||
)}
|
||||
<FormControl>
|
||||
<Input type="number" {...fieldPropsWithoutShowLabel} />
|
||||
</FormControl>
|
||||
<AutoFormTooltip fieldConfigItem={fieldConfigItem} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
183
src/components/ui/auto-form/fields/object.tsx
Normal file
183
src/components/ui/auto-form/fields/object.tsx
Normal file
@ -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<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>
|
||||
);
|
||||
}
|
63
src/components/ui/auto-form/fields/radio-group.tsx
Normal file
63
src/components/ui/auto-form/fields/radio-group.tsx
Normal file
@ -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<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/ui/auto-form/fields/switch.tsx
Normal file
34
src/components/ui/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 { 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>
|
||||
);
|
||||
}
|
30
src/components/ui/auto-form/fields/textarea.tsx
Normal file
30
src/components/ui/auto-form/fields/textarea.tsx
Normal file
@ -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 (
|
||||
<FormItem>
|
||||
{showLabel && (
|
||||
<AutoFormLabel
|
||||
label={fieldConfigItem?.label || label}
|
||||
isRequired={isRequired}
|
||||
/>
|
||||
)}
|
||||
<FormControl>
|
||||
<Textarea {...fieldPropsWithoutShowLabel} />
|
||||
</FormControl>
|
||||
<AutoFormTooltip fieldConfigItem={fieldConfigItem} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
115
src/components/ui/auto-form/index.tsx
Normal file
115
src/components/ui/auto-form/index.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import React from "react";
|
||||
import { DefaultValues, FormState, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import AutoFormObject from "./fields/object";
|
||||
import { Dependency, FieldConfig } from "./types";
|
||||
import {
|
||||
ZodObjectOrWrapped,
|
||||
getDefaultValues,
|
||||
getObjectFormSchema,
|
||||
} from "./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);
|
||||
|
||||
React.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;
|
244
src/components/ui/auto-form/tests/basics.cy.tsx
Normal file
244
src/components/ui/auto-form/tests/basics.cy.tsx
Normal file
@ -0,0 +1,244 @@
|
||||
import { z } from "zod";
|
||||
import AutoForm from "../index";
|
||||
|
||||
describe("<AutoForm />", () => {
|
||||
it("renders fields", () => {
|
||||
const formSchema = z.object({
|
||||
username: z.string().min(2, {
|
||||
message: "Username must be at least 2 characters.",
|
||||
}),
|
||||
|
||||
password: z.string().describe("Your secure password").min(8, {
|
||||
message: "Password must be at least 8 characters.",
|
||||
}),
|
||||
});
|
||||
|
||||
cy.mount(<AutoForm formSchema={formSchema} />);
|
||||
cy.get("input[name=username]").should("exist");
|
||||
cy.get("input[name=password]").should("exist");
|
||||
});
|
||||
|
||||
it("renders fields with custom labels", () => {
|
||||
const formSchema = z.object({
|
||||
username: z.string().describe("Your username"),
|
||||
});
|
||||
|
||||
cy.mount(<AutoForm formSchema={formSchema} />);
|
||||
|
||||
cy.get("label").contains("Your username");
|
||||
});
|
||||
|
||||
it("generates default labels", () => {
|
||||
const formSchema = z.object({
|
||||
someFieldName: z.string(),
|
||||
});
|
||||
|
||||
cy.mount(<AutoForm formSchema={formSchema} />);
|
||||
|
||||
cy.get("label").contains("Some Field Name");
|
||||
});
|
||||
|
||||
it("allows setting custom field labels", () => {
|
||||
const formSchema = z.object({
|
||||
someFieldName: z.string(),
|
||||
});
|
||||
|
||||
cy.mount(
|
||||
<AutoForm
|
||||
fieldConfig={{
|
||||
someFieldName: {
|
||||
label: "My field name",
|
||||
},
|
||||
}}
|
||||
formSchema={formSchema}
|
||||
/>,
|
||||
);
|
||||
|
||||
cy.get("label").contains("My field name");
|
||||
});
|
||||
|
||||
it("allows setting custom field props", () => {
|
||||
const formSchema = z.object({
|
||||
username: z.string(),
|
||||
});
|
||||
|
||||
cy.mount(
|
||||
<AutoForm
|
||||
formSchema={formSchema}
|
||||
fieldConfig={{
|
||||
username: {
|
||||
inputProps: {
|
||||
placeholder: "Enter your username",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
cy.get("input[name=username]").should(
|
||||
"have.attr",
|
||||
"placeholder",
|
||||
"Enter your username",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows setting custom field type", () => {
|
||||
const formSchema = z.object({
|
||||
username: z.string(),
|
||||
});
|
||||
|
||||
cy.mount(
|
||||
<AutoForm
|
||||
formSchema={formSchema}
|
||||
fieldConfig={{
|
||||
username: {
|
||||
fieldType: "number",
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
cy.get("input").should("have.attr", "type", "number");
|
||||
});
|
||||
|
||||
it("can submit valid forms", () => {
|
||||
const formSchema = z.object({
|
||||
username: z.string(),
|
||||
});
|
||||
|
||||
cy.mount(
|
||||
<AutoForm
|
||||
formSchema={formSchema}
|
||||
onSubmit={(values) => {
|
||||
expect(values).to.deep.equal({
|
||||
username: "john",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<button type="submit">Submit</button>
|
||||
</AutoForm>,
|
||||
);
|
||||
|
||||
cy.get("input[name=username]").type("john");
|
||||
cy.get("button[type=submit]").click();
|
||||
});
|
||||
|
||||
it("shows error for invalid forms", () => {
|
||||
const formSchema = z.object({
|
||||
username: z.string(),
|
||||
});
|
||||
|
||||
cy.mount(
|
||||
<AutoForm
|
||||
formSchema={formSchema}
|
||||
onSubmit={() => {
|
||||
expect.fail("Should not be called.");
|
||||
}}
|
||||
>
|
||||
<button type="submit">Submit</button>
|
||||
</AutoForm>,
|
||||
);
|
||||
|
||||
cy.get("button[type=submit]").click();
|
||||
});
|
||||
|
||||
it("can set default values", () => {
|
||||
const formSchema = z.object({
|
||||
username: z.string().default("john"),
|
||||
});
|
||||
|
||||
cy.mount(<AutoForm formSchema={formSchema} />);
|
||||
|
||||
cy.get("input[name=username]").should("have.value", "john");
|
||||
});
|
||||
|
||||
it("can submit with default values", () => {
|
||||
const formSchema = z.object({
|
||||
username: z.string().default("john"),
|
||||
});
|
||||
|
||||
cy.mount(
|
||||
<AutoForm
|
||||
formSchema={formSchema}
|
||||
onSubmit={(values) => {
|
||||
expect(values).to.deep.equal({
|
||||
username: "john",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<button type="submit">Submit</button>
|
||||
</AutoForm>,
|
||||
);
|
||||
|
||||
cy.get("button[type=submit]").click();
|
||||
});
|
||||
|
||||
it("can set and submit optional values", () => {
|
||||
const formSchema = z.object({
|
||||
username: z.string().optional(),
|
||||
});
|
||||
|
||||
cy.mount(
|
||||
<AutoForm
|
||||
formSchema={formSchema}
|
||||
onSubmit={(values) => {
|
||||
expect(values).to.deep.equal({
|
||||
username: undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<button type="submit">Submit</button>
|
||||
</AutoForm>,
|
||||
);
|
||||
|
||||
cy.get("input[name=username]").should("have.value", "");
|
||||
cy.get("button[type=submit]").click();
|
||||
});
|
||||
|
||||
it("can add description", () => {
|
||||
const formSchema = z.object({
|
||||
username: z.string(),
|
||||
});
|
||||
|
||||
cy.mount(
|
||||
<AutoForm
|
||||
formSchema={formSchema}
|
||||
fieldConfig={{
|
||||
username: {
|
||||
description: "Your username here",
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
cy.get("p").contains("Your username here");
|
||||
});
|
||||
|
||||
it("can set default values on array", () => {
|
||||
const formSchema = z.object({
|
||||
arr: z.array(z.object({ name: z.string(), age: z.number() })).default([
|
||||
{ name: "Haykal", age: 21 },
|
||||
{ name: "John", age: 20 },
|
||||
]),
|
||||
});
|
||||
|
||||
cy.mount(<AutoForm formSchema={formSchema} />);
|
||||
|
||||
//get button with text Arr
|
||||
cy.get("button").contains("Arr").click();
|
||||
cy.get("input[name='arr.0.name']").should("have.value", "Haykal");
|
||||
cy.get("input[name='arr.0.age']").should("have.value", "21");
|
||||
cy.get("input[name='arr.1.name']").should("have.value", "John");
|
||||
cy.get("input[name='arr.1.age']").should("have.value", "20");
|
||||
});
|
||||
|
||||
it("can set default value of number to 0", () => {
|
||||
const formSchema = z.object({
|
||||
number: z.number().default(0),
|
||||
});
|
||||
|
||||
cy.mount(<AutoForm formSchema={formSchema} />);
|
||||
|
||||
cy.get("input[name='number']").should("have.value", "0");
|
||||
});
|
||||
});
|
76
src/components/ui/auto-form/types.ts
Normal file
76
src/components/ui/auto-form/types.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { ControllerRenderProps, FieldValues } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { 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,
|
||||
REQUIRES,
|
||||
HIDES,
|
||||
SETS_OPTIONS,
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
180
src/components/ui/auto-form/utils.ts
Normal file
180
src/components/ui/auto-form/utils.ts
Normal file
@ -0,0 +1,180 @@
|
||||
import React from "react";
|
||||
import { DefaultValues } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { 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;
|
||||
}
|
46
src/components/ui/date-picker.tsx
Normal file
46
src/components/ui/date-picker.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
import { format } from "date-fns";
|
||||
import { Calendar as CalendarIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
export const DatePicker = forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
date?: Date;
|
||||
setDate: (date?: Date) => void;
|
||||
}
|
||||
>(function DatePickerCmp({ date, setDate }, ref) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!date && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{date ? format(date, "PPP") : <span>Pick a date</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" ref={ref}>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user