2026-04-23 14:38:41 +03:00

131 lines
4.4 KiB
TypeScript

"use client"
import { useState, type ReactNode } from "react"
import { useController, useFormContext, type FieldValues, type FieldPath } from "react-hook-form"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Field, FieldError } from "@/shared/components/ui/field"
import { Plus } from "lucide-react"
import {
ResourceSelectorDialog,
type ResourceSelectorDialogProps,
} from "./resource-selector-dialog"
import type { ResourcePageClient, ResourceItem } from "@/shared/data-view/resource-page"
export type RhfResourceFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
TClient extends ResourcePageClient,
> = {
/** RHF field name — value is an array of items after mapping */
name: TName
/** Label displayed on the card header */
label: string
/** Button text for the trigger */
triggerLabel?: string
/** Map a selected resource row into the form item shape */
mapSelected: (row: ResourceItem<TClient>) => TValues[TName][number]
/** Render the list of selected items inside the card */
renderItems: (
items: TValues[TName],
helpers: {
remove: (index: number) => void
update: (index: number, item: TValues[TName][number]) => void
replace: (items: TValues[TName]) => void
},
) => ReactNode
/** Props forwarded to ResourceSelectorDialog (columns, routeKey, getClient, etc.) */
dialogProps: Omit<
ResourceSelectorDialogProps<TClient>,
"open" | "onOpenChange" | "onConfirm"
>
/** Deduplicate by this key when merging new selections. Defaults to "id" */
itemKey?: string
}
export function RhfResourceField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
TClient extends ResourcePageClient,
>({
name,
label,
triggerLabel,
mapSelected,
renderItems,
dialogProps,
itemKey = "id",
}: RhfResourceFieldProps<TValues, TName, TClient>) {
const { control } = useFormContext<TValues>()
const {
field,
fieldState: { error },
} = useController({ name, control })
const [open, setOpen] = useState(false)
const items: TValues[TName] = field.value ?? ([] as unknown as TValues[TName])
const handleConfirm = (rows: ResourceItem<TClient>[]) => {
const mapped = rows.map(mapSelected)
// Merge: keep existing items, add new ones (deduplicate by itemKey)
const existingKeys = new Set(
(items as any[]).map((item: any) => String(item[itemKey])),
)
const newItems = mapped.filter(
(item: any) => !existingKeys.has(String(item[itemKey])),
)
field.onChange([...items, ...newItems])
}
const helpers = {
remove: (index: number) => {
const next = [...(items as any[])]
next.splice(index, 1)
field.onChange(next)
},
update: (index: number, item: TValues[TName][number]) => {
const next = [...(items as any[])]
next[index] = item
field.onChange(next)
},
replace: (newItems: TValues[TName]) => {
field.onChange(newItems)
},
}
return (
<Field data-invalid={!!error || undefined}>
<Card>
<CardHeader className=" flex justify-between items-center">
<span className="text-base font-medium">{label}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setOpen(true)}
>
<Plus className="mr-1.5 h-4 w-4" />
{triggerLabel ?? label}
</Button>
</CardHeader>
{
items.length > 0 &&
<CardContent>
{(items as any[]).length > 0
&& renderItems(items, helpers)
}
</CardContent>
}
</Card>
{error && <FieldError>{error.message}</FieldError>}
<ResourceSelectorDialog<TClient>
{...dialogProps}
open={open}
onOpenChange={setOpen}
onConfirm={handleConfirm}
/>
</Field>
)
}