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
+129
View File
@@ -0,0 +1,129 @@
import { ExternalLink, Search } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router-dom";
import {
DismissibleAlert,
LoadingIndicator,
PageScrollViewport,
useGuardedNavigate,
type PlatformRouteContext
} from "@govoplan/core-webui";
import { search, type SearchResponse } from "../../api/search";
export default function SearchPage({ settings }: PlatformRouteContext) {
const navigate = useGuardedNavigate();
const [params, setParams] = useSearchParams();
const query = params.get("q") ?? "";
const modules = params.getAll("module");
const resourceTypes = params.getAll("resource_type");
const [draft, setDraft] = useState(query);
const [response, setResponse] = useState<SearchResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const requestKey = useMemo(
() => JSON.stringify([query, modules, resourceTypes]),
[query, modules, resourceTypes]
);
useEffect(() => {
setDraft(query);
}, [query]);
useEffect(() => {
if (!query.trim()) {
setResponse(null);
return;
}
const controller = new AbortController();
setLoading(true);
setError("");
search(
settings,
{
query,
modules,
resourceTypes,
contextKind: modules.length ? "module" : "global",
contextId: params.get("context") ?? undefined,
limit: 100
},
controller.signal
).
then(setResponse).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Search failed.");
}
}).
finally(() => setLoading(false));
return () => controller.abort();
}, [requestKey, settings]);
function submit(event: FormEvent) {
event.preventDefault();
const next = new URLSearchParams(params);
if (draft.trim()) next.set("q", draft.trim());
else next.delete("q");
setParams(next);
}
return (
<main className="search-page">
<div className="search-page-toolbar">
<form className="search-page-form" onSubmit={submit}>
<Search size={18} aria-hidden="true" />
<input
value={draft}
onChange={(event) => setDraft(event.target.value)}
aria-label="Search query"
placeholder="Search"
autoFocus
/>
<button type="submit">Search</button>
</form>
{loading && <LoadingIndicator size="sm" label="Searching" />}
</div>
<PageScrollViewport className="search-results-viewport">
{error &&
<DismissibleAlert tone="danger" onDismiss={() => setError("")}>
{error}
</DismissibleAlert>
}
{response?.diagnostics.map((diagnostic) =>
<DismissibleAlert
key={diagnostic.provider_id}
tone="warning"
compact>
{diagnostic.message}
</DismissibleAlert>
)}
{query && response && response.results.length === 0 &&
<div className="search-empty">No results for {query}.</div>
}
<div className="search-result-list">
{response?.results.map((result) =>
<button
type="button"
className="search-result"
key={`${result.module_id}:${result.resource_type}:${result.resource_id}`}
onClick={() => navigate(result.url)}>
<span className="search-result-heading">
<strong>{result.title}</strong>
{result.external_reference?.canonical_url &&
<ExternalLink size={14} aria-label="External result" />
}
</span>
{result.summary && <span className="search-result-summary">{result.summary}</span>}
<span className="search-result-meta">
{result.breadcrumbs.length > 0 ?
result.breadcrumbs.join(" · ") :
`${result.module_id} · ${result.resource_type}`}
</span>
</button>
)}
</div>
</PageScrollViewport>
</main>
);
}