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