Make DynamicForm more modular
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
Control,
|
||||
DeepPartial,
|
||||
FieldValues,
|
||||
Path,
|
||||
SubmitHandler,
|
||||
useForm
|
||||
} from "react-hook-form";
|
||||
import { H4 } from "@components/UI/Typography/H4.js";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.js";
|
||||
import { DynamicFormField, FieldProps } from "./DynamicFormField.js";
|
||||
import { FieldWrapper } from "./FormWrapper.js";
|
||||
|
||||
interface DisabledBy<T> {
|
||||
fieldName: Path<T>;
|
||||
selector?: number;
|
||||
invert?: boolean;
|
||||
}
|
||||
|
||||
export interface BaseFormBuilderProps<T> {
|
||||
name: Path<T>;
|
||||
disabledBy?: DisabledBy<T>[];
|
||||
label: string;
|
||||
description?: string;
|
||||
properties?: {};
|
||||
}
|
||||
|
||||
export interface GenericFormElementProps<T extends FieldValues, Y> {
|
||||
control: Control<T>;
|
||||
disabled?: boolean;
|
||||
field: Y;
|
||||
}
|
||||
|
||||
export interface DynamicFormProps<T extends FieldValues> {
|
||||
onSubmit: SubmitHandler<T>;
|
||||
defaultValues?: DeepPartial<T>;
|
||||
fieldGroups: {
|
||||
label: string;
|
||||
description: string;
|
||||
fields: FieldProps<T>[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export function DynamicForm<T extends FieldValues>({
|
||||
fieldGroups,
|
||||
onSubmit,
|
||||
defaultValues
|
||||
}: DynamicFormProps<T>) {
|
||||
const { handleSubmit, control, getValues } = useForm<T>({
|
||||
mode: "onChange",
|
||||
defaultValues: defaultValues
|
||||
});
|
||||
|
||||
const isDisabled = (disabledBy?: DisabledBy<T>[]): boolean => {
|
||||
if (!disabledBy) return false;
|
||||
|
||||
return disabledBy.some((field) => {
|
||||
const value = getValues(field.fieldName);
|
||||
if (typeof value === "boolean") return field.invert ? value : !value;
|
||||
if (typeof value === "number")
|
||||
return field.invert
|
||||
? field.selector !== value
|
||||
: field.selector === value;
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-8 divide-y divide-gray-200"
|
||||
onChange={handleSubmit(onSubmit)}
|
||||
>
|
||||
{fieldGroups.map((fieldGroup, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="space-y-8 divide-y divide-gray-200 sm:space-y-5"
|
||||
>
|
||||
<div>
|
||||
<H4 className="font-medium">{fieldGroup.label}</H4>
|
||||
<Subtle>{fieldGroup.description}</Subtle>
|
||||
</div>
|
||||
|
||||
{fieldGroup.fields.map((field, index) => (
|
||||
<FieldWrapper label={field.label} description={field.description}>
|
||||
<DynamicFormField
|
||||
key={index}
|
||||
field={field}
|
||||
control={control}
|
||||
disabled={isDisabled(field.disabledBy)}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Control, FieldValues } from "react-hook-form";
|
||||
import { GenericInput, InputFieldProps } from "./FormInput.js";
|
||||
import { ToggleFieldProps, ToggleInput } from "./FormToggle.js";
|
||||
import { SelectFieldProps, SelectInput } from "./FormSelect.js";
|
||||
|
||||
export type FieldProps<T> =
|
||||
| InputFieldProps<T>
|
||||
| SelectFieldProps<T>
|
||||
| ToggleFieldProps<T>;
|
||||
|
||||
export interface DynamicFormFieldProps<T extends FieldValues> {
|
||||
field: FieldProps<T>;
|
||||
control: Control<T>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DynamicFormField<T extends FieldValues>({
|
||||
field,
|
||||
control,
|
||||
disabled
|
||||
}: DynamicFormFieldProps<T>) {
|
||||
switch (field.type) {
|
||||
case "text":
|
||||
case "password":
|
||||
case "number":
|
||||
return (
|
||||
<GenericInput field={field} control={control} disabled={disabled} />
|
||||
);
|
||||
|
||||
case "toggle":
|
||||
return (
|
||||
<ToggleInput field={field} control={control} disabled={disabled} />
|
||||
);
|
||||
case "select":
|
||||
return (
|
||||
<SelectInput field={field} control={control} disabled={disabled} />
|
||||
);
|
||||
case "multiSelect":
|
||||
return <div>tmp</div>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type {
|
||||
BaseFormBuilderProps,
|
||||
GenericFormElementProps
|
||||
} from "./DynamicForm.js";
|
||||
import { Input } from "../UI/Input.js";
|
||||
import { Controller, FieldValues } from "react-hook-form";
|
||||
|
||||
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "text" | "number" | "password";
|
||||
properties?: {
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
action?: {
|
||||
icon: LucideIcon;
|
||||
onClick: () => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function GenericInput<T extends FieldValues>({
|
||||
control,
|
||||
disabled,
|
||||
field
|
||||
}: GenericFormElementProps<T, InputFieldProps<T>>) {
|
||||
return (
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
render={({ field: { value, onChange, ...rest } }) => (
|
||||
<Input
|
||||
type={field.type}
|
||||
value={field.type === "number" ? parseInt(value) : value}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
field.type === "number"
|
||||
? parseInt(e.target.value)
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
disabled={disabled}
|
||||
{...field.properties}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
BaseFormBuilderProps,
|
||||
GenericFormElementProps
|
||||
} from "./DynamicForm.js";
|
||||
import { Controller, FieldValues } from "react-hook-form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "../UI/Select.js";
|
||||
|
||||
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "select" | "multiSelect";
|
||||
properties: BaseFormBuilderProps<T>["properties"] & {
|
||||
enumValue: {
|
||||
[s: string]: string | number;
|
||||
};
|
||||
formatEnumName?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function SelectInput<T extends FieldValues>({
|
||||
control,
|
||||
disabled,
|
||||
field
|
||||
}: GenericFormElementProps<T, SelectFieldProps<T>>) {
|
||||
return (
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
render={({ field: { value, onChange, ...rest } }) => {
|
||||
const { enumValue, formatEnumName, ...remainingProperties } =
|
||||
field.properties;
|
||||
const optionsEnumValues = enumValue
|
||||
? Object.entries(enumValue).filter(
|
||||
(value) => typeof value[1] === "number"
|
||||
)
|
||||
: [];
|
||||
return (
|
||||
<Select
|
||||
onValueChange={(e) => onChange(parseInt(e))}
|
||||
disabled={disabled}
|
||||
value={value?.toString()}
|
||||
{...remainingProperties}
|
||||
{...rest}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{optionsEnumValues.map(([name, value], index) => (
|
||||
<SelectItem key={index} value={value.toString()}>
|
||||
{formatEnumName
|
||||
? name
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.substring(1))
|
||||
.join(" ")
|
||||
: name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
BaseFormBuilderProps,
|
||||
GenericFormElementProps
|
||||
} from "./DynamicForm.js";
|
||||
import { Controller, FieldValues } from "react-hook-form";
|
||||
import { Switch } from "../UI/Switch.js";
|
||||
|
||||
export interface ToggleFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "toggle";
|
||||
}
|
||||
|
||||
export function ToggleInput<T extends FieldValues>({
|
||||
control,
|
||||
disabled,
|
||||
field
|
||||
}: GenericFormElementProps<T, ToggleFieldProps<T>>) {
|
||||
return (
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
render={({ field: { value, onChange, ...rest } }) => (
|
||||
<Switch
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled}
|
||||
{...field.properties}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Label } from "../UI/Label.js";
|
||||
import { ErrorMessage } from "@hookform/error-message";
|
||||
|
||||
export interface FieldWrapperProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FieldWrapper = ({
|
||||
label,
|
||||
description,
|
||||
children
|
||||
}: FieldWrapperProps): JSX.Element => (
|
||||
<div className="pt-6 sm:pt-5">
|
||||
<div role="group" aria-labelledby="label-notifications">
|
||||
<div className="sm:grid sm:grid-cols-3 sm:items-baseline sm:gap-4">
|
||||
<Label>{label}</Label>
|
||||
<div className="sm:col-span-2">
|
||||
<div className="max-w-lg">
|
||||
<p className="text-sm text-gray-500">{description}</p>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
Reference in New Issue
Block a user