feat(core): add bounded people picker foundation
This commit is contained in:
169
src/govoplan_core/core/people.py
Normal file
169
src/govoplan_core/core/people.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
99
tests/test_people_search_contract.py
Normal file
99
tests/test_people_search_contract.py
Normal file
@@ -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()
|
||||||
@@ -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-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: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: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: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"
|
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
517
webui/src/components/people/PeoplePicker.tsx
Normal file
517
webui/src/components/people/PeoplePicker.tsx
Normal file
@@ -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<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;
|
||||||
|
}
|
||||||
78
webui/src/components/people/peoplePickerTypes.ts
Normal file
78
webui/src/components/people/peoplePickerTypes.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
export type PeoplePickerItemKind = "account" | "contact" | "external" | "function" | (string & {});
|
||||||
|
|
||||||
|
export type PeoplePickerItem = {
|
||||||
|
selection_key: string;
|
||||||
|
kind: PeoplePickerItemKind;
|
||||||
|
reference_id?: string | null;
|
||||||
|
display_name: string;
|
||||||
|
email?: string | null;
|
||||||
|
source_module?: string | null;
|
||||||
|
source_label?: string | null;
|
||||||
|
source_ref?: string | null;
|
||||||
|
source_revision?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
provenance?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeoplePickerSearchCandidate = PeoplePickerItem & {
|
||||||
|
disabled?: boolean;
|
||||||
|
disabled_reason?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeoplePickerSearchGroup = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
candidates: readonly PeoplePickerSearchCandidate[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeoplePickerSearchOptions = {
|
||||||
|
limit: number;
|
||||||
|
signal: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeoplePickerSearch = (
|
||||||
|
query: string,
|
||||||
|
options: PeoplePickerSearchOptions
|
||||||
|
) => Promise<readonly PeoplePickerSearchGroup[]>;
|
||||||
|
|
||||||
|
export function peoplePickerDedupeKey(item: Pick<PeoplePickerItem, "selection_key" | "email">): string {
|
||||||
|
const email = item.email?.trim().toLowerCase();
|
||||||
|
return email ? `email:${email}` : `selection:${item.selection_key.trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupePeoplePickerItems(items: readonly PeoplePickerItem[]): PeoplePickerItem[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: PeoplePickerItem[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const key = peoplePickerDedupeKey(item);
|
||||||
|
if (!item.selection_key.trim() || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
result.push(item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function externalPeoplePickerItem(displayName: string, email?: string | null): PeoplePickerItem {
|
||||||
|
const normalizedName = displayName.trim();
|
||||||
|
const normalizedEmail = email?.trim().toLowerCase() || null;
|
||||||
|
const identityPart = normalizedEmail || normalizedName.toLowerCase();
|
||||||
|
return {
|
||||||
|
selection_key: `external:${identityPart}`,
|
||||||
|
kind: "external",
|
||||||
|
reference_id: null,
|
||||||
|
display_name: normalizedName,
|
||||||
|
email: normalizedEmail,
|
||||||
|
source_module: null,
|
||||||
|
source_label: "i18n:govoplan-core.external_person.35115d8e",
|
||||||
|
source_ref: null,
|
||||||
|
source_revision: null,
|
||||||
|
provenance: {},
|
||||||
|
metadata: { manual: true }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectionFromPeoplePickerCandidate(candidate: PeoplePickerSearchCandidate): PeoplePickerItem {
|
||||||
|
const { disabled: _disabled, disabled_reason: _disabledReason, ...selection } = candidate;
|
||||||
|
return selection;
|
||||||
|
}
|
||||||
@@ -563,6 +563,25 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.word_document.f593629d": "Word document",
|
"i18n:govoplan-core.word_document.f593629d": "Word document",
|
||||||
"i18n:govoplan-core.working.13b7bfca": "Working…",
|
"i18n:govoplan-core.working.13b7bfca": "Working…",
|
||||||
"i18n:govoplan-core.workspace.4ca0a75c": "Workspace",
|
"i18n:govoplan-core.workspace.4ca0a75c": "Workspace",
|
||||||
|
"i18n:govoplan-core.accounts.36bae316": "Accounts",
|
||||||
|
"i18n:govoplan-core.contacts.b0dd615c": "Contacts",
|
||||||
|
"i18n:govoplan-core.people.b37554f6": "People",
|
||||||
|
"i18n:govoplan-core.search_people_and_contacts.8e028a78": "Search people and contacts",
|
||||||
|
"i18n:govoplan-core.search_by_name_or_email_address.6283d84d": "Search by name or email address",
|
||||||
|
"i18n:govoplan-core.searching.1a6a5ba8": "Searching…",
|
||||||
|
"i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688": "No matching people or contacts found.",
|
||||||
|
"i18n:govoplan-core.external_person.35115d8e": "External person",
|
||||||
|
"i18n:govoplan-core.add_external_person.b19c7abc": "Add external person",
|
||||||
|
"i18n:govoplan-core.selected_people.49a953a1": "Selected people",
|
||||||
|
"i18n:govoplan-core.no_people_selected_yet.9383cc74": "No people selected yet.",
|
||||||
|
"i18n:govoplan-core.source.6da13add": "Source",
|
||||||
|
"i18n:govoplan-core.add_person.3b10561b": "Add person",
|
||||||
|
"i18n:govoplan-core.remove_person.2ea46758": "Remove person",
|
||||||
|
"i18n:govoplan-core.this_person_is_already_selected.d337154c": "This person is already selected.",
|
||||||
|
"i18n:govoplan-core.enter_a_name.86c810d9": "Enter a name.",
|
||||||
|
"i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7": "Unable to search people right now.",
|
||||||
|
"i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad": "Type at least {value0} characters to search.",
|
||||||
|
"i18n:govoplan-core.add_someone_to_the_selection.c617dd69": "Add someone to the selection",
|
||||||
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
|
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
|
||||||
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
|
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
|
||||||
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
|
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
|
||||||
@@ -1132,6 +1151,25 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.word_document.f593629d": "Word document",
|
"i18n:govoplan-core.word_document.f593629d": "Word document",
|
||||||
"i18n:govoplan-core.working.13b7bfca": "Working…",
|
"i18n:govoplan-core.working.13b7bfca": "Working…",
|
||||||
"i18n:govoplan-core.workspace.4ca0a75c": "Arbeitsbereich",
|
"i18n:govoplan-core.workspace.4ca0a75c": "Arbeitsbereich",
|
||||||
|
"i18n:govoplan-core.accounts.36bae316": "Benutzerkonten",
|
||||||
|
"i18n:govoplan-core.contacts.b0dd615c": "Kontakte",
|
||||||
|
"i18n:govoplan-core.people.b37554f6": "Personen",
|
||||||
|
"i18n:govoplan-core.search_people_and_contacts.8e028a78": "Personen und Kontakte suchen",
|
||||||
|
"i18n:govoplan-core.search_by_name_or_email_address.6283d84d": "Nach Name oder E-Mail-Adresse suchen",
|
||||||
|
"i18n:govoplan-core.searching.1a6a5ba8": "Suche…",
|
||||||
|
"i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688": "Keine passenden Personen oder Kontakte gefunden.",
|
||||||
|
"i18n:govoplan-core.external_person.35115d8e": "Externe Person",
|
||||||
|
"i18n:govoplan-core.add_external_person.b19c7abc": "Externe Person hinzufügen",
|
||||||
|
"i18n:govoplan-core.selected_people.49a953a1": "Ausgewählte Personen",
|
||||||
|
"i18n:govoplan-core.no_people_selected_yet.9383cc74": "Noch keine Personen ausgewählt.",
|
||||||
|
"i18n:govoplan-core.source.6da13add": "Quelle",
|
||||||
|
"i18n:govoplan-core.add_person.3b10561b": "Person hinzufügen",
|
||||||
|
"i18n:govoplan-core.remove_person.2ea46758": "Person entfernen",
|
||||||
|
"i18n:govoplan-core.this_person_is_already_selected.d337154c": "Diese Person ist bereits ausgewählt.",
|
||||||
|
"i18n:govoplan-core.enter_a_name.86c810d9": "Geben Sie einen Namen ein.",
|
||||||
|
"i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7": "Die Personensuche ist derzeit nicht möglich.",
|
||||||
|
"i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad": "Geben Sie mindestens {value0} Zeichen für die Suche ein.",
|
||||||
|
"i18n:govoplan-core.add_someone_to_the_selection.c617dd69": "Person zur Auswahl hinzufügen",
|
||||||
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
|
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
|
||||||
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
|
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
|
||||||
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
|
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel
|
|||||||
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
||||||
export { default as PageTitle } from "./components/PageTitle";
|
export { default as PageTitle } from "./components/PageTitle";
|
||||||
export { default as PasswordField } from "./components/PasswordField";
|
export { default as PasswordField } from "./components/PasswordField";
|
||||||
|
export { default as PeoplePicker } from "./components/people/PeoplePicker";
|
||||||
|
export type { PeoplePickerProps } from "./components/people/PeoplePicker";
|
||||||
|
export { dedupePeoplePickerItems, externalPeoplePickerItem, peoplePickerDedupeKey, selectionFromPeoplePickerCandidate } from "./components/people/peoplePickerTypes";
|
||||||
|
export type { PeoplePickerItem, PeoplePickerItemKind, PeoplePickerSearch, PeoplePickerSearchCandidate, PeoplePickerSearchGroup, PeoplePickerSearchOptions } from "./components/people/peoplePickerTypes";
|
||||||
export { default as ResourceAccessExplanation, formatResourceAccessProvenanceDetails, resourceAccessProvenanceKindLabel } from "./components/ResourceAccessExplanation";
|
export { default as ResourceAccessExplanation, formatResourceAccessProvenanceDetails, resourceAccessProvenanceKindLabel } from "./components/ResourceAccessExplanation";
|
||||||
export type { ResourceAccessExplanationProps } from "./components/ResourceAccessExplanation";
|
export type { ResourceAccessExplanationProps } from "./components/ResourceAccessExplanation";
|
||||||
export type { PasswordFieldProps } from "./components/PasswordField";
|
export type { PasswordFieldProps } from "./components/PasswordField";
|
||||||
|
|||||||
@@ -1516,6 +1516,166 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Principal-aware account/contact picker with structured external entry. */
|
||||||
|
.people-picker {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.people-picker-search-field,
|
||||||
|
.people-picker-selection,
|
||||||
|
.people-picker-manual {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.people-picker-search-control {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.people-picker-search-control > svg {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
z-index: 1;
|
||||||
|
color: var(--muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.people-picker-search-control input {
|
||||||
|
padding-left: 38px;
|
||||||
|
}
|
||||||
|
.people-picker-search-control:focus-within > svg {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.people-picker-results {
|
||||||
|
display: grid;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
border: var(--border-line-dark);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-popover);
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
.people-picker-result-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.people-picker-result-group + .people-picker-result-group {
|
||||||
|
margin-top: 5px;
|
||||||
|
border-top: var(--border-line);
|
||||||
|
padding-top: 5px;
|
||||||
|
}
|
||||||
|
.people-picker-result-group-label {
|
||||||
|
color: var(--text-label);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
padding: 5px 9px 3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.people-picker-result {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(120px, auto);
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 9px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.people-picker-result:hover:not(:disabled),
|
||||||
|
.people-picker-result.is-active:not(:disabled),
|
||||||
|
.people-picker-result:focus-visible:not(:disabled) {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
box-shadow: var(--primary-inset-ring);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.people-picker-result:disabled {
|
||||||
|
background: var(--control-disabled-bg);
|
||||||
|
color: var(--control-disabled-text);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.people-picker-result-main,
|
||||||
|
.people-picker-result-context,
|
||||||
|
.people-picker-selected-person {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.people-picker-result-main strong,
|
||||||
|
.people-picker-result-main small,
|
||||||
|
.people-picker-result-context small,
|
||||||
|
.people-picker-selected-person strong,
|
||||||
|
.people-picker-selected-person small {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.people-picker-result-main small,
|
||||||
|
.people-picker-result-context,
|
||||||
|
.people-picker-selected-person small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.people-picker-result-context {
|
||||||
|
justify-items: end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.people-picker-status {
|
||||||
|
min-height: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.people-picker-manual {
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
.people-picker-manual-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(160px, 1fr) minmax(220px, 1fr) auto;
|
||||||
|
align-items: end;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.people-picker-manual-form > label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.people-picker-manual-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.people-picker-manual-form > .form-help {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.people-picker-selection > .field-label,
|
||||||
|
.people-picker-selection > span > .field-label {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.people-picker-result {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.people-picker-result-context {
|
||||||
|
justify-items: start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.people-picker-manual-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Bootstrap-like switch controls without importing Bootstrap. */
|
/* Bootstrap-like switch controls without importing Bootstrap. */
|
||||||
.toggle-switch-row {
|
.toggle-switch-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
68
webui/tests/people-picker.test.tsx
Normal file
68
webui/tests/people-picker.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
function assert(condition: unknown, message = "assertion failed"): void {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import PeoplePicker from "../src/components/people/PeoplePicker";
|
||||||
|
import {
|
||||||
|
dedupePeoplePickerItems,
|
||||||
|
externalPeoplePickerItem,
|
||||||
|
selectionFromPeoplePickerCandidate,
|
||||||
|
type PeoplePickerSearch
|
||||||
|
} from "../src/components/people/peoplePickerTypes";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
|
||||||
|
const account = {
|
||||||
|
selection_key: "account:account-1",
|
||||||
|
kind: "account" as const,
|
||||||
|
reference_id: "account-1",
|
||||||
|
display_name: "Ada Lovelace",
|
||||||
|
email: "Ada@Example.Test",
|
||||||
|
source_module: "access",
|
||||||
|
source_label: "Accounts"
|
||||||
|
};
|
||||||
|
const contact = {
|
||||||
|
selection_key: "contact:contact-1:ada@example.test",
|
||||||
|
kind: "contact" as const,
|
||||||
|
reference_id: "contact-1",
|
||||||
|
display_name: "Ada Contact",
|
||||||
|
email: "ada@example.test",
|
||||||
|
source_module: "addresses",
|
||||||
|
source_label: "Contacts"
|
||||||
|
};
|
||||||
|
|
||||||
|
const deduped = dedupePeoplePickerItems([account, contact]);
|
||||||
|
assert(deduped.length === 1, "the same email is selected only once across directory sources");
|
||||||
|
assert(deduped[0].reference_id === "account-1", "selection order decides which discoverable reference is retained");
|
||||||
|
|
||||||
|
const external = externalPeoplePickerItem(" Grace Hopper ", " GRACE@EXAMPLE.TEST ");
|
||||||
|
assert(external.display_name === "Grace Hopper", "manual names are normalized");
|
||||||
|
assert(external.email === "grace@example.test", "manual email addresses are normalized");
|
||||||
|
assert(external.selection_key === "external:grace@example.test", "manual entries receive stable opaque selection keys");
|
||||||
|
|
||||||
|
const selected = selectionFromPeoplePickerCandidate({ ...contact, disabled: true, disabled_reason: "Policy" });
|
||||||
|
assert(!("disabled" in selected), "result-only disabled state is not persisted in selections");
|
||||||
|
assert(!("disabled_reason" in selected), "result-only policy wording is not persisted in selections");
|
||||||
|
|
||||||
|
let searchCalled = false;
|
||||||
|
const search: PeoplePickerSearch = async () => {
|
||||||
|
searchCalled = true;
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<PlatformLanguageProvider>
|
||||||
|
<PeoplePicker id="reviewers" value={[account]} onChange={() => undefined} search={search} help="Choose a reviewer." />
|
||||||
|
</PlatformLanguageProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(!searchCalled, "directory search is server-driven after user input, not during static rendering");
|
||||||
|
assert(markup.includes('role="combobox"'), "search exposes combobox semantics");
|
||||||
|
assert(markup.includes('aria-autocomplete="list"'), "search declares list autocomplete behavior");
|
||||||
|
assert(markup.includes('aria-controls="reviewers-results"'), "search and grouped result list are associated");
|
||||||
|
assert(markup.includes("inline-help"), "non-obvious picker fields use central inline help");
|
||||||
|
assert(markup.includes('role="table"'), "selected people are rendered through the central DataGrid");
|
||||||
|
assert(markup.includes("Ada Lovelace"), "selected structured rows retain the display name");
|
||||||
|
assert(markup.includes("Ada@Example.Test"), "selected structured rows retain the email address");
|
||||||
|
assert(markup.includes('aria-label="Add person"'), "row add uses an accessible central table action");
|
||||||
|
assert(markup.includes('aria-label="Remove person"'), "row removal uses an accessible central table action");
|
||||||
|
assert(markup.includes("Add external person"), "manual external entry remains a separate structured action");
|
||||||
@@ -23,12 +23,15 @@
|
|||||||
"tests/explorer-tree.test.tsx",
|
"tests/explorer-tree.test.tsx",
|
||||||
"tests/icon-button.test.tsx",
|
"tests/icon-button.test.tsx",
|
||||||
"tests/mail-components.test.tsx",
|
"tests/mail-components.test.tsx",
|
||||||
|
"tests/people-picker.test.tsx",
|
||||||
"tests/resource-access-explanation.test.tsx",
|
"tests/resource-access-explanation.test.tsx",
|
||||||
"tests/selection-list.test.tsx",
|
"tests/selection-list.test.tsx",
|
||||||
"src/components/CredentialPanel.tsx",
|
"src/components/CredentialPanel.tsx",
|
||||||
"src/components/email/EmailAddressInput.tsx",
|
"src/components/email/EmailAddressInput.tsx",
|
||||||
"src/components/PasswordField.tsx",
|
"src/components/PasswordField.tsx",
|
||||||
"src/components/MessageDisplayPanel.tsx",
|
"src/components/MessageDisplayPanel.tsx",
|
||||||
|
"src/components/people/PeoplePicker.tsx",
|
||||||
|
"src/components/people/peoplePickerTypes.ts",
|
||||||
"src/components/ResourceAccessExplanation.tsx",
|
"src/components/ResourceAccessExplanation.tsx",
|
||||||
"src/components/SelectionList.tsx",
|
"src/components/SelectionList.tsx",
|
||||||
"src/components/mail/MailServerSettingsPanel.tsx",
|
"src/components/mail/MailServerSettingsPanel.tsx",
|
||||||
|
|||||||
Reference in New Issue
Block a user