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

28
webui/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "@govoplan/search-webui",
"version": "0.1.14",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/search.css": "./src/styles/search.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

70
webui/src/api/search.ts Normal file
View File

@@ -0,0 +1,70 @@
import {
apiFetch,
apiPath,
type ApiSettings
} from "@govoplan/core-webui";
export type SearchExternalReference = {
system: string;
object_type: string;
object_id: string;
maturity: string;
connector_id?: string | null;
canonical_url?: string | null;
};
export type SearchResult = {
provider_id: string;
module_id: string;
resource_type: string;
resource_id: string;
title: string;
summary?: string | null;
url: string;
score: number;
highlights: string[];
breadcrumbs: string[];
external_reference?: SearchExternalReference | null;
metadata: Record<string, unknown>;
};
export type SearchResponse = {
query: string;
results: SearchResult[];
diagnostics: Array<{
provider_id: string;
status: "unavailable";
message: string;
}>;
};
export type SearchRequest = {
query: string;
modules?: string[];
resourceTypes?: string[];
contextKind?: "global" | "module" | "resource";
contextId?: string;
limit?: number;
offset?: number;
};
export function search(
settings: ApiSettings,
request: SearchRequest,
signal?: AbortSignal
): Promise<SearchResponse> {
return apiFetch<SearchResponse>(
settings,
apiPath("/api/v1/search", {
q: request.query,
module: request.modules,
resource_type: request.resourceTypes,
context_kind: request.contextKind,
context_id: request.contextId,
limit: request.limit,
offset: request.offset
}),
{ signal }
);
}

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>
);
}

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>
);
}

2
webui/src/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { default, searchModule } from "./module";
export * from "./api/search";

59
webui/src/module.ts Normal file
View File

@@ -0,0 +1,59 @@
import { createElement, lazy } from "react";
import type {
PlatformWebModule,
SearchRuntimeUiCapability
} from "@govoplan/core-webui";
import GlobalSearch from "./components/GlobalSearch";
import "./styles/search.css";
const SearchPage = lazy(() => import("./features/search/SearchPage"));
const searchRuntime: SearchRuntimeUiCapability = {
GlobalSearch
};
export const searchModule: PlatformWebModule = {
id: "search",
label: "Search",
version: "0.1.14",
optionalDependencies: [
"access",
"views",
"connectors",
"wiki",
"projects",
"tickets",
"cases"
],
routes: [
{
path: "/search",
anyOf: ["search:result:read"],
order: 12,
surfaceId: "search.results",
render: (context) => createElement(SearchPage, context)
}
],
viewSurfaces: [
{
id: "search.global",
moduleId: "search",
kind: "selector",
label: "Global search",
order: 10
},
{
id: "search.results",
moduleId: "search",
kind: "route",
label: "Search results",
order: 20
}
],
uiCapabilities: {
"search.runtime": searchRuntime
}
};
export default searchModule;

244
webui/src/styles/search.css Normal file
View File

@@ -0,0 +1,244 @@
.global-search {
position: relative;
display: flex;
align-items: center;
width: min(360px, 28vw);
min-width: 190px;
height: 34px;
box-sizing: border-box;
border: 1px solid var(--control-border);
border-radius: 4px;
background: var(--control-bg);
color: var(--muted);
padding: 0 8px;
box-shadow: var(--shadow-control-inset);
}
.global-search:focus-within {
border-color: var(--input-border-focus);
box-shadow: var(--focus-ring);
}
.global-search input {
min-width: 0;
flex: 1;
height: 30px;
border: 0;
outline: 0;
background: transparent;
color: var(--text-strong);
padding: 0 7px;
font: inherit;
font-size: 13px;
}
.global-search input::-webkit-search-cancel-button { display: none; }
.global-search-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 0;
border-radius: 3px;
background: transparent;
color: var(--muted);
cursor: pointer;
padding: 0;
}
.global-search-clear:hover {
background: var(--titlebar-hover-bg);
color: var(--text-strong);
}
.global-search-results {
position: absolute;
z-index: 300;
top: calc(100% + 7px);
right: 0;
width: min(460px, 80vw);
overflow: hidden;
border: var(--border-line);
border-radius: 6px;
background: var(--surface);
box-shadow: var(--shadow-menu);
}
.global-search-results button {
display: flex;
width: 100%;
min-width: 0;
flex-direction: column;
align-items: flex-start;
gap: 3px;
border: 0;
border-bottom: var(--border-line);
background: transparent;
color: var(--muted);
cursor: pointer;
padding: 9px 12px;
text-align: left;
font: inherit;
font-size: 11px;
}
.global-search-results button:hover,
.global-search-results button.active {
background: var(--titlebar-hover-bg);
}
.global-search-result-title {
width: 100%;
overflow: hidden;
color: var(--text-strong);
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 700;
}
.global-search-results .global-search-all {
flex-direction: row;
align-items: center;
gap: 7px;
border-bottom: 0;
color: var(--text-strong);
font-size: 12px;
font-weight: 700;
}
.search-page {
display: flex;
min-width: 0;
min-height: 0;
height: 100%;
flex-direction: column;
background: var(--panel);
}
.search-page-toolbar {
display: flex;
align-items: center;
gap: 12px;
flex: 0 0 auto;
border-bottom: var(--border-line);
background: var(--panel-header);
padding: 12px 20px;
}
.search-page-form {
display: flex;
align-items: center;
width: min(760px, 100%);
height: 38px;
border: 1px solid var(--control-border);
border-radius: 4px;
background: var(--control-bg);
color: var(--muted);
padding-left: 10px;
}
.search-page-form:focus-within {
border-color: var(--input-border-focus);
box-shadow: var(--focus-ring);
}
.search-page-form input {
min-width: 0;
flex: 1;
height: 36px;
border: 0;
outline: 0;
background: transparent;
color: var(--text-strong);
padding: 0 10px;
font: inherit;
}
.search-page-form button {
align-self: stretch;
border: 0;
border-left: var(--border-line);
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end));
color: var(--control-text);
cursor: pointer;
padding: 0 16px;
font: inherit;
font-weight: 700;
}
.search-page-form button:hover {
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-hover));
}
.search-results-viewport {
min-height: 0;
flex: 1;
padding: 20px;
}
.search-result-list {
display: grid;
max-width: 960px;
}
.search-result {
display: grid;
gap: 6px;
width: 100%;
border: 0;
border-bottom: var(--border-line);
background: transparent;
color: var(--text);
cursor: pointer;
padding: 14px 12px;
text-align: left;
font: inherit;
}
.search-result:hover {
background: var(--sidebar-hover-bg);
}
.search-result-heading {
display: flex;
align-items: center;
gap: 7px;
color: var(--text-strong);
font-size: 15px;
}
.search-result-summary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-result-meta,
.search-empty {
color: var(--muted);
font-size: 12px;
}
@media (max-width: 900px) {
.global-search {
width: 34px;
min-width: 34px;
padding: 0 8px;
}
.global-search:focus-within {
position: absolute;
right: 170px;
width: min(360px, calc(100vw - 190px));
}
.global-search:not(:focus-within) input,
.global-search:not(:focus-within) .global-search-clear {
width: 0;
padding: 0;
opacity: 0;
}
}