131 lines
4.4 KiB
TypeScript
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>
|
|
)
|
|
}
|