Make DynamicForm more modular

This commit is contained in:
Sacha Weatherstone
2023-02-13 22:46:29 +10:00
parent d79a6965c0
commit 53a4ae290c
23 changed files with 478 additions and 336 deletions
+97
View File
@@ -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>
);
}
+41
View File
@@ -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>;
}
}
+48
View File
@@ -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}
/>
)}
/>
);
}
+71
View File
@@ -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>
);
}}
/>
);
}
+32
View File
@@ -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}
/>
)}
/>
);
}
+31
View File
@@ -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>
);