23 changed files with 478 additions and 336 deletions
@ -1,262 +0,0 @@ |
|||||
import { |
|
||||
Controller, |
|
||||
DeepPartial, |
|
||||
FieldValues, |
|
||||
Path, |
|
||||
SubmitHandler, |
|
||||
useForm |
|
||||
} from "react-hook-form"; |
|
||||
import { Input } from "@components/UI/Input.js"; |
|
||||
import { Label } from "@components/UI/Label.js"; |
|
||||
import { ErrorMessage } from "@hookform/error-message"; |
|
||||
import { |
|
||||
Select, |
|
||||
SelectContent, |
|
||||
SelectItem, |
|
||||
SelectTrigger, |
|
||||
SelectValue |
|
||||
} from "@components/UI/Select.js"; |
|
||||
import { Switch } from "@components/UI/Switch.js"; |
|
||||
import { H4 } from "@components/UI/Typography/H4.js"; |
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.js"; |
|
||||
|
|
||||
interface DisabledBy<T> { |
|
||||
fieldName: Path<T>; |
|
||||
selector?: number; |
|
||||
invert?: boolean; |
|
||||
} |
|
||||
|
|
||||
interface BasicFieldProps<T> { |
|
||||
name: Path<T>; |
|
||||
label: string; |
|
||||
description?: string; |
|
||||
active?: boolean; |
|
||||
required?: boolean; |
|
||||
disabledBy?: DisabledBy<T>[]; |
|
||||
} |
|
||||
|
|
||||
interface InputFieldProps<T> extends BasicFieldProps<T> { |
|
||||
type: "text" | "number" | "password"; |
|
||||
suffix?: string; |
|
||||
} |
|
||||
|
|
||||
interface SelectFieldProps<T> extends BasicFieldProps<T> { |
|
||||
type: "select" | "multiSelect"; |
|
||||
|
|
||||
enumValue: { |
|
||||
[s: string]: string | number; |
|
||||
}; |
|
||||
formatEnumName?: boolean; |
|
||||
} |
|
||||
|
|
||||
interface ToggleFieldProps<T> extends BasicFieldProps<T> { |
|
||||
type: "toggle"; |
|
||||
} |
|
||||
|
|
||||
export interface FormProps<T extends FieldValues> { |
|
||||
onSubmit: SubmitHandler<T>; |
|
||||
defaultValues?: DeepPartial<T>; |
|
||||
fieldGroups: { |
|
||||
label: string; |
|
||||
description: string; |
|
||||
fields: (InputFieldProps<T> | SelectFieldProps<T> | ToggleFieldProps<T>)[]; |
|
||||
}[]; |
|
||||
} |
|
||||
|
|
||||
export function DynamicForm<T extends FieldValues>({ |
|
||||
fieldGroups, |
|
||||
onSubmit, |
|
||||
defaultValues |
|
||||
}: FormProps<T>) { |
|
||||
const { register, 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) => { |
|
||||
const fieldWrapperData: FieldWrapperProps = { |
|
||||
label: field.label, |
|
||||
description: field.description, |
|
||||
disabled: isDisabled(field.disabledBy) |
|
||||
}; |
|
||||
|
|
||||
switch (field.type) { |
|
||||
case "text": |
|
||||
return ( |
|
||||
<FieldWrapper key={index} {...fieldWrapperData}> |
|
||||
<Input |
|
||||
type="text" |
|
||||
suffix={field.suffix} |
|
||||
disabled={fieldWrapperData.disabled} |
|
||||
{...register(field.name)} |
|
||||
/> |
|
||||
</FieldWrapper> |
|
||||
); |
|
||||
case "number": |
|
||||
return ( |
|
||||
<FieldWrapper key={index} {...fieldWrapperData}> |
|
||||
<Controller |
|
||||
name={field.name} |
|
||||
control={control} |
|
||||
render={({ field: { value, onChange, ...rest } }) => ( |
|
||||
<Input |
|
||||
type="number" |
|
||||
value={parseInt(value)} |
|
||||
suffix={field.suffix} |
|
||||
onChange={(e) => onChange(parseInt(e.target.value))} |
|
||||
disabled={fieldWrapperData.disabled} |
|
||||
{...rest} |
|
||||
/> |
|
||||
)} |
|
||||
/> |
|
||||
</FieldWrapper> |
|
||||
); |
|
||||
case "password": |
|
||||
return ( |
|
||||
<FieldWrapper key={index} {...fieldWrapperData}> |
|
||||
<Input |
|
||||
type="password" |
|
||||
suffix={field.suffix} |
|
||||
disabled={fieldWrapperData.disabled} |
|
||||
// action={{
|
|
||||
// icon: hidden ? EyeIcon : EyeOffIcon,
|
|
||||
// onClick: () => {
|
|
||||
// }
|
|
||||
// }}
|
|
||||
{...register(field.name)} |
|
||||
/> |
|
||||
</FieldWrapper> |
|
||||
); |
|
||||
case "toggle": |
|
||||
return ( |
|
||||
<FieldWrapper key={index} {...fieldWrapperData}> |
|
||||
<Controller |
|
||||
name={field.name} |
|
||||
control={control} |
|
||||
render={({ field: { value, onChange, ...rest } }) => ( |
|
||||
<Switch |
|
||||
checked={value} |
|
||||
onCheckedChange={onChange} |
|
||||
disabled={fieldWrapperData.disabled} |
|
||||
{...rest} |
|
||||
/> |
|
||||
)} |
|
||||
/> |
|
||||
</FieldWrapper> |
|
||||
); |
|
||||
case "select": |
|
||||
const optionsEnumValues = field.enumValue |
|
||||
? Object.entries(field.enumValue).filter( |
|
||||
(value) => typeof value[1] === "number" |
|
||||
) |
|
||||
: []; |
|
||||
return ( |
|
||||
<FieldWrapper key={index} {...fieldWrapperData}> |
|
||||
<Controller |
|
||||
name={field.name} |
|
||||
control={control} |
|
||||
render={({ field: { value, onChange, ...rest } }) => ( |
|
||||
<Select |
|
||||
onValueChange={(e) => onChange(parseInt(e))} |
|
||||
disabled={fieldWrapperData.disabled} |
|
||||
value={value?.toString()} |
|
||||
{...rest} |
|
||||
> |
|
||||
<SelectTrigger> |
|
||||
<SelectValue /> |
|
||||
</SelectTrigger> |
|
||||
<SelectContent> |
|
||||
{optionsEnumValues.map(([name, value], index) => ( |
|
||||
<SelectItem key={index} value={value.toString()}> |
|
||||
{field.formatEnumName |
|
||||
? name |
|
||||
.replace(/_/g, " ") |
|
||||
.toLowerCase() |
|
||||
.split(" ") |
|
||||
.map( |
|
||||
(s) => |
|
||||
s.charAt(0).toUpperCase() + |
|
||||
s.substring(1) |
|
||||
) |
|
||||
.join(" ") |
|
||||
: name} |
|
||||
</SelectItem> |
|
||||
))} |
|
||||
</SelectContent> |
|
||||
</Select> |
|
||||
)} |
|
||||
/> |
|
||||
</FieldWrapper> |
|
||||
); |
|
||||
case "multiSelect": |
|
||||
return ( |
|
||||
<FieldWrapper key={index} {...fieldWrapperData}> |
|
||||
tmp |
|
||||
</FieldWrapper> |
|
||||
); |
|
||||
} |
|
||||
})} |
|
||||
</div> |
|
||||
))} |
|
||||
</form> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
interface FieldWrapperProps { |
|
||||
label: string; |
|
||||
description?: string; |
|
||||
disabled?: boolean; |
|
||||
children?: React.ReactNode; |
|
||||
} |
|
||||
|
|
||||
const FieldWrapper = ({ |
|
||||
label, |
|
||||
description, |
|
||||
disabled, |
|
||||
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> |
|
||||
); |
|
||||
@ -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> |
||||
|
); |
||||
Loading…
Reference in new issue