feat: implement durable permission-aware search

This commit is contained in:
2026-07-29 18:08:52 +02:00
parent c60ca2776e
commit a156e3d4fc
12 changed files with 2540 additions and 100 deletions
+9 -1
View File
@@ -27,6 +27,8 @@ export type SearchResult = {
breadcrumbs: string[];
external_reference?: SearchExternalReference | null;
metadata: Record<string, unknown>;
source_revision?: string | null;
provenance: Record<string, unknown>;
};
export type SearchResponse = {
@@ -37,6 +39,8 @@ export type SearchResponse = {
status: "unavailable";
message: string;
}>;
next_cursor?: string | null;
has_more: boolean;
};
export type SearchRequest = {
@@ -47,6 +51,8 @@ export type SearchRequest = {
contextId?: string;
limit?: number;
offset?: number;
cursor?: string;
language?: string;
};
export function search(
@@ -63,7 +69,9 @@ export function search(
context_kind: request.contextKind,
context_id: request.contextId,
limit: request.limit,
offset: request.offset
offset: request.offset,
cursor: request.cursor,
language: request.language
}),
{ signal }
);
+58 -15
View File
@@ -1,5 +1,5 @@
import { ExternalLink, Search } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { ChevronDown, ExternalLink, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router-dom";
import {
DismissibleAlert,
@@ -15,26 +15,31 @@ 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 moduleKey = params.getAll("module").join("\u001f");
const resourceTypeKey = params.getAll("resource_type").join("\u001f");
const contextId = params.get("context") ?? undefined;
const modules = useMemo(
() => moduleKey ? moduleKey.split("\u001f") : [],
[moduleKey]
);
const resourceTypes = useMemo(
() => resourceTypeKey ? resourceTypeKey.split("\u001f") : [],
[resourceTypeKey]
);
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]
() => JSON.stringify([query, moduleKey, resourceTypeKey, contextId]),
[contextId, moduleKey, query, resourceTypeKey]
);
useEffect(() => {
setDraft(query);
}, [query]);
useEffect(() => {
if (!query.trim()) {
setResponse(null);
return;
}
const loadResults = useCallback((cursor?: string) => {
const controller = new AbortController();
setLoading(true);
setError("");
@@ -45,20 +50,48 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
modules,
resourceTypes,
contextKind: modules.length ? "module" : "global",
contextId: params.get("context") ?? undefined,
limit: 100
contextId,
limit: 50,
cursor
},
controller.signal
).
then(setResponse).
then((next) => {
setResponse((current) =>
cursor && current ?
{
...next,
results: [...current.results, ...next.results],
diagnostics: [
...current.diagnostics,
...next.diagnostics.filter((item) =>
!current.diagnostics.some(
(currentItem) => currentItem.provider_id === item.provider_id
)
)
]
} :
next
);
}).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Search failed.");
}
}).
finally(() => setLoading(false));
return controller;
}, [contextId, modules, query, resourceTypes, settings]);
useEffect(() => {
if (!query.trim()) {
setResponse(null);
return;
}
setResponse(null);
const controller = loadResults();
return () => controller.abort();
}, [requestKey, settings]);
}, [loadResults, requestKey]);
function submit(event: FormEvent) {
event.preventDefault();
@@ -123,6 +156,16 @@ export default function SearchPage({ settings }: PlatformRouteContext) {
</button>
)}
</div>
{response?.next_cursor &&
<button
type="button"
className="search-load-more"
disabled={loading}
onClick={() => loadResults(response.next_cursor ?? undefined)}>
<ChevronDown size={16} />
<span>Load more</span>
</button>
}
</PageScrollViewport>
</main>
);
+30
View File
@@ -222,6 +222,36 @@
font-size: 12px;
}
.search-load-more {
display: inline-flex;
align-items: center;
gap: 7px;
margin-top: 14px;
border: 1px solid var(--control-border);
border-radius: 4px;
background: linear-gradient(
var(--control-gradient-start),
var(--control-gradient-end)
);
color: var(--control-text);
cursor: pointer;
padding: 7px 12px;
font: inherit;
font-weight: 700;
}
.search-load-more:hover:not(:disabled) {
background: linear-gradient(
var(--control-gradient-start),
var(--control-gradient-end-hover)
);
}
.search-load-more:disabled {
cursor: default;
opacity: 0.55;
}
@media (max-width: 900px) {
.global-search {
width: 34px;