import { useMemo } from "react"; import { Controller, useFormContext, type FieldPath, type FieldValues, } from "react-hook-form"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; import { useTheme } from "@mui/material/styles"; import { Trash2, GripVertical } from "lucide-react"; import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; import type { DropResult } from "@hello-pangea/dnd"; import { useTranslation } from "react-i18next"; import { Autocomplete } from "@/Components/inputs"; import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; export interface MultiSelectOption { id: string | number; name: string; } interface FormMultiSelectFieldProps { name: FieldPath; options: O[]; fieldLabel?: string; placeholder?: string; description?: string; renderRow?: (option: O) => React.ReactNode; renderOptionContent?: (option: O) => React.ReactNode; sortable?: boolean; } export const FormMultiSelectField = ({ name, options, fieldLabel, placeholder, description, renderRow, renderOptionContent, sortable = false, }: FormMultiSelectFieldProps) => { const { control } = useFormContext(); const theme = useTheme(); const { t } = useTranslation(); const optionsById = useMemo( () => new Map(options.map((o) => [o.id, o])), [options] ); return ( { const selectedIds: O["id"][] = field.value ?? []; const selected = selectedIds .map((id) => optionsById.get(id)) .filter((o): o is O => o !== undefined); const handleDragEnd = (result: DropResult) => { if (!result.destination) return; const reordered = selected.map((o) => o.id); const [moved] = reordered.splice(result.source.index, 1); reordered.splice(result.destination.index, 0, moved); const unresolved = selectedIds.filter((id) => !optionsById.has(id)); field.onChange([...reordered, ...unresolved]); }; const rowContent = (option: O) => renderRow ? ( renderRow(option) ) : ( {option.name} ); const removeButton = (option: O) => ( { field.onChange(selectedIds.filter((id) => id !== option.id)); }} aria-label={t("common.buttons.removeItem", { item: option.name })} > ); return ( option.name} isOptionEqualToValue={(option, value) => option.id === value.id} onChange={(_: unknown, newValue: O[]) => { // The Autocomplete only sees resolved options, so ids missing // from options must be re-appended or they'd be silently // dropped (e.g. a role excluded from the selectable list). const unresolved = selectedIds.filter((id) => !optionsById.has(id)); field.onChange([...newValue.map((o) => o.id), ...unresolved]); }} fieldLabel={fieldLabel} placeholder={selected.length > 0 ? "" : placeholder} renderOptionContent={renderOptionContent} error={!!fieldState.error} helperText={fieldState.error?.message ?? ""} /> {description && ( {description} )} {sortable ? selected.length > 0 && ( {(provided) => ( {selected.map((option, index) => ( {(provided) => ( {rowContent(option)} {removeButton(option)} )} ))} {provided.placeholder} )} ) : selected.map((option) => ( {rowContent(option)} {removeButton(option)} ))} ); }} /> ); };