diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 13280cc6ce..fcabd6942b 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -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" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 16982cc276..4bb6237843 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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" diff --git a/frontend/editor/src/core/data/processorSearchIndex.ts b/frontend/editor/src/core/data/processorSearchIndex.ts new file mode 100644 index 0000000000..3a06613070 --- /dev/null +++ b/frontend/editor/src/core/data/processorSearchIndex.ts @@ -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[] = []; diff --git a/frontend/editor/src/core/hooks/useSuperSearch.ts b/frontend/editor/src/core/hooks/useSuperSearch.ts index 1ee56303ce..8484c8183b 100644 --- a/frontend/editor/src/core/hooks/useSuperSearch.ts +++ b/frontend/editor/src/core/hooks/useSuperSearch.ts @@ -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(() => { + 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(() => { const byId: Record = { files: fileResults, tools: toolResults, settings: settingsResults, + processor: processorResults, }; const labels: Record = { 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]); diff --git a/frontend/editor/src/desktop/data/processorSearchIndex.ts b/frontend/editor/src/desktop/data/processorSearchIndex.ts new file mode 100644 index 0000000000..0d2b60f1b9 --- /dev/null +++ b/frontend/editor/src/desktop/data/processorSearchIndex.ts @@ -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"; diff --git a/frontend/editor/src/portal/api/search.ts b/frontend/editor/src/portal/api/search.ts deleted file mode 100644 index d3fc672577..0000000000 --- a/frontend/editor/src/portal/api/search.ts +++ /dev/null @@ -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 { - return apiClient.local.json("/v1/search/quick-actions"); -} diff --git a/frontend/editor/src/portal/components/SearchModal.css b/frontend/editor/src/portal/components/SearchModal.css index 6b139c975d..183937a790 100644 --- a/frontend/editor/src/portal/components/SearchModal.css +++ b/frontend/editor/src/portal/components/SearchModal.css @@ -68,3 +68,7 @@ font-size: 0.6875rem; color: var(--color-text-5); } + +.portal-search__item--active { + background: var(--color-bg-hover); +} diff --git a/frontend/editor/src/portal/components/SearchModal.tsx b/frontend/editor/src/portal/components/SearchModal.tsx index 6aac7e99d4..5940fc6bf2 100644 --- a/frontend/editor/src/portal/components/SearchModal.tsx +++ b/frontend/editor/src/portal/components/SearchModal.tsx @@ -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(null); const [query, setQuery] = useState(""); - const state = useAsync(() => 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>((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( + () => [ + ...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 ( 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() {
- {isLoading && ( -
- - - - -
- )} - {isEmpty && ( + {results.length === 0 ? ( - )} - {!isLoading && - !isEmpty && - Object.entries(groups).map(([group, items]) => ( -
-
{group}
- {items.map((item) => ( - - ))} + ) : ( +
+
+ {t("portal.search.goTo")}
- ))} + {results.map((entry, i) => ( + + ))} +
+ )}
diff --git a/frontend/editor/src/portal/mocks/handlers/index.ts b/frontend/editor/src/portal/mocks/handlers/index.ts index 2e50a14eb1..32d364849e 100644 --- a/frontend/editor/src/portal/mocks/handlers/index.ts +++ b/frontend/editor/src/portal/mocks/handlers/index.ts @@ -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, diff --git a/frontend/editor/src/portal/mocks/handlers/search.ts b/frontend/editor/src/portal/mocks/handlers/search.ts deleted file mode 100644 index b62e8bc910..0000000000 --- a/frontend/editor/src/portal/mocks/handlers/search.ts +++ /dev/null @@ -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); - }), -]; diff --git a/frontend/editor/src/portal/mocks/search.ts b/frontend/editor/src/portal/mocks/search.ts deleted file mode 100644 index 86d332de70..0000000000 --- a/frontend/editor/src/portal/mocks/search.ts +++ /dev/null @@ -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" }, -]; diff --git a/frontend/editor/src/proprietary/data/processorSearchIndex.ts b/frontend/editor/src/proprietary/data/processorSearchIndex.ts new file mode 100644 index 0000000000..5bd20421aa --- /dev/null +++ b/frontend/editor/src/proprietary/data/processorSearchIndex.ts @@ -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 + : [];