feat(web): add run inputs options on dashboard

This commit is contained in:
BennyKok
2023-12-22 16:09:36 +08:00
parent cdbf4f3dd5
commit 592cc2abcd
29 changed files with 1321 additions and 102 deletions
+33
View File
@@ -0,0 +1,33 @@
import AutoFormCheckbox from "./fields/checkbox";
import AutoFormDate from "./fields/date";
import AutoFormEnum from "./fields/enum";
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,
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",
};
@@ -0,0 +1,67 @@
import type { useForm , useForm } from "react-hook-form";
import { useFieldArray } from "react-hook-form";
import { Button } from "../../button";
import { Separator } from "../../separator";
import { beautifyObjectName } from "../utils";
import AutoFormObject from "./object";
import { Plus, Trash } from "lucide-react";
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import type { z } from "zod";
export default function AutoFormArray({
name,
item,
form,
path = [],
fieldConfig,
}: {
name: string;
item: z.ZodArray<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);
return (
<AccordionItem value={name}>
<AccordionTrigger>{title}</AccordionTrigger>
<AccordionContent className="border-l p-3 pl-6">
{fields.map((_field, index) => {
const key = [...path, index.toString()].join(".");
return (
<div className="mb-4 grid gap-6" key={`${key}`}>
<AutoFormObject
schema={item._def.type as z.ZodObject<any, any>}
form={form}
fieldConfig={fieldConfig}
path={[...path, index.toString()]}
/>
<Button
variant="secondary"
size="icon"
type="button"
onClick={() => remove(index)}
>
<Trash className="h-4 w-4" />
</Button>
<Separator />
</div>
);
})}
<Button
type="button"
onClick={() => append({})}
className="flex items-center"
>
<Plus className="mr-2" size={16} />
Add
</Button>
</AccordionContent>
</AccordionItem>
);
}
@@ -0,0 +1,32 @@
import { Checkbox } from "../../checkbox";
import { FormControl, FormDescription, FormItem, FormLabel } from "../../form";
import { AutoFormInputComponentProps } from "../types";
export default function AutoFormCheckbox({
label,
isRequired,
field,
fieldConfigItem,
fieldProps,
}: AutoFormInputComponentProps) {
return (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
{...fieldProps}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
{label}
{isRequired && <span className="text-destructive"> *</span>}
</FormLabel>
{fieldConfigItem.description && (
<FormDescription>{fieldConfigItem.description}</FormDescription>
)}
</div>
</FormItem>
);
}
@@ -0,0 +1,37 @@
import { DatePicker } from "../../date-picker";
import {
FormControl,
FormDescription,
FormItem,
FormLabel,
FormMessage,
} from "../../form";
import type { AutoFormInputComponentProps } from "../types";
export default function AutoFormDate({
label,
isRequired,
field,
fieldConfigItem,
fieldProps,
}: AutoFormInputComponentProps) {
return (
<FormItem>
<FormLabel>
{label}
{isRequired && <span className="text-destructive"> *</span>}
</FormLabel>
<FormControl>
<DatePicker
date={field.value}
setDate={field.onChange}
{...fieldProps}
/>
</FormControl>
{fieldConfigItem.description && (
<FormDescription>{fieldConfigItem.description}</FormDescription>
)}
<FormMessage />
</FormItem>
);
}
@@ -0,0 +1,71 @@
import {
FormControl,
FormDescription,
FormItem,
FormLabel,
FormMessage,
} from "../../form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../select";
import { AutoFormInputComponentProps } from "../types";
import { getBaseSchema } from "../utils";
import * as z from "zod";
export default function AutoFormEnum({
label,
isRequired,
field,
fieldConfigItem,
zodItem,
}: 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>
<FormLabel>
{label}
{isRequired && <span className="text-destructive"> *</span>}
</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<SelectTrigger>
<SelectValue
className="w-full"
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>
{fieldConfigItem.description && (
<FormDescription>{fieldConfigItem.description}</FormDescription>
)}
<FormMessage />
</FormItem>
);
}
@@ -0,0 +1,36 @@
import {
FormControl,
FormDescription,
FormItem,
FormLabel,
FormMessage,
} from "../../form";
import { Input } from "../../input";
import { AutoFormInputComponentProps } from "../types";
export default function AutoFormInput({
label,
isRequired,
fieldConfigItem,
fieldProps,
}: AutoFormInputComponentProps) {
const { showLabel: _showLabel, ...fieldPropsWithoutShowLabel } = fieldProps;
const showLabel = _showLabel === undefined ? true : _showLabel;
return (
<FormItem>
{showLabel && (
<FormLabel>
{label}
{isRequired && <span className="text-destructive"> *</span>}
</FormLabel>
)}
<FormControl>
<Input type="text" {...fieldPropsWithoutShowLabel} />
</FormControl>
{fieldConfigItem.description && (
<FormDescription>{fieldConfigItem.description}</FormDescription>
)}
<FormMessage />
</FormItem>
);
}
@@ -0,0 +1,17 @@
import { AutoFormInputComponentProps } from "../types";
import AutoFormInput from "./input";
export default function AutoFormNumber({
fieldProps,
...props
}: AutoFormInputComponentProps) {
return (
<AutoFormInput
fieldProps={{
type: "number",
...fieldProps,
}}
{...props}
/>
);
}
@@ -0,0 +1,130 @@
import * as z from "zod";
import { useForm } from "react-hook-form";
import { FieldConfig, FieldConfigItem } from "../types";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "../../accordion";
import {
beautifyObjectName,
getBaseSchema,
getBaseType,
zodToHtmlInputProps,
} from "../utils";
import { FormField } from "../../form";
import { DEFAULT_ZOD_HANDLERS, INPUT_COMPONENTS } from "../config";
import AutoFormArray from "./array";
function DefaultParent({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
export default function AutoFormObject<
SchemaType extends z.ZodObject<any, any>,
>({
schema,
form,
fieldConfig,
path = [],
}: {
schema: SchemaType | z.ZodEffects<SchemaType>;
form: ReturnType<typeof useForm>;
fieldConfig?: FieldConfig<z.infer<SchemaType>>;
path?: string[];
}) {
const { shape } = getBaseSchema<SchemaType>(schema);
return (
<Accordion type="multiple" className="space-y-5">
{Object.keys(shape).map((name) => {
const item = shape[name] as z.ZodAny;
const zodBaseType = getBaseType(item);
const itemName = item._def.description ?? beautifyObjectName(name);
const key = [...path, name].join(".");
if (zodBaseType === "ZodObject") {
return (
<AccordionItem value={name} key={key}>
<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 =
zodInputProps.required ||
fieldConfigItem.inputProps?.required ||
false;
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;
return (
<ParentElement key={`${key}.parent`}>
<InputComponent
zodInputProps={zodInputProps}
field={field}
fieldConfigItem={fieldConfigItem}
label={itemName}
isRequired={isRequired}
zodItem={item}
fieldProps={{
...zodInputProps,
...field,
...fieldConfigItem.inputProps,
value: !fieldConfigItem.inputProps?.defaultValue
? field.value ?? ""
: undefined,
}}
/>
</ParentElement>
);
}}
/>
);
})}
</Accordion>
);
}
@@ -0,0 +1,44 @@
import * as z from "zod";
import { AutoFormInputComponentProps } from "../types";
import { FormControl, FormItem, FormLabel, FormMessage } from "../../form";
import { RadioGroup, RadioGroupItem } from "../../radio-group";
export default function AutoFormRadioGroup({
label,
isRequired,
field,
zodItem,
fieldProps,
}: AutoFormInputComponentProps) {
const values = (zodItem as unknown as z.ZodEnum<any>)._def.values;
return (
<FormItem className="space-y-3">
<FormLabel>
{label}
{isRequired && <span className="text-destructive"> *</span>}
</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex flex-col space-y-1"
{...fieldProps}
>
{values.map((value: any) => (
<FormItem
className="flex items-center space-x-3 space-y-0"
key={value}
>
<FormControl>
<RadioGroupItem value={value} />
</FormControl>
<FormLabel className="font-normal">{value}</FormLabel>
</FormItem>
))}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
);
}
@@ -0,0 +1,32 @@
import { FormControl, FormDescription, FormItem, FormLabel } from "../../form";
import { Switch } from "../../switch";
import { AutoFormInputComponentProps } from "../types";
export default function AutoFormSwitch({
label,
isRequired,
field,
fieldConfigItem,
fieldProps,
}: AutoFormInputComponentProps) {
return (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
{...fieldProps}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
{label}
{isRequired && <span className="text-destructive"> *</span>}
</FormLabel>
{fieldConfigItem.description && (
<FormDescription>{fieldConfigItem.description}</FormDescription>
)}
</div>
</FormItem>
);
}
@@ -0,0 +1,36 @@
import {
FormControl,
FormDescription,
FormItem,
FormLabel,
FormMessage,
} from "../../form";
import { Textarea } from "../../textarea";
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 && (
<FormLabel>
{label}
{isRequired && <span className="text-destructive"> *</span>}
</FormLabel>
)}
<FormControl>
<Textarea {...fieldPropsWithoutShowLabel} />
</FormControl>
{fieldConfigItem.description && (
<FormDescription>{fieldConfigItem.description}</FormDescription>
)}
<FormMessage />
</FormItem>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import React from "react";
import { z } from "zod";
import { Form } from "../form";
import { DefaultValues, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "../button";
import { cn } from "@/lib/utils";
import { FieldConfig } from "./types";
import {
ZodObjectOrWrapped,
getDefaultValues,
getObjectFormSchema,
} from "./utils";
import AutoFormObject from "./fields/object";
export function AutoFormSubmit({ children }: { children?: React.ReactNode }) {
return <Button type="submit">{children ?? "Submit"}</Button>;
}
function AutoForm<SchemaType extends ZodObjectOrWrapped>({
formSchema,
values: valuesProp,
onValuesChange: onValuesChangeProp,
onParsedValuesChange,
onSubmit: onSubmitProp,
fieldConfig,
children,
className,
}: {
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;
className?: string;
}) {
const objectFormSchema = getObjectFormSchema(formSchema);
const defaultValues: DefaultValues<z.infer<typeof objectFormSchema>> =
getDefaultValues(objectFormSchema);
const form = useForm<z.infer<typeof objectFormSchema>>({
resolver: zodResolver(formSchema),
defaultValues,
values: valuesProp,
});
function onSubmit(values: z.infer<typeof formSchema>) {
const parsedValues = formSchema.safeParse(values);
if (parsedValues.success) {
onSubmitProp?.(parsedValues.data);
}
}
return (
<Form {...form}>
<form
onSubmit={(e) => {
form.handleSubmit(onSubmit)(e);
}}
onChange={() => {
const values = form.getValues();
onValuesChangeProp?.(values);
const parsedValues = formSchema.safeParse(values);
if (parsedValues.success) {
onParsedValuesChange?.(parsedValues.data);
}
}}
className={cn("space-y-5", className)}
>
<AutoFormObject
schema={objectFormSchema}
form={form}
fieldConfig={fieldConfig}
/>
{children}
</form>
</Form>
);
}
export default AutoForm;
+37
View File
@@ -0,0 +1,37 @@
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;
};
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;
};
/**
* 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;
};
+159
View File
@@ -0,0 +1,159 @@
import { DefaultValues } from "react-hook-form";
import { z } from "zod";
// 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) {
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 {
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 {
return getBaseSchema(schema)._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,
) {
const { shape } = schema;
type DefaultValuesType = DefaultValues<Partial<z.infer<Schema>>>;
const defaultValues = {} as DefaultValuesType;
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>,
);
for (const defaultItemKey of Object.keys(defaultItems)) {
const pathKey = `${key}.${defaultItemKey}` as keyof DefaultValuesType;
defaultValues[pathKey] = defaultItems[defaultItemKey];
}
} else {
const defaultValue = getDefaultValueInZodStack(item);
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;
}