feat: implement permission-aware search baseline

This commit is contained in:
2026-07-29 15:50:11 +02:00
parent 89a4fd0874
commit c60ca2776e
27 changed files with 2282 additions and 0 deletions
+223
View File
@@ -0,0 +1,223 @@
import { Search, X } from "lucide-react";
import {
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent
} from "react";
import { useLocation } from "react-router-dom";
import {
useGuardedNavigate,
usePlatformUiCapabilities,
type GlobalSearchProps,
type SearchContextContribution,
type SearchContextsUiCapability
} from "@govoplan/core-webui";
import { search, type SearchResult } from "../api/search";
const MIN_SUGGESTION_LENGTH = 2;
function currentContext(
pathname: string,
contexts: SearchContextContribution[]
): SearchContextContribution | null {
return [...contexts].
filter((context) =>
context.pathPrefixes.some((prefix) =>
pathname === prefix || pathname.startsWith(`${prefix}/`)
)
).
sort((left, right) => {
const leftLength = Math.max(...left.pathPrefixes.map((item) => item.length));
const rightLength = Math.max(...right.pathPrefixes.map((item) => item.length));
return rightLength - leftLength || (left.order ?? 100) - (right.order ?? 100);
})[0] ?? null;
}
export default function GlobalSearch({ settings }: GlobalSearchProps) {
const navigate = useGuardedNavigate();
const location = useLocation();
const contextCapabilities =
usePlatformUiCapabilities<SearchContextsUiCapability>("search.contexts");
const contexts = useMemo(
() => contextCapabilities.flatMap((capability) => capability.contexts),
[contextCapabilities]
);
const context = useMemo(
() => currentContext(location.pathname, contexts),
[contexts, location.pathname]
);
const [value, setValue] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
function focusSearch(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
inputRef.current?.focus();
}
}
window.addEventListener("keydown", focusSearch);
return () => window.removeEventListener("keydown", focusSearch);
}, []);
useEffect(() => {
function closeOutside(event: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
window.addEventListener("mousedown", closeOutside);
return () => window.removeEventListener("mousedown", closeOutside);
}, []);
useEffect(() => {
const query = value.trim();
if (query.length < MIN_SUGGESTION_LENGTH) {
setResults([]);
setOpen(false);
return;
}
const controller = new AbortController();
const timer = window.setTimeout(() => {
search(
settings,
{
query,
modules: context ? [context.moduleId] : undefined,
resourceTypes: context?.resourceTypes,
contextKind: context ? "module" : "global",
contextId: context?.id,
limit: 6
},
controller.signal
).
then((response) => {
setResults(response.results);
setOpen(true);
setActiveIndex(-1);
}).
catch((error) => {
if ((error as Error).name !== "AbortError") {
setResults([]);
}
});
}, 220);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [context, settings, value]);
function openResult(result: SearchResult) {
setOpen(false);
setValue("");
navigate(result.url);
}
function openResultsPage() {
const query = value.trim();
if (!query) return;
const params = new URLSearchParams({ q: query });
if (context) {
params.set("module", context.moduleId);
params.set("context", context.id);
for (const resourceType of context.resourceTypes ?? []) {
params.append("resource_type", resourceType);
}
}
setOpen(false);
navigate(`/search?${params.toString()}`);
}
function handleKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
if (event.key === "Escape") {
setOpen(false);
return;
}
if (event.key === "ArrowDown" && results.length) {
event.preventDefault();
setOpen(true);
setActiveIndex((current) => (current + 1) % results.length);
return;
}
if (event.key === "ArrowUp" && results.length) {
event.preventDefault();
setOpen(true);
setActiveIndex((current) =>
current <= 0 ? results.length - 1 : current - 1
);
return;
}
if (event.key === "Enter") {
event.preventDefault();
if (activeIndex >= 0 && results[activeIndex]) {
openResult(results[activeIndex]);
} else {
openResultsPage();
}
}
}
return (
<div className="global-search" ref={rootRef}>
<Search size={16} aria-hidden="true" />
<input
ref={inputRef}
type="search"
value={value}
placeholder={context?.placeholder ?? "Search"}
aria-label={context ? `Search ${context.label}` : "Search"}
aria-expanded={open}
aria-controls="global-search-results"
onFocus={() => setOpen(results.length > 0)}
onChange={(event) => setValue(event.target.value)}
onKeyDown={handleKeyDown}
/>
{value &&
<button
type="button"
className="global-search-clear"
aria-label="Clear search"
onClick={() => {
setValue("");
setResults([]);
inputRef.current?.focus();
}}>
<X size={14} />
</button>
}
{open && results.length > 0 &&
<div id="global-search-results" className="global-search-results" role="listbox">
{results.map((result, index) =>
<button
key={`${result.module_id}:${result.resource_type}:${result.resource_id}`}
type="button"
role="option"
aria-selected={index === activeIndex}
className={index === activeIndex ? "active" : ""}
onMouseDown={(event) => event.preventDefault()}
onClick={() => openResult(result)}>
<span className="global-search-result-title">{result.title}</span>
<span>{result.module_id} · {result.resource_type}</span>
</button>
)}
<button
type="button"
className="global-search-all"
onMouseDown={(event) => event.preventDefault()}
onClick={openResultsPage}>
<Search size={14} />
<span>All results</span>
</button>
</div>
}
</div>
);
}