garage-erp/shared/components/form/fields/rhf-async-select-field.tsx
2026-03-26 03:49:05 +03:00

279 lines
8.2 KiB
TypeScript

"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>
)
}