"use client"; import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useWatch } from "react-hook-form"; import { CheckIcon, ChevronDownIcon, SearchIcon, XIcon } from "lucide-react"; import { Badge } from "../../shadcnui/ui/badge"; import { Input } from "../../shadcnui/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "../../shadcnui/ui/popover"; import { FormFieldWrapper } from "./FormFieldWrapper"; import { type DataListRetriever, useDataListRetriever } from "../../hooks/useDataListRetriever"; import { useDebounce } from "../../hooks/useDebounce"; type EntityMultiSelectorProps = { id: string; form: any; label?: string; placeholder?: string; emptyText?: string; isRequired?: boolean; retriever: (params: any) => Promise; retrieverParams?: Record; module: any; getLabel: (entity: T) => string; toFormValue?: (entity: T) => { id: string; [key: string]: any }; getFormValueLabel?: (formValue: any) => string; excludeId?: string; onChange?: (entities?: T[]) => void; renderOption?: (entity: T, isSelected: boolean) => ReactNode; ready?: boolean; description?: string; disabled?: boolean; }; type OptionData = { id: string; label: string; entityData?: T; }; const defaultFormValueLabel = (v: any) => v.name ?? v.id; export function EntityMultiSelector({ id, form, label, placeholder = "Search...", emptyText = "No results found.", isRequired = false, retriever, retrieverParams = {}, module, getLabel, toFormValue, getFormValueLabel, excludeId, onChange, renderOption, ready = true, description, disabled = false, }: EntityMultiSelectorProps) { const [open, setOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(""); const [options, setOptions] = useState[]>([]); const searchInputRef = useRef(null); const searchTermRef = useRef(""); // Stabilize callback props in refs to prevent infinite re-render loops. // These functions are passed inline by consumers (e.g. getLabel={(w) => w.name}) // which creates new references every render. Using refs keeps effects stable. const getLabelRef = useRef(getLabel); const toFormValueRef = useRef(toFormValue); const getFormValueLabelRef = useRef(getFormValueLabel); const onChangeRef = useRef(onChange); useEffect(() => { getLabelRef.current = getLabel; }, [getLabel]); useEffect(() => { toFormValueRef.current = toFormValue; }, [toFormValue]); useEffect(() => { getFormValueLabelRef.current = getFormValueLabel; }, [getFormValueLabel]); useEffect(() => { onChangeRef.current = onChange; }, [onChange]); const stableGetFormValueLabel = useCallback((v: any) => { const fn = getFormValueLabelRef.current; return fn ? fn(v) : defaultFormValueLabel(v); }, []); const stableToFormValue = useCallback((entity: T) => { const fn = toFormValueRef.current; return fn ? fn(entity) : { id: entity.id, name: getLabelRef.current(entity) }; }, []); const selectedValues: { id: string; [key: string]: any }[] = useWatch({ control: form.control, name: id }) || []; const selectedIds = useMemo(() => new Set(selectedValues.map((v) => v.id)), [selectedValues]); const data: DataListRetriever = useDataListRetriever({ retriever: (params) => retriever(params), retrieverParams, ready, module, }); useEffect(() => { if (ready) data.setReady(true); }, [ready]); const updateSearch = useCallback( (searchedTerm: string) => { const trimmed = searchedTerm.trim(); if (trimmed === searchTermRef.current) return; searchTermRef.current = trimmed; data.search(trimmed); }, [data], ); const debouncedUpdateSearch = useDebounce(updateSearch, 500); useEffect(() => { debouncedUpdateSearch(searchTerm); }, [debouncedUpdateSearch, searchTerm]); useEffect(() => { if (data.data) { const entities = data.data as T[]; const filtered = excludeId ? entities.filter((e) => e.id !== excludeId) : entities; const entityOptions: OptionData[] = filtered.map((entity) => ({ id: entity.id, label: getLabelRef.current(entity), entityData: entity, })); const existingIds = new Set(entityOptions.map((o) => o.id)); const missingOptions: OptionData[] = selectedValues .filter((v) => !existingIds.has(v.id)) .map((v) => ({ id: v.id, label: stableGetFormValueLabel(v), entityData: v as unknown as T, })); setOptions([...entityOptions, ...missingOptions]); } }, [data.data, excludeId, selectedValues, stableGetFormValueLabel]); useEffect(() => { if (open) { setSearchTerm(""); requestAnimationFrame(() => { searchInputRef.current?.focus(); }); } }, [open]); const toggleEntity = useCallback( (option: OptionData) => { const current: any[] = form.getValues(id) ?? []; let next: any[]; if (selectedIds.has(option.id)) { next = current.filter((v: any) => v.id !== option.id); } else { const formValue = option.entityData ? stableToFormValue(option.entityData) : { id: option.id, name: option.label }; next = [...current, formValue]; } form.setValue(id, next, { shouldDirty: true, shouldTouch: true }); const cb = onChangeRef.current; if (cb) { const fullData = next .map((v: any) => options.find((opt) => opt.id === v.id)?.entityData) .filter(Boolean) as T[]; cb(fullData); } }, [form, id, selectedIds, options, stableToFormValue], ); const removeEntity = useCallback( (entityId: string) => { const current: any[] = form.getValues(id) ?? []; const next = current.filter((v: any) => v.id !== entityId); form.setValue(id, next, { shouldDirty: true, shouldTouch: true }); const cb = onChangeRef.current; if (cb) { const fullData = next .map((v: any) => options.find((opt) => opt.id === v.id)?.entityData) .filter(Boolean) as T[]; cb(fullData); } }, [form, id, options], ); // No client-side filtering: the search term is sent to the API (debounced), so filtering // the response again by label would hide matches found on other fields (e.g. email). const sortedOptions = useMemo(() => { return [...options].sort((a, b) => { const aSelected = selectedIds.has(a.id) ? 0 : 1; const bSelected = selectedIds.has(b.id) ? 0 : 1; return aSelected - bSelected; }); }, [options, selectedIds]); const triggerSummary = useMemo(() => { if (selectedValues.length === 0) return null; return selectedValues.map((v) => stableGetFormValueLabel(v)).join(", "); }, [selectedValues, stableGetFormValueLabel]); return (
{() => (
{selectedValues.length > 0 ? ( <> {triggerSummary} {selectedValues.length} ) : ( {placeholder} )}
setSearchTerm(e.target.value)} /> {searchTerm && ( )}
{sortedOptions.length === 0 ? (
{emptyText}
) : ( sortedOptions.map((option) => { const isSelected = selectedIds.has(option.id); return ( ); }) )}
{selectedValues.length > 0 && (
{selectedValues.map((value) => ( {stableGetFormValueLabel(value)} ))}
)}
)}
); }