518 lines
19 KiB
TypeScript
518 lines
19 KiB
TypeScript
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<string>();
|
|
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<HTMLInputElement | null>(null);
|
|
const manualNameRef = useRef<HTMLInputElement | null>(null);
|
|
const manualEmailRef = useRef<HTMLInputElement | null>(null);
|
|
const selectedItems = useMemo(() => dedupePeoplePickerItems(value), [value]);
|
|
const selectedKeys = useMemo(
|
|
() => new Set(selectedItems.map(peoplePickerDedupeKey)),
|
|
[selectedItems]
|
|
);
|
|
const [query, setQuery] = useState("");
|
|
const [groups, setGroups] = useState<readonly PeoplePickerSearchGroup[]>([]);
|
|
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<HTMLInputElement>) {
|
|
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<HTMLFormElement>) {
|
|
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<HTMLElement>) {
|
|
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<PeoplePickerItem>[] = [
|
|
{
|
|
id: "name",
|
|
header: "i18n:govoplan-core.name.709a2322",
|
|
minWidth: 180,
|
|
resizable: true,
|
|
render: (item) => (
|
|
<div className="people-picker-selected-person">
|
|
<strong>{item.display_name}</strong>
|
|
{item.description && <small>{item.description}</small>}
|
|
</div>
|
|
)
|
|
},
|
|
{
|
|
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) => (
|
|
<TableActionGroup
|
|
minimumSlots={2}
|
|
actions={[
|
|
{
|
|
id: "add",
|
|
label: I18N.addPerson,
|
|
icon: <Plus size={16} aria-hidden="true" />,
|
|
disabled,
|
|
onClick: focusSearch
|
|
},
|
|
{
|
|
id: "remove",
|
|
label: I18N.removePerson,
|
|
icon: <Trash2 size={16} aria-hidden="true" />,
|
|
variant: "danger",
|
|
disabled,
|
|
onClick: () => removeItem(item)
|
|
}
|
|
]}
|
|
/>
|
|
)
|
|
}
|
|
];
|
|
|
|
return (
|
|
<section className={`people-picker ${className}`.trim()} aria-labelledby={labelId} onBlur={closeResultsOnFocusLeave}>
|
|
<div className="people-picker-search-field">
|
|
<label htmlFor={inputId} id={labelId}>
|
|
<FieldLabel help={help}>{translatedNode(label, translateText)}</FieldLabel>
|
|
</label>
|
|
<div className="people-picker-search-control">
|
|
<Search size={17} aria-hidden="true" />
|
|
<input
|
|
ref={searchInputRef}
|
|
id={inputId}
|
|
type="search"
|
|
role="combobox"
|
|
value={query}
|
|
disabled={disabled}
|
|
required={required && selectedItems.length === 0}
|
|
placeholder={translateText(I18N.searchPlaceholder)}
|
|
aria-label={translateText(I18N.searchPeople)}
|
|
aria-autocomplete="list"
|
|
aria-expanded={resultsVisible}
|
|
aria-controls={listboxId}
|
|
aria-activedescendant={activeIndex >= 0 ? `${pickerId}-option-${activeIndex}` : undefined}
|
|
aria-describedby={statusId}
|
|
aria-busy={loading}
|
|
onFocus={() => setOpen(true)}
|
|
onChange={(event) => {
|
|
setQuery(event.target.value);
|
|
setOpen(true);
|
|
setSearchError("");
|
|
}}
|
|
onKeyDown={onSearchKeyDown}
|
|
/>
|
|
</div>
|
|
|
|
{resultsVisible && (
|
|
<div id={listboxId} className="people-picker-results" role="listbox" aria-label={translateText(I18N.searchPeople)}>
|
|
{groups.map((group) => {
|
|
const candidates = flattened.filter((item) => item.group.key === group.key);
|
|
if (candidates.length === 0) return null;
|
|
const groupLabelId = `${pickerId}-group-${group.key.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
|
return (
|
|
<div key={group.key} className="people-picker-result-group" role="group" aria-labelledby={groupLabelId}>
|
|
<div id={groupLabelId} className="people-picker-result-group-label">{translateText(group.label)}</div>
|
|
{candidates.map(({ candidate, index }) => {
|
|
const selected = selectedKeys.has(peoplePickerDedupeKey(candidate));
|
|
const unavailable = candidateUnavailable(candidate);
|
|
const disabledReason = selected ? I18N.alreadySelected : candidate.disabled_reason;
|
|
return (
|
|
<button
|
|
id={`${pickerId}-option-${index}`}
|
|
key={candidate.selection_key}
|
|
type="button"
|
|
role="option"
|
|
className={`people-picker-result ${activeIndex === index ? "is-active" : ""}`.trim()}
|
|
aria-selected={selected}
|
|
aria-disabled={unavailable}
|
|
disabled={unavailable}
|
|
title={disabledReason ? translateText(disabledReason) : undefined}
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onMouseEnter={() => !unavailable && setActiveIndex(index)}
|
|
onFocus={() => !unavailable && setActiveIndex(index)}
|
|
onClick={() => addCandidate(candidate)}
|
|
>
|
|
<span className="people-picker-result-main">
|
|
<strong>{candidate.display_name}</strong>
|
|
{candidate.email && <small>{candidate.email}</small>}
|
|
</span>
|
|
<span className="people-picker-result-context">
|
|
{candidate.description && <small>{candidate.description}</small>}
|
|
<small>{translateText(candidate.source_label || group.label)}</small>
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
<div id={statusId} className={`people-picker-status ${searchError ? "danger-text" : ""}`.trim()} role={searchError ? "alert" : "status"} aria-live="polite">
|
|
{statusMessage && translateText(statusMessage)}
|
|
</div>
|
|
</div>
|
|
|
|
{allowManualExternal && (
|
|
<div className="people-picker-manual">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
disabled={disabled}
|
|
aria-expanded={manualOpen}
|
|
onClick={() => {
|
|
setManualOpen((current) => !current);
|
|
setManualError("");
|
|
}}
|
|
>
|
|
<UserPlus size={16} aria-hidden="true" />
|
|
{translateText(I18N.addExternal)}
|
|
</Button>
|
|
{manualOpen && (
|
|
<form className="people-picker-manual-form" onSubmit={addManualPerson}>
|
|
<label>
|
|
<FieldLabel>{translateText("i18n:govoplan-core.name.709a2322")}</FieldLabel>
|
|
<input
|
|
ref={manualNameRef}
|
|
value={manualName}
|
|
disabled={disabled}
|
|
required
|
|
autoComplete="name"
|
|
onChange={(event) => {
|
|
setManualName(event.target.value);
|
|
setManualError("");
|
|
}}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<FieldLabel>{translateText("i18n:govoplan-core.email_address.c94d3175")}</FieldLabel>
|
|
<input
|
|
ref={manualEmailRef}
|
|
type="email"
|
|
inputMode="email"
|
|
value={manualEmail}
|
|
disabled={disabled}
|
|
required={manualEmailRequired}
|
|
autoComplete="email"
|
|
onChange={(event) => {
|
|
setManualEmail(event.target.value);
|
|
setManualError("");
|
|
}}
|
|
/>
|
|
</label>
|
|
<div className="people-picker-manual-actions">
|
|
<Button type="button" disabled={disabled} onClick={() => setManualOpen(false)}>
|
|
{translateText("i18n:govoplan-core.cancel.77dfd213")}
|
|
</Button>
|
|
<Button type="submit" variant="primary" disabled={disabled}>
|
|
{translateText(I18N.addPerson)}
|
|
</Button>
|
|
</div>
|
|
{manualError && <p className="form-help danger-text" role="alert">{translateText(manualError)}</p>}
|
|
</form>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="people-picker-selection" aria-labelledby={`${pickerId}-selection-label`}>
|
|
<span id={`${pickerId}-selection-label`}><FieldLabel>{translatedNode(selectedLabel, translateText)}</FieldLabel></span>
|
|
<DataGrid
|
|
id={`${pickerId}-selection`}
|
|
rows={selectedItems}
|
|
columns={columns}
|
|
getRowKey={peoplePickerDedupeKey}
|
|
emptyText={I18N.noSelection}
|
|
emptyActionColumnId="actions"
|
|
emptyAction={<DataGridEmptyAction disabled={disabled} reorderable={false} onAdd={focusSearch} label={I18N.addSomeone} />}
|
|
initialFit="container"
|
|
/>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function kindLabel(kind: string): string {
|
|
if (kind === "account") return "i18n:govoplan-core.accounts.36bae316";
|
|
if (kind === "contact") return "i18n:govoplan-core.contacts.b0dd615c";
|
|
if (kind === "external") return I18N.externalPerson;
|
|
return kind;
|
|
}
|