From 22646c614cde9af11a2d5d6a6142dc9e7339911d Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 03:01:56 +0200 Subject: [PATCH] feat(core): add bounded people picker foundation --- src/govoplan_core/core/people.py | 169 ++++++ tests/test_people_search_contract.py | 99 ++++ webui/package.json | 1 + webui/src/components/people/PeoplePicker.tsx | 517 ++++++++++++++++++ .../components/people/peoplePickerTypes.ts | 78 +++ webui/src/i18n/generatedTranslations.ts | 38 ++ webui/src/index.ts | 4 + webui/src/styles/components.css | 160 ++++++ webui/tests/people-picker.test.tsx | 68 +++ webui/tsconfig.component-tests.json | 3 + 10 files changed, 1137 insertions(+) create mode 100644 src/govoplan_core/core/people.py create mode 100644 tests/test_people_search_contract.py create mode 100644 webui/src/components/people/PeoplePicker.tsx create mode 100644 webui/src/components/people/peoplePickerTypes.ts create mode 100644 webui/tests/people-picker.test.tsx diff --git a/src/govoplan_core/core/people.py b/src/govoplan_core/core/people.py new file mode 100644 index 0000000..4071037 --- /dev/null +++ b/src/govoplan_core/core/people.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + + +CAPABILITY_ACCESS_PEOPLE_SEARCH = "access.people_search" +CAPABILITY_ADDRESSES_PEOPLE_SEARCH = "addresses.people_search" + +# Identity search is deliberately absent here. ``identity.search`` is an +# instance-wide canonical-identity directory and has no tenant/principal +# visibility contract. Ordinary task pickers must use principal-aware search +# providers instead. +DEFAULT_PEOPLE_SEARCH_CAPABILITIES = ( + CAPABILITY_ACCESS_PEOPLE_SEARCH, + CAPABILITY_ADDRESSES_PEOPLE_SEARCH, +) + + +class PeopleSearchError(ValueError): + """Stable, non-diagnostic error raised by people-search providers.""" + + +@dataclass(frozen=True, slots=True) +class PersonSearchCandidate: + """A policy-filtered selection target returned by a directory provider.""" + + selection_key: str + kind: str + reference_id: str + display_name: str + email: str | None = None + source_module: str | None = None + source_label: str | None = None + source_ref: str | None = None + source_revision: str | None = None + description: str | None = None + provenance: Mapping[str, object] = field(default_factory=dict) + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class PeopleSearchGroup: + key: str + label: str + candidates: tuple[PersonSearchCandidate, ...] = () + + +@runtime_checkable +class PeopleSearchProvider(Protocol): + """Server-side, principal-aware people/delivery-target search. + + Implementations must derive visibility from ``principal`` and may only + return records the principal can discover in its active tenant and policy + context. Callers remain responsible for authorizing the task for which the + picker is used (for example, creating a scheduling request). + """ + + def search_people( + self, + session: object, + principal: object, + *, + query: str, + limit: int = 25, + ) -> Sequence[PeopleSearchGroup]: + ... + + +def people_search_providers( + registry: object | None, + *, + capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES, +) -> tuple[PeopleSearchProvider, ...]: + if registry is None or not hasattr(registry, "has_capability") or not hasattr(registry, "capability"): + return () + + providers: list[PeopleSearchProvider] = [] + for capability_name in capability_names: + if not registry.has_capability(capability_name): + continue + capability = registry.capability(capability_name) + if isinstance(capability, PeopleSearchProvider): + providers.append(capability) + return tuple(providers) + + +def search_visible_people( + registry: object | None, + session: object, + principal: object, + *, + query: str, + limit: int = 25, + capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES, +) -> tuple[PeopleSearchGroup, ...]: + """Collect grouped results without importing optional feature modules. + + ``limit`` is enforced per provider so one directory cannot starve another + result group. Providers are expected to return at most that many candidates + in total. Duplicate group keys are merged and candidate selection keys are + de-duplicated while preserving provider order. + """ + + normalized_query = str(query or "").strip() + normalized_limit = max(1, min(int(limit), 100)) + ordered_group_keys: list[str] = [] + labels: dict[str, str] = {} + candidates_by_group: dict[str, list[PersonSearchCandidate]] = {} + candidate_keys_by_group: dict[str, set[str]] = {} + + for provider in people_search_providers(registry, capability_names=capability_names): + groups = provider.search_people( + session, + principal, + query=normalized_query, + limit=normalized_limit, + ) + provider_candidate_count = 0 + for group in groups: + if not group.key: + continue + if group.key not in candidates_by_group: + ordered_group_keys.append(group.key) + labels[group.key] = group.label + candidates_by_group[group.key] = [] + candidate_keys_by_group[group.key] = set() + for candidate in group.candidates: + if provider_candidate_count >= normalized_limit: + break + if not candidate.selection_key or candidate.selection_key in candidate_keys_by_group[group.key]: + continue + candidate_keys_by_group[group.key].add(candidate.selection_key) + candidates_by_group[group.key].append(candidate) + provider_candidate_count += 1 + + return tuple( + PeopleSearchGroup( + key=group_key, + label=labels[group_key], + candidates=tuple(candidates_by_group[group_key]), + ) + for group_key in ordered_group_keys + if candidates_by_group[group_key] + ) + + +def person_selection_key(kind: str, reference_id: str, *, email: str | None = None) -> str: + normalized_kind = str(kind).strip().casefold() + normalized_reference = str(reference_id).strip() + normalized_email = str(email or "").strip().casefold() + if not normalized_kind or not normalized_reference: + raise ValueError("Person selection keys require a kind and reference id.") + return ":".join(part for part in (normalized_kind, normalized_reference, normalized_email) if part) + + +__all__ = [ + "CAPABILITY_ACCESS_PEOPLE_SEARCH", + "CAPABILITY_ADDRESSES_PEOPLE_SEARCH", + "DEFAULT_PEOPLE_SEARCH_CAPABILITIES", + "PeopleSearchError", + "PeopleSearchGroup", + "PeopleSearchProvider", + "PersonSearchCandidate", + "people_search_providers", + "person_selection_key", + "search_visible_people", +] diff --git a/tests/test_people_search_contract.py b/tests/test_people_search_contract.py new file mode 100644 index 0000000..4c4ccc5 --- /dev/null +++ b/tests/test_people_search_contract.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import unittest + +from govoplan_core.core.modules import ModuleContext, ModuleManifest +from govoplan_core.core.people import ( + CAPABILITY_ACCESS_PEOPLE_SEARCH, + CAPABILITY_ADDRESSES_PEOPLE_SEARCH, + PeopleSearchGroup, + PersonSearchCandidate, + people_search_providers, + person_selection_key, + search_visible_people, +) +from govoplan_core.core.registry import PlatformRegistry + + +class FakePeopleSearchProvider: + def __init__(self, *groups: PeopleSearchGroup) -> None: + self.groups = groups + self.calls: list[tuple[object, object, str, int]] = [] + + def search_people(self, session: object, principal: object, *, query: str, limit: int = 25): + self.calls.append((session, principal, query, limit)) + return self.groups + + +class PeopleSearchContractTests(unittest.TestCase): + def test_collects_optional_providers_in_stable_group_order(self) -> None: + duplicate = PersonSearchCandidate( + selection_key="account:1", + kind="account", + reference_id="1", + display_name="Ada Lovelace", + ) + access = FakePeopleSearchProvider(PeopleSearchGroup(key="accounts", label="Accounts", candidates=(duplicate, duplicate))) + addresses = FakePeopleSearchProvider( + PeopleSearchGroup( + key="contacts", + label="Contacts", + candidates=( + PersonSearchCandidate( + selection_key="contact:2:ada@example.test", + kind="contact", + reference_id="2", + display_name="Ada Lovelace", + email="ada@example.test", + ), + ), + ) + ) + registry = PlatformRegistry() + registry.register(ModuleManifest( + id="access", + name="Access", + version="1.0.0", + capability_factories={CAPABILITY_ACCESS_PEOPLE_SEARCH: lambda _context: access}, + )) + registry.register(ModuleManifest( + id="addresses", + name="Addresses", + version="1.0.0", + capability_factories={CAPABILITY_ADDRESSES_PEOPLE_SEARCH: lambda _context: addresses}, + )) + registry.configure_capability_context(ModuleContext(registry=registry, settings=object())) + + session = object() + principal = object() + groups = search_visible_people(registry, session, principal, query=" ada ", limit=200) + + self.assertEqual([group.key for group in groups], ["accounts", "contacts"]) + self.assertEqual([len(group.candidates) for group in groups], [1, 1]) + self.assertEqual(access.calls, [(session, principal, "ada", 100)]) + self.assertEqual(addresses.calls, [(session, principal, "ada", 100)]) + self.assertEqual(people_search_providers(None), ()) + + def test_omits_empty_groups_and_ignores_unrelated_capabilities(self) -> None: + registry = PlatformRegistry() + registry.register(ModuleManifest( + id="access", + name="Access", + version="1.0.0", + capability_factories={CAPABILITY_ACCESS_PEOPLE_SEARCH: lambda _context: object()}, + )) + registry.configure_capability_context(ModuleContext(registry=registry, settings=object())) + + self.assertEqual(search_visible_people(registry, object(), object(), query="ada"), ()) + + def test_selection_key_is_stable_and_validated(self) -> None: + self.assertEqual( + person_selection_key("Account", "account-1", email=" Ada@Example.Test "), + "account:account-1:ada@example.test", + ) + with self.assertRaises(ValueError): + person_selection_key("", "account-1") + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json index 6321f9c..4a1d76a 100644 --- a/webui/package.json +++ b/webui/package.json @@ -29,6 +29,7 @@ "test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js", "test:module-permutations": "node scripts/test-module-permutations.mjs", "test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js", + "test:people-picker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/people-picker.test.js", "test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js", "test:selection-list": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/selection-list.test.js" }, diff --git a/webui/src/components/people/PeoplePicker.tsx b/webui/src/components/people/PeoplePicker.tsx new file mode 100644 index 0000000..6d54f7b --- /dev/null +++ b/webui/src/components/people/PeoplePicker.tsx @@ -0,0 +1,517 @@ +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) => ( +