init
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import { useRef } from "react"
|
||||
import type { AsyncOption, BaseFieldControlProps } from "../types"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxEmpty,
|
||||
} from "@/shared/components/ui/combobox"
|
||||
|
||||
const defaultGetOptionValue = (opt: any) => opt.value
|
||||
const defaultGetOptionLabel = (opt: any) => opt.label
|
||||
function defaultGetOptionKey(opt: any): string {
|
||||
const v = defaultGetOptionValue(opt)
|
||||
if (typeof v === "string" || typeof v === "number") return String(v)
|
||||
return String(opt.id ?? JSON.stringify(v))
|
||||
}
|
||||
|
||||
// ── Single-select ──
|
||||
|
||||
export type AsyncSelectFieldProps<TOption = AsyncOption> = BaseFieldControlProps<any> & {
|
||||
options: TOption[]
|
||||
loading?: boolean
|
||||
onInputValueChange?: (value: string) => void
|
||||
placeholder?: string
|
||||
getOptionValue?: (option: TOption) => any
|
||||
getOptionLabel?: (option: TOption) => string
|
||||
getOptionKey?: (option: TOption) => string
|
||||
}
|
||||
|
||||
export function AsyncSelectField<TOption = AsyncOption>({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
invalid,
|
||||
options,
|
||||
loading,
|
||||
onInputValueChange,
|
||||
placeholder = "Search...",
|
||||
getOptionValue = defaultGetOptionValue,
|
||||
getOptionLabel = defaultGetOptionLabel,
|
||||
getOptionKey = defaultGetOptionKey,
|
||||
}: AsyncSelectFieldProps<TOption>) {
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<div ref={anchorRef}>
|
||||
<Combobox
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
disabled={disabled}
|
||||
onInputValueChange={(val, { reason }) => {
|
||||
if (reason === "input-change") {
|
||||
onInputValueChange?.(val)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder={placeholder}
|
||||
showClear={!!value}
|
||||
onBlur={onBlur}
|
||||
aria-invalid={invalid || undefined}
|
||||
/>
|
||||
<ComboboxContent anchor={anchorRef}>
|
||||
<ComboboxList>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!loading &&
|
||||
options.map((opt) => (
|
||||
<ComboboxItem key={getOptionKey(opt)} value={getOptionValue(opt)}>
|
||||
{getOptionLabel(opt)}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
{!loading && options.length === 0 && (
|
||||
<ComboboxEmpty>No results found</ComboboxEmpty>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Multi-select ──
|
||||
|
||||
export type AsyncMultiSelectFieldProps<TOption = AsyncOption> = BaseFieldControlProps<any[]> & {
|
||||
options: TOption[]
|
||||
loading?: boolean
|
||||
onInputValueChange?: (value: string) => void
|
||||
placeholder?: string
|
||||
getOptionValue?: (option: TOption) => any
|
||||
getOptionLabel?: (option: TOption) => string
|
||||
getOptionKey?: (option: TOption) => string
|
||||
}
|
||||
|
||||
export function AsyncMultiSelectField<TOption = AsyncOption>({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
invalid,
|
||||
options,
|
||||
loading,
|
||||
onInputValueChange,
|
||||
placeholder = "Search...",
|
||||
getOptionValue = defaultGetOptionValue,
|
||||
getOptionLabel = defaultGetOptionLabel,
|
||||
getOptionKey = defaultGetOptionKey,
|
||||
}: AsyncMultiSelectFieldProps<TOption>) {
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<div ref={anchorRef}>
|
||||
<Combobox
|
||||
multiple
|
||||
value={value ?? []}
|
||||
onValueChange={(val) => onChange(val as any[])}
|
||||
disabled={disabled}
|
||||
onInputValueChange={(val, { reason }) => {
|
||||
if (reason === "input-change") {
|
||||
onInputValueChange?.(val)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder={placeholder}
|
||||
showClear={value && value.length > 0}
|
||||
onBlur={onBlur}
|
||||
aria-invalid={invalid || undefined}
|
||||
/>
|
||||
<ComboboxContent anchor={anchorRef}>
|
||||
<ComboboxList>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!loading &&
|
||||
options.map((opt) => (
|
||||
<ComboboxItem key={getOptionKey(opt)} value={getOptionValue(opt)}>
|
||||
{getOptionLabel(opt)}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
{!loading && options.length === 0 && (
|
||||
<ComboboxEmpty>No results found</ComboboxEmpty>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
import { Switch } from "@/shared/components/ui/switch"
|
||||
|
||||
export type CheckboxFieldProps = BaseFieldControlProps<boolean> & {
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function CheckboxField({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
name,
|
||||
disabled,
|
||||
invalid,
|
||||
}: CheckboxFieldProps) {
|
||||
return (
|
||||
<Switch
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => onChange(checked === true)}
|
||||
onBlur={onBlur}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
aria-invalid={invalid || undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
|
||||
export type FileInputFieldProps = BaseFieldControlProps<File | null> & {
|
||||
accept?: string
|
||||
}
|
||||
|
||||
export function FileInputField({
|
||||
// value intentionally unused — file inputs cannot be controlled
|
||||
onBlur,
|
||||
name,
|
||||
disabled,
|
||||
invalid,
|
||||
accept,
|
||||
onChange,
|
||||
}: FileInputFieldProps) {
|
||||
return (
|
||||
<Input
|
||||
type="file"
|
||||
accept={accept}
|
||||
onBlur={onBlur}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
aria-invalid={invalid || undefined}
|
||||
onChange={(e) => onChange(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import type { BaseFieldControlProps, SelectOption } from "../types"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
|
||||
export type SelectFieldProps = BaseFieldControlProps<string> & {
|
||||
placeholder?: string
|
||||
options: SelectOption[]
|
||||
}
|
||||
|
||||
export function SelectField({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
name,
|
||||
disabled,
|
||||
invalid,
|
||||
placeholder,
|
||||
options,
|
||||
}: SelectFieldProps) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange} disabled={disabled} name={name}>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
aria-invalid={invalid || undefined}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
|
||||
export type TextInputFieldProps = BaseFieldControlProps<string> & {
|
||||
placeholder?: string
|
||||
type?: React.HTMLInputTypeAttribute
|
||||
}
|
||||
|
||||
export function TextInputField({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
name,
|
||||
disabled,
|
||||
invalid,
|
||||
placeholder,
|
||||
type = "text",
|
||||
}: TextInputFieldProps) {
|
||||
return (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
aria-invalid={invalid || undefined}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
|
||||
export type TextareaFieldProps = BaseFieldControlProps<string> & {
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
}
|
||||
|
||||
export function TextareaField({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
name,
|
||||
disabled,
|
||||
invalid,
|
||||
placeholder,
|
||||
rows,
|
||||
}: TextareaFieldProps) {
|
||||
return (
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
aria-invalid={invalid || undefined}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { FieldShellProps } from "./types"
|
||||
import {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldError,
|
||||
FieldDescription,
|
||||
} from "@/shared/components/ui/field"
|
||||
|
||||
export function FieldShell({
|
||||
label,
|
||||
error,
|
||||
description,
|
||||
required,
|
||||
children,
|
||||
}: FieldShellProps) {
|
||||
return (
|
||||
<Field data-invalid={!!error || undefined}>
|
||||
{label && (
|
||||
<FieldLabel>
|
||||
{label}
|
||||
{required && <span className="text-destructive">*</span>}
|
||||
</FieldLabel>
|
||||
)}
|
||||
{children}
|
||||
{description && <FieldDescription>{description}</FieldDescription>}
|
||||
{error && <FieldError>{error}</FieldError>}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import type { FieldValues, FieldPath } from "react-hook-form"
|
||||
import {
|
||||
useFormContext,
|
||||
useController,
|
||||
} from "react-hook-form"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { FieldShell } from "../field-shell"
|
||||
import {
|
||||
AsyncSelectField,
|
||||
AsyncMultiSelectField,
|
||||
type AsyncSelectFieldProps,
|
||||
type AsyncMultiSelectFieldProps,
|
||||
} from "../controls/async-select-field"
|
||||
import { Field, FieldLabel, FieldError, FieldDescription } from "@/shared/components/ui/field"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { PlusIcon } from "lucide-react"
|
||||
|
||||
// ── Inline create types ──
|
||||
|
||||
export type InlineCreateFormProps = {
|
||||
onSuccess: (newItem?: { value: string; label: string }) => void
|
||||
}
|
||||
|
||||
export type InlineCreateConfig = {
|
||||
createForm: (props: InlineCreateFormProps) => React.ReactNode
|
||||
createLabel?: string
|
||||
}
|
||||
|
||||
function extractItems(response: unknown): any[] {
|
||||
if (Array.isArray(response)) return response
|
||||
if (response && typeof response === "object") {
|
||||
const obj = response as Record<string, unknown>
|
||||
if (obj.data && typeof obj.data === "object" && !Array.isArray(obj.data)) {
|
||||
const nested = obj.data as Record<string, unknown>
|
||||
if (Array.isArray(nested.data)) return nested.data
|
||||
}
|
||||
if (Array.isArray(obj.data)) return obj.data
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// ── Props forwarded to the underlying control ──
|
||||
|
||||
type AsyncSelectControlProps = Omit<
|
||||
AsyncSelectFieldProps,
|
||||
keyof import("../types").BaseFieldControlProps<any> | "options" | "loading" | "onInputValueChange"
|
||||
>
|
||||
|
||||
type AsyncMultiSelectControlProps = Omit<
|
||||
AsyncMultiSelectFieldProps,
|
||||
keyof import("../types").BaseFieldControlProps<any> | "options" | "loading" | "onInputValueChange"
|
||||
>
|
||||
|
||||
// ── Shared base props ──
|
||||
|
||||
type BaseRhfAsyncFieldProps = {
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
queryKey: string[]
|
||||
staleTime?: number
|
||||
} & Partial<InlineCreateConfig>
|
||||
|
||||
type WithLoadOptions = {
|
||||
loadOptions: () => Promise<any[]>
|
||||
listFn?: never
|
||||
mapOption?: never
|
||||
}
|
||||
|
||||
type WithCrudClient<TItem> = {
|
||||
loadOptions?: never
|
||||
listFn: () => Promise<any>
|
||||
mapOption?: (item: TItem) => any
|
||||
}
|
||||
|
||||
type DataSource<TItem = unknown> = WithLoadOptions | WithCrudClient<TItem>
|
||||
|
||||
function useAsyncOptions<TItem>(
|
||||
queryKey: string[],
|
||||
source: DataSource<TItem>,
|
||||
staleTime?: number,
|
||||
) {
|
||||
return useQuery<any[]>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
if ("loadOptions" in source && source.loadOptions) {
|
||||
return source.loadOptions()
|
||||
}
|
||||
if ("listFn" in source && source.listFn) {
|
||||
const response = await source.listFn()
|
||||
const items = extractItems(response)
|
||||
return source.mapOption ? items.map(source.mapOption) : items
|
||||
}
|
||||
return []
|
||||
},
|
||||
staleTime: staleTime ?? 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Single-select wrapper ──
|
||||
|
||||
type RhfAsyncSelectFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
TItem = unknown,
|
||||
> = {
|
||||
name: TName
|
||||
multiple?: false
|
||||
} & BaseRhfAsyncFieldProps & DataSource<TItem> & AsyncSelectControlProps
|
||||
|
||||
export function RhfAsyncSelectField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
TItem = unknown,
|
||||
>(props: RhfAsyncSelectFieldProps<TValues, TName, TItem>) {
|
||||
const {
|
||||
name, label, description, required, disabled,
|
||||
queryKey, staleTime,
|
||||
loadOptions, listFn, mapOption,
|
||||
createForm, createLabel,
|
||||
...controlProps
|
||||
} = props
|
||||
|
||||
const source = { loadOptions, listFn, mapOption } as DataSource<TItem>
|
||||
|
||||
const { control } = useFormContext<TValues>()
|
||||
const { field, fieldState: { error } } = useController({ name, control, disabled })
|
||||
const { data: options = [], isLoading } = useAsyncOptions(queryKey, source, staleTime)
|
||||
const [inputValue, setInputValue] = useState("")
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const getLabel = controlProps.getOptionLabel ?? ((o: any) => o.label)
|
||||
|
||||
const filtered = inputValue
|
||||
? options.filter((o) => String(getLabel(o)).toLowerCase().includes(inputValue.toLowerCase()))
|
||||
: options
|
||||
|
||||
const handleCreateSuccess = (newItem?: { value: string; label: string }) => {
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
if (newItem) {
|
||||
field.onChange(newItem)
|
||||
}
|
||||
setIsCreateOpen(false)
|
||||
}
|
||||
|
||||
// When a createForm is provided, render a custom label row with the "+" button
|
||||
if (createForm) {
|
||||
return (
|
||||
<Field data-invalid={!!error || undefined}>
|
||||
{label && (
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel>
|
||||
{label}
|
||||
{required && <span className="text-destructive ms-0.5">*</span>}
|
||||
</FieldLabel>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-5 w-5"
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
title={`Add new ${createLabel ?? label}`}
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<AsyncSelectField
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
disabled={field.disabled}
|
||||
invalid={!!error}
|
||||
options={filtered}
|
||||
loading={isLoading}
|
||||
onInputValueChange={setInputValue}
|
||||
{...controlProps}
|
||||
/>
|
||||
{description && <FieldDescription>{description}</FieldDescription>}
|
||||
{error && <FieldError>{error.message}</FieldError>}
|
||||
|
||||
<Dialog open={isCreateOpen} onOpenChange={(v) => { if (!v) setIsCreateOpen(false) }}>
|
||||
<DialogContent className="min-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
Add {createLabel ?? label}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[80vh] px-4">
|
||||
{createForm({ onSuccess: handleCreateSuccess })}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldShell label={label} error={error?.message} description={description} required={required}>
|
||||
<AsyncSelectField
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
disabled={field.disabled}
|
||||
invalid={!!error}
|
||||
options={filtered}
|
||||
loading={isLoading}
|
||||
onInputValueChange={setInputValue}
|
||||
{...controlProps}
|
||||
/>
|
||||
</FieldShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Multi-select wrapper ──
|
||||
|
||||
type RhfAsyncMultiSelectFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
TItem = unknown,
|
||||
> = {
|
||||
name: TName
|
||||
multiple: true
|
||||
} & BaseRhfAsyncFieldProps & DataSource<TItem> & AsyncMultiSelectControlProps
|
||||
|
||||
export function RhfAsyncMultiSelectField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
TItem = unknown,
|
||||
>(props: RhfAsyncMultiSelectFieldProps<TValues, TName, TItem>) {
|
||||
const {
|
||||
name, label, description, required, disabled,
|
||||
queryKey, staleTime,
|
||||
loadOptions, listFn, mapOption,
|
||||
...controlProps
|
||||
} = props
|
||||
|
||||
const source = { loadOptions, listFn, mapOption } as DataSource<TItem>
|
||||
|
||||
const { control } = useFormContext<TValues>()
|
||||
const { field, fieldState: { error } } = useController({ name, control, disabled })
|
||||
const { data: options = [], isLoading } = useAsyncOptions(queryKey, source, staleTime)
|
||||
const [inputValue, setInputValue] = useState("")
|
||||
|
||||
const getLabel = controlProps.getOptionLabel ?? ((o: any) => o.label)
|
||||
|
||||
const filtered = inputValue
|
||||
? options.filter((o) => String(getLabel(o)).toLowerCase().includes(inputValue.toLowerCase()))
|
||||
: options
|
||||
|
||||
return (
|
||||
<FieldShell label={label} error={error?.message} description={description} required={required}>
|
||||
<AsyncMultiSelectField
|
||||
value={field.value ?? []}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
disabled={field.disabled}
|
||||
invalid={!!error}
|
||||
options={filtered}
|
||||
loading={isLoading}
|
||||
onInputValueChange={setInputValue}
|
||||
{...controlProps}
|
||||
/>
|
||||
</FieldShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import type { FieldValues, FieldPath } from "react-hook-form"
|
||||
import { useFormContext, useController } from "react-hook-form"
|
||||
import { CheckboxField, type CheckboxFieldProps } from "../controls/checkbox-field"
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
import { FieldError } from "@/shared/components/ui/field"
|
||||
|
||||
type RhfCheckboxFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
} & Omit<CheckboxFieldProps, keyof BaseFieldControlProps<boolean>>
|
||||
|
||||
export function RhfCheckboxField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>({
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
disabled,
|
||||
...controlProps
|
||||
}: RhfCheckboxFieldProps<TValues, TName>) {
|
||||
const { control } = useFormContext<TValues>()
|
||||
const {
|
||||
field,
|
||||
fieldState: { error },
|
||||
} = useController({ name, control, disabled })
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border p-4">
|
||||
<div className="flex-1 space-y-0.5">
|
||||
{label && (
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{label}
|
||||
{required && <span className="text-destructive"> *</span>}
|
||||
</p>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{error && <FieldError>{error.message}</FieldError>}
|
||||
</div>
|
||||
<CheckboxField
|
||||
{...(controlProps as any)}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
disabled={field.disabled}
|
||||
invalid={!!error}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import type { FieldValues, FieldPath } from "react-hook-form"
|
||||
import { RhfField } from "../rhf-field"
|
||||
import { FileInputField, type FileInputFieldProps } from "../controls/file-input-field"
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
|
||||
type RhfFileFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
} & Omit<FileInputFieldProps, keyof BaseFieldControlProps<File | null>>
|
||||
|
||||
export function RhfFileField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>(props: RhfFileFieldProps<TValues, TName>) {
|
||||
return <RhfField {...props} component={FileInputField} />
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import type { FieldValues, FieldPath } from "react-hook-form"
|
||||
import { RhfField } from "../rhf-field"
|
||||
import { SelectField, type SelectFieldProps } from "../controls/select-field"
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
|
||||
type RhfSelectFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
} & Omit<SelectFieldProps, keyof BaseFieldControlProps<string>>
|
||||
|
||||
export function RhfSelectField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>(props: RhfSelectFieldProps<TValues, TName>) {
|
||||
return <RhfField {...props} component={SelectField} />
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import type { FieldValues, FieldPath } from "react-hook-form"
|
||||
import { RhfField } from "../rhf-field"
|
||||
import { TextInputField, type TextInputFieldProps } from "../controls/text-input-field"
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
|
||||
type RhfTextFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
} & Omit<TextInputFieldProps, keyof BaseFieldControlProps<string>>
|
||||
|
||||
export function RhfTextField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>(props: RhfTextFieldProps<TValues, TName>) {
|
||||
return <RhfField {...props} component={TextInputField} />
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import type { FieldValues, FieldPath } from "react-hook-form"
|
||||
import { RhfField } from "../rhf-field"
|
||||
import { TextareaField, type TextareaFieldProps } from "../controls/textarea-field"
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
|
||||
type RhfTextareaFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
} & Omit<TextareaFieldProps, keyof BaseFieldControlProps<string>>
|
||||
|
||||
export function RhfTextareaField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>(props: RhfTextareaFieldProps<TValues, TName>) {
|
||||
return <RhfField {...props} component={TextareaField} />
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
import { z } from "zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Plus } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import { Rhform, RhfTextField } from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import type { InlineCreateFormProps } from "./rhf-async-select-field"
|
||||
|
||||
const schema = z.object({ title: z.string().min(1, "Required") })
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export type SimpleTitleFormProps = InlineCreateFormProps & {
|
||||
placeholder?: string
|
||||
submitLabel?: string
|
||||
createFn: (title: string) => Promise<any>
|
||||
mapResult?: (data: any) => { value: string; label: string }
|
||||
}
|
||||
|
||||
const defaultMapResult = (data: any) => {
|
||||
const item = data?.data ?? data
|
||||
return {
|
||||
value: String(item.id),
|
||||
label: item.name ?? item.title ?? String(item.id),
|
||||
}
|
||||
}
|
||||
|
||||
export function SimpleTitleForm({
|
||||
onSuccess,
|
||||
placeholder = "Enter name",
|
||||
submitLabel = "Create",
|
||||
createFn,
|
||||
mapResult = defaultMapResult,
|
||||
}: SimpleTitleFormProps) {
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "" },
|
||||
})
|
||||
|
||||
const handleSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const result = await createFn(values.title)
|
||||
toast.success(`${submitLabel}d successfully`)
|
||||
form.reset()
|
||||
onSuccess(mapResult(result))
|
||||
} catch {
|
||||
toast.error(`Failed to ${submitLabel.toLowerCase()}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<RhfTextField name="title" label="Name" placeholder={placeholder} required />
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Plus />
|
||||
{form.formState.isSubmitting ? "Creating..." : submitLabel}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// ── Core ──
|
||||
export { Rhform } from "./rhform"
|
||||
export { RhfField } from "./rhf-field"
|
||||
export { FieldShell } from "./field-shell"
|
||||
|
||||
// ── Types ──
|
||||
export type {
|
||||
BaseFieldControlProps,
|
||||
FieldShellProps,
|
||||
RhformProps,
|
||||
AsyncOption,
|
||||
SelectOption,
|
||||
} from "./types"
|
||||
|
||||
// ── Controls ──
|
||||
export { TextInputField, type TextInputFieldProps } from "./controls/text-input-field"
|
||||
export { TextareaField, type TextareaFieldProps } from "./controls/textarea-field"
|
||||
export { CheckboxField, type CheckboxFieldProps } from "./controls/checkbox-field"
|
||||
export { SelectField, type SelectFieldProps } from "./controls/select-field"
|
||||
export {
|
||||
AsyncSelectField, type AsyncSelectFieldProps,
|
||||
AsyncMultiSelectField, type AsyncMultiSelectFieldProps,
|
||||
} from "./controls/async-select-field"
|
||||
export { FileInputField, type FileInputFieldProps } from "./controls/file-input-field"
|
||||
|
||||
// ── RHF Field Wrappers ──
|
||||
export { RhfTextField } from "./fields/rhf-text-field"
|
||||
export { RhfTextareaField } from "./fields/rhf-textarea-field"
|
||||
export { RhfCheckboxField } from "./fields/rhf-checkbox-field"
|
||||
export { RhfSelectField } from "./fields/rhf-select-field"
|
||||
export { RhfFileField } from "./fields/rhf-file-field"
|
||||
export { RhfAsyncSelectField, RhfAsyncMultiSelectField, type InlineCreateFormProps, type InlineCreateConfig } from "./fields/rhf-async-select-field"
|
||||
export { SimpleTitleForm, type SimpleTitleFormProps } from "./fields/simple-title-form"
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
useFormContext,
|
||||
useController,
|
||||
type FieldValues,
|
||||
type FieldPath,
|
||||
} from "react-hook-form"
|
||||
import { FieldShell } from "./field-shell"
|
||||
import type { BaseFieldControlProps } from "./types"
|
||||
|
||||
type RhfFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
TControlProps extends BaseFieldControlProps<any>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
component: React.ComponentType<TControlProps>
|
||||
} & Omit<TControlProps, keyof BaseFieldControlProps<any>>
|
||||
|
||||
export function RhfField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
TControlProps extends BaseFieldControlProps<any>,
|
||||
>({
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
disabled,
|
||||
component: Component,
|
||||
...controlProps
|
||||
}: RhfFieldProps<TValues, TName, TControlProps>) {
|
||||
const { control } = useFormContext<TValues>()
|
||||
const {
|
||||
field,
|
||||
fieldState: { error },
|
||||
} = useController({ name, control, disabled })
|
||||
|
||||
return (
|
||||
<FieldShell
|
||||
label={label}
|
||||
error={error?.message}
|
||||
description={description}
|
||||
required={required}
|
||||
>
|
||||
<Component
|
||||
{...(controlProps as any)}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
disabled={field.disabled}
|
||||
invalid={!!error}
|
||||
/>
|
||||
</FieldShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import type { FieldValues } from "react-hook-form"
|
||||
import { FormProvider } from "react-hook-form"
|
||||
import type { RhformProps } from "./types"
|
||||
|
||||
export function Rhform<TValues extends FieldValues>({
|
||||
form,
|
||||
onSubmit,
|
||||
children,
|
||||
className,
|
||||
}: RhformProps<TValues>) {
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
onSubmit={(e) => { e.stopPropagation(); form.handleSubmit(onSubmit)(e) }}
|
||||
noValidate
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type {
|
||||
FieldValues,
|
||||
FieldPath,
|
||||
UseFormReturn,
|
||||
} from "react-hook-form"
|
||||
|
||||
// ── Base contract for all field control components ──
|
||||
|
||||
export type BaseFieldControlProps<TValue = string> = {
|
||||
value: TValue
|
||||
onChange: (value: TValue) => void
|
||||
onBlur?: () => void
|
||||
name?: string
|
||||
disabled?: boolean
|
||||
invalid?: boolean
|
||||
}
|
||||
|
||||
// ── FieldShell props (pure UI) ──
|
||||
|
||||
export type FieldShellProps = {
|
||||
label?: string
|
||||
error?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
// ── Rhform props ──
|
||||
|
||||
export type RhformProps<TValues extends FieldValues> = {
|
||||
form: UseFormReturn<TValues>
|
||||
onSubmit: (values: TValues) => void
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
// ── RhfField props ──
|
||||
|
||||
export type RhfFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
TControlProps extends BaseFieldControlProps<any>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
component: React.ComponentType<TControlProps>
|
||||
} & Omit<TControlProps, keyof BaseFieldControlProps<any>>
|
||||
|
||||
// ── Option types ──
|
||||
|
||||
export type AsyncOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type SelectOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
Reference in New Issue
Block a user