Compare commits
2 Commits
606f37e78f
...
dc17e4a8f7
Author | SHA1 | Date | |
---|---|---|---|
dc17e4a8f7 | |||
036acfce23 |
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>
|
||||||
|
);
|
||||||
|
});
|
@ -3,7 +3,7 @@
|
|||||||
import { createContext, useContext, useState } from 'react';
|
import { createContext, useContext, useState } from 'react';
|
||||||
import {IUserData} from "@/interfaces/userdata.interface";
|
import {IUserData} from "@/interfaces/userdata.interface";
|
||||||
|
|
||||||
const UserDataContext = createContext<IUserData>(false)
|
const UserDataContext = createContext<IUserData>({name: "Avnyr"})
|
||||||
|
|
||||||
//TODO Run register task
|
//TODO Run register task
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user