type Payload = { username: string; password: string }
import { useForm, FormProvider } from 'react-hook-form'
function Form() {
const methods = useForm<Payload>({
mode: 'all',
});
return (
<form onSubmit={methods.handleSubmit(console.log, console.error)}>
<FormProvider {...methods}>
<GeneratedForm structure={[
[{
name: 'username',
label: 'Username',
type: 'text',
placeholder: 'Enter username'
}], [{
name: "password",
label: "Password",
type: "password",
placeholder: "Enter Password"
}]
] satisfies Structure<Payload>} />
</FormProvider>
</form>
)
}
Last active
July 4, 2023 17:52
-
-
Save rawnly/a86ae349c931a0322902e83032034a3b to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"use client"; | |
import { nanoid } from "nanoid"; | |
import { ClassValue } from "clsx"; | |
import TooltipIcon from "@aquacloud-dev/smartfish-icons/InfoCircleIcon"; | |
import { | |
Controller, | |
FieldPath, | |
FieldValues, | |
PathValue, | |
RegisterOptions, | |
ValidationRule, | |
useFormContext, | |
} from "react-hook-form"; | |
import { match } from "ts-pattern"; | |
import CurrencyInput from "./currency-input"; | |
import FormGroup from "./form-group"; | |
import Hint from "./hint"; | |
import Label from "./label"; | |
import { TextArea } from "../base/inputs/input"; | |
import Input from "../base/input"; | |
import * as Tooltip from "../base/tooltip"; | |
import DatePicker from "./date-picker"; | |
import { CurrencySymbols } from "@smartfish/utils/currencies"; | |
import { | |
Select, | |
SelectContent, | |
SelectItem, | |
SelectTrigger, | |
SelectValue, | |
} from "../base/select"; | |
import isDate from "date-fns/isDate"; | |
import { cx } from "./utils"; | |
export interface BaseFieldProps< | |
T extends FieldValues = FieldValues, | |
K extends FieldPath<T> = FieldPath<T> | |
> { | |
label?: string; | |
name: K; | |
value?: PathValue<T, K>; | |
defaultValue?: PathValue<T, K>; | |
placeholder?: string; | |
hint?: string; | |
tooltip?: string | false; | |
readOnly?: boolean; | |
disabled?: boolean; | |
rules?: RegisterOptions<T, K>; | |
className?: ClassValue; | |
resize?: boolean; | |
/** | |
* | |
* default 12 / number of fields in the group | |
*/ | |
span?: | |
| "1" | |
| "2" | |
| "3" | |
| "4" | |
| "5" | |
| "6" | |
| "7" | |
| "8" | |
| "9" | |
| "10" | |
| "11" | |
| "12"; | |
} | |
export type GenericFieldProps<T extends FieldValues = FieldValues> = | |
BaseFieldProps<T> & { | |
type: | |
| "text" | |
| "password" | |
| "email" | |
| "search" | |
| "date" | |
| "url" | |
| "textarea"; | |
}; | |
export type NumberInputProps<T extends FieldValues = FieldValues> = | |
BaseFieldProps<T> & { | |
type: "number"; | |
min?: number; | |
max?: number; | |
step?: number; | |
}; | |
export type CurrencyInputProps<T extends FieldValues = FieldValues> = | |
BaseFieldProps<T> & { | |
type: "currency"; | |
currency?: CurrencySymbols; | |
}; | |
export type SelectInputProps<T extends FieldValues = FieldValues> = | |
BaseFieldProps<T> & { | |
type: "select"; | |
portal?: boolean; | |
options: { | |
label: string; | |
value: string; | |
}[]; | |
}; | |
export type Field<T extends FieldValues = FieldValues> = | |
| CurrencyInputProps<T> | |
| SelectInputProps<T> | |
| NumberInputProps<T> | |
| GenericFieldProps<T>; | |
export type Structure<T extends FieldValues = FieldValues> = Field<T>[][]; | |
export function GeneratedForm<T extends FieldValues>({ | |
structure, | |
}: { | |
structure: Structure<T>; | |
}) { | |
const { | |
control, | |
register, | |
formState: { errors }, | |
} = useFormContext<T>(); | |
return ( | |
<> | |
{structure.map((row) => ( | |
<div | |
key={nanoid()} | |
className="grid grid-cols-12 gap-4" | |
data-generated-row={nanoid()} | |
> | |
{row.map((field, idx) => { | |
return ( | |
<FormGroup | |
key={idx} | |
className={`col-span-${field.span ?? 12 / row.length}`} | |
> | |
<Label | |
className={cx({ | |
"flex items-center justify-start": field.tooltip, | |
})} | |
> | |
{field.label} | |
{field.tooltip && ( | |
<Tooltip.Root> | |
<Tooltip.Trigger className="ml-auto"> | |
<TooltipIcon className="w-4 h-4" /> | |
</Tooltip.Trigger> | |
<Tooltip.Content>{field.tooltip}</Tooltip.Content> | |
</Tooltip.Root> | |
)} | |
</Label> | |
{match(field) | |
.with({ type: "select" }, (field) => ( | |
<Controller | |
name={field.name} | |
control={control} | |
rules={field.rules} | |
render={({ field: f }) => ( | |
<Select | |
value={f.value} | |
onValueChange={(s) => f.onChange(s as any)} | |
defaultValue={field.defaultValue} | |
> | |
<SelectTrigger | |
ref={f.ref} | |
name={f.name} | |
onBlur={f.onBlur} | |
id={f.name} | |
placeholder={field.placeholder} | |
> | |
<SelectValue /> | |
</SelectTrigger> | |
<SelectContent portal={field.portal}> | |
{field.options.map((item) => ( | |
<SelectItem key={item.value} value={item.value}> | |
{item.label} | |
</SelectItem> | |
))} | |
</SelectContent> | |
</Select> | |
)} | |
/> | |
)) | |
.with({ type: "date" }, (field) => ( | |
<Controller | |
control={control} | |
name={field.name} | |
rules={field.rules} | |
defaultValue={field.defaultValue} | |
render={({ field: f }) => { | |
return ( | |
<DatePicker | |
{...field} | |
ref={f.ref} | |
value={f.value} | |
onValueChange={(s) => f.onChange(s as any)} | |
className={cx(field.className)} | |
min={getValidationValue(field.rules?.min)} | |
max={getValidationValue(field.rules?.max)} | |
/> | |
); | |
}} | |
/> | |
)) | |
.with({ type: "currency" }, (field) => ( | |
<Controller | |
control={control} | |
name={field.name} | |
rules={{ | |
validate(p) { | |
return (p ?? -1) >= 0 || "Invalid cost"; | |
}, | |
}} | |
render={({ field: f }) => ( | |
<CurrencyInput | |
value={f.value?.toString()} | |
placeholder={field.placeholder} | |
prefix={`${field.currency} `} | |
onValueChange={(value) => | |
f.onChange( | |
(value !== undefined ? Number(value) : 0) as any | |
) | |
} | |
decimalsLimit={2} | |
readOnly={field.readOnly} | |
disabled={field.disabled} | |
className={cx(field.className)} | |
/> | |
)} | |
/> | |
)) | |
.with({ type: "textarea" }, (field) => ( | |
<TextArea | |
{...register(field.name, field.rules)} | |
{...field} | |
className={cx(field.className)} | |
/> | |
)) | |
.otherwise((field) => ( | |
<Input | |
{...register(field.name, field.rules)} | |
{...field} | |
className={cx(field.className)} | |
/> | |
))} | |
{errors[field.name] && errors?.[field.name]?.message ? ( | |
<Hint error>{(errors as any)[field.name]?.message}</Hint> | |
) : ( | |
field.hint && <Hint>{field.hint}</Hint> | |
)} | |
</FormGroup> | |
); | |
})} | |
</div> | |
))} | |
</> | |
); | |
} | |
function getValidationValue( | |
validation?: ValidationRule<string | number> | |
): number | Date | undefined { | |
if (!validation) return; | |
if (isDate(validation)) return validation as unknown as Date; | |
if (typeof validation === "number") return validation; | |
if (typeof validation === "string") { | |
return new Date(validation).getTime(); | |
} | |
return getValidationValue(validation.value); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment