feat(super-search): extend across the app — Processor group + portal palette

The editor's super search gains a Processor group: the portal's in-app
views (labelled by the same portal.nav.* keys its sidebar uses), gated
the way the portal itself is — admin (or a login-disabled single-user
operator), and only in builds that mount the portal route-set. The index
is a flavor seam: core and desktop ship it empty, so the group never
renders where there is no portal to navigate to.

The portal's ⌘K palette becomes the portal face of the same search:
instead of a mock quick-actions endpoint whose rows didn't even navigate,
it fuzzy-ranks the sidebar's flavor-aware destinations (plus the editor)
with keyboard navigation, and jumps on select. The mock search API,
handler, and fixtures are deleted.
This commit is contained in:
Reece
2026-07-15 13:12:27 +01:00
parent df0f56a127
commit 515175bf0b
12 changed files with 275 additions and 120 deletions
@@ -7897,13 +7897,12 @@ title = "Nothing here yet"
[portal.search]
ariaLabel = "Search"
placeholder = "Search Stirling — endpoints, pipelines, docs…"
goTo = "Go to"
placeholder = "Search the portal…"
[portal.search.empty]
noActionsDescription = "Quick actions will appear here once they're available."
noActionsTitle = "No quick actions"
noMatches = "No matches for \"{{query}}\""
noMatchesDescription = "Try a different keyword or browse the catalogue."
noMatchesDescription = "Try a different keyword."
[portal.settings.groups]
admin = "Admin"
@@ -9733,6 +9732,7 @@ placeholder = "Search files, tools and settings…"
[superSearch.group]
files = "Files"
processor = "Processor"
settings = "Settings"
tools = "Tools"
@@ -7987,13 +7987,12 @@ title = "Nothing here yet"
[portal.search]
ariaLabel = "Search"
placeholder = "Search Stirling — endpoints, pipelines, docs…"
goTo = "Go to"
placeholder = "Search the portal…"
[portal.search.empty]
noActionsDescription = "Quick actions will appear here once they're available."
noActionsTitle = "No quick actions"
noMatches = "No matches for \"{{query}}\""
noMatchesDescription = "Try a different keyword or browse the catalogue."
noMatchesDescription = "Try a different keyword."
[portal.settings.groups]
admin = "Admin"
@@ -9855,6 +9854,7 @@ placeholder = "Search files, tools and settings…"
[superSearch.group]
files = "Files"
processor = "Processor"
settings = "Settings"
tools = "Tools"
@@ -0,0 +1,21 @@
/**
* Search index for the admin portal ("Processor") destinations offered by the
* global super search. Core and desktop builds ship no portal, so the index is
* empty and the Processor group never renders; the proprietary build (which
* mounts the portal as a route-set) shadows this with the real view list.
*/
export interface ProcessorSearchEntry {
/** Portal view id — stable key for the result row. */
id: string;
/** i18n key for the view's display name (shared with the portal sidebar). */
labelKey: string;
labelFallback: string;
/** In-app path to navigate to. Empty when externalUrl is set. */
path: string;
/** Opens in a new tab instead of navigating (e.g. hosted docs). */
externalUrl?: string;
/** Extra fuzzy-match terms beyond the label. */
keywords?: string[];
}
export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = [];
@@ -21,8 +21,9 @@ import type { StirlingFileStub } from "@app/types/fileContext";
import type { ToolId } from "@app/types/toolId";
import { SETTINGS_SEARCH_INDEX } from "@app/data/settingsSearchIndex";
import { SETTINGS_SECTION_REGISTRY } from "@app/data/settingsSectionRegistry";
import { PROCESSOR_SEARCH_INDEX } from "@app/data/processorSearchIndex";
export type SuperSearchGroupId = "files" | "tools" | "settings";
export type SuperSearchGroupId = "files" | "tools" | "settings" | "processor";
export interface SuperSearchResult {
/** Stable unique key across all groups. */
@@ -46,7 +47,12 @@ export interface SuperSearchGroup {
/** Per-group result caps so the dropdown stays scannable. */
const GROUP_LIMIT = 6;
/** Group display order in the dropdown. */
const GROUP_ORDER: SuperSearchGroupId[] = ["files", "tools", "settings"];
const GROUP_ORDER: SuperSearchGroupId[] = [
"files",
"tools",
"settings",
"processor",
];
export interface UseSuperSearchResult {
/** Non-empty groups, in display order. */
@@ -244,24 +250,56 @@ export function useSuperSearch(
.slice(0, GROUP_LIMIT);
}, [trimmed, config, t, openSettings]);
// --- Processor (admin portal) results ------------------------------------
const processorResults = useMemo<SuperSearchResult[]>(() => {
if (!trimmed || PROCESSOR_SEARCH_INDEX.length === 0) return [];
// The portal is an admin surface; mirror the settings modal's admin gate
// (a login-disabled single-user deployment has a full-access operator).
const isAdmin = config?.isAdmin ?? false;
const loginEnabled = config?.enableLogin ?? false;
if (!(isAdmin || !loginEnabled)) return [];
return rankByFuzzy(PROCESSOR_SEARCH_INDEX, trimmed, [
(e) => t(e.labelKey, e.labelFallback),
(e) => e.labelFallback,
(e) => e.keywords?.join(" ") ?? "",
])
.slice(0, GROUP_LIMIT)
.map(({ item, score }) => ({
key: `processor:${item.id}`,
group: "processor" as const,
title: t(item.labelKey, item.labelFallback),
iconName: "grid-view-rounded",
score,
onSelect: () => {
if (item.externalUrl) {
window.open(item.externalUrl, "_blank", "noopener,noreferrer");
} else {
navigate(item.path);
}
},
}));
}, [trimmed, config, t, navigate]);
// --- Assemble ----------------------------------------------------------
const groups = useMemo<SuperSearchGroup[]>(() => {
const byId: Record<SuperSearchGroupId, SuperSearchResult[]> = {
files: fileResults,
tools: toolResults,
settings: settingsResults,
processor: processorResults,
};
const labels: Record<SuperSearchGroupId, string> = {
files: t("superSearch.group.files", "Files"),
tools: t("superSearch.group.tools", "Tools"),
settings: t("superSearch.group.settings", "Settings"),
processor: t("superSearch.group.processor", "Processor"),
};
return GROUP_ORDER.map((id) => ({
id,
label: labels[id],
results: byId[id],
})).filter((g) => g.results.length > 0);
}, [fileResults, toolResults, settingsResults, t]);
}, [fileResults, toolResults, settingsResults, processorResults, t]);
const flatResults = useMemo(() => groups.flatMap((g) => g.results), [groups]);
@@ -0,0 +1,7 @@
/**
* Desktop inherits proprietary's app but must NOT ship the portal (see the
* admin-route seam) — shadow the index back to core's empty list so the super
* search never offers Processor destinations that don't exist on desktop.
*/
export type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
export { PROCESSOR_SEARCH_INDEX } from "@core/data/processorSearchIndex";
-13
View File
@@ -1,13 +0,0 @@
import { apiClient } from "@portal/api/http";
export interface QuickAction {
group: "Jump to" | "Create" | "Theme";
label: string;
/** Keyboard hint shown to the right. */
hint: string;
}
/** GET /v1/search/quick-actions */
export async function fetchQuickActions(): Promise<QuickAction[]> {
return apiClient.local.json<QuickAction[]>("/v1/search/quick-actions");
}
@@ -68,3 +68,7 @@
font-size: 0.6875rem;
color: var(--color-text-5);
}
.portal-search__item--active {
background: var(--color-bg-hover);
}
@@ -1,49 +1,91 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Button, EmptyState, Modal, Skeleton } from "@app/ui";
import { Button, EmptyState, Modal } from "@app/ui";
import { useTranslation } from "react-i18next";
import { rankByFuzzy } from "@app/utils/fuzzySearch";
import { useUI } from "@portal/contexts/UIContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import { fetchQuickActions, type QuickAction } from "@portal/api/search";
import { useView, type ViewId } from "@portal/contexts/ViewContext";
import {
GROUP_PRIMARY,
GROUP_OPERATIONAL,
GROUP_PLATFORM,
type NavEntry,
} from "@portal/components/sidebarGroups";
import { SearchIcon } from "@portal/components/icons";
import "@portal/components/SearchModal.css";
/**
* The portal's ⌘K palette — the portal face of the global super search. It
* searches the portal's own destinations (the same flavor-aware nav set the
* sidebar shows, plus the editor) and navigates on select; the editor's top-bar
* search is the same idea pointed at files/tools/settings/Processor.
*/
export function SearchModal() {
const { t } = useTranslation();
const { searchOpen, closeSearch } = useUI();
const { setActiveView } = useView();
const inputRef = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState("");
const state = useAsync<QuickAction[]>(() => fetchQuickActions(), []);
const { data: actions } = state;
const { isLoading } = useSectionFlags(state);
const [highlight, setHighlight] = useState(0);
useEffect(() => {
if (searchOpen) {
setQuery("");
const t = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(t);
setHighlight(0);
const timer = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(timer);
}
return undefined;
}, [searchOpen]);
const filtered = useMemo(() => {
if (!actions) return [] as QuickAction[];
if (!query.trim()) return actions;
const needle = query.trim().toLowerCase();
return actions.filter((item) => item.label.toLowerCase().includes(needle));
}, [actions, query]);
const groups = useMemo(
() =>
filtered.reduce<Record<string, QuickAction[]>>((acc, item) => {
if (!acc[item.group]) acc[item.group] = [];
acc[item.group].push(item);
return acc;
}, {}),
[filtered],
// Every destination the palette can jump to: the sidebar's nav groups (a
// flavor seam — saas ships a reduced set) plus the editor app itself.
const entries = useMemo<NavEntry[]>(
() => [
...GROUP_PRIMARY,
...GROUP_OPERATIONAL,
...GROUP_PLATFORM,
{ id: "editor" as ViewId, icon: null },
],
[],
);
const groupKeys = Object.keys(groups);
const isEmpty = !isLoading && groupKeys.length === 0;
const results = useMemo(() => {
const q = query.trim();
if (!q) return entries;
return rankByFuzzy(entries, q, [
(e) => t(`portal.nav.${e.id}`),
(e) => e.id,
]).map(({ item }) => item);
}, [entries, query, t]);
useEffect(() => {
setHighlight((h) =>
results.length === 0 ? 0 : Math.min(h, results.length - 1),
);
}, [results.length]);
const select = (entry: NavEntry | undefined) => {
if (!entry) return;
closeSearch();
if (entry.externalUrl) {
window.open(entry.externalUrl, "_blank", "noopener,noreferrer");
return;
}
setActiveView(entry.id);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlight((h) => Math.min(h + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlight((h) => Math.max(h - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
select(results[highlight]);
}
};
return (
<Modal
@@ -58,7 +100,11 @@ export function SearchModal() {
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onChange={(e) => {
setQuery(e.target.value);
setHighlight(0);
}}
onKeyDown={onKeyDown}
placeholder={t("portal.search.placeholder")}
aria-label={t("portal.search.ariaLabel")}
className="portal-search__input"
@@ -71,53 +117,38 @@ export function SearchModal() {
</div>
<div className="portal-search__results">
{isLoading && (
<div className="portal-search__loading">
<Skeleton height="1rem" width="40%" />
<Skeleton height="0.875rem" />
<Skeleton height="0.875rem" width="80%" />
<Skeleton height="0.875rem" width="65%" />
</div>
)}
{isEmpty && (
{results.length === 0 ? (
<EmptyState
size="compact"
title={
query.trim()
? t("portal.search.empty.noMatches", { query: query.trim() })
: t("portal.search.empty.noActionsTitle")
}
description={
query.trim()
? t("portal.search.empty.noMatchesDescription")
: t("portal.search.empty.noActionsDescription")
}
title={t("portal.search.empty.noMatches", {
query: query.trim(),
})}
description={t("portal.search.empty.noMatchesDescription")}
/>
)}
{!isLoading &&
!isEmpty &&
Object.entries(groups).map(([group, items]) => (
<div key={group} className="portal-search__group">
<div className="portal-search__group-label">{group}</div>
{items.map((item) => (
<Button
key={item.label}
variant="tertiary"
justify="start"
fullWidth
className="portal-search__item"
onClick={closeSearch}
>
<span className="portal-search__item-label">
{item.label}
</span>
<span className="portal-search__item-hint">
{item.hint}
</span>
</Button>
))}
) : (
<div className="portal-search__group">
<div className="portal-search__group-label">
{t("portal.search.goTo")}
</div>
))}
{results.map((entry, i) => (
<Button
key={entry.id}
variant="tertiary"
justify="start"
fullWidth
className={`portal-search__item${
i === highlight ? " portal-search__item--active" : ""
}`}
onClick={() => select(entry)}
onMouseEnter={() => setHighlight(i)}
>
<span className="portal-search__item-label">
{t(`portal.nav.${entry.id}`)}
</span>
</Button>
))}
</div>
)}
</div>
</div>
</Modal>
@@ -1,7 +1,6 @@
import { assistantHandlers } from "@portal/mocks/handlers/assistant";
import { authHandlers } from "@portal/mocks/handlers/auth";
import { notificationsHandlers } from "@portal/mocks/handlers/notifications";
import { searchHandlers } from "@portal/mocks/handlers/search";
import { pipelinesHandlers } from "@portal/mocks/handlers/pipelines";
import { sourcesHandlers } from "@portal/mocks/handlers/sources";
import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure";
@@ -22,7 +21,6 @@ export const handlers = [
...authHandlers,
...notificationsHandlers,
...assistantHandlers,
...searchHandlers,
...pipelinesHandlers,
...sourcesHandlers,
...infrastructureHandlers,
@@ -1,8 +0,0 @@
import { http, HttpResponse } from "msw";
import { QUICK_ACTIONS } from "@portal/mocks/search";
export const searchHandlers = [
http.get("/v1/search/quick-actions", () => {
return HttpResponse.json(QUICK_ACTIONS);
}),
];
@@ -1,17 +0,0 @@
/**
* Mock quick-action catalogue for the ⌘K search palette. The QuickAction type
* lives in api/search.ts (the backend contract); this module only builds fake
* data for Storybook and tests.
*/
import type { QuickAction } from "@portal/api/search";
export const QUICK_ACTIONS: QuickAction[] = [
{ group: "Jump to", label: "Home", hint: "G H" },
{ group: "Jump to", label: "Pipelines", hint: "G P" },
{ group: "Jump to", label: "Sources", hint: "G S" },
{ group: "Jump to", label: "Documents", hint: "G D" },
{ group: "Create", label: "New pipeline", hint: "N P" },
{ group: "Create", label: "New API key", hint: "N K" },
{ group: "Theme", label: "Toggle dark / light", hint: "T" },
];
@@ -0,0 +1,94 @@
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
import type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
export type { ProcessorSearchEntry };
// Mirrors the admin-route seam's gate: the portal route-set is only mounted in
// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not
// offer destinations that would 404 elsewhere.
const includePortal =
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
/**
* The portal's in-app views. Deliberately a static mirror of the portal's nav
* (labels via the same portal.nav.* keys its sidebar uses) rather than an
* import from @portal/* — referencing the portal package here would pull the
* lazy portal chunk into the main bundle.
*/
const VIEWS: ProcessorSearchEntry[] = [
{
id: "home",
labelKey: "portal.nav.home",
labelFallback: "Home",
path: PORTAL_BASENAME,
keywords: ["portal", "processor", "admin"],
},
{
id: "users",
labelKey: "portal.nav.users",
labelFallback: "Users",
path: `${PORTAL_BASENAME}/users`,
keywords: ["team", "members", "roles", "admin"],
},
{
id: "sources",
labelKey: "portal.nav.sources",
labelFallback: "Sources",
path: `${PORTAL_BASENAME}/sources`,
keywords: ["s3", "connections", "webhooks", "folders"],
},
{
id: "policies",
labelKey: "portal.nav.policies",
labelFallback: "Policies",
path: `${PORTAL_BASENAME}/policies`,
keywords: ["enforcement", "redact", "compliance"],
},
{
id: "pipelines",
labelKey: "portal.nav.pipelines",
labelFallback: "Pipelines",
path: `${PORTAL_BASENAME}/pipelines`,
keywords: ["automation", "workflows", "operations"],
},
{
id: "documents",
labelKey: "portal.nav.documents",
labelFallback: "Documents",
path: `${PORTAL_BASENAME}/documents`,
keywords: ["audit", "files"],
},
{
id: "components",
labelKey: "portal.nav.components",
labelFallback: "Components",
path: `${PORTAL_BASENAME}/components`,
keywords: ["sdk", "embed"],
},
{
id: "infrastructure",
labelKey: "portal.nav.infrastructure",
labelFallback: "Infrastructure",
path: `${PORTAL_BASENAME}/infrastructure`,
keywords: ["deployment", "instances", "health"],
},
{
id: "usage",
labelKey: "portal.nav.usage",
labelFallback: "Usage & Billing",
path: `${PORTAL_BASENAME}/usage`,
keywords: ["billing", "invoices", "plan", "wallet"],
},
{
id: "docs",
labelKey: "portal.nav.docs",
labelFallback: "Developer Docs",
path: "",
externalUrl: "https://docs.stirlingpdf.com/",
keywords: ["api", "documentation", "reference"],
},
];
export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = includePortal
? VIEWS
: [];