import { useEffect, useId, useMemo, useRef, useState, type FormEvent, type FocusEvent, type KeyboardEvent, type ReactNode } from "react"; import { Plus, Search, Trash2, UserPlus } from "lucide-react"; import { i18nMessage, usePlatformLanguage } from "../../i18n/LanguageContext"; import { isValidEmailAddress } from "../../utils/emailAddresses"; import Button from "../Button"; import FieldLabel from "../help/FieldLabel"; import DataGrid, { DataGridEmptyAction, type DataGridColumn } from "../table/DataGrid"; import TableActionGroup from "../table/TableActionGroup"; import { dedupePeoplePickerItems, externalPeoplePickerItem, peoplePickerDedupeKey, selectionFromPeoplePickerCandidate, type PeoplePickerItem, type PeoplePickerSearch, type PeoplePickerSearchCandidate, type PeoplePickerSearchGroup } from "./peoplePickerTypes"; export type PeoplePickerProps = { id?: string; label?: ReactNode; help?: ReactNode; selectedLabel?: ReactNode; value: readonly PeoplePickerItem[]; onChange: (value: PeoplePickerItem[]) => void; search: PeoplePickerSearch; disabled?: boolean; required?: boolean; allowManualExternal?: boolean; manualEmailRequired?: boolean; minQueryLength?: number; searchLimit?: number; debounceMs?: number; className?: string; }; type IndexedCandidate = { candidate: PeoplePickerSearchCandidate; group: PeoplePickerSearchGroup; index: number; }; const I18N = { people: "i18n:govoplan-core.people.b37554f6", searchPeople: "i18n:govoplan-core.search_people_and_contacts.8e028a78", searchPlaceholder: "i18n:govoplan-core.search_by_name_or_email_address.6283d84d", searching: "i18n:govoplan-core.searching.1a6a5ba8", noMatches: "i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688", addExternal: "i18n:govoplan-core.add_external_person.b19c7abc", externalPerson: "i18n:govoplan-core.external_person.35115d8e", selectedPeople: "i18n:govoplan-core.selected_people.49a953a1", noSelection: "i18n:govoplan-core.no_people_selected_yet.9383cc74", source: "i18n:govoplan-core.source.6da13add", addPerson: "i18n:govoplan-core.add_person.3b10561b", removePerson: "i18n:govoplan-core.remove_person.2ea46758", alreadySelected: "i18n:govoplan-core.this_person_is_already_selected.d337154c", enterName: "i18n:govoplan-core.enter_a_name.86c810d9", searchFailed: "i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7", addSomeone: "i18n:govoplan-core.add_someone_to_the_selection.c617dd69" } as const; function translatedNode(node: ReactNode, translateText: (value: string) => string): ReactNode { return typeof node === "string" ? translateText(node) : node; } function flattenGroups(groups: readonly PeoplePickerSearchGroup[]): IndexedCandidate[] { const flattened: IndexedCandidate[] = []; const seen = new Set(); for (const group of groups) { for (const candidate of group.candidates) { if (!candidate.selection_key || seen.has(candidate.selection_key)) continue; seen.add(candidate.selection_key); flattened.push({ candidate, group, index: flattened.length }); } } return flattened; } export default function PeoplePicker({ id, label = I18N.people, help, selectedLabel = I18N.selectedPeople, value, onChange, search, disabled = false, required = false, allowManualExternal = true, manualEmailRequired = true, minQueryLength = 2, searchLimit = 25, debounceMs = 250, className = "" }: PeoplePickerProps) { const { translateText } = usePlatformLanguage(); const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "-"); const pickerId = id || `people-picker-${generatedId}`; const labelId = `${pickerId}-label`; const inputId = `${pickerId}-search`; const listboxId = `${pickerId}-results`; const statusId = `${pickerId}-status`; const searchInputRef = useRef(null); const manualNameRef = useRef(null); const manualEmailRef = useRef(null); const selectedItems = useMemo(() => dedupePeoplePickerItems(value), [value]); const selectedKeys = useMemo( () => new Set(selectedItems.map(peoplePickerDedupeKey)), [selectedItems] ); const [query, setQuery] = useState(""); const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(false); const [hasSearched, setHasSearched] = useState(false); const [searchError, setSearchError] = useState(""); const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const [manualOpen, setManualOpen] = useState(false); const [manualName, setManualName] = useState(""); const [manualEmail, setManualEmail] = useState(""); const [manualError, setManualError] = useState(""); const normalizedMinQueryLength = Math.max(0, Math.floor(minQueryLength)); const normalizedSearchLimit = Math.max(1, Math.min(Math.floor(searchLimit), 100)); const flattened = useMemo(() => flattenGroups(groups), [groups]); function candidateUnavailable(candidate: PeoplePickerSearchCandidate): boolean { return Boolean(candidate.disabled || selectedKeys.has(peoplePickerDedupeKey(candidate))); } useEffect(() => { const normalizedQuery = query.trim(); if (disabled || normalizedQuery.length < normalizedMinQueryLength) { setGroups([]); setLoading(false); setHasSearched(false); setSearchError(""); return; } const controller = new AbortController(); const timer = window.setTimeout(() => { setLoading(true); setSearchError(""); void search(normalizedQuery, { limit: normalizedSearchLimit, signal: controller.signal }) .then((nextGroups) => { if (controller.signal.aborted) return; setGroups(nextGroups); setHasSearched(true); }) .catch((error: unknown) => { if (controller.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) return; setGroups([]); setHasSearched(true); setSearchError(I18N.searchFailed); }) .finally(() => { if (!controller.signal.aborted) setLoading(false); }); }, Math.max(0, Math.floor(debounceMs))); return () => { controller.abort(); window.clearTimeout(timer); }; }, [debounceMs, disabled, normalizedMinQueryLength, normalizedSearchLimit, query, search]); useEffect(() => { const firstAvailable = flattened.find((item) => !candidateUnavailable(item.candidate)); setActiveIndex(firstAvailable?.index ?? -1); }, [flattened, selectedKeys]); useEffect(() => { if (manualOpen) manualNameRef.current?.focus(); }, [manualOpen]); function focusSearch() { if (disabled) return; setOpen(true); searchInputRef.current?.focus(); } function addCandidate(candidate: PeoplePickerSearchCandidate) { if (disabled || candidateUnavailable(candidate)) return; onChange(dedupePeoplePickerItems([...selectedItems, selectionFromPeoplePickerCandidate(candidate)])); setQuery(""); setGroups([]); setHasSearched(false); setOpen(false); window.requestAnimationFrame(() => searchInputRef.current?.focus()); } function removeItem(item: PeoplePickerItem) { if (disabled) return; const removedKey = peoplePickerDedupeKey(item); onChange(selectedItems.filter((candidate) => peoplePickerDedupeKey(candidate) !== removedKey)); } function moveActive(delta: 1 | -1) { const available = flattened.filter((item) => !candidateUnavailable(item.candidate)); if (available.length === 0) { setActiveIndex(-1); return; } const currentPosition = available.findIndex((item) => item.index === activeIndex); const nextPosition = currentPosition < 0 ? (delta > 0 ? 0 : available.length - 1) : (currentPosition + delta + available.length) % available.length; setActiveIndex(available[nextPosition].index); } function onSearchKeyDown(event: KeyboardEvent) { if (event.key === "ArrowDown") { event.preventDefault(); setOpen(true); moveActive(1); return; } if (event.key === "ArrowUp") { event.preventDefault(); setOpen(true); moveActive(-1); return; } if (event.key === "Enter" && activeIndex >= 0 && open) { event.preventDefault(); const active = flattened.find((item) => item.index === activeIndex); if (active) addCandidate(active.candidate); return; } if (event.key === "Escape") { event.preventDefault(); setOpen(false); setActiveIndex(-1); } } function addManualPerson(event: FormEvent) { event.preventDefault(); const name = manualName.trim(); const email = manualEmail.trim().toLowerCase(); if (!name) { setManualError(I18N.enterName); manualNameRef.current?.focus(); return; } if ((manualEmailRequired && !email) || (email && !isValidEmailAddress(email))) { setManualError("i18n:govoplan-core.enter_a_valid_email_address.2e09edea"); manualEmailRef.current?.focus(); return; } const item = externalPeoplePickerItem(name, email || null); if (selectedKeys.has(peoplePickerDedupeKey(item))) { setManualError(I18N.alreadySelected); return; } onChange(dedupePeoplePickerItems([...selectedItems, item])); setManualName(""); setManualEmail(""); setManualError(""); setManualOpen(false); } function closeResultsOnFocusLeave(event: FocusEvent) { const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; setOpen(false); } const statusMessage = loading ? I18N.searching : searchError ? searchError : query.trim() && query.trim().length < normalizedMinQueryLength ? i18nMessage("i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad", { value0: normalizedMinQueryLength }) : hasSearched && flattened.length === 0 ? I18N.noMatches : ""; const resultsVisible = open && !disabled && query.trim().length >= normalizedMinQueryLength; const columns: DataGridColumn[] = [ { id: "name", header: "i18n:govoplan-core.name.709a2322", minWidth: 180, resizable: true, render: (item) => (
{item.display_name} {item.description && {item.description}}
) }, { id: "email", header: "i18n:govoplan-core.email_address.c94d3175", minWidth: 220, resizable: true, value: (item) => item.email || "—" }, { id: "source", header: I18N.source, minWidth: 130, resizable: true, value: (item) => translateText(item.source_label || kindLabel(item.kind)) }, { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 104, sticky: "end", align: "right", render: (item) => (