mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Super search across Editor and Processor (#6734)
One search bar for the whole app, in both the Editor and the Processor. **Cmd/Ctrl+K** from anywhere. - **Editor** — your files, tools, settings, and everything in the Processor: its pages plus live users, policies, pipelines, sources, and full-text developer docs. - **Processor** — the same bar over its own data (users, policies, pipelines, sources, docs) plus tools and settings. Files stay editor-only; pages stay out (the sidebar covers them). - **Filter chips** (and typed prefixes like `policy: invoice`) narrow to one lane; each group shows a few results with **Show more** to expand. - **Access-gated**: users only see lanes and settings sections they can actually open — no Processor chips, results, or entity fetches without portal access, no admin settings for non-admins, no account-bound sections for anonymous SaaS sessions. Asserted by unit tests and a stubbed Playwright suite. - Selecting a result takes you straight there, across apps when needed — results group under collapsible sections. Replaces the separate search boxes that previously lived in the file sidebar, the tool panel header, the settings modal, and the docs page. ### Editor <img src="https://gist.githubusercontent.com/reecebrowne/5879e797c5ab6abc027a7bfd5cd4de17/raw/editor-search.png?v=2" width="800" alt="Editor super search" /> ### Processor <img src="https://gist.githubusercontent.com/reecebrowne/5879e797c5ab6abc027a7bfd5cd4de17/raw/portal-search.png?v=2" width="800" alt="Processor super search" /> --------- Co-authored-by: EthanHealy01 <ethan.healy.21@gmail.com>
This commit is contained in:
co-authored by
EthanHealy01
parent
cd49daf5c6
commit
d2844c4f90
@@ -3895,8 +3895,6 @@ openFromComputer = "Open from computer"
|
||||
openSettings = "Open settings"
|
||||
other = "Other"
|
||||
recent = "Recent"
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search files..."
|
||||
viewAll = "View all {{count}} files"
|
||||
|
||||
[fileSidebar.fileItem]
|
||||
@@ -3925,7 +3923,6 @@ backToMyFiles = "Back to My Files"
|
||||
breadcrumbs = "Folder path"
|
||||
cancel = "Cancel"
|
||||
classification = "Classification"
|
||||
clearSearch = "Clear search"
|
||||
clearSelection = "Clear selection"
|
||||
closeDetails = "Close details"
|
||||
create = "Create"
|
||||
@@ -3990,8 +3987,6 @@ resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; doub
|
||||
save = "Save"
|
||||
saveToServer = "Save to server"
|
||||
saveToServerDisabledHint = "Saving to the server isn't enabled on this server. Ask your admin to enable it."
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search this folder & subfolders"
|
||||
selectAll = "Select all"
|
||||
selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range."
|
||||
selectedCount = "{{count}} selected"
|
||||
@@ -4051,6 +4046,10 @@ title = "No cloud files yet"
|
||||
hint = "Files saved without uploading stay here. Drop a file to add one."
|
||||
title = "No local-only files"
|
||||
|
||||
[filesPage.empty.noResults]
|
||||
hint = "No files in this folder match your filter. Try a different term or clear the filter."
|
||||
title = "No matching files"
|
||||
|
||||
[filesPage.empty.recent]
|
||||
hint = "Files you open or edit will appear here."
|
||||
title = "Nothing modified yet"
|
||||
@@ -4120,6 +4119,11 @@ localHint = "Only stored in this browser"
|
||||
shared = "Shared"
|
||||
sharedHint = "Shared with you via link"
|
||||
|
||||
[filesPage.search]
|
||||
clear = "Clear filter"
|
||||
label = "Filter files by name"
|
||||
placeholder = "Filter files…"
|
||||
|
||||
[filesPage.sort]
|
||||
modifiedAsc = "Oldest first"
|
||||
modifiedDesc = "Recent first"
|
||||
@@ -5005,9 +5009,6 @@ title = "Upload from Mobile"
|
||||
tags = "Multi Tool,Multi operation,UI,click drag,front end,client side,interactive,intractable,move,delete,migrate,divide"
|
||||
title = "PDF Multi Tool"
|
||||
|
||||
[navbar]
|
||||
search = "Search"
|
||||
|
||||
[oauth.error]
|
||||
message = "Authentication was not successful. You can close this window and try again."
|
||||
title = "Authentication Failed"
|
||||
@@ -7091,11 +7092,6 @@ title = "Official SDKs"
|
||||
beta = "Beta"
|
||||
deprecated = "Deprecated"
|
||||
|
||||
[portal.docs.search]
|
||||
empty = "No matching docs"
|
||||
placeholder = "Search docs"
|
||||
results = "{{count}} results"
|
||||
|
||||
[portal.docs.skills]
|
||||
eyebrow = "SKILLS"
|
||||
lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached."
|
||||
@@ -8701,16 +8697,6 @@ maxed = "Maxed out"
|
||||
subtitle = "Your free trial runs through {{date}}. No card required."
|
||||
title = "Enterprise trial"
|
||||
|
||||
[portal.search]
|
||||
ariaLabel = "Search"
|
||||
placeholder = "Search Stirling — endpoints, pipelines, docs…"
|
||||
|
||||
[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."
|
||||
|
||||
[portal.settings.groups]
|
||||
admin = "Admin"
|
||||
|
||||
@@ -9999,9 +9985,6 @@ title = "Policies & Privacy"
|
||||
[settings.preferences]
|
||||
title = "Preferences"
|
||||
|
||||
[settings.search]
|
||||
placeholder = "Search settings pages..."
|
||||
|
||||
[settings.security]
|
||||
description = "Update your password to keep your account secure."
|
||||
title = "Security"
|
||||
@@ -10644,6 +10627,23 @@ title = "Upload to Server"
|
||||
updateButton = "Update on Server"
|
||||
uploadButton = "Upload to Server"
|
||||
|
||||
[superSearch]
|
||||
all = "All"
|
||||
ariaLabel = "Super search"
|
||||
filtersAriaLabel = "Search filters"
|
||||
hint = "Type to search"
|
||||
placeholder = "Search Stirling"
|
||||
showLess = "Show less"
|
||||
showMore = "Show {{count}} more"
|
||||
|
||||
[superSearch.group]
|
||||
docs = "Docs"
|
||||
files = "Files"
|
||||
pages = "Pages"
|
||||
processor = "Processor"
|
||||
settings = "Settings"
|
||||
tools = "Tools"
|
||||
|
||||
[survey]
|
||||
title = "Stirling-PDF Survey"
|
||||
|
||||
@@ -10793,7 +10793,6 @@ goBack = "Go back"
|
||||
pdfTools = "PDF Tools"
|
||||
placeholder = "Choose a tool to get started"
|
||||
premiumFeature = "Premium feature:"
|
||||
searchTools = "Search tools"
|
||||
toolsHeader = "Tools"
|
||||
viewAllTools = "View all tools"
|
||||
|
||||
@@ -11665,6 +11664,7 @@ exitRedaction = "Exit Redaction Mode"
|
||||
exportAll = "Export PDF"
|
||||
exportSelected = "Export Selected Pages"
|
||||
formFill = "Fill Form"
|
||||
hideToolbar = "Hide toolbar"
|
||||
multiTool = "Multi-Tool"
|
||||
panMode = "Pan Mode"
|
||||
print = "Print PDF"
|
||||
@@ -11685,6 +11685,7 @@ selectAll = "Select All"
|
||||
selectByNumber = "Select by Page Numbers"
|
||||
selectLanguage = "Select language"
|
||||
share = "Share"
|
||||
showToolbar = "Show toolbar"
|
||||
toggleAnnotations = "Toggle Annotations Visibility"
|
||||
toggleAttachments = "Toggle Attachments"
|
||||
toggleBookmarks = "Toggle Bookmarks"
|
||||
|
||||
@@ -3904,8 +3904,6 @@ openFromComputer = "Open from computer"
|
||||
openSettings = "Open settings"
|
||||
other = "Other"
|
||||
recent = "Recent"
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search files..."
|
||||
viewAll = "View all {{count}} files"
|
||||
|
||||
[fileSidebar.fileItem]
|
||||
@@ -3934,7 +3932,6 @@ backToMyFiles = "Back to My Files"
|
||||
breadcrumbs = "Folder path"
|
||||
cancel = "Cancel"
|
||||
classification = "Classification"
|
||||
clearSearch = "Clear search"
|
||||
clearSelection = "Clear selection"
|
||||
closeDetails = "Close details"
|
||||
create = "Create"
|
||||
@@ -3999,8 +3996,6 @@ resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; doub
|
||||
save = "Save"
|
||||
saveToServer = "Save to server"
|
||||
saveToServerDisabledHint = "Saving to the server isn't enabled on this server. Ask your admin to enable it."
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search this folder & subfolders"
|
||||
selectAll = "Select all"
|
||||
selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range."
|
||||
selectedCount = "{{count}} selected"
|
||||
@@ -4060,6 +4055,10 @@ title = "No cloud files yet"
|
||||
hint = "Files saved without uploading stay here. Drop a file to add one."
|
||||
title = "No local-only files"
|
||||
|
||||
[filesPage.empty.noResults]
|
||||
hint = "No files in this folder match your filter. Try a different term or clear the filter."
|
||||
title = "No matching files"
|
||||
|
||||
[filesPage.empty.recent]
|
||||
hint = "Files you open or edit will appear here."
|
||||
title = "Nothing modified yet"
|
||||
@@ -4129,6 +4128,11 @@ localHint = "Only stored in this browser"
|
||||
shared = "Shared"
|
||||
sharedHint = "Shared with you via link"
|
||||
|
||||
[filesPage.search]
|
||||
clear = "Clear filter"
|
||||
label = "Filter files by name"
|
||||
placeholder = "Filter files…"
|
||||
|
||||
[filesPage.sort]
|
||||
modifiedAsc = "Oldest first"
|
||||
modifiedDesc = "Recent first"
|
||||
@@ -5047,9 +5051,6 @@ title = "Upload from Mobile"
|
||||
tags = "Multi Tool,Multi operation,UI,click drag,front end,client side,interactive,intractable,move,delete,migrate,divide"
|
||||
title = "PDF Multi Tool"
|
||||
|
||||
[navbar]
|
||||
search = "Search"
|
||||
|
||||
[oauth.error]
|
||||
message = "Authentication was not successful. You can close this window and try again."
|
||||
title = "Authentication Failed"
|
||||
@@ -7133,11 +7134,6 @@ title = "Official SDKs"
|
||||
beta = "Beta"
|
||||
deprecated = "Deprecated"
|
||||
|
||||
[portal.docs.search]
|
||||
empty = "No matching docs"
|
||||
placeholder = "Search docs"
|
||||
results = "{{count}} results"
|
||||
|
||||
[portal.docs.skills]
|
||||
eyebrow = "SKILLS"
|
||||
lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached."
|
||||
@@ -8813,16 +8809,6 @@ maxed = "Maxed out"
|
||||
subtitle = "Your free trial runs through {{date}}. No card required."
|
||||
title = "Enterprise trial"
|
||||
|
||||
[portal.search]
|
||||
ariaLabel = "Search"
|
||||
placeholder = "Search Stirling — endpoints, pipelines, docs…"
|
||||
|
||||
[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."
|
||||
|
||||
[portal.settings.groups]
|
||||
admin = "Admin"
|
||||
|
||||
@@ -10111,9 +10097,6 @@ title = "Policies & Privacy"
|
||||
[settings.preferences]
|
||||
title = "Preferences"
|
||||
|
||||
[settings.search]
|
||||
placeholder = "Search settings pages..."
|
||||
|
||||
[settings.security]
|
||||
description = "Update your password to keep your account secure."
|
||||
title = "Security"
|
||||
@@ -10769,6 +10752,23 @@ title = "Upload to Server"
|
||||
updateButton = "Update on Server"
|
||||
uploadButton = "Upload to Server"
|
||||
|
||||
[superSearch]
|
||||
all = "All"
|
||||
ariaLabel = "Super search"
|
||||
filtersAriaLabel = "Search filters"
|
||||
hint = "Type to search"
|
||||
placeholder = "Search Stirling"
|
||||
showLess = "Show less"
|
||||
showMore = "Show {{count}} more"
|
||||
|
||||
[superSearch.group]
|
||||
docs = "Docs"
|
||||
files = "Files"
|
||||
pages = "Pages"
|
||||
processor = "Processor"
|
||||
settings = "Settings"
|
||||
tools = "Tools"
|
||||
|
||||
[survey]
|
||||
title = "Stirling-PDF Survey"
|
||||
|
||||
@@ -10918,7 +10918,6 @@ goBack = "Go back"
|
||||
pdfTools = "PDF Tools"
|
||||
placeholder = "Choose a tool to get started"
|
||||
premiumFeature = "Premium feature:"
|
||||
searchTools = "Search tools"
|
||||
toolsHeader = "Tools"
|
||||
viewAllTools = "View all tools"
|
||||
|
||||
@@ -11799,6 +11798,7 @@ exitRedaction = "Exit Redaction Mode"
|
||||
exportAll = "Export PDF"
|
||||
exportSelected = "Export Selected Pages"
|
||||
formFill = "Fill Form"
|
||||
hideToolbar = "Hide toolbar"
|
||||
multiTool = "Multi-Tool"
|
||||
panMode = "Pan Mode"
|
||||
print = "Print PDF"
|
||||
@@ -11819,6 +11819,7 @@ selectAll = "Select All"
|
||||
selectByNumber = "Select by Page Numbers"
|
||||
selectLanguage = "Select language"
|
||||
share = "Share"
|
||||
showToolbar = "Show toolbar"
|
||||
toggleAnnotations = "Toggle Annotations Visibility"
|
||||
toggleAttachments = "Toggle Attachments"
|
||||
toggleBookmarks = "Toggle Bookmarks"
|
||||
|
||||
@@ -26,6 +26,14 @@ export interface AuthContextType {
|
||||
error: Error | null;
|
||||
signOut: () => Promise<void>;
|
||||
refreshSession: () => Promise<void>;
|
||||
/**
|
||||
* Session-level permission flags, provided by layers whose auth knows them
|
||||
* (proprietary's Spring session carries both). Absent in core OSS, which
|
||||
* has no auth context — consumers must treat undefined as "not granted"
|
||||
* and fall back to app-config gates.
|
||||
*/
|
||||
isAdmin?: boolean;
|
||||
portalAccess?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,7 @@ import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutli
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
|
||||
import { FileId } from "@app/types/file";
|
||||
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
|
||||
@@ -89,6 +90,8 @@ interface FileGridProps {
|
||||
onChangeSortMode?: (mode: FilesPageSortMode) => void;
|
||||
/** Drives the empty-state copy. */
|
||||
currentTab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe";
|
||||
/** A filter is applied; an empty result then means "no matches", not "no files". */
|
||||
searchActive?: boolean;
|
||||
/** Cloud reachability; switches the cloud empty-state copy. */
|
||||
serverReachable?: boolean;
|
||||
/** Empty-state CTA handlers; if absent the matching button hides. */
|
||||
@@ -104,6 +107,7 @@ export function FileGrid(props: FileGridProps & { loading?: boolean }) {
|
||||
entries,
|
||||
loading,
|
||||
currentTab,
|
||||
searchActive,
|
||||
serverReachable,
|
||||
onEmptyUpload,
|
||||
onEmptyCreateFolder,
|
||||
@@ -115,15 +119,28 @@ export function FileGrid(props: FileGridProps & { loading?: boolean }) {
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
const emptyState = (
|
||||
<EmptyState
|
||||
tab={currentTab}
|
||||
searchActive={searchActive}
|
||||
serverReachable={serverReachable}
|
||||
onUpload={onEmptyUpload}
|
||||
onCreateFolder={onEmptyCreateFolder}
|
||||
newFolderDisabledReason={newFolderDisabledReason}
|
||||
/>
|
||||
);
|
||||
// When a filter empties the list view, keep the column headers in place and
|
||||
// show the no-results message beneath them, rather than replacing the whole
|
||||
// table. Grid view (cards, no headers) just shows the empty state.
|
||||
if (viewMode === "list" && searchActive) {
|
||||
return (
|
||||
<>
|
||||
<ListView {...props} />
|
||||
{emptyState}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return emptyState;
|
||||
}
|
||||
|
||||
if (viewMode === "list") {
|
||||
@@ -187,6 +204,8 @@ function SkeletonGrid({ viewMode }: { viewMode: FilesPageViewMode }) {
|
||||
interface EmptyStateProps {
|
||||
/** Drives copy + iconography. */
|
||||
tab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe";
|
||||
/** When true the empty list is the result of a filter, not a bare folder. */
|
||||
searchActive?: boolean;
|
||||
/** Switches the cloud empty-state copy. */
|
||||
serverReachable?: boolean;
|
||||
/** CTA handlers; absent => button hidden. */
|
||||
@@ -198,12 +217,35 @@ interface EmptyStateProps {
|
||||
|
||||
function EmptyState({
|
||||
tab = "all",
|
||||
searchActive = false,
|
||||
serverReachable = true,
|
||||
onUpload,
|
||||
onCreateFolder,
|
||||
newFolderDisabledReason,
|
||||
}: EmptyStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// A filter with no matches isn't an empty folder - say so, and skip the
|
||||
// upload / new-folder CTAs since clearing the filter is the way out.
|
||||
if (searchActive) {
|
||||
return (
|
||||
<div className="files-page-empty">
|
||||
<span className="files-page-empty-icon">
|
||||
<SearchIcon style={{ fontSize: "2.5rem" }} />
|
||||
</span>
|
||||
<div className="files-page-empty-title">
|
||||
{t("filesPage.empty.noResults.title", "No matching files")}
|
||||
</div>
|
||||
<div className="files-page-empty-hint">
|
||||
{t(
|
||||
"filesPage.empty.noResults.hint",
|
||||
"No files in this folder match your filter. Try a different term or clear the filter.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { titleKey, titleFallback, hintKey, hintFallback } = (() => {
|
||||
switch (tab) {
|
||||
case "local":
|
||||
|
||||
@@ -7,11 +7,19 @@ import React, {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Drawer, Group, MultiSelect, Select, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
Drawer,
|
||||
Group,
|
||||
MultiSelect,
|
||||
Select,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { SegmentedControl } from "@app/ui/SegmentedControl";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import QrCode2Icon from "@mui/icons-material/QrCode2";
|
||||
@@ -51,6 +59,8 @@ import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { FolderId, ROOT_FOLDER_ID } from "@app/types/folder";
|
||||
|
||||
import { FileGrid, FilesPageEntry } from "@app/components/filesPage/FileGrid";
|
||||
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
|
||||
import { useEditorSearchScopes } from "@app/hooks/useSuperSearch";
|
||||
import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel";
|
||||
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
|
||||
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
|
||||
@@ -74,6 +84,7 @@ export default function FileManagerView() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const searchScopes = useEditorSearchScopes();
|
||||
|
||||
// Hide Shared tab when storageSharingEnabled is false.
|
||||
const { sharingEnabled } = useSharingEnabled();
|
||||
@@ -686,12 +697,11 @@ export default function FileManagerView() {
|
||||
}, [navigate]);
|
||||
|
||||
// ─── keyboard shortcuts ─────────────────────────────────────────────────
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
// External focus trigger (used by the FileSidebar rail Search button).
|
||||
useEffect(() => {
|
||||
const onFocus = () => searchInputRef.current?.focus();
|
||||
window.addEventListener("files-page:focus-search", onFocus);
|
||||
return () => window.removeEventListener("files-page:focus-search", onFocus);
|
||||
// Focus the super-search input (stable id), used by the "/" shortcut.
|
||||
const focusSearch = useCallback(() => {
|
||||
(
|
||||
document.getElementById("super-search-input") as HTMLInputElement | null
|
||||
)?.focus();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -735,13 +745,19 @@ export default function FileManagerView() {
|
||||
// "/" focuses the search field.
|
||||
if (e.key === "/" && !inInput) {
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
focusSearch();
|
||||
return;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [visibleFiles, selectedFileIds, removeFiles, setSelectedFileIds]);
|
||||
}, [
|
||||
visibleFiles,
|
||||
selectedFileIds,
|
||||
removeFiles,
|
||||
setSelectedFileIds,
|
||||
focusSearch,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -890,11 +906,9 @@ export default function FileManagerView() {
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<SearchField
|
||||
ref={searchInputRef}
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
/>
|
||||
<div className="files-page-header-search">
|
||||
<SuperSearch scopes={searchScopes} />
|
||||
</div>
|
||||
<div className="files-page-header-actions">
|
||||
<Tooltip
|
||||
label={
|
||||
@@ -1343,6 +1357,27 @@ export default function FileManagerView() {
|
||||
aria-label={t("filesPage.typeFilter.label", "Filter by type")}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
placeholder={t("filesPage.search.placeholder", "Filter files…")}
|
||||
leftSection={<SearchIcon sx={{ fontSize: "1rem" }} />}
|
||||
rightSection={
|
||||
search ? (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => setSearch("")}
|
||||
aria-label={t("filesPage.search.clear", "Clear filter")}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "0.9rem" }} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
aria-label={t("filesPage.search.label", "Filter files by name")}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
value={sortMode}
|
||||
@@ -1434,6 +1469,7 @@ export default function FileManagerView() {
|
||||
entries={entries}
|
||||
loading={loading}
|
||||
currentTab={currentTab}
|
||||
searchActive={search.trim().length > 0}
|
||||
serverReachable={folders.serverReachable}
|
||||
selectedFileIds={selectedFileIds}
|
||||
activeWorkspaceFileIds={activeWorkspaceFileIdSet}
|
||||
@@ -1656,39 +1692,6 @@ export default function FileManagerView() {
|
||||
);
|
||||
}
|
||||
|
||||
const SearchField = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
{ value: string; onChange: (v: string) => void }
|
||||
>(function SearchField({ value, onChange }, ref) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="files-page-search">
|
||||
<SearchIcon fontSize="small" style={{ color: "var(--c-text-subtle)" }} />
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={t(
|
||||
"filesPage.searchPlaceholder",
|
||||
"Search this folder & subfolders",
|
||||
)}
|
||||
aria-label={t("filesPage.search", "Search")}
|
||||
/>
|
||||
{value && (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => onChange("")}
|
||||
aria-label={t("filesPage.clearSearch", "Clear search")}
|
||||
>
|
||||
×
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function Breadcrumbs() {
|
||||
const { t } = useTranslation();
|
||||
const folders = useFolders();
|
||||
|
||||
@@ -34,6 +34,33 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.files-page-header-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.files-page-header-search .super-search {
|
||||
flex: 0 1 24rem;
|
||||
width: min(100%, 24rem);
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.files-page-header-search .super-search input {
|
||||
background-color: transparent;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"]
|
||||
.files-page-header-search
|
||||
.super-search
|
||||
input {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.files-page-breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -79,28 +106,6 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.files-page-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.75rem;
|
||||
/* Fills its grid cell; the cell's minmax(...) clamps to a sensible range. */
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.files-page-search input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
flex: 1;
|
||||
color: var(--c-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.files-page-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
@@ -1376,23 +1381,6 @@
|
||||
overflow-x: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.files-page-search {
|
||||
/* Shrink hard so the search bar doesn't eat the whole action row.
|
||||
Users still see the icon + a few chars of the placeholder.
|
||||
`overflow: hidden` clips the input's natural intrinsic width so
|
||||
placeholder text never leaks outside the rounded pill. */
|
||||
min-width: 0;
|
||||
flex: 0 1 5.5rem;
|
||||
max-width: 6.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.files-page-search input {
|
||||
/* `min-width: 0` lets flex actually shrink the input below its
|
||||
default ~20-char intrinsic size - without this, the placeholder
|
||||
extends beyond the parent's clip box and bleeds onto neighbours. */
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* Upload becomes an icon-only square button on mobile so the action
|
||||
row stops getting clipped. Scoped to `.files-page-header-actions`
|
||||
so the Back button at the header level keeps its visible "Back"
|
||||
|
||||
@@ -1,19 +1,48 @@
|
||||
/* WorkbenchBar slide-in/out animation using CSS grid trick */
|
||||
/* Positioning context for the viewer toolbar's reopen tab, which hangs below
|
||||
the bar and so must sit outside the overflow-clipped wrapper below. */
|
||||
.workbenchBarShell {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
/* Little pull-tab shown while the viewer tool row is retracted. Anchored to the
|
||||
bar's bottom-right edge (where the retract handle sat). Small by design - it
|
||||
just brings the row back. */
|
||||
.workbenchBarReopenTab {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
/* Right-align with the retract handle inside the bar: the bar's right
|
||||
margin (--nav-gutter) + 1px border + 8px bar padding + the handle's own
|
||||
6px inset. */
|
||||
right: calc(var(--nav-gutter) + 15px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 0.875rem;
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
border-top: none;
|
||||
border-radius: 0 0 7px 7px;
|
||||
background: var(--c-bg-raised);
|
||||
color: var(--c-text-subtle);
|
||||
cursor: pointer;
|
||||
z-index: 52;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.workbenchBarReopenTab:hover {
|
||||
color: var(--c-text);
|
||||
background: var(--c-hover);
|
||||
}
|
||||
|
||||
.workbenchBarWrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows 280ms ease;
|
||||
}
|
||||
|
||||
.workbenchBarWrapper[data-hidden="true"] {
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
|
||||
.workbenchBarWrapper[data-no-transition="true"] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Direct child must have min-height: 0 so the row can collapse below content size */
|
||||
.workbenchBarInner {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect, useState, Suspense, lazy } from "react";
|
||||
import { useState, Suspense, lazy } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import { Box, Loader, Center } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||
import { useAllFiles } from "@app/contexts/FileContext";
|
||||
@@ -66,18 +69,13 @@ export default function Workbench() {
|
||||
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
|
||||
const { addFiles } = useFileHandler();
|
||||
const hasFiles = activeFiles.length > 0;
|
||||
// Custom workbench views (e.g. Watched Folders) manage their own content and may
|
||||
// have no workbench files, but still need the bar's view switcher so users can
|
||||
// navigate back out.
|
||||
const isCustomViewActive = !isBaseWorkbench(currentView);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Enable bar transitions after first paint so the initial hidden state shows
|
||||
// without animating (landing page on load shouldn't animate the bar up).
|
||||
const [barTransitionEnabled, setBarTransitionEnabled] = useState(false);
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(() => setBarTransitionEnabled(true));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
// The viewer's tool row can be retracted to give the document more height.
|
||||
// State lives here (not in WorkbenchBar) so the reopen tab can hang below the
|
||||
// bar, outside the bar's overflow-clipped wrapper. Scoped to the viewer.
|
||||
const [viewerToolbarCollapsed, setViewerToolbarCollapsed] = useState(false);
|
||||
const showReopenTab = currentView === "viewer" && viewerToolbarCollapsed;
|
||||
|
||||
const handlePreviewClose = () => {
|
||||
setPreviewFile(null);
|
||||
@@ -219,22 +217,39 @@ export default function Workbench() {
|
||||
data-tour="workbench"
|
||||
style={{ backgroundColor: "var(--c-bg)", minWidth: 0 }}
|
||||
>
|
||||
{/* Workbench Bar - animates in/out based on file presence */}
|
||||
{/* Workbench Bar — always visible outside My Files (it hosts the
|
||||
global search), even with no files loaded. */}
|
||||
{currentView !== "myFiles" &&
|
||||
!customWorkbenchViews.find((v) => v.workbenchId === currentView)
|
||||
?.hideTopControls && (
|
||||
<div
|
||||
className={styles.workbenchBarWrapper}
|
||||
data-hidden={String(!hasFiles && !isCustomViewActive)}
|
||||
data-no-transition={String(!barTransitionEnabled)}
|
||||
>
|
||||
<div className={styles.workbenchBarInner}>
|
||||
<WorkbenchBar
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
hasFiles={hasFiles}
|
||||
/>
|
||||
<div className={styles.workbenchBarShell}>
|
||||
<div className={styles.workbenchBarWrapper}>
|
||||
<div className={styles.workbenchBarInner}>
|
||||
<WorkbenchBar
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
hasFiles={hasFiles}
|
||||
viewerToolbarCollapsed={viewerToolbarCollapsed}
|
||||
onCollapseViewerToolbar={setViewerToolbarCollapsed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Reopen tab: a little handle hanging off the bar's bottom-right
|
||||
while the viewer tool row is retracted. */}
|
||||
{showReopenTab && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="quiet"
|
||||
className={styles.workbenchBarReopenTab}
|
||||
onClick={() => setViewerToolbarCollapsed(false)}
|
||||
aria-expanded={false}
|
||||
aria-label={t("workbenchBar.showToolbar", "Show toolbar")}
|
||||
title={t("workbenchBar.showToolbar", "Show toolbar")}
|
||||
leftSection={
|
||||
<KeyboardArrowDownIcon sx={{ fontSize: "1rem" }} />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
/* AppConfigModal styles */
|
||||
|
||||
/* Deep-link highlight: pulses the control the super search jumped to
|
||||
(navigated via /settings/{section}?focus={anchor}). */
|
||||
@keyframes settings-focus-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 3px var(--mantine-color-blue-5);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-5) 16%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 6px transparent;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-focus-target {
|
||||
animation: settings-focus-pulse 1.8s ease-out;
|
||||
border-radius: 8px;
|
||||
scroll-margin: 1rem;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
@@ -173,16 +197,6 @@
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.settings-search-select {
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.settings-search-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.confirm-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
UnsavedChangesProvider,
|
||||
useUnsavedChanges,
|
||||
} from "@app/contexts/UnsavedChangesContext";
|
||||
import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar";
|
||||
import { stripBasePath, withBasePath } from "@app/constants/app";
|
||||
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
|
||||
|
||||
@@ -46,6 +45,8 @@ interface AppConfigModalProps {
|
||||
/** Section to land on when opening. Only honoured when urlSync is off (URL
|
||||
* deep links win otherwise). */
|
||||
initialSection?: NavKey | null;
|
||||
/** Row anchor to focus when opening on a non-URL host. */
|
||||
initialFocus?: string | null;
|
||||
/** Host-specific sections appended after the build's registry sections. */
|
||||
extraSections?: ConfigNavSection[];
|
||||
/** Registry section keys to drop, for hosts a section can't run in. */
|
||||
@@ -67,6 +68,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
onClose,
|
||||
urlSync = true,
|
||||
initialSection,
|
||||
initialFocus,
|
||||
extraSections,
|
||||
hiddenSectionKeys,
|
||||
}) => {
|
||||
@@ -150,6 +152,35 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
[navigate, urlSync],
|
||||
);
|
||||
|
||||
// Deep-link: /settings/{section}?focus={anchor} scrolls to and briefly
|
||||
// highlights the matching control (used by the global super search to jump
|
||||
// straight to an individual setting row).
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
const focus = urlSync
|
||||
? new URLSearchParams(location.search).get("focus")
|
||||
: initialFocus;
|
||||
if (!focus) return;
|
||||
let raf = 0;
|
||||
// Wait for the (possibly just-switched) section to render before scrolling.
|
||||
const timer = window.setTimeout(() => {
|
||||
raf = window.requestAnimationFrame(() => {
|
||||
const el = document.getElementById(focus);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
el.classList.add("settings-focus-target");
|
||||
window.setTimeout(
|
||||
() => el.classList.remove("settings-focus-target"),
|
||||
1800,
|
||||
);
|
||||
});
|
||||
}, 150);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
if (raf) window.cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [opened, active, initialFocus, location.search, urlSync]);
|
||||
|
||||
// Backwards-compat: external `appConfig:navigate` events route through the
|
||||
// same switchSection path so they get the no-flash treatment too.
|
||||
useEffect(() => {
|
||||
@@ -185,9 +216,10 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
const runningEE = config?.runningEE ?? false;
|
||||
const loginEnabled = config?.enableLogin ?? false;
|
||||
|
||||
/** Resolves false when a dirty-state confirm kept the modal open. */
|
||||
const handleClose = useCallback(async () => {
|
||||
const canProceed = await confirmIfDirty();
|
||||
if (!canProceed) return;
|
||||
if (!canProceed) return false;
|
||||
|
||||
// Only unwind history if settings was opened via the URL; opened via state
|
||||
// there's no /settings entry to pop and navigate(-1) would jump to /files.
|
||||
@@ -200,6 +232,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
return true;
|
||||
}, [
|
||||
confirmIfDirty,
|
||||
location.key,
|
||||
@@ -214,6 +247,24 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
void handleClose();
|
||||
}, [handleClose]);
|
||||
|
||||
// Cmd/Ctrl+K: hand over to the global super search. The bar's own shortcut
|
||||
// is inert while a dialog traps focus, so the modal closes itself (through
|
||||
// the same dirty-check as any other close) and asks the bar to take focus.
|
||||
// Settings results deep-link straight back into this modal.
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const combo = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey;
|
||||
if (!combo || e.code !== "KeyK") return;
|
||||
e.preventDefault();
|
||||
void handleClose().then((closed) => {
|
||||
if (closed) window.dispatchEvent(new Event("superSearch:focus"));
|
||||
});
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [opened, handleClose]);
|
||||
|
||||
// Left navigation structure and icons
|
||||
const registrySections = useConfigNavSections(
|
||||
isAdmin,
|
||||
@@ -413,11 +464,6 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
{activeLabel}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<SettingsSearchBar
|
||||
configNavSections={configNavSections}
|
||||
onNavigate={handleNavigation}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
<ActionIcon
|
||||
ref={closeButtonRef}
|
||||
variant="tertiary"
|
||||
|
||||
@@ -18,6 +18,8 @@ interface AppConfigModalLazyProps {
|
||||
urlSync?: boolean;
|
||||
/** Section to land on when opening (non-URL hosts). */
|
||||
initialSection?: NavKey | null;
|
||||
/** Row anchor to highlight when opening (non-URL hosts). */
|
||||
initialFocus?: string | null;
|
||||
/** Host-specific sections appended after the build's registry sections. */
|
||||
extraSections?: ConfigNavSection[];
|
||||
/** Registry section keys to drop, for hosts a section can't run in. */
|
||||
@@ -29,6 +31,7 @@ export default function AppConfigModalLazy({
|
||||
onClose,
|
||||
urlSync,
|
||||
initialSection,
|
||||
initialFocus,
|
||||
extraSections,
|
||||
hiddenSectionKeys,
|
||||
}: AppConfigModalLazyProps) {
|
||||
@@ -46,6 +49,7 @@ export default function AppConfigModalLazy({
|
||||
onClose={onClose}
|
||||
urlSync={urlSync}
|
||||
initialSection={initialSection}
|
||||
initialFocus={initialFocus}
|
||||
extraSections={extraSections}
|
||||
hiddenSectionKeys={hiddenSectionKeys}
|
||||
/>
|
||||
|
||||
@@ -95,7 +95,6 @@
|
||||
}
|
||||
/* Centre each row's icon in the narrow rail (no side padding/margin to shove
|
||||
it off the edge). */
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-search-row,
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-action-row,
|
||||
.file-sidebar[data-collapsed="true"] .file-sidebar-cloud-row {
|
||||
justify-content: center;
|
||||
@@ -139,55 +138,6 @@
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
/* ---- Search row ---- */
|
||||
.file-sidebar-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 8px;
|
||||
gap: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.file-sidebar-search-row:not(.active):hover {
|
||||
background-color: var(--c-hover);
|
||||
}
|
||||
|
||||
.file-sidebar-search-icon {
|
||||
color: var(--c-text-subtle) !important;
|
||||
font-size: 18px !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-search-close {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-sidebar-search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
color: var(--c-text);
|
||||
margin-left: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-sidebar-search-input::placeholder {
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
.file-sidebar-search-label {
|
||||
margin-left: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
/* ---- Scrollable content ---- */
|
||||
/* This is a flex column - action rows are fixed, only the file list scrolls */
|
||||
.file-sidebar-scroll {
|
||||
|
||||
@@ -33,11 +33,9 @@ import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
|
||||
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
|
||||
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
@@ -102,8 +100,6 @@ export interface FileSidebarProps {
|
||||
onUploadFiles?: (files: File[]) => void | Promise<void>;
|
||||
/** Override the Google Drive handler. */
|
||||
onPickGoogleDriveFiles?: (files: File[]) => void | Promise<void>;
|
||||
/** Override the Search row click (e.g. focus the /files search input). */
|
||||
onSearchClick?: () => void;
|
||||
/** Extra action row inserted under Open-from-computer (e.g. New folder). */
|
||||
extraAction?: {
|
||||
icon: React.ReactNode;
|
||||
@@ -155,7 +151,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
onOpenSettings,
|
||||
onUploadFiles,
|
||||
onPickGoogleDriveFiles,
|
||||
onSearchClick,
|
||||
extraAction,
|
||||
toggleAriaLabel,
|
||||
toggleIcon,
|
||||
@@ -168,9 +163,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
// Classification off (non-SaaS / AI-off) → never show the per-row label chip,
|
||||
// even if a stub carries labels from an imported PDF; keeps the row plain.
|
||||
const classificationEnabled = useClassificationEnabled();
|
||||
const [searchActive, setSearchActive] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const nativeFileInputRef = useRef<HTMLInputElement>(null);
|
||||
// State (not ref) so setting it triggers a re-render - avoids racing addFiles state updates.
|
||||
const [pendingViewFileId, setPendingViewFileId] = useState<string | null>(
|
||||
@@ -439,17 +431,8 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
}
|
||||
}, [pendingViewFileId, state.files.ids, setActiveFileId, navActions]);
|
||||
|
||||
// Memoized so an unrelated re-render (e.g. a policy-run store tick) keeps a
|
||||
// stable array identity — avoids re-running the grouping memo + backfill effect.
|
||||
const filteredFileStubs = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
return q
|
||||
? allFileStubs.filter((stub) => stub.name.toLowerCase().includes(q))
|
||||
: allFileStubs;
|
||||
}, [allFileStubs, searchQuery]);
|
||||
|
||||
// SaaS groups by classification label; core returns null → one flat, recency-sorted list.
|
||||
const fileGroups = useFileSidebarGroups(filteredFileStubs);
|
||||
const fileGroups = useFileSidebarGroups(allFileStubs);
|
||||
// Workbench membership as a Set for O(1) per-row lookups (see renderFileRow).
|
||||
const workbenchIds = useMemo(
|
||||
() => new Set(state.files.ids.map((id) => id as string)),
|
||||
@@ -459,12 +442,12 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
// must key by their unique leaf id rather than the shared lineage (see renderFileRow).
|
||||
const lineageCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const s of filteredFileStubs) {
|
||||
for (const s of allFileStubs) {
|
||||
const k = (s.originalFileId ?? s.id) as string;
|
||||
counts.set(k, (counts.get(k) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [filteredFileStubs]);
|
||||
}, [allFileStubs]);
|
||||
// Per-group expand/collapse, falling back to each group's default until toggled.
|
||||
const [groupOpen, setGroupOpen] = useState<Record<string, boolean>>({});
|
||||
const setGroupOpenState = useCallback(
|
||||
@@ -473,29 +456,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle search activation
|
||||
const handleSearchClick = useCallback(() => {
|
||||
if (onSearchClick) {
|
||||
onSearchClick();
|
||||
return;
|
||||
}
|
||||
if (collapsed && onToggleCollapse) {
|
||||
onToggleCollapse();
|
||||
}
|
||||
setSearchActive(true);
|
||||
}, [collapsed, onToggleCollapse, onSearchClick]);
|
||||
|
||||
const handleSearchClose = useCallback(() => {
|
||||
setSearchActive(false);
|
||||
setSearchQuery("");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchActive && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [searchActive]);
|
||||
|
||||
// Handle Google Drive
|
||||
const handleGoogleDriveClick = useCallback(async () => {
|
||||
if (!isGoogleDriveEnabled) return;
|
||||
@@ -854,58 +814,9 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Box 1 — top controls (search + open / my files / cloud). No title. */}
|
||||
{/* Box 1 — top controls (open / my files / cloud). No title. File
|
||||
search lives in the global super search (top bar), not here. */}
|
||||
<NavSurface className="file-sidebar-controls">
|
||||
{/* Search row */}
|
||||
<Tooltip
|
||||
label={t("fileSidebar.search", "Search")}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
>
|
||||
<div
|
||||
className={`file-sidebar-search-row${searchActive && !collapsed ? " active" : ""}`}
|
||||
onClick={!searchActive ? handleSearchClick : undefined}
|
||||
role={!searchActive ? "button" : undefined}
|
||||
tabIndex={!searchActive ? 0 : undefined}
|
||||
onKeyDown={
|
||||
!searchActive
|
||||
? (e) => e.key === "Enter" && handleSearchClick()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{searchActive && !collapsed ? (
|
||||
<CloseIcon
|
||||
className="file-sidebar-search-icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSearchClose();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SearchIcon className="file-sidebar-search-icon" />
|
||||
)}
|
||||
{!collapsed &&
|
||||
(searchActive ? (
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="file-sidebar-search-input sidebar-content-fade"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t(
|
||||
"fileSidebar.searchPlaceholder",
|
||||
"Search files...",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="file-sidebar-search-label sidebar-content-fade">
|
||||
{t("fileSidebar.search", "Search")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{/* Hidden native file input - kept outside the !collapsed gate so
|
||||
the "Open from computer" row below (always rendered) can fire
|
||||
it in either sidebar state without a silent no-op. */}
|
||||
@@ -1138,7 +1049,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
<span className="file-sidebar-section-label">
|
||||
{t("fileSidebar.library", "PDF Library")}
|
||||
</span>
|
||||
<FileSidebarGroupControls stubs={filteredFileStubs} />
|
||||
<FileSidebarGroupControls stubs={allFileStubs} />
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
className="file-sidebar-section-btn file-sidebar-section-btn-external"
|
||||
@@ -1172,7 +1083,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
<div className="file-sidebar-loading">
|
||||
<Loader size="sm" color="var(--c-text-subtle)" />
|
||||
</div>
|
||||
) : filteredFileStubs.length > 0 ? (
|
||||
) : allFileStubs.length > 0 ? (
|
||||
<div className="file-sidebar-file-list">
|
||||
{fileGroups ? (
|
||||
<>
|
||||
@@ -1250,29 +1161,24 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
"fileSidebar.viewAll",
|
||||
"View all {{count}} files",
|
||||
{
|
||||
count: filteredFileStubs.length,
|
||||
count: allFileStubs.length,
|
||||
},
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
filteredFileStubs.map(renderFileRow)
|
||||
allFileStubs.map(renderFileRow)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
!searchActive && (
|
||||
<div className="file-sidebar-empty">
|
||||
<p className="file-sidebar-empty-text">
|
||||
{t("fileSidebar.noFiles", "No files yet")}
|
||||
</p>
|
||||
<p className="file-sidebar-empty-hint">
|
||||
{t(
|
||||
"fileSidebar.dropHint",
|
||||
"Open files to get started",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
<div className="file-sidebar-empty">
|
||||
<p className="file-sidebar-empty-text">
|
||||
{t("fileSidebar.noFiles", "No files yet")}
|
||||
</p>
|
||||
<p className="file-sidebar-empty-hint">
|
||||
{t("fileSidebar.dropHint", "Open files to get started")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -36,6 +36,13 @@ export interface TextInputProps {
|
||||
readOnly?: boolean;
|
||||
/** Accessibility label */
|
||||
"aria-label"?: string;
|
||||
/** ARIA role override (e.g. "combobox" for inputs driving a listbox). */
|
||||
role?: React.AriaRole;
|
||||
/** Combobox wiring — forwarded to the native input. */
|
||||
"aria-expanded"?: boolean;
|
||||
"aria-controls"?: string;
|
||||
"aria-activedescendant"?: string;
|
||||
"aria-autocomplete"?: React.AriaAttributes["aria-autocomplete"];
|
||||
/** Focus event handler */
|
||||
onFocus?: () => void;
|
||||
/** Allow the icon to receive pointer events (e.g. when icon is a clickable button) */
|
||||
|
||||
@@ -2,11 +2,17 @@
|
||||
/* Horizontal toolbar at the top of the workbench area. */
|
||||
|
||||
.workbench-bar {
|
||||
/* Flex-wrap layout that reflows by content width (measured in JS, which sets
|
||||
data-wrapped). Roomy: row 1 is [views | search | globals] and the tool
|
||||
icons sit on their own full-width row below. Crowded (data-wrapped): the
|
||||
search bumps to its own row, leaving [views | globals] on row 1 and the
|
||||
tools on row 3 — three rows total. `order` drives the reflow. */
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
align-content: flex-start;
|
||||
min-height: 40px;
|
||||
column-gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 0 8px;
|
||||
/* No left margin: the file sidebar's own 0.5rem padding already provides the
|
||||
gutter on that side, so adding one here would double it and leave the bar
|
||||
@@ -21,12 +27,16 @@
|
||||
|
||||
/* ---- View switcher (left) ---- */
|
||||
.workbench-bar-views {
|
||||
order: 1;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
order: 1;
|
||||
height: 40px;
|
||||
/* min-width:0 lets it shrink instead of forcing a wrap; overflow clips labels
|
||||
on very narrow viewports. */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
height: 38px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@@ -34,7 +44,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
padding: 2px 8px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
@@ -86,27 +96,47 @@
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* ---- Center: tool buttons ---- */
|
||||
|
||||
/* Single-row: center sits between views and globals */
|
||||
.workbench-bar-center {
|
||||
/* ---- Super search ---- */
|
||||
/* Roomy: grows to fill the centre between views and globals. */
|
||||
.workbench-bar-search {
|
||||
order: 2;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
height: 38px;
|
||||
transform: translateX(var(--workbench-bar-search-offset, 0px));
|
||||
}
|
||||
|
||||
/* Two-row: center drops below views+globals */
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-center {
|
||||
.workbench-bar-search .super-search {
|
||||
flex: 0 1 24rem;
|
||||
width: min(100%, 24rem);
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.workbench-bar-search .super-search input {
|
||||
background-color: transparent;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .workbench-bar-search .super-search input {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Crowded: drops to its own full-width row between the top row and the tools. */
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-search {
|
||||
order: 3;
|
||||
flex: 0 0 100%;
|
||||
height: auto;
|
||||
padding: 4px 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Two-row: the tool row scrolls sideways instead of wrapping. */
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-center {
|
||||
border-top: 1px solid var(--c-border-subtle);
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
@@ -118,21 +148,68 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Right: global buttons (theme / language / download) ---- */
|
||||
/* ---- Tool buttons (own full-width row, always below the top row) ---- */
|
||||
.workbench-bar-center {
|
||||
order: 4;
|
||||
flex: 0 0 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
/* Symmetric side padding leaves room for the retract handle pinned right
|
||||
without knocking the centred tool icons off-centre. */
|
||||
padding: 4px 36px;
|
||||
border-top: 1px solid var(--c-border-subtle);
|
||||
}
|
||||
|
||||
/* Retract / reopen handle for the viewer tool row. */
|
||||
.workbench-bar-toolbar-handle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--c-text-subtle);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.workbench-bar-toolbar-handle:hover {
|
||||
color: var(--c-text);
|
||||
background: var(--c-hover);
|
||||
}
|
||||
|
||||
/* Pinned to the right edge of the tool row. */
|
||||
.workbench-bar-toolbar-handle-retract {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* ---- Right: global buttons (print / download / save / close), row 1 ---- */
|
||||
.workbench-bar-globals {
|
||||
order: 3;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
order: 3;
|
||||
height: 40px;
|
||||
min-width: 0;
|
||||
height: 38px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* In two-row mode globals moves to row 1 right side */
|
||||
/* Crowded: globals stays on row 1, moving up beside the views. */
|
||||
.workbench-bar[data-wrapped="true"] .workbench-bar-globals {
|
||||
order: 2;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Shared action icon style - applies to both center and global buttons.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
@@ -33,6 +33,8 @@ import { ViewerContext, useViewer } from "@app/contexts/ViewerContext";
|
||||
import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
|
||||
import { useEditorSearchScopes } from "@app/hooks/useSuperSearch";
|
||||
import ViewerShareButton from "@app/components/viewer/ViewerShareButton";
|
||||
import { useSharingEnabled } from "@app/hooks/useSharingEnabled";
|
||||
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
|
||||
@@ -53,6 +55,7 @@ import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutl
|
||||
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import PrintIcon from "@mui/icons-material/Print";
|
||||
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import "@app/components/shared/WorkbenchBar.css";
|
||||
|
||||
@@ -68,6 +71,10 @@ interface WorkbenchBarProps {
|
||||
currentView: WorkbenchType;
|
||||
setCurrentView: (view: WorkbenchType) => void;
|
||||
hasFiles: boolean;
|
||||
/** Whether the viewer's tool row is currently retracted. */
|
||||
viewerToolbarCollapsed?: boolean;
|
||||
/** Setter for the viewer tool-row retract state (owned by Workbench). */
|
||||
onCollapseViewerToolbar?: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
function renderWithTooltip(
|
||||
@@ -92,9 +99,12 @@ export default function WorkbenchBar({
|
||||
currentView,
|
||||
setCurrentView,
|
||||
hasFiles,
|
||||
viewerToolbarCollapsed = false,
|
||||
onCollapseViewerToolbar,
|
||||
}: WorkbenchBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const searchScopes = useEditorSearchScopes();
|
||||
const returnRoute = useSyncExternalStore(
|
||||
subscribeFilesPageReturnRoute,
|
||||
getFilesPageReturnRoute,
|
||||
@@ -115,6 +125,7 @@ export default function WorkbenchBar({
|
||||
} = useToolWorkflow();
|
||||
const { selectedTool } = useNavigationState();
|
||||
const isCustomView = !isBaseWorkbench(currentView);
|
||||
const isViewer = currentView === "viewer";
|
||||
const disableForFullscreen =
|
||||
toolPanelMode === "fullscreen" && leftPanelView === "toolPicker";
|
||||
const terminology = useFileActionTerminology();
|
||||
@@ -435,34 +446,47 @@ export default function WorkbenchBar({
|
||||
})),
|
||||
];
|
||||
|
||||
// Reflow the top row by content width: when the views + globals leave too
|
||||
// little room for a usable search, bump the search to its own row
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
const bar = barRef.current;
|
||||
if (!bar) return;
|
||||
|
||||
const MIN_SEARCH_WIDTH = 320;
|
||||
const measure = () => {
|
||||
const viewsEl = bar.querySelector<HTMLElement>(".workbench-bar-views");
|
||||
const globalsEl = bar.querySelector<HTMLElement>(
|
||||
".workbench-bar-globals",
|
||||
);
|
||||
const centerEl = bar.querySelector<HTMLElement>(".workbench-bar-center");
|
||||
|
||||
const viewsWidth = viewsEl?.offsetWidth ?? 0;
|
||||
const globalsWidth = globalsEl?.offsetWidth ?? 0;
|
||||
const centerChildren = centerEl
|
||||
? (Array.from(centerEl.children) as HTMLElement[])
|
||||
: [];
|
||||
const centerWidth =
|
||||
centerChildren.reduce((sum, el) => sum + el.offsetWidth, 0) +
|
||||
Math.max(0, centerChildren.length - 1) * 2; // gap: 2px
|
||||
|
||||
const needed = viewsWidth + centerWidth + globalsWidth + 24; // 24px bar padding
|
||||
bar.dataset.wrapped = String(needed > bar.clientWidth);
|
||||
// clientWidth minus the two side clusters, the bar's 16px h-padding and
|
||||
// the two 8px column gaps flanking the search.
|
||||
const available = bar.clientWidth - viewsWidth - globalsWidth - 16 - 16;
|
||||
const wrapped = available < MIN_SEARCH_WIDTH;
|
||||
bar.dataset.wrapped = String(wrapped);
|
||||
// Centre the search on the bar rather than its slot — clamped to the
|
||||
// slot's spare width, because the shift is a transform (no layout) and
|
||||
// an unclamped value would paint the pill over the adjacent cluster.
|
||||
const slotEl = bar.querySelector<HTMLElement>(".workbench-bar-search");
|
||||
const pillEl = slotEl?.querySelector<HTMLElement>(".super-search");
|
||||
const slack = Math.max(
|
||||
0,
|
||||
((slotEl?.offsetWidth ?? 0) - (pillEl?.offsetWidth ?? 0)) / 2,
|
||||
);
|
||||
const centred = (globalsWidth - viewsWidth) / 2;
|
||||
const offset = Math.min(slack, Math.max(-slack, centred));
|
||||
bar.style.setProperty(
|
||||
"--workbench-bar-search-offset",
|
||||
wrapped ? "0px" : `${offset}px`,
|
||||
);
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(bar);
|
||||
const viewsEl = bar.querySelector<HTMLElement>(".workbench-bar-views");
|
||||
const globalsEl = bar.querySelector<HTMLElement>(".workbench-bar-globals");
|
||||
if (viewsEl) ro.observe(viewsEl);
|
||||
if (globalsEl) ro.observe(globalsEl);
|
||||
measure();
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
@@ -471,7 +495,7 @@ export default function WorkbenchBar({
|
||||
<div
|
||||
ref={barRef}
|
||||
className="workbench-bar"
|
||||
data-wrapped="true"
|
||||
data-wrapped="false"
|
||||
data-tour="workbench-bar"
|
||||
>
|
||||
{/* Left: optional "Back to My Files" + view switcher */}
|
||||
@@ -524,27 +548,50 @@ export default function WorkbenchBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool buttons - second row, only rendered when buttons exist */}
|
||||
{sectionsWithButtons.length > 0 && (
|
||||
<div className="workbench-bar-center">
|
||||
{sectionsWithButtons.map(
|
||||
({ section, buttons: sectionButtons }, idx) => (
|
||||
<React.Fragment key={section}>
|
||||
{idx > 0 && <div className="workbench-bar-divider" />}
|
||||
{sectionButtons.map((btn) => {
|
||||
const content = renderButton(btn);
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div key={btn.id} className="workbench-bar-action-wrapper">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Global super search - always present, even on the homepage */}
|
||||
<div className="workbench-bar-search">
|
||||
<SuperSearch scopes={searchScopes} />
|
||||
</div>
|
||||
|
||||
{/* Tool buttons - second row, only rendered when buttons exist. In the
|
||||
viewer the row is retractable: a handle on its right edge hides the
|
||||
whole row; Workbench then shows a tab below the bar to bring it back. */}
|
||||
{sectionsWithButtons.length > 0 &&
|
||||
!(isViewer && viewerToolbarCollapsed) && (
|
||||
<div className="workbench-bar-center">
|
||||
{sectionsWithButtons.map(
|
||||
({ section, buttons: sectionButtons }, idx) => (
|
||||
<React.Fragment key={section}>
|
||||
{idx > 0 && <div className="workbench-bar-divider" />}
|
||||
{sectionButtons.map((btn) => {
|
||||
const content = renderButton(btn);
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div
|
||||
key={btn.id}
|
||||
className="workbench-bar-action-wrapper"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
),
|
||||
)}
|
||||
{isViewer && onCollapseViewerToolbar && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="quiet"
|
||||
className="workbench-bar-toolbar-handle workbench-bar-toolbar-handle-retract"
|
||||
onClick={() => onCollapseViewerToolbar(true)}
|
||||
aria-expanded
|
||||
aria-label={t("workbenchBar.hideToolbar", "Hide toolbar")}
|
||||
title={t("workbenchBar.hideToolbar", "Hide toolbar")}
|
||||
leftSection={<KeyboardArrowUpIcon sx={{ fontSize: "1rem" }} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right: Global buttons - export group left, close anchored right */}
|
||||
<div className="workbench-bar-globals">
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar";
|
||||
import type { ConfigNavSection } from "@app/components/shared/config/configNavSections";
|
||||
|
||||
const mockConfigNavSections: ConfigNavSection[] = [
|
||||
{
|
||||
title: "Preferences",
|
||||
items: [
|
||||
{
|
||||
key: "general",
|
||||
label: "General",
|
||||
icon: "settings-rounded",
|
||||
component: null,
|
||||
},
|
||||
{
|
||||
key: "hotkeys",
|
||||
label: "Keyboard Shortcuts",
|
||||
icon: "keyboard-rounded",
|
||||
component: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Workspace",
|
||||
items: [
|
||||
{
|
||||
key: "people",
|
||||
label: "People",
|
||||
icon: "group-rounded",
|
||||
component: null,
|
||||
},
|
||||
{
|
||||
key: "teams",
|
||||
label: "Teams",
|
||||
icon: "groups-rounded",
|
||||
component: null,
|
||||
disabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: "Shared/Config/SettingsSearchBar",
|
||||
component: SettingsSearchBar,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
configNavSections: mockConfigNavSections,
|
||||
onNavigate: async () => {},
|
||||
isMobile: false,
|
||||
},
|
||||
} satisfies Meta<typeof SettingsSearchBar>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {
|
||||
isMobile: true,
|
||||
},
|
||||
};
|
||||
@@ -1,240 +0,0 @@
|
||||
import React, { useMemo, useState, useCallback } from "react";
|
||||
import { Select, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import type {
|
||||
ConfigNavSection,
|
||||
ConfigNavItem,
|
||||
} from "@app/components/shared/config/configNavSections";
|
||||
|
||||
interface SettingsSearchBarProps {
|
||||
configNavSections: ConfigNavSection[];
|
||||
onNavigate: (key: NavKey) => Promise<void>;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
interface SettingsSearchOption {
|
||||
value: NavKey;
|
||||
label: string;
|
||||
sectionTitle: string;
|
||||
destinationPath: string;
|
||||
searchableContent: string[];
|
||||
matchedContext?: string;
|
||||
}
|
||||
|
||||
const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial<Record<string, string[]>> =
|
||||
{
|
||||
general: ["settings.general"],
|
||||
hotkeys: ["settings.hotkeys"],
|
||||
account: ["account"],
|
||||
people: ["settings.workspace"],
|
||||
teams: ["settings.workspace", "settings.team"],
|
||||
"api-keys": ["settings.developer"],
|
||||
connectionMode: ["settings.connection"],
|
||||
planBilling: ["settings.planBilling"],
|
||||
adminGeneral: ["admin.settings.general"],
|
||||
adminFeatures: ["admin.settings.features"],
|
||||
adminEndpoints: ["admin.settings.endpoints"],
|
||||
adminDatabase: ["admin.settings.database"],
|
||||
adminAdvanced: ["admin.settings.advanced"],
|
||||
adminSecurity: ["admin.settings.security"],
|
||||
adminMcp: ["admin.settings.mcp"],
|
||||
adminConnections: [
|
||||
"admin.settings.connections",
|
||||
"admin.settings.mail",
|
||||
"admin.settings.security",
|
||||
"admin.settings.telegram",
|
||||
"admin.settings.premium",
|
||||
"admin.settings.general",
|
||||
"settings.securityAuth",
|
||||
"settings.connection",
|
||||
],
|
||||
adminPlan: [
|
||||
"settings.planBilling",
|
||||
"admin.settings.premium",
|
||||
"settings.licensingAnalytics",
|
||||
],
|
||||
adminAudit: ["settings.licensingAnalytics"],
|
||||
adminUsage: ["settings.licensingAnalytics"],
|
||||
adminLegal: ["admin.settings.legal"],
|
||||
adminPrivacy: ["admin.settings.privacy"],
|
||||
};
|
||||
|
||||
const getTranslationPrefixesForNavKey = (key: string): string[] => {
|
||||
const explicitPrefixes = SETTINGS_SEARCH_TRANSLATION_PREFIXES[key] ?? [];
|
||||
|
||||
const inferredPrefixes: string[] = [];
|
||||
|
||||
if (key.startsWith("admin")) {
|
||||
const adminSuffix = key.replace(/^admin/, "");
|
||||
const normalizedAdminSuffix =
|
||||
adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1);
|
||||
inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`);
|
||||
} else {
|
||||
inferredPrefixes.push(`settings.${key}`);
|
||||
}
|
||||
|
||||
return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes]));
|
||||
};
|
||||
|
||||
const flattenTranslationStrings = (value: unknown): string[] => {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap(flattenTranslationStrings);
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
return Object.values(value as Record<string, unknown>).flatMap(
|
||||
flattenTranslationStrings,
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const buildMatchSnippet = (text: string, query: string): string => {
|
||||
const normalizedText = text.toLocaleLowerCase();
|
||||
const normalizedQuery = query.toLocaleLowerCase();
|
||||
const matchIndex = normalizedText.indexOf(normalizedQuery);
|
||||
|
||||
if (matchIndex === -1) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const maxLength = 84;
|
||||
const contextPadding = 28;
|
||||
const start = Math.max(0, matchIndex - contextPadding);
|
||||
const end = Math.min(text.length, matchIndex + query.length + contextPadding);
|
||||
const snippet = text.slice(start, end);
|
||||
|
||||
if (snippet.length <= maxLength) {
|
||||
return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`;
|
||||
}
|
||||
|
||||
return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < text.length ? "…" : ""}`;
|
||||
};
|
||||
|
||||
export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({
|
||||
configNavSections,
|
||||
onNavigate,
|
||||
isMobile,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
// Build a global index from every accessible settings tab in the modal navigation.
|
||||
// This does not render section components, so API calls still happen only when a tab is opened.
|
||||
const searchableSections = useMemo<SettingsSearchOption[]>(() => {
|
||||
return configNavSections.flatMap((section) =>
|
||||
section.items
|
||||
.filter((item: ConfigNavItem) => !item.disabled)
|
||||
.map((item: ConfigNavItem) => {
|
||||
const translationPrefixes = getTranslationPrefixesForNavKey(item.key);
|
||||
const translationContent = translationPrefixes.flatMap((prefix) =>
|
||||
flattenTranslationStrings(
|
||||
t(prefix, { returnObjects: true, defaultValue: {} }),
|
||||
),
|
||||
);
|
||||
|
||||
const searchableContent = Array.from(
|
||||
new Set([
|
||||
item.label,
|
||||
section.title,
|
||||
`/settings/${item.key}`,
|
||||
...translationContent,
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
value: item.key,
|
||||
label: item.label,
|
||||
sectionTitle: section.title,
|
||||
destinationPath: `/settings/${item.key}`,
|
||||
searchableContent,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, [configNavSections, t]);
|
||||
|
||||
const filteredSearchableSections = useMemo<SettingsSearchOption[]>(() => {
|
||||
const query = searchValue.trim();
|
||||
if (!query) {
|
||||
return searchableSections;
|
||||
}
|
||||
|
||||
const normalizedQuery = query.toLocaleLowerCase();
|
||||
|
||||
return searchableSections.reduce<SettingsSearchOption[]>(
|
||||
(accumulator, option) => {
|
||||
const matchedEntry = option.searchableContent.find((entry) =>
|
||||
entry.toLocaleLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
|
||||
if (!matchedEntry) {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
accumulator.push({
|
||||
...option,
|
||||
matchedContext: buildMatchSnippet(matchedEntry, query),
|
||||
});
|
||||
|
||||
return accumulator;
|
||||
},
|
||||
[],
|
||||
);
|
||||
}, [searchValue, searchableSections]);
|
||||
|
||||
const handleSearchNavigation = useCallback(
|
||||
async (value: string | null) => {
|
||||
if (!value) return;
|
||||
if (!VALID_NAV_KEYS.includes(value as NavKey)) return;
|
||||
await onNavigate(value as NavKey);
|
||||
setSearchValue("");
|
||||
},
|
||||
[onNavigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
className="settings-search-select"
|
||||
data={filteredSearchableSections}
|
||||
value={null}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
onChange={handleSearchNavigation}
|
||||
placeholder={t("settings.search.placeholder", "Search settings pages...")}
|
||||
leftSection={<LocalIcon icon="search-rounded" width={16} height={16} />}
|
||||
aria-label={t("navbar.search", "Search")}
|
||||
nothingFoundMessage={t("search.noResults", "No results found")}
|
||||
searchable
|
||||
clearable={false}
|
||||
w={isMobile ? 170 : 320}
|
||||
filter={({ options }) => options}
|
||||
renderOption={({ option }) => {
|
||||
const searchOption = option as unknown as SettingsSearchOption;
|
||||
return (
|
||||
<div className="settings-search-option">
|
||||
<Text size="sm" fw={600}>
|
||||
{searchOption.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{searchOption.sectionTitle} ·{" "}
|
||||
{searchOption.matchedContext || searchOption.destinationPath}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -478,6 +478,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div
|
||||
id="setting-theme"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -520,6 +521,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
{/* Language */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<div
|
||||
id="setting-language"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -544,6 +546,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div
|
||||
id="setting-tool-picker-mode"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -582,6 +585,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="setting-startup-view"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -624,6 +628,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="setting-reader-zoom"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -680,6 +685,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="setting-hide-unavailable-tools"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -712,6 +718,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="setting-hide-unavailable-conversions"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -753,6 +760,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
withArrow
|
||||
>
|
||||
<div
|
||||
id="setting-auto-unzip"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -791,6 +799,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
withArrow
|
||||
>
|
||||
<div
|
||||
id="setting-auto-unzip-file-limit"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -146,7 +146,7 @@ const HotkeysSection: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<div id="setting-hotkeys-search">
|
||||
<Text fw={600} size="lg">
|
||||
{t("settings.hotkeys.title", "Keyboard Shortcuts")}
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
.super-search {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 560px;
|
||||
--super-search-input-border: color-mix(
|
||||
in srgb,
|
||||
var(--c-text) 14%,
|
||||
var(--c-border-subtle)
|
||||
);
|
||||
--super-search-input-outline: color-mix(
|
||||
in srgb,
|
||||
var(--c-text) 5%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.super-search-input-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.super-search input {
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--super-search-input-border);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--c-input-bg);
|
||||
box-shadow: 0 0 0 1px var(--super-search-input-outline);
|
||||
/* Tighter than the shared TextInput default (8px/14px) for a compact,
|
||||
professional bar — height lands in line with the 28px action icons. */
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.super-search input:focus {
|
||||
border-color: var(--c-primary);
|
||||
}
|
||||
|
||||
/* Keyboard-shortcut hint pinned to the right of the input. */
|
||||
.super-search-kbd {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--c-text-subtle);
|
||||
background: var(--c-hover);
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
/* Tiny breathing room between the ⌘ glyph and the K. */
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* Portalled to <body>; coords are set inline from the input's viewport rect. */
|
||||
.super-search-dropdown {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.35rem;
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.super-search-empty {
|
||||
padding: 0.75rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
.super-search-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.35rem 0.5rem;
|
||||
margin-bottom: 0.15rem;
|
||||
border-bottom: 1px solid var(--c-border-subtle);
|
||||
}
|
||||
|
||||
.super-search-filters .sui-chip {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.super-search-section + .super-search-section {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.super-search-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.super-search-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-left: -0.65rem;
|
||||
padding: 0.15rem 0.25rem 0.05rem 0;
|
||||
}
|
||||
|
||||
.super-search-section-header::after {
|
||||
content: "";
|
||||
flex: 1 1 auto;
|
||||
min-width: 1.5rem;
|
||||
height: 1px;
|
||||
background: color-mix(in srgb, var(--c-text) 12%, var(--c-border-subtle));
|
||||
}
|
||||
|
||||
.super-search-section-body {
|
||||
padding: 0.05rem 0.05rem 0.2rem 0;
|
||||
}
|
||||
|
||||
.super-search-section-toggle.mantine-Button-root {
|
||||
--button-height: auto;
|
||||
--button-padding-x: 0;
|
||||
min-height: 0;
|
||||
padding-block: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.super-search-section-toggle.mantine-Button-root:hover,
|
||||
.super-search-section-toggle.mantine-Button-root:focus-visible {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.super-search-section-toggle:hover .super-search-section-label {
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.super-search-section-toggle .mantine-Button-inner {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Per-group "show N more" / "show less" toggle. */
|
||||
.super-search-show-more.mantine-Button-root {
|
||||
--button-height: auto;
|
||||
min-height: 0;
|
||||
padding: 0.25rem 0.5rem 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
.super-search-show-more.mantine-Button-root:hover,
|
||||
.super-search-show-more.mantine-Button-root:focus-visible {
|
||||
background: transparent;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.super-search-section-toggle .mantine-Button-label {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.super-search-section-toggle .mantine-Button-section {
|
||||
margin-inline-start: 0.2rem;
|
||||
}
|
||||
|
||||
.super-search-section-chevron {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: var(--c-text-subtle);
|
||||
transition:
|
||||
transform 140ms ease,
|
||||
color 140ms ease;
|
||||
}
|
||||
|
||||
.super-search-section-toggle:hover .super-search-section-chevron {
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.super-search-section-chevron--collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.super-search-group + .super-search-group {
|
||||
margin-top: 0.45rem;
|
||||
padding-top: 0.35rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--c-text) 8%, transparent);
|
||||
}
|
||||
|
||||
.super-search-section-label {
|
||||
padding: 0.18rem 0;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.super-search-group-label {
|
||||
padding: 0.35rem 0.15rem 0.18rem 0.85rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
.super-search-group-results {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.super-search-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.super-search-item.active,
|
||||
.super-search-item:hover {
|
||||
background: var(--c-hover);
|
||||
}
|
||||
|
||||
.super-search-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.super-search-item-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.super-search-item-title {
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
vi.mock("@app/hooks/useSuperSearch", () => ({
|
||||
useSuperSearch: vi.fn(() => ({
|
||||
groups: [],
|
||||
flatResults: [],
|
||||
loadingFiles: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@app/utils/hotkeys", () => ({
|
||||
isMacLike: () => false,
|
||||
}));
|
||||
|
||||
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
|
||||
import type {
|
||||
SuperSearchQueryOptions,
|
||||
SuperSearchScope,
|
||||
} from "@app/hooks/useSuperSearch";
|
||||
|
||||
interface TestResult {
|
||||
key: string;
|
||||
group: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
score: number;
|
||||
onSelect: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface TestGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
sectionLabel?: string;
|
||||
results: TestResult[];
|
||||
}
|
||||
|
||||
interface TestUseResultsResult {
|
||||
groups: TestGroup[];
|
||||
flatResults: TestResult[];
|
||||
loadingFiles: boolean;
|
||||
}
|
||||
|
||||
type TestUseResultsHook = (
|
||||
query: string,
|
||||
active: boolean,
|
||||
options?: SuperSearchQueryOptions,
|
||||
) => TestUseResultsResult;
|
||||
|
||||
const TEST_SCOPES: SuperSearchScope[] = [
|
||||
{
|
||||
id: "portal-policies",
|
||||
label: "Policies",
|
||||
aliases: ["policy", "policies"],
|
||||
},
|
||||
{
|
||||
id: "portal-pipelines",
|
||||
label: "Pipelines",
|
||||
aliases: ["pipeline", "pipelines"],
|
||||
},
|
||||
];
|
||||
|
||||
function makeResult(
|
||||
key: string,
|
||||
title: string,
|
||||
onSelect = vi.fn(),
|
||||
): TestResult {
|
||||
return {
|
||||
key,
|
||||
group: "tools",
|
||||
title,
|
||||
score: 100,
|
||||
onSelect,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSearch(
|
||||
useResults?: TestUseResultsHook,
|
||||
scopes?: readonly SuperSearchScope[],
|
||||
) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<SuperSearch
|
||||
inputId="test-super-search"
|
||||
useResults={useResults as TestUseResultsHook | undefined}
|
||||
scopes={scopes}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SuperSearch", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 320,
|
||||
bottom: 40,
|
||||
width: 320,
|
||||
height: 40,
|
||||
toJSON: () => "",
|
||||
} as DOMRect);
|
||||
|
||||
Object.defineProperty(Element.prototype, "scrollIntoView", {
|
||||
value: vi.fn(),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("opens from Ctrl+K based on e.code and exposes combobox wiring", async () => {
|
||||
const useResults: TestUseResultsHook = () => ({
|
||||
groups: [],
|
||||
flatResults: [],
|
||||
loadingFiles: false,
|
||||
});
|
||||
|
||||
renderSearch(useResults);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
fireEvent.keyDown(document.body, {
|
||||
ctrlKey: true,
|
||||
code: "KeyK",
|
||||
key: "x",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input).toHaveFocus();
|
||||
expect(input).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
expect(input).toHaveAttribute("aria-controls", "test-super-search-listbox");
|
||||
expect(screen.getByRole("listbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the highlight on the same result key when async results reorder", async () => {
|
||||
const alphaSelect = vi.fn();
|
||||
const betaSelect = vi.fn();
|
||||
|
||||
const useAsyncResults: TestUseResultsHook = (query, active) => {
|
||||
const [swapped, setSwapped] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !query.trim()) {
|
||||
setSwapped(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => setSwapped(true), 50);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [active, query]);
|
||||
|
||||
const results = swapped
|
||||
? [
|
||||
makeResult("beta", "Beta", betaSelect),
|
||||
makeResult("alpha", "Alpha", alphaSelect),
|
||||
]
|
||||
: [
|
||||
makeResult("alpha", "Alpha", alphaSelect),
|
||||
makeResult("beta", "Beta", betaSelect),
|
||||
];
|
||||
const groups = query.trim()
|
||||
? [{ id: "tools", label: "Tools", results }]
|
||||
: [];
|
||||
|
||||
return {
|
||||
groups,
|
||||
flatResults: groups.flatMap((group) => group.results),
|
||||
loadingFiles: false,
|
||||
};
|
||||
};
|
||||
|
||||
renderSearch(useAsyncResults);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: "a" } });
|
||||
|
||||
await screen.findByText("Alpha");
|
||||
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
expect(input).toHaveAttribute(
|
||||
"aria-activedescendant",
|
||||
"test-super-search-option-1",
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input).toHaveAttribute(
|
||||
"aria-activedescendant",
|
||||
"test-super-search-option-0",
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText("Beta").closest('[role="option"]')).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(betaSelect).toHaveBeenCalledTimes(1);
|
||||
expect(alphaSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports multi-select scope chips and clears manual filters on close", async () => {
|
||||
const useScopedResults: TestUseResultsHook = (query, active, options) => {
|
||||
if (!active || !query.trim()) {
|
||||
return { groups: [], flatResults: [], loadingFiles: false };
|
||||
}
|
||||
|
||||
const enabledScopes = new Set(options?.scopeIds ?? []);
|
||||
const allGroups: TestGroup[] = [
|
||||
{
|
||||
id: "portal-policies",
|
||||
label: "Policies",
|
||||
results: [makeResult("policy", "Policy Match")],
|
||||
},
|
||||
{
|
||||
id: "portal-pipelines",
|
||||
label: "Pipelines",
|
||||
results: [makeResult("pipeline", "Pipeline Match")],
|
||||
},
|
||||
];
|
||||
const groups =
|
||||
enabledScopes.size === 0
|
||||
? allGroups
|
||||
: allGroups.filter((group) => enabledScopes.has(group.id));
|
||||
|
||||
return {
|
||||
groups,
|
||||
flatResults: groups.flatMap((group) => group.results),
|
||||
loadingFiles: false,
|
||||
};
|
||||
};
|
||||
|
||||
renderSearch(useScopedResults, TEST_SCOPES);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: "invoice" } });
|
||||
|
||||
await screen.findByText("Policy Match");
|
||||
expect(screen.getByText("Pipeline Match")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Policies" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Policy Match")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Pipeline Match")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pipelines" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Policy Match")).toBeInTheDocument();
|
||||
expect(screen.getByText("Pipeline Match")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.mouseDown(document.body);
|
||||
await waitFor(() => {
|
||||
expect(input).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
fireEvent.focus(input);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Policy Match")).toBeInTheDocument();
|
||||
expect(screen.getByText("Pipeline Match")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("maps scope prefixes onto active chips and lets the chip remove them", async () => {
|
||||
const useScopedResults: TestUseResultsHook = (query, active, options) => {
|
||||
if (!active || !query.trim()) {
|
||||
return { groups: [], flatResults: [], loadingFiles: false };
|
||||
}
|
||||
|
||||
const enabledScopes = new Set(options?.scopeIds ?? []);
|
||||
const groups: TestGroup[] = [
|
||||
{
|
||||
id: "portal-policies",
|
||||
label: "Policies",
|
||||
results: [makeResult("policy", "Policy Match")],
|
||||
},
|
||||
{
|
||||
id: "portal-pipelines",
|
||||
label: "Pipelines",
|
||||
results: [makeResult("pipeline", "Pipeline Match")],
|
||||
},
|
||||
].filter(
|
||||
(group) => enabledScopes.size === 0 || enabledScopes.has(group.id),
|
||||
);
|
||||
|
||||
return {
|
||||
groups,
|
||||
flatResults: groups.flatMap((group) => group.results),
|
||||
loadingFiles: false,
|
||||
};
|
||||
};
|
||||
|
||||
renderSearch(useScopedResults, TEST_SCOPES);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: "policy: invoice" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Policy Match")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Pipeline Match")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const policyChip = screen.getByRole("button", { name: "Policies" });
|
||||
expect(policyChip).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
fireEvent.click(policyChip);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input).toHaveValue("invoice");
|
||||
expect(screen.getByText("Policy Match")).toBeInTheDocument();
|
||||
expect(screen.getByText("Pipeline Match")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows no-results below scoped filters before typing", async () => {
|
||||
const useResults: TestUseResultsHook = () => ({
|
||||
groups: [],
|
||||
flatResults: [],
|
||||
loadingFiles: false,
|
||||
});
|
||||
|
||||
renderSearch(useResults, TEST_SCOPES);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.focus(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Policies" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Type to search")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("search.noResults")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders shared section headers once for consecutive groups", async () => {
|
||||
const useResults: TestUseResultsHook = (query) => {
|
||||
if (!query.trim()) {
|
||||
return { groups: [], flatResults: [], loadingFiles: false };
|
||||
}
|
||||
|
||||
const groups: TestGroup[] = [
|
||||
{
|
||||
id: "portal-users",
|
||||
label: "Users",
|
||||
sectionLabel: "Processor",
|
||||
results: [makeResult("user", "Alice")],
|
||||
},
|
||||
{
|
||||
id: "portal-policies",
|
||||
label: "Policies",
|
||||
sectionLabel: "Processor",
|
||||
results: [makeResult("policy", "Security Policy")],
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
label: "Tools",
|
||||
sectionLabel: "Editor",
|
||||
results: [makeResult("tool", "Merge PDFs")],
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
groups,
|
||||
flatResults: groups.flatMap((group) => group.results),
|
||||
loadingFiles: false,
|
||||
};
|
||||
};
|
||||
|
||||
renderSearch(useResults);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: "a" } });
|
||||
|
||||
await screen.findByText("Alice");
|
||||
|
||||
expect(screen.getAllByText("Processor")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Editor")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("caps a large group behind show-more and expands/collapses it", async () => {
|
||||
const titles = Array.from({ length: 8 }, (_, i) => `Tool ${i + 1}`);
|
||||
const useResults: TestUseResultsHook = (query) => {
|
||||
if (!query.trim()) {
|
||||
return { groups: [], flatResults: [], loadingFiles: false };
|
||||
}
|
||||
const groups: TestGroup[] = [
|
||||
{
|
||||
id: "tools",
|
||||
label: "Tools",
|
||||
results: titles.map((title, i) => makeResult(`tool-${i}`, title)),
|
||||
},
|
||||
];
|
||||
return {
|
||||
groups,
|
||||
flatResults: groups.flatMap((group) => group.results),
|
||||
loadingFiles: false,
|
||||
};
|
||||
};
|
||||
|
||||
renderSearch(useResults);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: "tool" } });
|
||||
|
||||
await screen.findByText("Tool 1");
|
||||
expect(screen.getByText("Tool 5")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Tool 6")).not.toBeInTheDocument();
|
||||
|
||||
const toggle = screen.getByRole("button", {
|
||||
name: /superSearch\.showMore/,
|
||||
});
|
||||
fireEvent.click(toggle);
|
||||
|
||||
await screen.findByText("Tool 8");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /superSearch\.showLess/ }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /superSearch\.showLess/ }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Tool 6")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Tool 5")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a cap+1 group in full with no show-more toggle", async () => {
|
||||
const useResults: TestUseResultsHook = (query) => {
|
||||
if (!query.trim()) {
|
||||
return { groups: [], flatResults: [], loadingFiles: false };
|
||||
}
|
||||
const groups: TestGroup[] = [
|
||||
{
|
||||
id: "tools",
|
||||
label: "Tools",
|
||||
results: Array.from({ length: 6 }, (_, i) =>
|
||||
makeResult(`tool-${i}`, `Tool ${i + 1}`),
|
||||
),
|
||||
},
|
||||
];
|
||||
return {
|
||||
groups,
|
||||
flatResults: groups.flatMap((group) => group.results),
|
||||
loadingFiles: false,
|
||||
};
|
||||
};
|
||||
|
||||
renderSearch(useResults);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: "tool" } });
|
||||
|
||||
await screen.findByText("Tool 6");
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /superSearch\.show/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lets a section collapse and reopen without hiding the other section", async () => {
|
||||
const useResults: TestUseResultsHook = (query) => {
|
||||
if (!query.trim()) {
|
||||
return { groups: [], flatResults: [], loadingFiles: false };
|
||||
}
|
||||
|
||||
const groups: TestGroup[] = [
|
||||
{
|
||||
id: "portal-users",
|
||||
label: "Users",
|
||||
sectionLabel: "Processor",
|
||||
results: [makeResult("user", "Alice")],
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
label: "Tools",
|
||||
sectionLabel: "Editor",
|
||||
results: [makeResult("tool", "Merge PDFs")],
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
groups,
|
||||
flatResults: groups.flatMap((group) => group.results),
|
||||
loadingFiles: false,
|
||||
};
|
||||
};
|
||||
|
||||
renderSearch(useResults);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: "a" } });
|
||||
|
||||
await screen.findByText("Alice");
|
||||
const processorToggle = screen.getByRole("button", { name: "Processor" });
|
||||
|
||||
expect(processorToggle).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
expect(screen.getByText("Merge PDFs")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(processorToggle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(processorToggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByText("Alice")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Merge PDFs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(processorToggle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(processorToggle).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,656 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { Chip } from "@app/ui/Chip";
|
||||
import { TextInput } from "@app/components/shared/TextInput";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { isMacLike } from "@app/utils/hotkeys";
|
||||
import {
|
||||
useSuperSearch,
|
||||
SuperSearchResult,
|
||||
type SuperSearchQueryOptions,
|
||||
type SuperSearchScope,
|
||||
type UseSuperSearchResult,
|
||||
} from "@app/hooks/useSuperSearch";
|
||||
import {
|
||||
parseSuperSearchQuery,
|
||||
rebuildSuperSearchQuery,
|
||||
} from "@app/components/shared/superSearch/superSearchFilters";
|
||||
import "@app/components/shared/superSearch/SuperSearch.css";
|
||||
|
||||
/** Rows shown per group before a "show more" toggle reveals the rest. */
|
||||
const COLLAPSED_GROUP_SIZE = 5;
|
||||
|
||||
interface DropdownRect {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface SuperSearchSection {
|
||||
key: string;
|
||||
label?: string;
|
||||
groups: UseSuperSearchResult["groups"];
|
||||
}
|
||||
|
||||
interface SuperSearchProps {
|
||||
/**
|
||||
* Results provider, called as a hook — it MUST be referentially stable for
|
||||
* the component's lifetime. Defaults to the editor's files/tools/settings/
|
||||
* Processor provider; the portal passes its own destinations provider.
|
||||
*/
|
||||
useResults?: (
|
||||
query: string,
|
||||
active: boolean,
|
||||
options?: SuperSearchQueryOptions,
|
||||
) => UseSuperSearchResult;
|
||||
/**
|
||||
* DOM id for the input — external focus helpers target the default. A host
|
||||
* whose bar can coexist with another instance must pass a distinct id.
|
||||
*/
|
||||
inputId?: string;
|
||||
/** Optional scope chips + `scope:` prefixes for host-specific filters. */
|
||||
scopes?: readonly SuperSearchScope[];
|
||||
/** Dropdown width floor — the results panel needs more room than the
|
||||
* compact input. Defaults shared by every bar; override per host if a
|
||||
* layout can't fit it. */
|
||||
dropdownMinWidth?: number;
|
||||
/**
|
||||
* Extra class for the dropdown. The dropdown is portalled to <body>, so a
|
||||
* host's descendant selectors can't reach it — this is the styling hook.
|
||||
*/
|
||||
dropdownClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global "super search": a single entry point that searches across the host
|
||||
* app's destinations from a persistent bar. The results hang in a dropdown
|
||||
* directly below the input; Cmd/Ctrl+K focuses and opens it.
|
||||
*
|
||||
* The dropdown is portalled to <body>: the host bar's inner wrapper may set
|
||||
* `overflow: hidden`, which would otherwise clip it.
|
||||
*/
|
||||
export default function SuperSearch({
|
||||
useResults = useSuperSearch,
|
||||
inputId = "super-search-input",
|
||||
scopes = [],
|
||||
dropdownMinWidth = 760,
|
||||
dropdownClassName,
|
||||
}: SuperSearchProps = {}) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const [rect, setRect] = useState<DropdownRect | null>(null);
|
||||
const [manualScopeIds, setManualScopeIds] = useState<string[]>([]);
|
||||
const [collapsedSectionKeys, setCollapsedSectionKeys] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
// Groups the user has expanded past the initial per-group cap ("show more").
|
||||
const [expandedGroupKeys, setExpandedGroupKeys] = useState<string[]>([]);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
// The key of the highlighted result, so async pop-in (entities, file stubs)
|
||||
// can't silently shift the highlight onto a different row.
|
||||
const highlightKeyRef = useRef<string | null>(null);
|
||||
|
||||
// A hook received as a prop is invisible to the rules-of-hooks lint; pinning
|
||||
// the first value makes swapping it mid-life structurally impossible.
|
||||
const useResultsHook = useRef(useResults).current;
|
||||
const parsedQuery = useMemo(
|
||||
() => parseSuperSearchQuery(query, scopes),
|
||||
[query, scopes],
|
||||
);
|
||||
const effectiveScopeIds = useMemo(
|
||||
() =>
|
||||
Array.from(new Set([...parsedQuery.prefixedScopeIds, ...manualScopeIds])),
|
||||
[manualScopeIds, parsedQuery.prefixedScopeIds],
|
||||
);
|
||||
const activeScopeIds = useMemo(
|
||||
() => new Set(effectiveScopeIds),
|
||||
[effectiveScopeIds],
|
||||
);
|
||||
const { groups, flatResults, loadingFiles } = useResultsHook(
|
||||
parsedQuery.query,
|
||||
open,
|
||||
effectiveScopeIds.length > 0 ? { scopeIds: effectiveScopeIds } : undefined,
|
||||
);
|
||||
|
||||
const trimmed = parsedQuery.query.trim();
|
||||
const hasQuery = trimmed.length > 0;
|
||||
|
||||
const listboxId = `${inputId}-listbox`;
|
||||
const optionId = useCallback(
|
||||
(index: number) => `${inputId}-option-${index}`,
|
||||
[inputId],
|
||||
);
|
||||
|
||||
const sections = useMemo<SuperSearchSection[]>(() => {
|
||||
const out: SuperSearchSection[] = [];
|
||||
|
||||
for (const group of groups) {
|
||||
const current = out[out.length - 1];
|
||||
if (current && current.label === group.sectionLabel) {
|
||||
current.groups.push(group);
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push({
|
||||
key: group.sectionLabel ?? group.id,
|
||||
label: group.sectionLabel,
|
||||
groups: [group],
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}, [groups]);
|
||||
|
||||
const visibleSections = useMemo(
|
||||
() =>
|
||||
sections.map((section) => ({
|
||||
...section,
|
||||
collapsed:
|
||||
section.label != null && collapsedSectionKeys.includes(section.key),
|
||||
})),
|
||||
[collapsedSectionKeys, sections],
|
||||
);
|
||||
|
||||
// How many rows a group currently shows: its full set when expanded, else
|
||||
// the initial cap. A group at exactly cap+1 just shows the extra row rather
|
||||
// than a "show 1 more" toggle.
|
||||
const visibleCountFor = useCallback(
|
||||
(group: UseSuperSearchResult["groups"][number]) =>
|
||||
expandedGroupKeys.includes(group.id) ||
|
||||
group.results.length <= COLLAPSED_GROUP_SIZE + 1
|
||||
? group.results.length
|
||||
: COLLAPSED_GROUP_SIZE,
|
||||
[expandedGroupKeys],
|
||||
);
|
||||
|
||||
const visibleFlatResults = useMemo(
|
||||
() =>
|
||||
visibleSections.flatMap((section) =>
|
||||
section.collapsed
|
||||
? []
|
||||
: section.groups.flatMap((group) =>
|
||||
group.results.slice(0, visibleCountFor(group)),
|
||||
),
|
||||
),
|
||||
[visibleSections, visibleCountFor],
|
||||
);
|
||||
// Row index per result key — the render below would otherwise indexOf()
|
||||
// every row against the flat list (quadratic in visible results).
|
||||
const flatIndexByKey = useMemo(
|
||||
() => new Map(visibleFlatResults.map((result, i) => [result.key, i])),
|
||||
[visibleFlatResults],
|
||||
);
|
||||
|
||||
const moveHighlight = useCallback(
|
||||
(index: number) => {
|
||||
setHighlight(index);
|
||||
highlightKeyRef.current = visibleFlatResults[index]?.key ?? null;
|
||||
},
|
||||
[visibleFlatResults],
|
||||
);
|
||||
|
||||
// When results change, follow the highlighted result to its new index; if
|
||||
// it's gone, reset to the top.
|
||||
useEffect(() => {
|
||||
const key = highlightKeyRef.current;
|
||||
if (key) {
|
||||
const index = visibleFlatResults.findIndex((r) => r.key === key);
|
||||
if (index >= 0) {
|
||||
setHighlight(index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
highlightKeyRef.current = null;
|
||||
setHighlight(0);
|
||||
}, [visibleFlatResults]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false);
|
||||
setHighlight(0);
|
||||
setManualScopeIds([]);
|
||||
setCollapsedSectionKeys([]);
|
||||
setExpandedGroupKeys([]);
|
||||
setQuery((current) => parseSuperSearchQuery(current, scopes).query);
|
||||
}, [scopes]);
|
||||
|
||||
// Position the portalled dropdown directly under the input while open.
|
||||
const updateRect = useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const viewportPadding = 8;
|
||||
const width = Math.min(
|
||||
Math.max(r.width, dropdownMinWidth ?? r.width),
|
||||
window.innerWidth - viewportPadding * 2,
|
||||
);
|
||||
const centeredLeft = r.left - (width - r.width) / 2;
|
||||
const left = Math.min(
|
||||
Math.max(viewportPadding, centeredLeft),
|
||||
window.innerWidth - width - viewportPadding,
|
||||
);
|
||||
setRect({ top: r.bottom + 6, left, width });
|
||||
}, [dropdownMinWidth]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updateRect();
|
||||
window.addEventListener("resize", updateRect);
|
||||
window.addEventListener("scroll", updateRect, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateRect);
|
||||
window.removeEventListener("scroll", updateRect, true);
|
||||
};
|
||||
}, [open, updateRect]);
|
||||
|
||||
const selectResult = useCallback(
|
||||
(result: SuperSearchResult | undefined) => {
|
||||
if (!result) return;
|
||||
void result.onSelect();
|
||||
setQuery("");
|
||||
close();
|
||||
inputRef.current?.blur();
|
||||
},
|
||||
[close],
|
||||
);
|
||||
|
||||
// Global Cmd/Ctrl+K to focus + open the search. Matched on e.code so it
|
||||
// works on non-Latin keyboard layouts, where e.key isn't "k".
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
// Accept both modifiers everywhere: Macs have a Control key too, and
|
||||
// muscle memory from other platforms expects Ctrl+K to keep working.
|
||||
const combo = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey;
|
||||
if (!combo || e.code !== "KeyK") return;
|
||||
// Leave the shortcut alone while a modal owns the screen — focusing an
|
||||
// input underneath the overlay would strand keyboard focus. Modals that
|
||||
// want to cede to the search (the settings modal does) close themselves
|
||||
// and dispatch "superSearch:focus" instead.
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('[role="dialog"]')) return;
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
};
|
||||
// Focus handover from a closing dialog. Only the on-screen instance
|
||||
// responds (offsetParent is null while display:none / unmounted hosts),
|
||||
// and focus waits two frames so the dialog's own return-focus runs first.
|
||||
const onFocusRequest = () => {
|
||||
const input = inputRef.current;
|
||||
if (!input || input.offsetParent === null) return;
|
||||
setOpen(true);
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => {
|
||||
input.focus();
|
||||
input.select();
|
||||
}),
|
||||
);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("superSearch:focus", onFocusRequest);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("superSearch:focus", onFocusRequest);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keep the highlighted row visible when keyboard navigation moves it past
|
||||
// the dropdown's scroll fold.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
document
|
||||
.getElementById(optionId(highlight))
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}, [open, highlight, optionId]);
|
||||
|
||||
// Close on click outside the input or the (portalled) dropdown.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
containerRef.current?.contains(target) ||
|
||||
dropdownRef.current?.contains(target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
close();
|
||||
};
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
return () => document.removeEventListener("mousedown", onMouseDown);
|
||||
}, [open, close]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
// During IME composition (CJK input), Enter commits the composition and
|
||||
// arrows pick candidates — those keystrokes are not for us.
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (!open) setOpen(true);
|
||||
moveHighlight(
|
||||
visibleFlatResults.length === 0
|
||||
? 0
|
||||
: (highlight + 1) % visibleFlatResults.length,
|
||||
);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
moveHighlight(
|
||||
visibleFlatResults.length === 0
|
||||
? 0
|
||||
: (highlight - 1 + visibleFlatResults.length) %
|
||||
visibleFlatResults.length,
|
||||
);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
selectResult(visibleFlatResults[highlight]);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
// Consume the event: hosts with their own window-level Escape handling
|
||||
// (e.g. the Files page closing itself) must not also act on this press —
|
||||
// especially since the blur below makes their focus guards pass.
|
||||
e.stopPropagation();
|
||||
if (hasQuery) {
|
||||
setQuery("");
|
||||
} else {
|
||||
close();
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const shortcutHint = useMemo(() => (isMacLike() ? "⌘K" : "Ctrl+K"), []);
|
||||
|
||||
const toggleScope = useCallback(
|
||||
(scopeId: string) => {
|
||||
if (scopeId === "__all__") {
|
||||
setManualScopeIds([]);
|
||||
if (parsedQuery.prefixTokens.length > 0) {
|
||||
setQuery(parsedQuery.query);
|
||||
}
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const hasPrefix = parsedQuery.prefixedScopeIds.includes(scopeId);
|
||||
if (hasPrefix) {
|
||||
setManualScopeIds((current) => current.filter((id) => id !== scopeId));
|
||||
const remainingPrefixScopeIds = new Set(
|
||||
parsedQuery.prefixedScopeIds.filter((id) => id !== scopeId),
|
||||
);
|
||||
setQuery(rebuildSuperSearchQuery(parsedQuery, remainingPrefixScopeIds));
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
setManualScopeIds((current) =>
|
||||
current.includes(scopeId)
|
||||
? current.filter((id) => id !== scopeId)
|
||||
: [...current, scopeId],
|
||||
);
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
[parsedQuery],
|
||||
);
|
||||
|
||||
const showNoResults =
|
||||
open &&
|
||||
!loadingFiles &&
|
||||
flatResults.length === 0 &&
|
||||
(hasQuery || scopes.length > 0);
|
||||
|
||||
const toggleSection = useCallback((sectionKey: string) => {
|
||||
setCollapsedSectionKeys((current) =>
|
||||
current.includes(sectionKey)
|
||||
? current.filter((key) => key !== sectionKey)
|
||||
: [...current, sectionKey],
|
||||
);
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const toggleGroupExpanded = useCallback((groupId: string) => {
|
||||
setExpandedGroupKeys((current) =>
|
||||
current.includes(groupId)
|
||||
? current.filter((id) => id !== groupId)
|
||||
: [...current, groupId],
|
||||
);
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const scopeFilters =
|
||||
scopes.length > 0 ? (
|
||||
<div
|
||||
className="super-search-filters"
|
||||
aria-label={t("superSearch.filtersAriaLabel", "Search filters")}
|
||||
>
|
||||
<Chip
|
||||
size="sm"
|
||||
accent={activeScopeIds.size === 0 ? "default" : "neutral"}
|
||||
variant={activeScopeIds.size === 0 ? "primary" : "secondary"}
|
||||
aria-pressed={activeScopeIds.size === 0}
|
||||
onClick={() => toggleScope("__all__")}
|
||||
>
|
||||
{t("superSearch.all", "All")}
|
||||
</Chip>
|
||||
{scopes.map((scope) => {
|
||||
const active = activeScopeIds.has(scope.id);
|
||||
return (
|
||||
<Chip
|
||||
key={scope.id}
|
||||
size="sm"
|
||||
accent={active ? "default" : "neutral"}
|
||||
variant={active ? "primary" : "secondary"}
|
||||
aria-pressed={active}
|
||||
onClick={() => toggleScope(scope.id)}
|
||||
>
|
||||
{scope.label}
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const dropdown =
|
||||
open && rect ? (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
id={listboxId}
|
||||
className={`super-search-dropdown${
|
||||
dropdownClassName ? ` ${dropdownClassName}` : ""
|
||||
}`}
|
||||
role="listbox"
|
||||
style={{ top: rect.top, left: rect.left, width: rect.width }}
|
||||
// Keep focus on the input when clicking inside the dropdown.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{scopeFilters}
|
||||
|
||||
{!hasQuery && scopes.length === 0 && (
|
||||
<div className="super-search-empty">
|
||||
{t("superSearch.hint", "Type to search")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNoResults && (
|
||||
<div className="super-search-empty">
|
||||
{t("search.noResults", "No results found")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasQuery &&
|
||||
visibleSections.map((section) => (
|
||||
<div key={section.key} className="super-search-section">
|
||||
{section.label && (
|
||||
<div className="super-search-section-header">
|
||||
<Button
|
||||
className="super-search-section-toggle"
|
||||
variant="quiet"
|
||||
aria-expanded={!section.collapsed}
|
||||
onClick={() => toggleSection(section.key)}
|
||||
rightSection={
|
||||
<span
|
||||
className={`super-search-section-chevron${
|
||||
section.collapsed
|
||||
? " super-search-section-chevron--collapsed"
|
||||
: ""
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<LocalIcon
|
||||
icon="expand-more-rounded"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="super-search-section-label">
|
||||
{section.label}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!section.collapsed && (
|
||||
<div className="super-search-section-body">
|
||||
{section.groups.map((group) => {
|
||||
const shownCount = visibleCountFor(group);
|
||||
const hiddenCount = group.results.length - shownCount;
|
||||
// Groups within one row of the cap always show everything
|
||||
// (visibleCountFor's cap+1 exception) — no toggle for them.
|
||||
const expandable =
|
||||
group.results.length > COLLAPSED_GROUP_SIZE + 1;
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
className="super-search-group"
|
||||
role="group"
|
||||
aria-label={group.label}
|
||||
>
|
||||
{/* A lone group repeating its section's name reads as a
|
||||
duplicate header — the section title covers it. */}
|
||||
{group.label !== section.label && (
|
||||
<div
|
||||
className="super-search-group-label"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{group.label}
|
||||
</div>
|
||||
)}
|
||||
<div className="super-search-group-results">
|
||||
{group.results.slice(0, shownCount).map((result) => {
|
||||
const index = flatIndexByKey.get(result.key) ?? -1;
|
||||
const active = index === highlight;
|
||||
return (
|
||||
<Button
|
||||
key={result.key}
|
||||
id={optionId(index)}
|
||||
type="button"
|
||||
variant="quiet"
|
||||
justify="start"
|
||||
fullWidth
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`super-search-item${active ? " active" : ""}`}
|
||||
onMouseEnter={() => moveHighlight(index)}
|
||||
onClick={() => selectResult(result)}
|
||||
>
|
||||
<span className="super-search-item-icon">
|
||||
{result.icon ?? (
|
||||
<LocalIcon
|
||||
icon={result.iconName ?? "search-rounded"}
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="super-search-item-text">
|
||||
<span className="super-search-item-title">
|
||||
{result.title}
|
||||
</span>
|
||||
{result.subtitle && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{result.subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{expandable && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="quiet"
|
||||
justify="start"
|
||||
className="super-search-show-more"
|
||||
onClick={() => toggleGroupExpanded(group.id)}
|
||||
>
|
||||
{hiddenCount > 0
|
||||
? t(
|
||||
"superSearch.showMore",
|
||||
"Show {{count}} more",
|
||||
{ count: hiddenCount },
|
||||
)
|
||||
: t("superSearch.showLess", "Show less")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="super-search" ref={containerRef} onKeyDown={handleKeyDown}>
|
||||
<div className="super-search-input-row">
|
||||
<TextInput
|
||||
id={inputId}
|
||||
name={inputId}
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t("superSearch.placeholder", "Search Stirling")}
|
||||
icon={
|
||||
<LocalIcon icon="search-rounded" width="1.1rem" height="1.1rem" />
|
||||
}
|
||||
autoComplete="off"
|
||||
role="combobox"
|
||||
aria-label={t("superSearch.ariaLabel", "Super search")}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listboxId : undefined}
|
||||
aria-activedescendant={
|
||||
open && visibleFlatResults[highlight]
|
||||
? optionId(highlight)
|
||||
: undefined
|
||||
}
|
||||
aria-autocomplete="list"
|
||||
onFocus={() => setOpen(true)}
|
||||
/>
|
||||
{!open && !hasQuery && (
|
||||
<kbd className="super-search-kbd" aria-hidden="true">
|
||||
{shortcutHint}
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
{dropdown && createPortal(dropdown, document.body)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseSuperSearchQuery,
|
||||
rebuildSuperSearchQuery,
|
||||
} from "@app/components/shared/superSearch/superSearchFilters";
|
||||
import type { SuperSearchScope } from "@app/hooks/useSuperSearch";
|
||||
|
||||
const SCOPES: SuperSearchScope[] = [
|
||||
{ id: "portal-policies", label: "Policies", aliases: ["policy", "policies"] },
|
||||
{ id: "tools", label: "Tools", aliases: ["tool", "tools"] },
|
||||
];
|
||||
|
||||
describe("parseSuperSearchQuery", () => {
|
||||
it("returns the trimmed query untouched when no scopes exist", () => {
|
||||
const parsed = parseSuperSearchQuery(" policy: invoice ", []);
|
||||
expect(parsed).toEqual({
|
||||
query: "policy: invoice",
|
||||
prefixTokens: [],
|
||||
prefixedScopeIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a known prefix to its scope and strips it from the query", () => {
|
||||
const parsed = parseSuperSearchQuery("policy: invoice", SCOPES);
|
||||
expect(parsed.query).toBe("invoice");
|
||||
expect(parsed.prefixedScopeIds).toEqual(["portal-policies"]);
|
||||
expect(parsed.prefixTokens).toEqual([
|
||||
{ scopeId: "portal-policies", token: "policy:" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts chained prefixes and aliases case-insensitively", () => {
|
||||
const parsed = parseSuperSearchQuery("Policy:TOOLS: merge", SCOPES);
|
||||
expect(parsed.query).toBe("merge");
|
||||
expect(parsed.prefixedScopeIds).toEqual(["portal-policies", "tools"]);
|
||||
});
|
||||
|
||||
it("dedupes repeated prefixes for the same scope", () => {
|
||||
const parsed = parseSuperSearchQuery("policy: policies: invoice", SCOPES);
|
||||
expect(parsed.prefixedScopeIds).toEqual(["portal-policies"]);
|
||||
expect(parsed.prefixTokens).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("stops at the first unknown token so colon text stays in the query", () => {
|
||||
const parsed = parseSuperSearchQuery("chapter: 1 policy: x", SCOPES);
|
||||
expect(parsed.query).toBe("chapter: 1 policy: x");
|
||||
expect(parsed.prefixedScopeIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves colons after the first word alone", () => {
|
||||
const parsed = parseSuperSearchQuery("policy: name: value", SCOPES);
|
||||
expect(parsed.query).toBe("name: value");
|
||||
expect(parsed.prefixedScopeIds).toEqual(["portal-policies"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rebuildSuperSearchQuery", () => {
|
||||
it("keeps only the requested scopes' tokens, in original order", () => {
|
||||
const parsed = parseSuperSearchQuery("policy: tools: merge", SCOPES);
|
||||
expect(rebuildSuperSearchQuery(parsed, new Set(["tools"]))).toBe(
|
||||
"tools: merge",
|
||||
);
|
||||
expect(rebuildSuperSearchQuery(parsed, new Set())).toBe("merge");
|
||||
expect(
|
||||
rebuildSuperSearchQuery(parsed, new Set(["portal-policies", "tools"])),
|
||||
).toBe("policy: tools: merge");
|
||||
});
|
||||
|
||||
it("drops the trailing space when the free text is empty", () => {
|
||||
const parsed = parseSuperSearchQuery("policy:", SCOPES);
|
||||
expect(rebuildSuperSearchQuery(parsed, new Set())).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { SuperSearchScope } from "@app/hooks/useSuperSearch";
|
||||
|
||||
export interface SuperSearchPrefixToken {
|
||||
scopeId: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface ParsedSuperSearchQuery {
|
||||
query: string;
|
||||
prefixTokens: SuperSearchPrefixToken[];
|
||||
prefixedScopeIds: string[];
|
||||
}
|
||||
|
||||
function buildScopeLookup(scopes: readonly SuperSearchScope[]) {
|
||||
const lookup = new Map<string, string>();
|
||||
for (const scope of scopes) {
|
||||
lookup.set(scope.id.toLowerCase(), scope.id);
|
||||
for (const alias of scope.aliases ?? []) {
|
||||
lookup.set(alias.toLowerCase(), scope.id);
|
||||
}
|
||||
}
|
||||
return lookup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses leading `scope:` tokens into active scopes and returns the remaining
|
||||
* free-text query. Prefixes only count at the start so normal colon text in
|
||||
* the search term is left alone.
|
||||
*/
|
||||
export function parseSuperSearchQuery(
|
||||
rawQuery: string,
|
||||
scopes: readonly SuperSearchScope[],
|
||||
): ParsedSuperSearchQuery {
|
||||
if (scopes.length === 0) {
|
||||
return {
|
||||
query: rawQuery.trim(),
|
||||
prefixTokens: [],
|
||||
prefixedScopeIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const scopeLookup = buildScopeLookup(scopes);
|
||||
const prefixTokens: SuperSearchPrefixToken[] = [];
|
||||
let remainder = rawQuery.trimStart();
|
||||
|
||||
while (remainder.length > 0) {
|
||||
const match = remainder.match(/^([^\s:]+):/);
|
||||
if (!match) break;
|
||||
|
||||
const token = match[1];
|
||||
const scopeId = scopeLookup.get(token.toLowerCase());
|
||||
if (!scopeId) break;
|
||||
|
||||
prefixTokens.push({ scopeId, token: `${token}:` });
|
||||
remainder = remainder.slice(match[0].length).trimStart();
|
||||
}
|
||||
|
||||
return {
|
||||
query: remainder.trim(),
|
||||
prefixTokens,
|
||||
prefixedScopeIds: [...new Set(prefixTokens.map((token) => token.scopeId))],
|
||||
};
|
||||
}
|
||||
|
||||
export function rebuildSuperSearchQuery(
|
||||
parsed: ParsedSuperSearchQuery,
|
||||
keepScopeIds: ReadonlySet<string>,
|
||||
): string {
|
||||
return [
|
||||
...parsed.prefixTokens
|
||||
.filter((token) => keepScopeIds.has(token.scopeId))
|
||||
.map((token) => token.token),
|
||||
parsed.query,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import ToolPanel from "@app/components/tools/ToolPanel";
|
||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||
import { usePoliciesEnabled } from "@app/components/policies/usePoliciesEnabled";
|
||||
import { PolicyAutoRunController } from "@app/components/policies/PolicyAutoRunController";
|
||||
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
|
||||
@@ -17,7 +16,6 @@ import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { withViewTransition } from "@app/utils/viewTransition";
|
||||
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import {
|
||||
@@ -43,7 +41,6 @@ export default function RightSidebar() {
|
||||
const {
|
||||
leftPanelView,
|
||||
isPanelVisible,
|
||||
searchQuery,
|
||||
filteredTools,
|
||||
toolRegistry,
|
||||
setSearchQuery,
|
||||
@@ -79,7 +76,6 @@ export default function RightSidebar() {
|
||||
};
|
||||
|
||||
const [allToolsView, setAllToolsView] = useState(false);
|
||||
const [headerSearchOpen, setHeaderSearchOpen] = useState(false);
|
||||
|
||||
const handleShowAllTools = () => {
|
||||
withViewTransition(() => setAllToolsView(true));
|
||||
@@ -97,7 +93,6 @@ export default function RightSidebar() {
|
||||
const inToolView = leftPanelView !== "toolPicker";
|
||||
// Show X (close) button only when there's somewhere to go back to.
|
||||
const showCloseButton = inToolView || allToolsView;
|
||||
const showHeaderSearch = showCloseButton || headerSearchOpen;
|
||||
|
||||
const handleHeaderBack = () => {
|
||||
if (inToolView) {
|
||||
@@ -111,20 +106,6 @@ export default function RightSidebar() {
|
||||
withViewTransition(() => handleToolSelect(id));
|
||||
};
|
||||
|
||||
// Typing in the header search while inside a tool exits the tool and lifts the
|
||||
// panel into the all-tools view so the user immediately sees search results.
|
||||
const handleHeaderSearchChange = (value: string) => {
|
||||
if (inToolView) {
|
||||
withViewTransition(() => {
|
||||
handleBackToTools();
|
||||
setAllToolsView(true);
|
||||
setSearchQuery(value);
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSearchQuery(value);
|
||||
};
|
||||
|
||||
const activeTool: ToolRegistryEntry | null =
|
||||
inToolView && selectedToolKey
|
||||
? (toolRegistry[selectedToolKey as ToolId] ?? null)
|
||||
@@ -235,6 +216,7 @@ export default function RightSidebar() {
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<>
|
||||
@@ -257,41 +239,10 @@ export default function RightSidebar() {
|
||||
/>
|
||||
) : (
|
||||
<div className="tool-panel__compact-header">
|
||||
{showHeaderSearch ? (
|
||||
<div className="tool-panel__compact-header-search">
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={handleHeaderSearchChange}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="tool-panel__compact-title">
|
||||
{t("toolPanel.pdfTools", "PDF Tools")}
|
||||
</span>
|
||||
)}
|
||||
<span className="tool-panel__compact-title">
|
||||
{t("toolPanel.pdfTools", "PDF Tools")}
|
||||
</span>
|
||||
<div className="tool-panel__compact-header-actions">
|
||||
{!showCloseButton && (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="md"
|
||||
shape="circle"
|
||||
onClick={() => {
|
||||
if (headerSearchOpen) handleHeaderSearchChange("");
|
||||
setHeaderSearchOpen((open) => !open);
|
||||
}}
|
||||
aria-label={t("toolPanel.searchTools", "Search tools")}
|
||||
className="tool-panel__expand-btn"
|
||||
>
|
||||
{headerSearchOpen ? (
|
||||
<CloseIcon sx={{ fontSize: "1.1rem" }} />
|
||||
) : (
|
||||
<SearchIcon sx={{ fontSize: "1.1rem" }} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
)}
|
||||
{showCloseButton ? (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
|
||||
@@ -177,15 +177,6 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-panel__compact-header-search {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tool-panel__compact-header-search .search-input-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Search that separates the Policies section from the Tools list below it. */
|
||||
.tool-panel__between-search {
|
||||
padding: 0.5rem 0.75rem 0.25rem;
|
||||
|
||||
@@ -209,7 +209,11 @@ export function useViewerWorkbenchBarButtons(
|
||||
aria-label={searchLabel}
|
||||
onClick={viewer.searchInterfaceActions.toggle}
|
||||
>
|
||||
<LocalIcon icon="search" width="1rem" height="1rem" />
|
||||
<LocalIcon
|
||||
icon="find-in-page-rounded"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { SuperSearchGroup } from "@app/types/superSearch";
|
||||
|
||||
const NO_GROUPS: SuperSearchGroup[] = [];
|
||||
|
||||
/**
|
||||
* Processor entity results (users, policies, pipelines, sources) for the
|
||||
* editor's super search. Core and desktop builds ship no portal, so this stub
|
||||
* returns nothing; the proprietary build shadows it with an implementation
|
||||
* that lazily loads the portal's entity-search module.
|
||||
*/
|
||||
export function useProcessorEntityGroups(
|
||||
_trimmed: string,
|
||||
_enabled: boolean,
|
||||
_t: TFunction,
|
||||
_navigate: (path: string) => void,
|
||||
_scopeEnabled?: (scopeId: string) => boolean,
|
||||
_isAdmin?: boolean,
|
||||
): SuperSearchGroup[] {
|
||||
return NO_GROUPS;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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[] = [];
|
||||
|
||||
/** No portal → no entity scopes; trivially accessible. Real logic lives in
|
||||
* the proprietary shadow, keyed on the flavor's users capabilities. */
|
||||
export function isPortalEntityScopeAccessible(
|
||||
_scopeId: string,
|
||||
_isAdmin: boolean,
|
||||
): boolean {
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import i18n from "i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
/**
|
||||
* Content-level settings search: matches a query against every translation
|
||||
* string a settings section renders, so a term that appears anywhere on a
|
||||
* settings page ("SMTP", "OCR", a field label…) finds that section without a
|
||||
* curated keyword.
|
||||
*
|
||||
* Component-free (translation subtrees only) so the always-mounted super
|
||||
* search can use it without pulling the lazy settings modal into the main
|
||||
* bundle.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Translation subtrees whose strings appear on each settings section, for the
|
||||
* sections where the nav key doesn't map 1:1 onto a toml prefix. Keys missing
|
||||
* here fall back to the inferred `settings.<key>` / `admin.settings.<key>`
|
||||
* prefix.
|
||||
*/
|
||||
const SECTION_TRANSLATION_PREFIXES: Partial<Record<string, string[]>> = {
|
||||
general: ["settings.general"],
|
||||
hotkeys: ["settings.hotkeys"],
|
||||
account: ["account"],
|
||||
people: ["settings.workspace"],
|
||||
teams: ["settings.workspace", "settings.team"],
|
||||
"api-keys": ["settings.developer"],
|
||||
connectionMode: ["settings.connection"],
|
||||
planBilling: ["settings.planBilling"],
|
||||
adminGeneral: ["admin.settings.general"],
|
||||
adminFeatures: ["admin.settings.features"],
|
||||
adminEndpoints: ["admin.settings.endpoints"],
|
||||
adminDatabase: ["admin.settings.database"],
|
||||
adminAdvanced: ["admin.settings.advanced"],
|
||||
adminFolderAccess: ["admin.settings.folderAccess"],
|
||||
// The four AI tabs all render slices of the one admin.settings.ai subtree.
|
||||
adminAiGeneral: ["admin.settings.ai"],
|
||||
adminAiModels: ["admin.settings.ai"],
|
||||
adminAiDocuments: ["admin.settings.ai"],
|
||||
adminAiLimits: ["admin.settings.ai"],
|
||||
adminSecurity: ["admin.settings.security"],
|
||||
adminMcp: ["admin.settings.mcp"],
|
||||
adminConnections: [
|
||||
"admin.settings.connections",
|
||||
"admin.settings.mail",
|
||||
"admin.settings.security",
|
||||
"admin.settings.telegram",
|
||||
"admin.settings.premium",
|
||||
"admin.settings.general",
|
||||
"settings.securityAuth",
|
||||
"settings.connection",
|
||||
],
|
||||
adminPlan: [
|
||||
"settings.planBilling",
|
||||
"admin.settings.premium",
|
||||
"settings.licensingAnalytics",
|
||||
],
|
||||
adminAudit: ["settings.licensingAnalytics"],
|
||||
adminUsage: ["settings.licensingAnalytics"],
|
||||
adminLegal: ["admin.settings.legal"],
|
||||
adminPrivacy: ["admin.settings.privacy"],
|
||||
};
|
||||
|
||||
export const getTranslationPrefixesForNavKey = (key: string): string[] => {
|
||||
const explicitPrefixes = SECTION_TRANSLATION_PREFIXES[key] ?? [];
|
||||
|
||||
const inferredPrefixes: string[] = [];
|
||||
|
||||
if (key.startsWith("admin")) {
|
||||
const adminSuffix = key.replace(/^admin/, "");
|
||||
const normalizedAdminSuffix =
|
||||
adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1);
|
||||
inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`);
|
||||
} else {
|
||||
inferredPrefixes.push(`settings.${key}`);
|
||||
}
|
||||
|
||||
return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes]));
|
||||
};
|
||||
|
||||
export const flattenTranslationStrings = (value: unknown): string[] => {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap(flattenTranslationStrings);
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
return Object.values(value as Record<string, unknown>).flatMap(
|
||||
flattenTranslationStrings,
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
/** Trims a matched string to a short snippet centred on the query hit. */
|
||||
export const buildMatchSnippet = (text: string, query: string): string => {
|
||||
const normalizedText = text.toLocaleLowerCase();
|
||||
const normalizedQuery = query.toLocaleLowerCase();
|
||||
const matchIndex = normalizedText.indexOf(normalizedQuery);
|
||||
|
||||
if (matchIndex === -1) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Lowercasing can change string length in some locales (Turkish İ, ß), so
|
||||
// indices computed on the copy only align with the original when the
|
||||
// lengths match; otherwise snippet the copy itself.
|
||||
const source = normalizedText.length === text.length ? text : normalizedText;
|
||||
|
||||
const maxLength = 84;
|
||||
const contextPadding = 28;
|
||||
const start = Math.max(0, matchIndex - contextPadding);
|
||||
const end = Math.min(
|
||||
source.length,
|
||||
matchIndex + query.length + contextPadding,
|
||||
);
|
||||
const snippet = source.slice(start, end);
|
||||
|
||||
if (snippet.length <= maxLength) {
|
||||
return `${start > 0 ? "…" : ""}${snippet}${end < source.length ? "…" : ""}`;
|
||||
}
|
||||
|
||||
return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < source.length ? "…" : ""}`;
|
||||
};
|
||||
|
||||
// Flattening every subtree on each keystroke would be wasteful; sections'
|
||||
// content is static per language, so cache it and drop the cache on switch.
|
||||
const contentCache = new Map<string, string[]>();
|
||||
let contentCacheLanguage: string | undefined;
|
||||
|
||||
// Locale files load over HTTP after boot, so content computed before the
|
||||
// bundle resolves is empty — without this, an early query would cache empty
|
||||
// content for the whole session. Cleared whenever a resource bundle lands.
|
||||
i18n.on("loaded", () => contentCache.clear());
|
||||
|
||||
export function getSettingsSectionContent(key: string, t: TFunction): string[] {
|
||||
if (contentCacheLanguage !== i18n.language) {
|
||||
contentCache.clear();
|
||||
contentCacheLanguage = i18n.language;
|
||||
}
|
||||
const cached = contentCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const content = getTranslationPrefixesForNavKey(key).flatMap((prefix) =>
|
||||
flattenTranslationStrings(
|
||||
t(prefix, { returnObjects: true, defaultValue: {} }),
|
||||
),
|
||||
);
|
||||
contentCache.set(key, content);
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* First content string of the section containing the query
|
||||
* (case-insensitive), or null. Substring only — fuzzy matching across whole
|
||||
* paragraphs of copy produces junk hits.
|
||||
*/
|
||||
export function findSettingsContentMatch(
|
||||
key: string,
|
||||
query: string,
|
||||
t: TFunction,
|
||||
): string | null {
|
||||
const normalizedQuery = query.toLocaleLowerCase();
|
||||
return (
|
||||
getSettingsSectionContent(key, t).find((text) =>
|
||||
text.toLocaleLowerCase().includes(normalizedQuery),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { NavKey } from "@app/components/shared/config/types";
|
||||
|
||||
/**
|
||||
* A single, searchable setting *row* inside the settings modal.
|
||||
*
|
||||
* Section-level content matching (settingsContentSearch) only navigates to a
|
||||
* whole section; this index lets the global super search deep-link to an
|
||||
* individual control: navigating to `/settings/{section}?focus={anchor}`,
|
||||
* where `anchor` is the DOM `id` placed on that control's row (see
|
||||
* AppConfigModal's focus-scroll effect and the `id=` attributes added to the
|
||||
* matching section components).
|
||||
*/
|
||||
export interface SettingsSearchEntry {
|
||||
/** Settings section this row lives in (nav key, e.g. "general"). */
|
||||
section: NavKey;
|
||||
/** DOM id on the control's row; used as the `?focus=` anchor. */
|
||||
anchor: string;
|
||||
/** i18n key for the display label. */
|
||||
labelKey: string;
|
||||
/** English fallback / default for the label. */
|
||||
labelFallback: string;
|
||||
/** Extra English terms to match against (synonyms, related words). */
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated row-level entries for the high-value, user-facing settings sections.
|
||||
* Section-level results (every other tab) come from the nav sections directly,
|
||||
* so this list only needs the rows worth jumping straight to.
|
||||
*/
|
||||
export const SETTINGS_SEARCH_INDEX: SettingsSearchEntry[] = [
|
||||
// --- General > Appearance ---
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-theme",
|
||||
labelKey: "settings.general.theme",
|
||||
labelFallback: "Theme",
|
||||
keywords: ["dark", "light", "mode", "appearance", "colour", "color"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-language",
|
||||
labelKey: "settings.general.language",
|
||||
labelFallback: "Language",
|
||||
keywords: ["locale", "translation", "i18n"],
|
||||
},
|
||||
// --- General > Behaviour ---
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-tool-picker-mode",
|
||||
labelKey: "settings.general.defaultToolPickerMode",
|
||||
labelFallback: "Default tool picker mode",
|
||||
keywords: ["sidebar", "fullscreen", "tools", "panel"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-startup-view",
|
||||
labelKey: "settings.general.defaultStartupView",
|
||||
labelFallback: "Default view on launch",
|
||||
keywords: ["startup", "launch", "home", "reader", "automate"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-reader-zoom",
|
||||
labelKey: "settings.general.defaultViewerZoom",
|
||||
labelFallback: "Default reader zoom",
|
||||
keywords: ["zoom", "viewer", "fit width", "fit page", "magnification"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-hide-unavailable-tools",
|
||||
labelKey: "settings.general.hideUnavailableTools",
|
||||
labelFallback: "Hide unavailable tools",
|
||||
keywords: ["disabled", "greyed", "tools"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-hide-unavailable-conversions",
|
||||
labelKey: "settings.general.hideUnavailableConversions",
|
||||
labelFallback: "Hide unavailable conversions",
|
||||
keywords: ["disabled", "convert", "conversions"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-auto-unzip",
|
||||
labelKey: "settings.general.autoUnzip",
|
||||
labelFallback: "Auto-unzip API responses",
|
||||
keywords: ["zip", "extract", "unzip", "archive"],
|
||||
},
|
||||
{
|
||||
section: "general",
|
||||
anchor: "setting-auto-unzip-file-limit",
|
||||
labelKey: "settings.general.autoUnzipFileLimit",
|
||||
labelFallback: "Auto-unzip file limit",
|
||||
keywords: ["zip", "limit", "files", "extract"],
|
||||
},
|
||||
// --- Keyboard Shortcuts ---
|
||||
{
|
||||
section: "hotkeys",
|
||||
anchor: "setting-hotkeys-search",
|
||||
labelKey: "settings.hotkeys.title",
|
||||
labelFallback: "Keyboard Shortcuts",
|
||||
keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NavKey } from "@app/components/shared/config/types";
|
||||
|
||||
/**
|
||||
* A whole settings section (nav tab), described as pure data so the global
|
||||
* super search can offer section-level results.
|
||||
*
|
||||
* This is the single source of truth for *which settings sections are
|
||||
* searchable* in a given build. It is intentionally **component-free**: the
|
||||
* always-mounted top bar imports it (via `@app/data/settingsSectionRegistry`)
|
||||
* to feed the super search, and pulling the heavy settings component tree in
|
||||
* here would defeat the lazy-loaded settings modal (AppConfigModalLazy).
|
||||
*
|
||||
* Layering mirrors the nav builders (`configNavSections`): core lists the
|
||||
* always-present sections; higher layers shadow this module to add their own
|
||||
* (proprietary admin sections, saas cloud sections, …). A section only belongs
|
||||
* in a layer's registry if that layer's settings modal can actually render it —
|
||||
* otherwise search would deep-link to a tab that doesn't exist.
|
||||
*
|
||||
* Per-user visibility (admin / login) is expressed by the flags below and
|
||||
* applied by the super search at query time, mirroring the modal's own gating.
|
||||
*/
|
||||
export interface SettingsSectionEntry {
|
||||
/** Nav key; used for the `/settings/{key}` deep link. */
|
||||
key: NavKey;
|
||||
/** i18n key for the display label. */
|
||||
labelKey: string;
|
||||
/** English fallback / default for the label. */
|
||||
labelFallback: string;
|
||||
/**
|
||||
* The modal nav group this section sits under ("Preferences",
|
||||
* "Configuration", …) — shown as result context ("Group · match").
|
||||
* Same keys the nav builder uses.
|
||||
*/
|
||||
groupLabelKey?: string;
|
||||
groupLabelFallback?: string;
|
||||
/** Extra English terms to match against (synonyms, related words). */
|
||||
keywords?: string[];
|
||||
/** Surface only when login mode is on (e.g. account, API keys). */
|
||||
requiresLogin?: boolean;
|
||||
/**
|
||||
* Admin-area section: the self-hosted modal surfaces it when the user is an
|
||||
* admin OR login mode is off (single-user self-host). Mirrors the proprietary
|
||||
* nav builder's `isAdmin || !loginEnabled` gate.
|
||||
*/
|
||||
adminArea?: boolean;
|
||||
/**
|
||||
* Section only mounts for a signed-in (non-anonymous) account — mirrors the
|
||||
* SaaS nav builder's `!isAnonymous` gate. Distinct from `requiresLogin`,
|
||||
* which keys off the local backend's login mode rather than auth state.
|
||||
*/
|
||||
requiresAccount?: boolean;
|
||||
}
|
||||
|
||||
/** Core (OSS) sections — always present in every build. */
|
||||
export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
|
||||
{
|
||||
key: "general",
|
||||
labelKey: "settings.general.title",
|
||||
labelFallback: "General",
|
||||
keywords: ["theme", "language", "appearance", "preferences", "startup"],
|
||||
groupLabelKey: "settings.preferences.title",
|
||||
groupLabelFallback: "Preferences",
|
||||
},
|
||||
{
|
||||
key: "hotkeys",
|
||||
labelKey: "settings.hotkeys.title",
|
||||
labelFallback: "Keyboard Shortcuts",
|
||||
keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
|
||||
groupLabelKey: "settings.preferences.title",
|
||||
groupLabelFallback: "Preferences",
|
||||
},
|
||||
{
|
||||
key: "help",
|
||||
labelKey: "settings.help.label",
|
||||
labelFallback: "Tours",
|
||||
keywords: ["help", "tour", "guide", "support", "docs"],
|
||||
groupLabelKey: "settings.help.title",
|
||||
groupLabelFallback: "Help",
|
||||
},
|
||||
{
|
||||
key: "legal",
|
||||
labelKey: "settings.legal.label",
|
||||
labelFallback: "Legal",
|
||||
keywords: ["legal", "terms", "privacy", "licenses"],
|
||||
groupLabelKey: "settings.legal.title",
|
||||
groupLabelFallback: "Legal",
|
||||
},
|
||||
];
|
||||
@@ -216,6 +216,17 @@ export const isValidToolId = (
|
||||
return toolId in registry;
|
||||
};
|
||||
|
||||
/**
|
||||
* A "coming soon" placeholder: listed in the catalogue but not openable — no
|
||||
* UI component and no external link. "read" and "multiTool" are exempt as
|
||||
* workbench-only tools that render without a component.
|
||||
*/
|
||||
export const isComingSoonTool = (
|
||||
toolId: string,
|
||||
tool: ToolRegistryEntry,
|
||||
): boolean =>
|
||||
!tool.component && !tool.link && toolId !== "read" && toolId !== "multiTool";
|
||||
|
||||
/**
|
||||
* Check if a tool supports automation (defaults to true)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { useScopedFetchCache } from "@app/hooks/useScopedFetchCache";
|
||||
|
||||
const TTL = 30_000;
|
||||
|
||||
describe("useScopedFetchCache", () => {
|
||||
it("exposes fetched values and clears loading", async () => {
|
||||
const fetcher = vi.fn(async (key: string) => `value-${key}`);
|
||||
const { result, rerender } = renderHook(
|
||||
({ keys }: { keys: readonly string[] }) =>
|
||||
useScopedFetchCache(keys, fetcher, TTL),
|
||||
{ initialProps: { keys: ["a", "b"] as readonly string[] } },
|
||||
);
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.values).toEqual({ a: "value-a", b: "value-b" });
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Fresh keys within the TTL are served from cache, not refetched.
|
||||
rerender({ keys: ["a", "b"] });
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("stamps failed keys so they are not retried within the TTL", async () => {
|
||||
const debug = vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||
const fetcher = vi.fn(async () => {
|
||||
throw new Error("source unavailable");
|
||||
});
|
||||
const { result, rerender } = renderHook(
|
||||
({ keys }: { keys: readonly string[] }) =>
|
||||
useScopedFetchCache(keys, fetcher, TTL),
|
||||
{ initialProps: { keys: ["a"] as readonly string[] } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.values).toEqual({});
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Re-requesting the failed key (a keystroke re-render) must not refire.
|
||||
rerender({ keys: ["a"] });
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
debug.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps successes when a sibling key fails", async () => {
|
||||
const fetcher = vi.fn(async (key: string) => {
|
||||
if (key === "bad") throw new Error("nope");
|
||||
return `value-${key}`;
|
||||
});
|
||||
const debug = vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||
const { result } = renderHook(() =>
|
||||
useScopedFetchCache(["good", "bad"], fetcher, TTL),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.values).toEqual({ good: "value-good" });
|
||||
debug.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface FetchSuccess<K extends string, V> {
|
||||
key: K;
|
||||
status: "fulfilled";
|
||||
value: V;
|
||||
}
|
||||
interface FetchFailure<K extends string> {
|
||||
key: K;
|
||||
status: "rejected";
|
||||
reason: unknown;
|
||||
}
|
||||
type FetchOutcome<K extends string, V> = FetchSuccess<K, V> | FetchFailure<K>;
|
||||
|
||||
export interface ScopedFetchCache<K extends string, V> {
|
||||
/** Latest successfully fetched value per key; absent until first success. */
|
||||
values: Partial<Record<K, V>>;
|
||||
/** True while any requested key is being (re)fetched. */
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A keyed async cache for search sources: each requested key is fetched at
|
||||
* most once per TTL window, concurrent requests for the same key share one
|
||||
* in-flight promise, and results from a superseded request generation are
|
||||
* dropped (though their in-flight promises are still awaited by the next
|
||||
* generation rather than re-fired).
|
||||
*
|
||||
* Failed keys are stamped like successes — a deployment without that endpoint
|
||||
* answers the same way on every keystroke, so hammering it per keypress buys
|
||||
* nothing. The failure is logged at debug level and retried after the TTL.
|
||||
*
|
||||
* `fetchKey` identity is the cache's world-view: when it changes (e.g. a tier
|
||||
* change producing a different fetcher), every key is considered stale.
|
||||
*/
|
||||
export function useScopedFetchCache<K extends string, V>(
|
||||
requestedKeys: readonly K[],
|
||||
fetchKey: (key: K) => Promise<V>,
|
||||
ttlMs: number,
|
||||
): ScopedFetchCache<K, V> {
|
||||
const [values, setValues] = useState<Partial<Record<K, V>>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const fetchedAtRef = useRef(new Map<K, number>());
|
||||
const inFlightRef = useRef(new Map<K, Promise<FetchOutcome<K, V>>>());
|
||||
const requestIdRef = useRef(0);
|
||||
const fetcherRef = useRef(fetchKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcherRef.current !== fetchKey) {
|
||||
fetcherRef.current = fetchKey;
|
||||
fetchedAtRef.current.clear();
|
||||
inFlightRef.current.clear();
|
||||
// Values from the old world-view must not surface under the new one
|
||||
// (e.g. a tier change altering what a payload contains).
|
||||
setValues({});
|
||||
}
|
||||
|
||||
if (requestedKeys.length === 0) {
|
||||
requestIdRef.current += 1;
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const staleKeys = requestedKeys.filter(
|
||||
(key) => now - (fetchedAtRef.current.get(key) ?? 0) >= ttlMs,
|
||||
);
|
||||
if (staleKeys.length === 0) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
setLoading(true);
|
||||
|
||||
void Promise.all(
|
||||
staleKeys.map((key) => {
|
||||
const existing = inFlightRef.current.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const request = fetchKey(key)
|
||||
.then((value) => ({ key, status: "fulfilled" as const, value }))
|
||||
.catch((reason) => ({ key, status: "rejected" as const, reason }))
|
||||
.finally(() => {
|
||||
if (inFlightRef.current.get(key) === request) {
|
||||
inFlightRef.current.delete(key);
|
||||
}
|
||||
});
|
||||
inFlightRef.current.set(key, request);
|
||||
return request;
|
||||
}),
|
||||
).then((results) => {
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
|
||||
const fetchedAt = Date.now();
|
||||
for (const result of results) {
|
||||
// Stamp failures too — see the hook doc.
|
||||
fetchedAtRef.current.set(result.key, fetchedAt);
|
||||
if (result.status === "rejected") {
|
||||
console.debug(
|
||||
"[useScopedFetchCache] source unavailable:",
|
||||
result.key,
|
||||
result.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const fulfilled = results.filter(
|
||||
(result): result is FetchSuccess<K, V> => result.status === "fulfilled",
|
||||
);
|
||||
if (fulfilled.length > 0) {
|
||||
setValues((current) => {
|
||||
const next = { ...current };
|
||||
for (const result of fulfilled) next[result.key] = result.value;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (requestIdRef.current === requestId) {
|
||||
requestIdRef.current += 1;
|
||||
}
|
||||
};
|
||||
}, [requestedKeys, fetchKey, ttlMs]);
|
||||
|
||||
return { values, loading };
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useNavigate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@app/contexts/ToolWorkflowContext", () => ({
|
||||
useToolWorkflow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@app/contexts/NavigationContext", () => ({
|
||||
useNavigationActions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@app/contexts/ViewerContext", () => ({
|
||||
ViewerContext: React.createContext(null),
|
||||
}));
|
||||
|
||||
vi.mock("@app/contexts/AppConfigContext", () => ({
|
||||
useAppConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@app/contexts/file/fileHooks", () => ({
|
||||
useFileActions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@app/auth/UseSession", () => ({
|
||||
useAuth: vi.fn(() => ({
|
||||
portalAccess: false,
|
||||
isAdmin: false,
|
||||
role: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@app/services/fileStorage", () => ({
|
||||
fileStorage: {
|
||||
getLeafStirlingFileStubs: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@app/data/settingsSearchIndex", () => ({
|
||||
SETTINGS_SEARCH_INDEX: [
|
||||
{
|
||||
section: "email",
|
||||
anchor: "smtp-host",
|
||||
labelKey: "settings.email.smtpHost",
|
||||
labelFallback: "SMTP host",
|
||||
keywords: ["smtp"],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("@app/data/settingsSectionRegistry", () => ({
|
||||
SETTINGS_SECTION_REGISTRY: [
|
||||
{
|
||||
key: "general",
|
||||
labelKey: "settings.general.title",
|
||||
labelFallback: "General",
|
||||
keywords: ["general"],
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
labelKey: "settings.email.title",
|
||||
labelFallback: "Email",
|
||||
keywords: ["mail"],
|
||||
requiresLogin: true,
|
||||
},
|
||||
{
|
||||
key: "admin",
|
||||
labelKey: "settings.admin.title",
|
||||
labelFallback: "Admin",
|
||||
keywords: ["admin"],
|
||||
adminArea: true,
|
||||
},
|
||||
{
|
||||
key: "teams",
|
||||
labelKey: "settings.teams.title",
|
||||
labelFallback: "Team",
|
||||
keywords: ["team"],
|
||||
requiresAccount: true,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("@app/data/settingsContentSearch", () => ({
|
||||
findSettingsContentMatch: vi.fn((section: string, query: string) => {
|
||||
if (query === "smtp" && (section === "general" || section === "email")) {
|
||||
return { section, query };
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
buildMatchSnippet: vi.fn(
|
||||
(_match: unknown, query: string) => `Match: ${query}`,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@app/data/processorSearchIndex", () => ({
|
||||
PROCESSOR_SEARCH_INDEX: [
|
||||
{
|
||||
id: "users",
|
||||
labelKey: "superSearch.processor.users",
|
||||
labelFallback: "Users",
|
||||
path: "/users",
|
||||
keywords: ["members"],
|
||||
},
|
||||
{
|
||||
id: "docs",
|
||||
labelKey: "superSearch.processor.docs",
|
||||
labelFallback: "Docs",
|
||||
path: "",
|
||||
externalUrl: "https://example.com/docs",
|
||||
keywords: ["manual"],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
assembleSuperSearchGroups,
|
||||
rankProcessorResults,
|
||||
rankSettingsResults,
|
||||
} from "@app/hooks/useSuperSearch";
|
||||
|
||||
const t = ((key: string, fallback?: string) =>
|
||||
fallback ?? key) as unknown as TFunction;
|
||||
|
||||
describe("useSuperSearch helpers", () => {
|
||||
it("drops empty groups and preserves the requested group order", () => {
|
||||
const groups = assembleSuperSearchGroups(
|
||||
{
|
||||
tools: [
|
||||
{
|
||||
key: "tool:rotate",
|
||||
group: "tools",
|
||||
title: "Rotate",
|
||||
score: 60,
|
||||
onSelect: vi.fn(),
|
||||
},
|
||||
],
|
||||
processor: [
|
||||
{
|
||||
key: "processor:users",
|
||||
group: "processor",
|
||||
title: "Users",
|
||||
score: 70,
|
||||
onSelect: vi.fn(),
|
||||
},
|
||||
],
|
||||
},
|
||||
t,
|
||||
["processor", "settings", "tools", "files"],
|
||||
);
|
||||
|
||||
expect(groups.map((group) => group.id)).toEqual(["processor", "tools"]);
|
||||
expect(groups.map((group) => group.label)).toEqual(["Processor", "Tools"]);
|
||||
});
|
||||
|
||||
it("prefers row-level setting hits and skips duplicate content matches", () => {
|
||||
const openSettings = vi.fn();
|
||||
|
||||
const results = rankSettingsResults(
|
||||
"smtp",
|
||||
t,
|
||||
{
|
||||
isAdmin: false,
|
||||
loginEnabled: true,
|
||||
},
|
||||
openSettings,
|
||||
);
|
||||
|
||||
expect(results.map((result) => result.key)).toEqual([
|
||||
"setting:email:smtp-host",
|
||||
"setting-content:general",
|
||||
]);
|
||||
|
||||
void results[0]?.onSelect();
|
||||
expect(openSettings).toHaveBeenCalledWith("email", "smtp-host");
|
||||
});
|
||||
|
||||
it("keeps gated settings hidden while app config is unresolved", () => {
|
||||
const results = rankSettingsResults("admin", t, null, vi.fn());
|
||||
|
||||
expect(results).toEqual([]);
|
||||
expect(rankSettingsResults("team", t, null, vi.fn())).toEqual([]);
|
||||
});
|
||||
|
||||
it("respects showSettingsWhenNoLogin for the no-login admin preview", () => {
|
||||
const noLoginGates = { isAdmin: false, loginEnabled: false };
|
||||
|
||||
// Default (flag unset / true): no-login mode keeps the admin preview.
|
||||
const shown = rankSettingsResults("admin", t, noLoginGates, vi.fn());
|
||||
expect(shown.map((result) => result.key)).toEqual([
|
||||
"setting-section:admin",
|
||||
]);
|
||||
|
||||
// Flag off: the modal hides admin sections, so search must too.
|
||||
const hidden = rankSettingsResults(
|
||||
"admin",
|
||||
t,
|
||||
{ ...noLoginGates, showSettingsWhenNoLogin: false },
|
||||
vi.fn(),
|
||||
);
|
||||
expect(hidden).toEqual([]);
|
||||
|
||||
// Admins keep admin sections regardless of the flag.
|
||||
const admin = rankSettingsResults(
|
||||
"admin",
|
||||
t,
|
||||
{ isAdmin: true, loginEnabled: true, showSettingsWhenNoLogin: false },
|
||||
vi.fn(),
|
||||
);
|
||||
expect(admin.map((result) => result.key)).toEqual([
|
||||
"setting-section:admin",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides account-bound settings from anonymous sessions", () => {
|
||||
const anonymous = rankSettingsResults(
|
||||
"team",
|
||||
t,
|
||||
{ isAdmin: false, loginEnabled: true, isAnonymous: true },
|
||||
vi.fn(),
|
||||
);
|
||||
expect(anonymous).toEqual([]);
|
||||
|
||||
const signedIn = rankSettingsResults(
|
||||
"team",
|
||||
t,
|
||||
{ isAdmin: false, loginEnabled: true, isAnonymous: false },
|
||||
vi.fn(),
|
||||
);
|
||||
expect(signedIn.map((result) => result.key)).toEqual([
|
||||
"setting-section:teams",
|
||||
]);
|
||||
|
||||
// Hosts that omit the flag (portal, always signed-in) keep the section.
|
||||
const flagless = rankSettingsResults(
|
||||
"team",
|
||||
t,
|
||||
{ isAdmin: false, loginEnabled: true },
|
||||
vi.fn(),
|
||||
);
|
||||
expect(flagless.map((result) => result.key)).toEqual([
|
||||
"setting-section:teams",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the Processor group closed until gates resolve, then opens it for single-user mode", () => {
|
||||
const selectEntry = vi.fn();
|
||||
|
||||
expect(rankProcessorResults("members", t, null, selectEntry)).toEqual([]);
|
||||
|
||||
const results = rankProcessorResults(
|
||||
"members",
|
||||
t,
|
||||
{
|
||||
isAdmin: false,
|
||||
loginEnabled: false,
|
||||
},
|
||||
selectEntry,
|
||||
);
|
||||
|
||||
expect(results.map((result) => result.key)).toEqual(["processor:users"]);
|
||||
|
||||
void results[0]?.onSelect();
|
||||
expect(selectEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "users", path: "/users" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the Processor group for a user with explicit portal access", () => {
|
||||
const results = rankProcessorResults(
|
||||
"members",
|
||||
t,
|
||||
{
|
||||
isAdmin: false,
|
||||
loginEnabled: true,
|
||||
portalAccessible: true,
|
||||
},
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(results.map((result) => result.key)).toEqual(["processor:users"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,663 @@
|
||||
import {
|
||||
createElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { ViewerContext } from "@app/contexts/ViewerContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useFileActions } from "@app/contexts/file/fileHooks";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
|
||||
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
|
||||
import { detectFileExtension } from "@app/utils/fileUtils";
|
||||
import { openExternalUrl } from "@app/utils/safeNavigation";
|
||||
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
|
||||
import {
|
||||
rankByFuzzy,
|
||||
idToWords,
|
||||
FUZZY_MIN_SCORE,
|
||||
} from "@app/utils/fuzzySearch";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
import { isComingSoonTool, type ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { SETTINGS_SEARCH_INDEX } from "@app/data/settingsSearchIndex";
|
||||
import { SETTINGS_SECTION_REGISTRY } from "@app/data/settingsSectionRegistry";
|
||||
import {
|
||||
buildMatchSnippet,
|
||||
findSettingsContentMatch,
|
||||
} from "@app/data/settingsContentSearch";
|
||||
import {
|
||||
PROCESSOR_SEARCH_INDEX,
|
||||
isPortalEntityScopeAccessible,
|
||||
type ProcessorSearchEntry,
|
||||
} from "@app/data/processorSearchIndex";
|
||||
import { useProcessorEntityGroups } from "@app/data/processorEntitySearch";
|
||||
import {
|
||||
PORTAL_ENTITY_SCOPE_DEFS,
|
||||
PORTAL_DOCS_SCOPE_ID,
|
||||
type SuperSearchGates,
|
||||
type SuperSearchGroup,
|
||||
type SuperSearchGroupId,
|
||||
type SuperSearchQueryOptions,
|
||||
type SuperSearchResult,
|
||||
type SuperSearchScope,
|
||||
type UseSuperSearchResult,
|
||||
} from "@app/types/superSearch";
|
||||
|
||||
// Re-exported so existing consumers keep one import site; the definitions
|
||||
// live in the types leaf (see that module for why).
|
||||
export type {
|
||||
SuperSearchGates,
|
||||
SuperSearchGroup,
|
||||
SuperSearchGroupId,
|
||||
SuperSearchQueryOptions,
|
||||
SuperSearchResult,
|
||||
SuperSearchScope,
|
||||
UseSuperSearchResult,
|
||||
};
|
||||
|
||||
/**
|
||||
* How many results a ranker computes per group. This is the ceiling the
|
||||
* dropdown can reveal via "show more" — the component shows a small initial
|
||||
* slice per group and expands to the rest on demand.
|
||||
*/
|
||||
export const GROUP_RESULT_CEILING = 24;
|
||||
/** Group display order in the dropdown. */
|
||||
const GROUP_ORDER: SuperSearchGroupId[] = [
|
||||
"files",
|
||||
"tools",
|
||||
"settings",
|
||||
"processor",
|
||||
];
|
||||
|
||||
/**
|
||||
* Whether the current user can enter the Processor at all: explicit portal
|
||||
* access, admin, or single-user mode with login disabled. Null gates (config
|
||||
* still loading) stay closed.
|
||||
*/
|
||||
export function isProcessorGateOpen(gates: SuperSearchGates | null): boolean {
|
||||
return (
|
||||
!!gates &&
|
||||
(gates.portalAccessible === true || gates.isAdmin || !gates.loginEnabled)
|
||||
);
|
||||
}
|
||||
|
||||
/** The editor's visibility gates, from app config + the session's flags. */
|
||||
export function useSuperSearchGates(): SuperSearchGates | null {
|
||||
const authState = useAuth();
|
||||
const { config } = useAppConfig();
|
||||
return useMemo(
|
||||
() =>
|
||||
config
|
||||
? {
|
||||
isAdmin: authState.isAdmin ?? config.isAdmin ?? false,
|
||||
loginEnabled: config.enableLogin ?? false,
|
||||
portalAccessible: authState.portalAccess ?? false,
|
||||
isAnonymous: authState.isAnonymous,
|
||||
showSettingsWhenNoLogin: config.showSettingsWhenNoLogin ?? true,
|
||||
}
|
||||
: null,
|
||||
[authState.isAdmin, authState.isAnonymous, authState.portalAccess, config],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor bar's filter chips — one per source lane, granular over the
|
||||
* Processor's contents (pages plus each entity type). Processor lanes only
|
||||
* show when this build ships the portal and the user can enter it.
|
||||
*/
|
||||
export function useEditorSearchScopes(): SuperSearchScope[] {
|
||||
const { t } = useTranslation();
|
||||
const gates = useSuperSearchGates();
|
||||
const processorAvailable =
|
||||
PROCESSOR_SEARCH_INDEX.length > 0 && isProcessorGateOpen(gates);
|
||||
|
||||
return useMemo(() => {
|
||||
const visibleViewIds = new Set(
|
||||
PROCESSOR_SEARCH_INDEX.map((entry) => entry.id),
|
||||
);
|
||||
return [
|
||||
{
|
||||
id: "files",
|
||||
label: t("superSearch.group.files", "Files"),
|
||||
aliases: ["file", "files"],
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
label: t("superSearch.group.tools", "Tools"),
|
||||
aliases: ["tool", "tools"],
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: t("superSearch.group.settings", "Settings"),
|
||||
aliases: ["setting", "settings"],
|
||||
},
|
||||
...(processorAvailable
|
||||
? [
|
||||
{
|
||||
id: "processor",
|
||||
label: t("superSearch.group.pages", "Pages"),
|
||||
aliases: ["page", "pages", "processor", "portal"],
|
||||
},
|
||||
...PORTAL_ENTITY_SCOPE_DEFS.filter(
|
||||
(def) =>
|
||||
visibleViewIds.has(def.viewId) &&
|
||||
isPortalEntityScopeAccessible(def.id, gates?.isAdmin ?? false),
|
||||
).map((def) => ({
|
||||
id: def.id,
|
||||
label: t(def.labelKey, def.labelFallback),
|
||||
aliases: [...def.aliases],
|
||||
})),
|
||||
...(visibleViewIds.has("docs")
|
||||
? [
|
||||
{
|
||||
id: PORTAL_DOCS_SCOPE_ID,
|
||||
label: t("superSearch.group.docs", "Docs"),
|
||||
aliases: ["doc", "docs", "documentation"],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}, [t, processorAvailable, gates?.isAdmin]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared scope handling for hosts that accept SuperSearchQueryOptions:
|
||||
* which source lanes are enabled for the current chip/prefix selection.
|
||||
*/
|
||||
export function useSearchScopeFilter(options?: SuperSearchQueryOptions): {
|
||||
scopeEnabled: (scopeId: string) => boolean;
|
||||
} {
|
||||
const scopedIds = useMemo(
|
||||
() => new Set(options?.scopeIds ?? []),
|
||||
[options?.scopeIds],
|
||||
);
|
||||
const hasScopedSearch = scopedIds.size > 0;
|
||||
const scopeEnabled = useCallback(
|
||||
(scopeId: string) => !hasScopedSearch || scopedIds.has(scopeId),
|
||||
[hasScopedSearch, scopedIds],
|
||||
);
|
||||
return { scopeEnabled };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared sources. Every host bar (editor workbench, portal shell) builds its
|
||||
// results from these, so a query ranks identically everywhere — only the
|
||||
// select actions differ (in-app contexts vs cross-app navigation).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Loads the My Files stubs whenever the search surface is open. */
|
||||
export function useMyFilesStubs(active: boolean): {
|
||||
stubs: StirlingFileStub[];
|
||||
loadingFiles: boolean;
|
||||
} {
|
||||
const [stubs, setStubs] = useState<StirlingFileStub[]>([]);
|
||||
const [loadingFiles, setLoadingFiles] = useState(false);
|
||||
const loadedOnceRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
// Refresh whenever the surface opens so newly added files appear.
|
||||
let cancelled = false;
|
||||
if (!loadedOnceRef.current) setLoadingFiles(true);
|
||||
fileStorage
|
||||
.getLeafStirlingFileStubs()
|
||||
.then((all) => {
|
||||
if (cancelled) return;
|
||||
setStubs(all);
|
||||
loadedOnceRef.current = true;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[SuperSearch] Failed to load file stubs:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingFiles(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
return { stubs, loadingFiles };
|
||||
}
|
||||
|
||||
export function rankFileResults(
|
||||
stubs: StirlingFileStub[],
|
||||
trimmed: string,
|
||||
openFile: (stub: StirlingFileStub) => void | Promise<void>,
|
||||
limit = GROUP_RESULT_CEILING,
|
||||
): SuperSearchResult[] {
|
||||
if (!trimmed) return [];
|
||||
return rankByFuzzy(stubs, trimmed, [(s) => s.name])
|
||||
.slice(0, limit)
|
||||
.map(({ item, score }) => ({
|
||||
key: `file:${item.id}`,
|
||||
group: "files",
|
||||
title: item.name,
|
||||
// The file-type doc icon (PDF/image/doc/…) the sidebar and grid use,
|
||||
// rather than a flat generic file glyph. Sized by height so the portrait
|
||||
// doc shape sits level with the square tool/settings icons instead of
|
||||
// overflowing the row.
|
||||
icon: createElement(FileDocIcon, {
|
||||
variant: getFileDocVariant(
|
||||
detectFileExtension(item.name.toLowerCase()),
|
||||
(item.type ?? "").toLowerCase(),
|
||||
),
|
||||
style: { height: "1.15rem", width: "auto" },
|
||||
}),
|
||||
score,
|
||||
onSelect: () => openFile(item),
|
||||
}));
|
||||
}
|
||||
|
||||
export function rankToolResults(
|
||||
registry: Partial<ToolRegistry>,
|
||||
trimmed: string,
|
||||
openTool: (id: ToolId) => void,
|
||||
limit = GROUP_RESULT_CEILING,
|
||||
): SuperSearchResult[] {
|
||||
if (!trimmed) return [];
|
||||
// Coming-soon placeholders are listed in the catalogue but can't open —
|
||||
// selecting one would land on the "tool not found" panel.
|
||||
const entries = (
|
||||
Object.entries(registry) as [ToolId, ToolRegistry[ToolId] | undefined][]
|
||||
).filter(([id, tool]) => tool && !isComingSoonTool(id, tool));
|
||||
return rankByFuzzy(entries, trimmed, [
|
||||
([id]) => idToWords(id),
|
||||
([, v]) => v?.name ?? "",
|
||||
([, v]) => v?.description ?? "",
|
||||
([, v]) => v?.synonyms?.join(" ") ?? "",
|
||||
])
|
||||
.slice(0, limit)
|
||||
.map(({ item: [id, tool], score }) => ({
|
||||
key: `tool:${id}`,
|
||||
group: "tools",
|
||||
title: tool?.name ?? id,
|
||||
subtitle: tool?.description,
|
||||
icon: tool?.icon,
|
||||
score,
|
||||
onSelect: () => openTool(id),
|
||||
}));
|
||||
}
|
||||
|
||||
export function rankSettingsResults(
|
||||
trimmed: string,
|
||||
t: TFunction,
|
||||
gates: SuperSearchGates | null,
|
||||
openSettings: (section: string, anchor?: string) => void,
|
||||
limit = GROUP_RESULT_CEILING,
|
||||
/** Sections the host's settings modal refuses to show (e.g. the portal's
|
||||
* hiddenSectionKeys) — offering them would deep-link into a blank modal. */
|
||||
excludeSections?: readonly string[],
|
||||
): SuperSearchResult[] {
|
||||
if (!trimmed) return [];
|
||||
|
||||
// Sections gated like the modal nav. The registry resolves per build
|
||||
// (core / proprietary / saas / desktop), so this only ever sees sections
|
||||
// the current build's settings modal can actually show.
|
||||
const visibleSections = SETTINGS_SECTION_REGISTRY.filter((s) => {
|
||||
if (excludeSections?.includes(s.key)) return false;
|
||||
// Null gates (config still loading): hide every gated section.
|
||||
// requiresLogin keys off the deployment's login *mode*, mirroring the nav
|
||||
// builder: with login on the editor is login-walled (an unauthenticated
|
||||
// visitor never reaches this code), and with login off those sections
|
||||
// (account, API keys) have no meaning. Actual per-session auth state only
|
||||
// matters on SaaS, where requiresAccount/isAnonymous carries it.
|
||||
if (s.requiresLogin && !(gates?.loginEnabled ?? false)) return false;
|
||||
// Admin-area sections mirror the builder's gate: admins always; no-login
|
||||
// mode only while system.showSettingsWhenNoLogin keeps the admin preview.
|
||||
const adminGateOpen =
|
||||
!!gates &&
|
||||
(gates.isAdmin ||
|
||||
(!gates.loginEnabled && (gates.showSettingsWhenNoLogin ?? true)));
|
||||
if (s.adminArea && !adminGateOpen) return false;
|
||||
// Account-bound sections mirror the SaaS builder's `!isAnonymous` gate.
|
||||
if (s.requiresAccount && (gates ? (gates.isAnonymous ?? false) : true))
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
// Row context: the display label of the section the row lives in.
|
||||
const sectionLabelFor = new Map(
|
||||
visibleSections.map((s) => [s.key, t(s.labelKey, s.labelFallback)]),
|
||||
);
|
||||
|
||||
// Row-level entries (deep-link with ?focus=) take priority. Rows for
|
||||
// sections this build/user can't open are dropped with them.
|
||||
const rowMatches = rankByFuzzy(
|
||||
SETTINGS_SEARCH_INDEX.filter((e) => sectionLabelFor.has(e.section)),
|
||||
trimmed,
|
||||
[
|
||||
(e) => t(e.labelKey, e.labelFallback),
|
||||
(e) => e.labelFallback,
|
||||
(e) => e.keywords?.join(" ") ?? "",
|
||||
],
|
||||
);
|
||||
const rows = rowMatches.map(({ item, score }) => ({
|
||||
key: `setting:${item.section}:${item.anchor}`,
|
||||
group: "settings",
|
||||
title: t(item.labelKey, item.labelFallback),
|
||||
subtitle: sectionLabelFor.get(item.section),
|
||||
iconName: "settings-rounded",
|
||||
score: score + 1, // nudge rows above bare section matches
|
||||
onSelect: () => openSettings(item.section, item.anchor),
|
||||
}));
|
||||
// The nav group a section lives under, shown as result context (joined to a
|
||||
// content-match snippet with " · ").
|
||||
const groupTitle = (s: (typeof SETTINGS_SECTION_REGISTRY)[number]) =>
|
||||
s.groupLabelKey
|
||||
? t(s.groupLabelKey, s.groupLabelFallback ?? "")
|
||||
: undefined;
|
||||
|
||||
const sectionMatches = rankByFuzzy(visibleSections, trimmed, [
|
||||
(s) => t(s.labelKey, s.labelFallback),
|
||||
(s) => s.labelFallback,
|
||||
(s) => s.keywords?.join(" ") ?? "",
|
||||
]);
|
||||
const sections = sectionMatches.map(({ item, score }) => ({
|
||||
key: `setting-section:${item.key}`,
|
||||
group: "settings",
|
||||
title: t(item.labelKey, item.labelFallback),
|
||||
subtitle: groupTitle(item),
|
||||
iconName: "settings-rounded",
|
||||
score,
|
||||
onSelect: () => openSettings(item.key),
|
||||
}));
|
||||
|
||||
// Content matches: sections whose rendered copy contains the query, so terms
|
||||
// with no curated keyword ("SMTP", a field label) still find their section.
|
||||
// Ranked below every label/keyword match; 3+ chars so a single letter doesn't
|
||||
// match half the modal. Sections already surfaced by a label match — their
|
||||
// own or a row's — are skipped so the same hit isn't listed twice.
|
||||
const labelMatchedKeys = new Set<string>([
|
||||
...sectionMatches.map(({ item }) => item.key),
|
||||
...rowMatches.map(({ item }) => item.section),
|
||||
]);
|
||||
const contentMatches =
|
||||
trimmed.length < 3
|
||||
? []
|
||||
: visibleSections
|
||||
.filter((s) => !labelMatchedKeys.has(s.key))
|
||||
.flatMap((s) => {
|
||||
const match = findSettingsContentMatch(s.key, trimmed, t);
|
||||
if (!match) return [];
|
||||
const snippet = buildMatchSnippet(match, trimmed);
|
||||
const group = groupTitle(s);
|
||||
return [
|
||||
{
|
||||
key: `setting-content:${s.key}`,
|
||||
group: "settings",
|
||||
title: t(s.labelKey, s.labelFallback),
|
||||
subtitle: group ? `${group} · ${snippet}` : snippet,
|
||||
iconName: "settings-rounded",
|
||||
// Always below the weakest possible label/keyword match.
|
||||
score: FUZZY_MIN_SCORE - 10,
|
||||
onSelect: () => openSettings(s.key),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return [...rows, ...sections, ...contentMatches]
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export function rankProcessorResults(
|
||||
trimmed: string,
|
||||
t: TFunction,
|
||||
gates: SuperSearchGates | null,
|
||||
selectEntry: (entry: ProcessorSearchEntry) => void,
|
||||
limit = GROUP_RESULT_CEILING,
|
||||
): SuperSearchResult[] {
|
||||
if (!trimmed || PROCESSOR_SEARCH_INDEX.length === 0) return [];
|
||||
// Only offer Processor pages to users who can actually enter that app.
|
||||
if (!isProcessorGateOpen(gates)) return [];
|
||||
return rankByFuzzy(PROCESSOR_SEARCH_INDEX, trimmed, [
|
||||
(e) => t(e.labelKey, e.labelFallback),
|
||||
(e) => e.labelFallback,
|
||||
(e) => e.keywords?.join(" ") ?? "",
|
||||
])
|
||||
.slice(0, limit)
|
||||
.map(({ item, score }) => ({
|
||||
key: `processor:${item.id}`,
|
||||
group: "processor",
|
||||
title: t(item.labelKey, item.labelFallback),
|
||||
// Must exist in the bundled Material Symbols set (LocalIcon falls back
|
||||
// to a network fetch for unknown names — blank when self-hosted offline).
|
||||
iconName: "grid-view",
|
||||
score,
|
||||
onSelect: () => selectEntry(item),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders the sources into the shared group layout, dropping empties. Hosts
|
||||
* pass their own order so local results lead (the editor puts its own
|
||||
* files/tools first and Processor pages last; the portal the reverse).
|
||||
*/
|
||||
export function assembleSuperSearchGroups(
|
||||
byId: Partial<Record<SuperSearchGroupId, SuperSearchResult[]>>,
|
||||
t: TFunction,
|
||||
order: SuperSearchGroupId[] = GROUP_ORDER,
|
||||
): SuperSearchGroup[] {
|
||||
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 order
|
||||
.map((id) => ({
|
||||
id,
|
||||
label: labels[id],
|
||||
results: byId[id] ?? [],
|
||||
}))
|
||||
.filter((g) => g.results.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's results provider: the shared sources wired to in-app select
|
||||
* actions (open file → viewer, select tool in the workbench, deep-link into
|
||||
* the settings modal, route into the Processor).
|
||||
*
|
||||
* @param query current search text
|
||||
* @param active whether the search surface is open; gates the My Files load
|
||||
*/
|
||||
export function useSuperSearch(
|
||||
query: string,
|
||||
active: boolean,
|
||||
options?: SuperSearchQueryOptions,
|
||||
): UseSuperSearchResult {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
toolRegistry,
|
||||
handleToolSelect,
|
||||
handleToolSelectForced,
|
||||
toolAvailability,
|
||||
} = useToolWorkflow();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
// ViewerContext is only present once the viewer subtree mounts; treat as
|
||||
// optional. Read through a ref: the provider value is rebuilt every viewer
|
||||
// render, and a direct dependency would re-rank every result lane on each
|
||||
// page turn — openFile only needs the value at click time.
|
||||
const viewer = useContext(ViewerContext);
|
||||
const viewerRef = useRef(viewer);
|
||||
useEffect(() => {
|
||||
viewerRef.current = viewer;
|
||||
}, [viewer]);
|
||||
|
||||
const trimmed = query.trim();
|
||||
const { stubs, loadingFiles } = useMyFilesStubs(active);
|
||||
const { scopeEnabled } = useSearchScopeFilter(options);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
// Workbench-bound selections must leave the file manager through the router.
|
||||
// Tool/file selection pins its URL via raw history.pushState, which the
|
||||
// router never observes — so on /files the route keeps re-asserting the
|
||||
// "myFiles" workbench and the selection appears to do nothing. Exit to the
|
||||
// editor's home path: on processor-shipping builds "/" is a role router,
|
||||
// not the editor.
|
||||
const leaveFileManager = useCallback(() => {
|
||||
if (pathname.startsWith("/files")) {
|
||||
navigate(EDITOR_BASENAME);
|
||||
}
|
||||
}, [pathname, navigate]);
|
||||
|
||||
// --- Actions -----------------------------------------------------------
|
||||
const openFile = useCallback(
|
||||
async (stub: StirlingFileStub) => {
|
||||
try {
|
||||
// The file already lives in storage — load it as a stub so its id and
|
||||
// metadata are preserved (addFiles would persist a duplicate record).
|
||||
await fileActions.addStirlingFileStubs([stub], { selectFiles: true });
|
||||
navActions.setWorkbench("viewer");
|
||||
viewerRef.current?.setActiveFileId?.(stub.id);
|
||||
leaveFileManager();
|
||||
} catch (err) {
|
||||
console.error("[SuperSearch] Failed to open file:", stub.name, err);
|
||||
}
|
||||
},
|
||||
[fileActions, navActions, leaveFileManager],
|
||||
);
|
||||
|
||||
const openTool = useCallback(
|
||||
(id: ToolId) => {
|
||||
// Link tools have no in-editor UI — selecting one shows a "tool not
|
||||
// found" panel. Open their destination directly, matching how the
|
||||
// editor's tool lists treat them.
|
||||
const link = toolRegistry[id]?.link;
|
||||
if (link) {
|
||||
openExternalUrl(link);
|
||||
return;
|
||||
}
|
||||
// Tools whose backend endpoint isn't served in this environment are
|
||||
// flagged unavailable; handleToolSelect silently no-ops them. For a
|
||||
// search ("take me to Repair") we still want the click to open the
|
||||
// tool's UI, so fall back to the forced path for those. Available tools
|
||||
// keep the normal path so the unsaved-changes guard still applies.
|
||||
const available = toolAvailability[id]?.available !== false;
|
||||
if (available) {
|
||||
handleToolSelect(id);
|
||||
} else {
|
||||
handleToolSelectForced(id);
|
||||
}
|
||||
leaveFileManager();
|
||||
},
|
||||
[
|
||||
handleToolSelect,
|
||||
handleToolSelectForced,
|
||||
toolAvailability,
|
||||
toolRegistry,
|
||||
leaveFileManager,
|
||||
],
|
||||
);
|
||||
|
||||
const openSettings = useCallback(
|
||||
(section: string, anchor?: string) => {
|
||||
const path = anchor
|
||||
? `/settings/${section}?focus=${encodeURIComponent(anchor)}`
|
||||
: `/settings/${section}`;
|
||||
navigate(path);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const selectProcessorEntry = useCallback(
|
||||
(item: ProcessorSearchEntry) => {
|
||||
if (item.externalUrl) {
|
||||
openExternalUrl(item.externalUrl);
|
||||
} else {
|
||||
navigate(item.path);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
// --- Assemble ----------------------------------------------------------
|
||||
const gates = useSuperSearchGates();
|
||||
|
||||
// Processor entities (users, policies, pipelines, sources) join the pages
|
||||
// under the Processor section — same access gate as the pages group, each
|
||||
// entity type filterable by its own scope.
|
||||
const entityGroups = useProcessorEntityGroups(
|
||||
trimmed,
|
||||
active && trimmed.length > 0 && isProcessorGateOpen(gates),
|
||||
t,
|
||||
navigate,
|
||||
scopeEnabled,
|
||||
gates?.isAdmin ?? false,
|
||||
);
|
||||
|
||||
const groups = useMemo<SuperSearchGroup[]>(() => {
|
||||
const assembledGroups = assembleSuperSearchGroups(
|
||||
{
|
||||
files: scopeEnabled("files")
|
||||
? rankFileResults(stubs, trimmed, openFile)
|
||||
: [],
|
||||
tools: scopeEnabled("tools")
|
||||
? rankToolResults(toolRegistry, trimmed, openTool)
|
||||
: [],
|
||||
settings: scopeEnabled("settings")
|
||||
? rankSettingsResults(trimmed, t, gates, openSettings)
|
||||
: [],
|
||||
processor: scopeEnabled("processor")
|
||||
? rankProcessorResults(trimmed, t, gates, selectProcessorEntry)
|
||||
: [],
|
||||
},
|
||||
t,
|
||||
);
|
||||
|
||||
// Section order: Editor first, Settings second, Processor last.
|
||||
const processorSection = t("superSearch.group.processor", "Processor");
|
||||
const sectionFor = (groupId: string): string => {
|
||||
if (groupId === "processor") return processorSection;
|
||||
if (groupId === "settings")
|
||||
return t("superSearch.group.settings", "Settings");
|
||||
return t("portal.nav.editor", "Editor");
|
||||
};
|
||||
return [
|
||||
...assembledGroups.map((group) => ({
|
||||
...group,
|
||||
label:
|
||||
group.id === "processor"
|
||||
? t("superSearch.group.pages", "Pages")
|
||||
: group.label,
|
||||
sectionLabel: sectionFor(group.id),
|
||||
})),
|
||||
...entityGroups.map((group) => ({
|
||||
...group,
|
||||
sectionLabel: processorSection,
|
||||
})),
|
||||
];
|
||||
}, [
|
||||
stubs,
|
||||
trimmed,
|
||||
openFile,
|
||||
toolRegistry,
|
||||
openTool,
|
||||
gates,
|
||||
openSettings,
|
||||
selectProcessorEntry,
|
||||
scopeEnabled,
|
||||
entityGroups,
|
||||
t,
|
||||
]);
|
||||
|
||||
const flatResults = useMemo(() => groups.flatMap((g) => g.results), [groups]);
|
||||
|
||||
return { groups, flatResults, loadingFiles };
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import {
|
||||
getAllEndpoints,
|
||||
isComingSoonTool,
|
||||
type ToolRegistryEntry,
|
||||
type ToolRegistry,
|
||||
} from "@app/data/toolsTaxonomy";
|
||||
@@ -167,12 +168,7 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
? availabilityInfo.available !== false
|
||||
: true;
|
||||
|
||||
// Check if tool is "coming soon" (has no component and no link)
|
||||
const isComingSoon =
|
||||
!baseTool.component &&
|
||||
!baseTool.link &&
|
||||
toolKey !== "read" &&
|
||||
toolKey !== "multiTool";
|
||||
const isComingSoon = isComingSoonTool(toolKey, baseTool);
|
||||
|
||||
if (preferences.hideUnavailableTools && (!isAvailable || isComingSoon)) {
|
||||
return;
|
||||
|
||||
@@ -79,7 +79,8 @@ export const I18N_PROJECTS: TranslationProject[] = [
|
||||
// SignSettings / SavedSignaturesSection resolve every key as
|
||||
// t(`${scope}.${key}`); scope and leaf only ever exist as separate literals.
|
||||
/^(sign|addText|addImage)\./,
|
||||
// SettingsSearchBar indexes whole subtrees via t(prefix, { returnObjects }).
|
||||
// Super search's settings content matching (settingsContentSearch)
|
||||
// indexes whole subtrees via t(prefix, { returnObjects }).
|
||||
/^admin\.settings\./,
|
||||
/^settings\./,
|
||||
/^account\./,
|
||||
|
||||
@@ -599,11 +599,6 @@ const MyFilesSidebarOverrides = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
<FileSidebar
|
||||
ref={ref}
|
||||
{...props}
|
||||
onSearchClick={() => {
|
||||
// Just focus the central search field; don't toggle collapse
|
||||
// (which on /files navigates back home).
|
||||
window.dispatchEvent(new Event("files-page:focus-search"));
|
||||
}}
|
||||
onUploadFiles={handleUpload}
|
||||
onPickGoogleDriveFiles={handleUpload}
|
||||
extraAction={{
|
||||
|
||||
@@ -126,6 +126,10 @@ export interface MockAppApiOptions {
|
||||
username?: string;
|
||||
email?: string;
|
||||
roles?: string[];
|
||||
/** Spring role string (e.g. "ROLE_ADMIN") — drives `isAdmin` in the auth seam. */
|
||||
role?: string;
|
||||
/** Portal (Processor) access flag — gates the super search's Processor lanes. */
|
||||
portalAccess?: boolean;
|
||||
} | null;
|
||||
/** Languages advertised by `/config/app-config`. */
|
||||
languages?: string[];
|
||||
|
||||
@@ -12,10 +12,9 @@ test.describe("20. Edge Cases and Security", () => {
|
||||
test("should prevent XSS via search input", async ({ page }) => {
|
||||
await loginAndSetup(page);
|
||||
|
||||
// Step 1: Open the search box (the tool panel header shows a search
|
||||
// toggle; the field only mounts once it's pressed) and enter the payload
|
||||
await page.getByRole("button", { name: /search tools/i }).click();
|
||||
const searchBox = page.getByPlaceholder(/search|cari/i).first();
|
||||
// Step 1: Enter the payload into the always-mounted super search bar
|
||||
const searchBox = page.locator("#super-search-input");
|
||||
await searchBox.click();
|
||||
await searchBox.fill('"><img src=x onerror=alert(1)>');
|
||||
|
||||
// Step 2: Verify no script execution or image error handler fires
|
||||
|
||||
@@ -565,25 +565,6 @@ test.describe("Files page", () => {
|
||||
test.describe("Side-rail integration with /files", () => {
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("Rail Search focuses the central search field, no navigation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await gotoFilesPage(page);
|
||||
// Click the search row in the rail.
|
||||
await page.locator(".file-sidebar-search-row").click();
|
||||
// The central search input should be focused.
|
||||
const focused = await page.evaluate(
|
||||
() => document.activeElement?.getAttribute("aria-label") ?? "",
|
||||
);
|
||||
expect(focused).toMatch(/Search/i);
|
||||
// And we must still be on /files (i.e. didn't navigate home).
|
||||
await expect(page).toHaveURL(/\/files/);
|
||||
});
|
||||
|
||||
test("Rail New folder button visible on /files", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
|
||||
@@ -54,12 +54,11 @@ test.describe("13. Language / Localization", () => {
|
||||
// Step 5: Wait for page reload (language change triggers window.location.reload())
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
// Step 6: Verify the UI text is in English. The tool search is a
|
||||
// header toggle, so assert its English label rather than the field,
|
||||
// which only mounts once the toggle is pressed.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /search tools/i }).first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
// Step 6: Verify the UI text is in English via the always-mounted
|
||||
// super search bar's placeholder.
|
||||
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,14 +17,7 @@ test.describe("2. Main Dashboard / Home Page", () => {
|
||||
page.locator('[data-testid="config-button"]').first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Tool search sits behind a header toggle now, so assert the affordance
|
||||
// AND that pressing it actually mounts a usable search field — dropping
|
||||
// the second half would stop covering the input entirely.
|
||||
const searchToggle = page
|
||||
.getByRole("button", { name: /search tools/i })
|
||||
.first();
|
||||
await expect(searchToggle).toBeVisible();
|
||||
await searchToggle.click();
|
||||
// Tool search lives in the global super search bar, always mounted.
|
||||
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
|
||||
|
||||
await expect(
|
||||
@@ -82,10 +75,8 @@ test.describe("2. Main Dashboard / Home Page", () => {
|
||||
|
||||
await page.goto("/editor");
|
||||
|
||||
// Tool search is a header toggle; the field mounts only once pressed.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /search tools/i }).first(),
|
||||
).toBeVisible();
|
||||
// Tool search lives in the global super search bar, always mounted.
|
||||
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import { openSettings } from "@app/tests/helpers/ui-helpers";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Super search E2E: the bar itself (open, filter, select, show-more, XSS
|
||||
* hygiene) plus the access gating — a user without Processor access must see
|
||||
* no Processor chips, no Processor results, and trigger no entity fetches.
|
||||
*
|
||||
* Portal-lane presence differs by build: `vite dev` ships the portal, the CI
|
||||
* preview build does not. Gate-closed assertions hold in both (closed lanes
|
||||
* look identical to absent ones); gate-open lane assertions skip themselves
|
||||
* when the build ships no portal.
|
||||
*/
|
||||
|
||||
const INPUT = "#super-search-input";
|
||||
|
||||
/**
|
||||
* URLs only the search's Processor entity fetch hits. `/api/v1/policies`
|
||||
* itself is deliberately absent: the editor's policy auto-run also reads it at
|
||||
* boot for every user, so it can't distinguish a search leak.
|
||||
*/
|
||||
const ENTITY_API_PATTERN =
|
||||
/\/api\/v1\/policies\/overview|\/api\/v1\/sources|\/api\/v1\/proprietary\/ui-data\/admin-settings/;
|
||||
|
||||
async function openSearch(page: Page) {
|
||||
const input = page.locator(INPUT);
|
||||
await input.click();
|
||||
await expect(input).toHaveAttribute("aria-expanded", "true");
|
||||
return input;
|
||||
}
|
||||
|
||||
test.describe("Super search — bar basics", () => {
|
||||
test("Ctrl+K opens the bar and results filter as you type", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.locator(INPUT);
|
||||
// The shortcut listener mounts with the bar — wait for it before pressing.
|
||||
await expect(input).toBeVisible();
|
||||
await page.keyboard.press("Control+KeyK");
|
||||
await expect(input).toBeFocused();
|
||||
await expect(input).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
await input.fill("merge");
|
||||
await expect(
|
||||
page.getByRole("option", { name: /Merge/ }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// A different query replaces the results in place.
|
||||
await input.fill("compress");
|
||||
await expect(
|
||||
page.getByRole("option", { name: /Compress/ }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("option", { name: /^Merge/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("selecting a tool result opens that tool", async ({ page }) => {
|
||||
const input = await openSearch(page);
|
||||
await input.fill("merge");
|
||||
await page
|
||||
.getByRole("option", { name: /^Merge/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForURL("**/merge**");
|
||||
});
|
||||
|
||||
test("shows the empty state for a query with no matches", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = await openSearch(page);
|
||||
await input.fill("xyznonexistent123");
|
||||
await expect(page.getByText("No results found")).toBeVisible();
|
||||
});
|
||||
|
||||
test("treats markup in the query as plain text", async ({ page }) => {
|
||||
let dialogFired = false;
|
||||
page.on("dialog", (dialog) => {
|
||||
dialogFired = true;
|
||||
void dialog.dismiss();
|
||||
});
|
||||
|
||||
const input = await openSearch(page);
|
||||
const payload =
|
||||
'<img src=x onerror="window.__xss=1"><script>alert(1)</script>';
|
||||
await input.fill(payload);
|
||||
await expect(input).toHaveValue(payload);
|
||||
|
||||
await expect(page.getByText("No results found")).toBeVisible();
|
||||
expect(dialogFired).toBe(false);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => (window as unknown as { __xss?: number }).__xss,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("show more reveals the rest of a large group and collapses again", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = await openSearch(page);
|
||||
// Broad query — the tools lane alone exceeds the 5-row collapsed slice.
|
||||
// Assertions scope to the Tools group: other groups (docs, entities)
|
||||
// pop in asynchronously, so page-wide option counts are racy.
|
||||
await input.fill("pdf");
|
||||
const tools = page.getByRole("group", { name: "Tools" });
|
||||
await expect(tools.getByRole("option").first()).toBeVisible();
|
||||
|
||||
const collapsedCount = await tools.getByRole("option").count();
|
||||
const showMore = tools.getByRole("button", { name: /Show \d+ more/ });
|
||||
await expect(showMore).toBeVisible();
|
||||
const hidden = Number(
|
||||
(await showMore.innerText()).match(/\d+/)?.[0] ?? "0",
|
||||
);
|
||||
await showMore.click();
|
||||
|
||||
await expect(tools.getByRole("option")).toHaveCount(
|
||||
collapsedCount + hidden,
|
||||
);
|
||||
const showLess = tools.getByRole("button", { name: "Show less" });
|
||||
await expect(showLess).toBeVisible();
|
||||
|
||||
await showLess.click();
|
||||
await expect(tools.getByRole("option")).toHaveCount(collapsedCount);
|
||||
});
|
||||
|
||||
test("selecting a tool from the /files page leaves the file manager", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/files");
|
||||
const input = page.locator(INPUT).first();
|
||||
await expect(input).toBeVisible({ timeout: 15000 });
|
||||
await input.click();
|
||||
await input.fill("merge");
|
||||
await page
|
||||
.getByRole("option", { name: /^Merge/ })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// The selection pins its tool URL via raw history.pushState, which the
|
||||
// router never sees — the sink must leave /files through the router or
|
||||
// the myFiles workbench swallows the selection (and "/" is a role router
|
||||
// on processor-shipping builds, so the exit must target the editor home).
|
||||
await expect(page.locator(".files-page-header-search")).not.toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
await expect(page).toHaveURL(/\/merge/);
|
||||
});
|
||||
|
||||
test("Ctrl+K inside the settings modal closes it and focuses the bar", async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = page.locator(INPUT);
|
||||
await expect(input).toBeVisible();
|
||||
await openSettings(page);
|
||||
|
||||
// The modal traps focus, so the bar's own shortcut is inert; the modal
|
||||
// cedes: it closes itself and hands focus to the bar.
|
||||
await page.keyboard.press("Control+KeyK");
|
||||
await expect(page.locator(".modal-container")).not.toBeVisible();
|
||||
await expect(input).toBeFocused();
|
||||
await expect(input).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
// Full loop: a settings result deep-links straight back into the modal.
|
||||
await input.fill("general");
|
||||
await page
|
||||
.getByRole("option", { name: /General/ })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.locator(".modal-container")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Super search — user without Processor access", () => {
|
||||
// Login enabled, signed in as a plain member: no admin role, no portal
|
||||
// access. The Processor gate must stay closed.
|
||||
test.use({
|
||||
stubOptions: {
|
||||
enableLogin: true,
|
||||
user: {
|
||||
id: 33,
|
||||
username: "bob",
|
||||
email: "bob@example.com",
|
||||
role: "ROLE_USER",
|
||||
portalAccess: false,
|
||||
},
|
||||
},
|
||||
seedJwt: true,
|
||||
});
|
||||
|
||||
test("sees no Processor chips, results or entity fetches", async ({
|
||||
page,
|
||||
}) => {
|
||||
const entityRequests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (ENTITY_API_PATTERN.test(request.url())) {
|
||||
entityRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
|
||||
const input = await openSearch(page);
|
||||
|
||||
// Chip row: the editor lanes only. No Processor lanes of any kind.
|
||||
await expect(page.getByRole("button", { name: "Tools" })).toBeVisible();
|
||||
for (const lane of [
|
||||
"Pages",
|
||||
"Users",
|
||||
"Policies",
|
||||
"Pipelines",
|
||||
"Sources",
|
||||
"Docs",
|
||||
]) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: lane, exact: true }),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
|
||||
// A query that would hit policies/docs/admin settings when the gate is
|
||||
// open must yield no Processor section for this user.
|
||||
await input.fill("security");
|
||||
await expect(page.getByRole("option").first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator(".super-search-section-label", { hasText: "Processor" }),
|
||||
).toHaveCount(0);
|
||||
|
||||
// And the search must not have fetched any entity data on their behalf.
|
||||
await page.waitForTimeout(750);
|
||||
expect(entityRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("sees login-gated settings but no admin settings", async ({ page }) => {
|
||||
const input = await openSearch(page);
|
||||
|
||||
// Positive control — requiresLogin sections are visible to a signed-in
|
||||
// user, proving the settings lane itself works for this account.
|
||||
await input.fill("account");
|
||||
await expect(
|
||||
page.getByRole("option", { name: /Account Settings/ }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Admin-only sections stay hidden (label and content matches alike).
|
||||
await input.fill("endpoints");
|
||||
await expect(page.getByRole("option", { name: /^Endpoints/ })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Super search — portal access without admin", () => {
|
||||
// Self-hosted grants portal access beyond admins (team owners, ACL
|
||||
// grantees), but the users roster endpoint is admin-only — the Users lane
|
||||
// must not be offered to a session the endpoint would always refuse.
|
||||
test.use({
|
||||
stubOptions: {
|
||||
enableLogin: true,
|
||||
user: {
|
||||
id: 44,
|
||||
username: "owner",
|
||||
email: "owner@example.com",
|
||||
role: "ROLE_USER",
|
||||
portalAccess: true,
|
||||
},
|
||||
},
|
||||
seedJwt: true,
|
||||
});
|
||||
|
||||
test("sees Processor lanes but no Users chip and no roster fetch", async ({
|
||||
page,
|
||||
}) => {
|
||||
const rosterRequests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (
|
||||
request.url().includes("/api/v1/proprietary/ui-data/admin-settings")
|
||||
) {
|
||||
rosterRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
for (const [pattern, json] of [
|
||||
["**/api/v1/policies", []],
|
||||
["**/api/v1/policies/runs", []],
|
||||
["**/api/v1/policies/overview", { pipelines: [] }],
|
||||
["**/api/v1/sources", { sources: [] }],
|
||||
] as const) {
|
||||
await page.route(pattern, (route) => route.fulfill({ json }));
|
||||
}
|
||||
|
||||
const input = await openSearch(page);
|
||||
const portalShips =
|
||||
(await page.getByRole("button", { name: "Pages", exact: true }).count()) >
|
||||
0;
|
||||
test.skip(!portalShips, "this build ships no portal — no lanes to gate");
|
||||
|
||||
for (const lane of ["Policies", "Pipelines", "Sources"]) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: lane, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Users", exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
// A query that used to fire the doomed roster fetch once per TTL.
|
||||
await input.fill("admin");
|
||||
await expect(page.locator(".super-search-dropdown").first()).toBeVisible();
|
||||
await page.waitForTimeout(1500);
|
||||
expect(rosterRequests).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Super search — admin with Processor access", () => {
|
||||
test.use({
|
||||
stubOptions: {
|
||||
enableLogin: true,
|
||||
isAdmin: true,
|
||||
user: {
|
||||
id: 1,
|
||||
username: "admin",
|
||||
email: "admin@example.com",
|
||||
role: "ROLE_ADMIN",
|
||||
portalAccess: true,
|
||||
},
|
||||
},
|
||||
seedJwt: true,
|
||||
});
|
||||
|
||||
test("sees Processor lanes and live entity results", async ({ page }) => {
|
||||
// Entity data the gate-open bar fetches, stubbed with one source row.
|
||||
await page.route("**/api/v1/policies", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
await page.route("**/api/v1/policies/runs", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
await page.route("**/api/v1/policies/overview", (route) =>
|
||||
route.fulfill({ json: { pipelines: [] } }),
|
||||
);
|
||||
await page.route("**/api/v1/sources", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
sources: [{ id: "src-1", name: "Contract Intake", type: "email" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await openSearch(page);
|
||||
|
||||
// The Processor lanes only exist in builds that ship the portal (dev,
|
||||
// VITE_INCLUDE_PORTAL) — the CI preview build has none to show.
|
||||
const portalShips =
|
||||
(await page.getByRole("button", { name: "Pages", exact: true }).count()) >
|
||||
0;
|
||||
test.skip(!portalShips, "this build ships no portal — no lanes to gate");
|
||||
|
||||
for (const lane of ["Users", "Policies", "Pipelines", "Sources"]) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: lane, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
const input = page.locator(INPUT);
|
||||
await input.fill("contract intake");
|
||||
await expect(
|
||||
page.getByRole("option", { name: /Contract Intake/ }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator(".super-search-section-label", { hasText: "Processor" })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Portal bar — tool results hop into the editor", () => {
|
||||
test.use({
|
||||
stubOptions: {
|
||||
enableLogin: true,
|
||||
isAdmin: true,
|
||||
user: {
|
||||
id: 1,
|
||||
username: "admin",
|
||||
email: "admin@example.com",
|
||||
role: "ROLE_ADMIN",
|
||||
portalAccess: true,
|
||||
},
|
||||
},
|
||||
seedJwt: true,
|
||||
autoGoto: false,
|
||||
});
|
||||
|
||||
test("selecting a tool routes client-side, not via a full page load", async ({
|
||||
page,
|
||||
}) => {
|
||||
for (const [pattern, json] of [
|
||||
["**/api/v1/policies", []],
|
||||
["**/api/v1/policies/runs", []],
|
||||
["**/api/v1/policies/overview", { pipelines: [] }],
|
||||
["**/api/v1/sources", { sources: [] }],
|
||||
["**/api/v1/team/my", []],
|
||||
] as const) {
|
||||
await page.route(pattern, (route) => route.fulfill({ json }));
|
||||
}
|
||||
|
||||
await page.goto("/processor");
|
||||
const input = page.locator("#portal-search-input");
|
||||
// The portal only ships in dev / VITE_INCLUDE_PORTAL builds — on the CI
|
||||
// preview build /processor falls through to the editor and there is no
|
||||
// portal bar to hop from.
|
||||
const portalShips = await input
|
||||
.waitFor({ state: "visible", timeout: 20000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
test.skip(!portalShips, "this build ships no portal — no bar to hop from");
|
||||
|
||||
// A full page load would drop this marker — and on bundled deploys it
|
||||
// would also 401: document GETs carry no Authorization header, so the
|
||||
// backend bounces them to /login even with a live session.
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { __spaMarker?: boolean }).__spaMarker = true;
|
||||
});
|
||||
|
||||
await input.click();
|
||||
await input.fill("merge");
|
||||
await page
|
||||
.getByRole("option", { name: /^Merge/ })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/merge/);
|
||||
// Editor mounted: its own search bar replaces the portal's.
|
||||
await expect(page.locator("#super-search-input")).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
// The editor's URL-driven tool init must run on this route-swap mount,
|
||||
// not just on a cold page load: the Merge tool panel actually opens.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Merge", exact: true }),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => (window as unknown as { __spaMarker?: boolean }).__spaMarker,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
|
||||
/**
|
||||
* The tool panel header shows a search *toggle*; the field only mounts once
|
||||
* it's pressed. Open it and hand back the focused input.
|
||||
*/
|
||||
async function openToolSearch(page: Page) {
|
||||
await page.getByRole("button", { name: /search tools/i }).click();
|
||||
const searchBox = page.getByPlaceholder(/search|cari/i).first();
|
||||
await expect(searchBox).toBeVisible({ timeout: 5000 });
|
||||
return searchBox;
|
||||
}
|
||||
|
||||
test.describe("3. Tool Search", () => {
|
||||
test.describe("3.1 Search - Happy Path", () => {
|
||||
test("should filter tools in real time based on search input", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Step 1: Open the search box from the header toggle
|
||||
const searchBox = await openToolSearch(page);
|
||||
|
||||
// Step 2: Type "merge"
|
||||
await searchBox.fill("merge");
|
||||
|
||||
// Step 3: Verify search results filter to show relevant tools
|
||||
await expect(
|
||||
page.locator("text=/merge|menggabungkan/i").first(),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Step 5: Clear the search field
|
||||
await searchBox.clear();
|
||||
|
||||
// Step 6: Verify all tools reappear (check for multiple categories)
|
||||
await expect(
|
||||
page.locator("text=/recommended|direkomendasikan/i").first(),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("3.2 Search - No Results", () => {
|
||||
test("should handle queries with no matching tools gracefully", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Step 1: Open the search box from the header toggle
|
||||
const searchBox = await openToolSearch(page);
|
||||
|
||||
// Step 2: Type xyznonexistent123
|
||||
await searchBox.fill("xyznonexistent123");
|
||||
|
||||
// Step 3: Verify the search field accepted the input
|
||||
await expect(searchBox).toHaveValue("xyznonexistent123");
|
||||
|
||||
// The app uses fuzzy search with a fallback that shows all tools when nothing
|
||||
// matches, so we verify the search state is active (no "recommended" section)
|
||||
// and the page remains functional without errors.
|
||||
await expect(
|
||||
page.locator("text=/recommended|direkomendasikan/i"),
|
||||
).toHaveCount(0, { timeout: 5000 });
|
||||
|
||||
// Step 4: Clear the search field
|
||||
await searchBox.clear();
|
||||
|
||||
// Step 5: Verify all tools reappear (recommended section comes back)
|
||||
await expect(
|
||||
page.locator("text=/recommended|direkomendasikan/i").first(),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("3.3 Search - Special Characters", () => {
|
||||
test("should sanitize search input against XSS", async ({ page }) => {
|
||||
// Step 1: Type XSS payload into the search box
|
||||
const searchBox = await openToolSearch(page);
|
||||
await searchBox.fill("<script>alert(1)</script>");
|
||||
|
||||
// Step 2: Verify no script execution occurs (no alert dialog)
|
||||
// If an alert appeared, Playwright would throw an unhandled dialog error
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Step 3: Verify the search treats the input as plain text
|
||||
await expect(searchBox).toHaveValue("<script>alert(1)</script>");
|
||||
|
||||
// Step 4: Clear the search field
|
||||
await searchBox.clear();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import type React from "react";
|
||||
|
||||
/**
|
||||
* The super search's shared contract: what a results provider returns and the
|
||||
* shapes hosts exchange with the shared rankers. Pure types, kept apart from
|
||||
* the provider implementations so leaf modules (flavor seams, the portal's
|
||||
* entity search) can depend on the contract without importing a provider —
|
||||
* which would be a circular import.
|
||||
*/
|
||||
|
||||
export type SuperSearchGroupId = "files" | "tools" | "settings" | "processor";
|
||||
|
||||
export interface SuperSearchResult {
|
||||
/** Stable unique key across all groups. */
|
||||
key: string;
|
||||
/** Group id — the editor uses SuperSearchGroupId; other hosts use their own. */
|
||||
group: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** LocalIcon name (files/settings); tools provide a React node via `icon`. */
|
||||
iconName?: string;
|
||||
icon?: React.ReactNode;
|
||||
score: number;
|
||||
onSelect: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface SuperSearchGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Optional higher-level section label rendered above consecutive groups. */
|
||||
sectionLabel?: string;
|
||||
results: SuperSearchResult[];
|
||||
}
|
||||
|
||||
export interface SuperSearchScope {
|
||||
id: string;
|
||||
label: string;
|
||||
aliases?: string[];
|
||||
}
|
||||
|
||||
export interface UseSuperSearchResult {
|
||||
/** Non-empty groups, in display order. */
|
||||
groups: SuperSearchGroup[];
|
||||
/** All results flattened in display order (for keyboard navigation). */
|
||||
flatResults: SuperSearchResult[];
|
||||
/** True while the My Files store is loading for the first time. */
|
||||
loadingFiles: boolean;
|
||||
}
|
||||
|
||||
export interface SuperSearchQueryOptions {
|
||||
scopeIds?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Visibility gates shared by the settings and Processor sources. Hosts pass
|
||||
* `null` while the app config is still loading — the rankers treat that as
|
||||
* "most restrictive" so gated results can appear once config lands but never
|
||||
* flash open before it.
|
||||
*/
|
||||
export interface SuperSearchGates {
|
||||
isAdmin: boolean;
|
||||
loginEnabled: boolean;
|
||||
portalAccessible?: boolean;
|
||||
/**
|
||||
* Whether no-login mode keeps the read-only admin settings preview
|
||||
* (`system.showSettingsWhenNoLogin`, default true). Mirrors the settings
|
||||
* nav builder's admin gate: `isAdmin || (!loginEnabled && this)`.
|
||||
*/
|
||||
showSettingsWhenNoLogin?: boolean;
|
||||
/**
|
||||
* True for a signed-out (anonymous) session. Gates account-bound sections
|
||||
* on hosts where auth state, not the local backend config, is the truth
|
||||
* (SaaS). Hosts whose users are always signed in may omit it.
|
||||
*/
|
||||
isAnonymous?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Processor's entity lanes as scope definitions — ids, the portal view
|
||||
* each targets, chip labels and typed-prefix aliases. Both hosts' chip lists
|
||||
* and the portal's entity module derive from this one list, so the chips
|
||||
* can't drift apart; it lives in the types leaf because the editor's chip
|
||||
* list must not import portal code.
|
||||
*/
|
||||
export interface PortalEntityScopeDef {
|
||||
id:
|
||||
| "portal-users"
|
||||
| "portal-policies"
|
||||
| "portal-pipelines"
|
||||
| "portal-sources";
|
||||
/** Portal view id the scope targets (visibility check vs the page index). */
|
||||
viewId: string;
|
||||
labelKey: string;
|
||||
labelFallback: string;
|
||||
aliases: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The developer-docs scope. Separate from the entity defs above: docs are a
|
||||
* bundled full-text manifest, not a fetched entity list, so they don't ride
|
||||
* the fetch cache — but they get a chip and a results group like the rest.
|
||||
*/
|
||||
export const PORTAL_DOCS_SCOPE_ID = "portal-docs";
|
||||
|
||||
export const PORTAL_ENTITY_SCOPE_DEFS: readonly PortalEntityScopeDef[] = [
|
||||
{
|
||||
id: "portal-users",
|
||||
viewId: "users",
|
||||
labelKey: "portal.nav.users",
|
||||
labelFallback: "Users",
|
||||
aliases: ["user", "users", "member", "members"],
|
||||
},
|
||||
{
|
||||
id: "portal-policies",
|
||||
viewId: "policies",
|
||||
labelKey: "portal.nav.policies",
|
||||
labelFallback: "Policies",
|
||||
aliases: ["policy", "policies"],
|
||||
},
|
||||
{
|
||||
id: "portal-pipelines",
|
||||
viewId: "pipelines",
|
||||
labelKey: "portal.nav.pipelines",
|
||||
labelFallback: "Pipelines",
|
||||
aliases: ["pipeline", "pipelines"],
|
||||
},
|
||||
{
|
||||
id: "portal-sources",
|
||||
viewId: "sources",
|
||||
labelKey: "portal.nav.sources",
|
||||
labelFallback: "Sources",
|
||||
aliases: ["source", "sources"],
|
||||
},
|
||||
];
|
||||
@@ -81,9 +81,14 @@ export function scoreMatch(queryRaw: string, targetRaw: string): number {
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Lowest score any fuzzy match can pass with (see minScoreForQuery). Rankers
|
||||
* that mix fuzzy results with fixed-score entries key off this to stay below
|
||||
* every real match. */
|
||||
export const FUZZY_MIN_SCORE = 30;
|
||||
|
||||
export function minScoreForQuery(query: string): number {
|
||||
const len = normalizeText(query).length;
|
||||
return len <= 3 ? 40 : 30;
|
||||
return len <= 3 ? 40 : FUZZY_MIN_SCORE;
|
||||
}
|
||||
|
||||
// Decide if a target matches a query based on a threshold
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Guarded client-side navigation for URLs that arrive as data (tool registry
|
||||
* links, search index entries, flavor config) rather than literals. The
|
||||
* values are trusted today, but funnelling them through a scheme allowlist
|
||||
* means a poisoned or mistyped entry (`javascript:…`) can never become a
|
||||
* script-execution sink.
|
||||
*/
|
||||
|
||||
const SAFE_PROTOCOLS = new Set(["http:", "https:"]);
|
||||
|
||||
/** The URL resolved against the current origin, or null if unparseable or a
|
||||
* non-web scheme (javascript:, data:, …). */
|
||||
export function toSafeWebUrl(url: string): URL | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url, window.location.origin);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return SAFE_PROTOCOLS.has(parsed.protocol) ? parsed : null;
|
||||
}
|
||||
|
||||
/** window.open in a new tab, dropped silently for non-web schemes. */
|
||||
export function openExternalUrl(url: string): void {
|
||||
const safe = toSafeWebUrl(url);
|
||||
if (safe) window.open(safe.href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-page navigation (window.location), dropped for non-web schemes. The
|
||||
* guard is scheme-only: cross-origin http(s) targets pass by design (a
|
||||
* separately-hosted editor is configured build-time via VITE_EDITOR_URL), so
|
||||
* callers must pass build/config values — never user-supplied input, which
|
||||
* could redirect anywhere on the web.
|
||||
*/
|
||||
export function assignLocation(url: string): void {
|
||||
const safe = toSafeWebUrl(url);
|
||||
if (safe) window.location.assign(safe.href);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Desktop inherits proprietary's app but must NOT ship the portal (see the
|
||||
* admin-route seam) — shadow the entity search back to core's empty stub so
|
||||
* the super search never fetches or offers Processor entities on desktop.
|
||||
*/
|
||||
export { useProcessorEntityGroups } from "@core/data/processorEntitySearch";
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 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,
|
||||
isPortalEntityScopeAccessible,
|
||||
} from "@core/data/processorSearchIndex";
|
||||
@@ -0,0 +1,50 @@
|
||||
import { type SettingsSectionEntry } from "@core/data/settingsSectionRegistry";
|
||||
|
||||
export type { SettingsSectionEntry };
|
||||
|
||||
/**
|
||||
* Desktop settings sections. The desktop modal reflows heavily by connection
|
||||
* mode (`configNavSections`): local mode shows only Preferences + Connection
|
||||
* Mode + Legal, while SaaS mode swaps in the cloud Plan/Team sections and hides
|
||||
* the self-hosted admin area. To stay safe across both modes without threading
|
||||
* the (async) connection state into the always-mounted search, this lists only
|
||||
* the sections guaranteed to render in every desktop mode.
|
||||
*
|
||||
* Cloud Plan/Team search on desktop-SaaS is a deliberate Tier-0 gap, not a
|
||||
* regression — better to omit them than to deep-link to a dead tab in local
|
||||
* mode.
|
||||
*/
|
||||
export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
|
||||
{
|
||||
key: "general",
|
||||
labelKey: "settings.general.title",
|
||||
labelFallback: "General",
|
||||
keywords: ["theme", "language", "appearance", "preferences", "startup"],
|
||||
groupLabelKey: "settings.preferences.title",
|
||||
groupLabelFallback: "Preferences",
|
||||
},
|
||||
{
|
||||
key: "hotkeys",
|
||||
labelKey: "settings.hotkeys.title",
|
||||
labelFallback: "Keyboard Shortcuts",
|
||||
keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
|
||||
groupLabelKey: "settings.preferences.title",
|
||||
groupLabelFallback: "Preferences",
|
||||
},
|
||||
{
|
||||
key: "connectionMode",
|
||||
labelKey: "settings.connection.title",
|
||||
labelFallback: "Connection Mode",
|
||||
keywords: ["connection", "local", "cloud", "server", "sign in"],
|
||||
groupLabelKey: "settings.connection.title",
|
||||
groupLabelFallback: "Connection Mode",
|
||||
},
|
||||
{
|
||||
key: "legal",
|
||||
labelKey: "settings.legal.label",
|
||||
labelFallback: "Legal",
|
||||
keywords: ["legal", "terms", "privacy", "licenses"],
|
||||
groupLabelKey: "settings.legal.title",
|
||||
groupLabelFallback: "Legal",
|
||||
},
|
||||
];
|
||||
@@ -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");
|
||||
}
|
||||
@@ -56,4 +56,12 @@ export interface UsersCapabilities {
|
||||
manageGrants: boolean;
|
||||
/** Whether "remove" takes the member out of the whole org or just the team. */
|
||||
removeScope: "org" | "team";
|
||||
/**
|
||||
* Listing the roster needs the org-admin role. Self-hosted reads the
|
||||
* admin-only endpoints (`@PreAuthorize(hasRole('ADMIN'))`), which refuse
|
||||
* the non-admin sessions portal access also admits (team owners, ACL
|
||||
* grantees). SaaS lists through the team-leader endpoints, which every
|
||||
* portal-eligible session can call.
|
||||
*/
|
||||
listingRequiresAdmin: boolean;
|
||||
}
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0 0.625rem;
|
||||
background: var(--color-sidebar-bg);
|
||||
border-bottom: 1px solid var(--color-sidebar-border);
|
||||
background: var(--c-bg-raised);
|
||||
border-bottom: 1px solid var(--c-border-subtle);
|
||||
}
|
||||
|
||||
.portal-shell__topbar-wordmark {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
|
||||
import { AppShell } from "@portal/components/AppShell";
|
||||
import { Home } from "@portal/views/Home";
|
||||
|
||||
@@ -12,6 +13,14 @@ const meta: Meta<typeof AppShell> = {
|
||||
title: "Portal/Shell/AppShell",
|
||||
component: AppShell,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [
|
||||
// The shell hosts the portal search bar, which reads the tool registry.
|
||||
(Story) => (
|
||||
<ToolRegistryProvider>
|
||||
<Story />
|
||||
</ToolRegistryProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AppShell>;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { ActionIcon } from "@app/ui";
|
||||
import { Sidebar } from "@portal/components/Sidebar";
|
||||
import { PortalSearchBar } from "@portal/components/PortalSearchBar";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { MenuIcon, SearchIcon } from "@portal/components/icons";
|
||||
import { Logo } from "@app/ui/Logo";
|
||||
@@ -10,12 +11,12 @@ import "@portal/components/AppShell.css";
|
||||
|
||||
/**
|
||||
* Compact header shown only under the mobile breakpoint (CSS-hidden on
|
||||
* desktop): hamburger opens the sidebar drawer, search opens the palette
|
||||
* (there's no ⌘K on a phone).
|
||||
* desktop): hamburger opens the sidebar drawer, search focuses the global
|
||||
* search bar below (there's no ⌘K on a phone).
|
||||
*/
|
||||
function MobileTopbar() {
|
||||
const { t } = useTranslation();
|
||||
const { mobileNavOpen, toggleMobileNav, openSearch } = useUI();
|
||||
const { mobileNavOpen, toggleMobileNav, closeMobileNav } = useUI();
|
||||
return (
|
||||
<header className="portal-shell__topbar">
|
||||
<ActionIcon
|
||||
@@ -37,7 +38,10 @@ function MobileTopbar() {
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
aria-label={t("portal.shell.topbar.search")}
|
||||
onClick={openSearch}
|
||||
onClick={() => {
|
||||
closeMobileNav();
|
||||
document.getElementById("portal-search-input")?.focus();
|
||||
}}
|
||||
>
|
||||
<SearchIcon size={19} />
|
||||
</ActionIcon>
|
||||
@@ -47,9 +51,10 @@ function MobileTopbar() {
|
||||
|
||||
/**
|
||||
* Two-column layout: fixed-width sidebar on the left, a scrolling main column on
|
||||
* the right. Under the mobile breakpoint the sidebar becomes an off-canvas
|
||||
* drawer behind a scrim, opened from the topbar hamburger. The Sidebar reads
|
||||
* its state from context, so this shell stays prop-free.
|
||||
* the right (topped by the global search bar). Under the mobile breakpoint the
|
||||
* sidebar becomes an off-canvas drawer behind a scrim, opened from the topbar
|
||||
* hamburger. The Sidebar reads its state from context, so this shell stays
|
||||
* prop-free.
|
||||
*/
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const { mobileNavOpen, closeMobileNav } = useUI();
|
||||
@@ -83,6 +88,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
)}
|
||||
<div className="portal-shell__main">
|
||||
<MobileTopbar />
|
||||
<PortalSearchBar />
|
||||
<main className="portal-shell__view">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,39 +1,11 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
|
||||
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { AppShell } from "@portal/components/AppShell";
|
||||
import { SearchModal } from "@portal/components/SearchModal";
|
||||
import { PortalSettingsHost } from "@portal/components/PortalSettingsHost";
|
||||
import { ViewRouter } from "@portal/ViewRouter";
|
||||
|
||||
/**
|
||||
* Global keyboard shortcuts. Lives below the UIProvider so it can dispatch into
|
||||
* the overlay state. Currently just ⌘K / Ctrl+K to toggle the search palette.
|
||||
*/
|
||||
function GlobalShortcuts() {
|
||||
const { toggleSearch, closeSearch } = useUI();
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
|
||||
if (isCmdK) {
|
||||
e.preventDefault();
|
||||
toggleSearch();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
closeSearch();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [toggleSearch, closeSearch]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The routed view, wrapped in an error boundary so a single view crashing can't
|
||||
* white-screen the portal (the shell + nav stay alive). Keyed by route so
|
||||
@@ -49,24 +21,24 @@ function RoutedContent() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The flavor-agnostic portal chrome: the shell (sidebar + header + routed view)
|
||||
* plus the global overlays that every flavor shares. Requires only the Tier and
|
||||
* UI contexts above it — both flavors provide those. Flavor-specific overlays
|
||||
* (e.g. the self-hosted account-link modal) are mounted by PortalProviders, not
|
||||
* here.
|
||||
* The flavor-agnostic portal chrome: the shell (sidebar + search bar + routed
|
||||
* view) plus the global overlays that every flavor shares. Requires only the
|
||||
* Tier and UI contexts above it — both flavors provide those. Flavor-specific
|
||||
* overlays (e.g. the self-hosted account-link modal) are mounted by
|
||||
* PortalProviders, not here.
|
||||
*/
|
||||
export function PortalChrome() {
|
||||
return (
|
||||
<>
|
||||
<GlobalShortcuts />
|
||||
// One app-config instance for every portal consumer (search gates, the
|
||||
// settings modal) so they can't fetch twice or disagree.
|
||||
<AppConfigProvider bootstrapMode="non-blocking">
|
||||
{/* The pipeline builder reads the tool registry to list and configure operations. */}
|
||||
<ToolRegistryProvider>
|
||||
<AppShell>
|
||||
<RoutedContent />
|
||||
</AppShell>
|
||||
</ToolRegistryProvider>
|
||||
<SearchModal />
|
||||
<PortalSettingsHost />
|
||||
</>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/* Slim strip at the top of the main column hosting the shared search bar.
|
||||
Deliberately unpainted: only the input itself shows, on the page ground. */
|
||||
.portal-searchbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.22rem 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.portal-searchbar .super-search {
|
||||
flex: 0 1 24rem;
|
||||
width: min(100%, 24rem);
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.portal-searchbar .super-search input {
|
||||
background-color: transparent;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* The dropdown is portalled to <body>, so it can't inherit portal styling by
|
||||
nesting — this class (passed via dropdownClassName) is the portal's hook.
|
||||
Colours come from the shared semantic tokens; only the roomier padding
|
||||
differs from the editor dropdown. */
|
||||
.portal-search-dropdown.super-search-dropdown {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
|
||||
import {
|
||||
usePortalSearchResults,
|
||||
usePortalSearchScopes,
|
||||
} from "@portal/hooks/usePortalSearchResults";
|
||||
import "@portal/components/PortalSearchBar.css";
|
||||
|
||||
/**
|
||||
* The portal face of the global super search — the same bar the editor's
|
||||
* workbench shows, fed by the portal-wired results provider. Cmd/Ctrl+K
|
||||
* focuses it (the bar registers its own shortcut). App config (the
|
||||
* admin/login gates) comes from PortalChrome's shared provider. The distinct
|
||||
* input id keeps this instance clear of the editor bar's stable id, which
|
||||
* external focus helpers target.
|
||||
*/
|
||||
export function PortalSearchBar() {
|
||||
const scopes = usePortalSearchScopes();
|
||||
|
||||
return (
|
||||
<div className="portal-searchbar">
|
||||
<SuperSearch
|
||||
useResults={usePortalSearchResults}
|
||||
inputId="portal-search-input"
|
||||
scopes={scopes}
|
||||
dropdownClassName="portal-search-dropdown"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,22 +13,38 @@ import {
|
||||
import { accountLinkSettings } from "@portal/components/settings/accountLinkSettings";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
|
||||
/**
|
||||
* Settings sections the portal cannot host. Shared with the portal's search
|
||||
* provider so these never surface as search results either.
|
||||
*
|
||||
* TODO: opening Keyboard Shortcuts in the portal white-screens the app (the
|
||||
* section expects editor-only context). Hidden here as a stopgap; fix the
|
||||
* section properly and drop this.
|
||||
*/
|
||||
export const PORTAL_HIDDEN_SECTION_KEYS: NavKey[] = ["hotkeys"];
|
||||
|
||||
/**
|
||||
* Mounts the editor's settings modal (the app-wide settings surface) inside the
|
||||
* portal. The portal deliberately lives outside the editor's AppProviders, so
|
||||
* this host supplies the contexts the settings tree needs: app config, user
|
||||
* preferences, the session provider the account sections read (flavor-resolved:
|
||||
* Spring on self-hosted, Supabase on SaaS — same underlying session the portal
|
||||
* is already signed in with), and the editor ThemeProvider (which also carries
|
||||
* the Mantine theme + toasts the sections expect). URL sync is off — the portal
|
||||
* owns its own route subtree, so the modal keeps its section purely in state.
|
||||
* this host supplies the contexts the settings tree needs: user preferences,
|
||||
* the session provider the account sections read (flavor-resolved: Spring on
|
||||
* self-hosted, Supabase on SaaS — same underlying session the portal is
|
||||
* already signed in with), and the editor ThemeProvider (which also carries
|
||||
* the Mantine theme + toasts the sections expect). App config comes from
|
||||
* PortalChrome's shared provider. URL sync is off — the portal owns its own
|
||||
* route subtree, so the modal keeps its section purely in state.
|
||||
*
|
||||
* Everything (providers included) mounts on first open and stays mounted, so
|
||||
* the editor theme wiring never runs for portal sessions that never open
|
||||
* settings.
|
||||
*/
|
||||
export function PortalSettingsHost() {
|
||||
const { settingsOpen, settingsInitialSection, closeSettings } = useUI();
|
||||
const {
|
||||
settingsOpen,
|
||||
settingsInitialSection,
|
||||
settingsInitialFocus,
|
||||
closeSettings,
|
||||
} = useUI();
|
||||
const { t } = useTranslation();
|
||||
const [everOpened, setEverOpened] = useState(false);
|
||||
|
||||
@@ -74,11 +90,9 @@ export function PortalSettingsHost() {
|
||||
onClose={closeSettings}
|
||||
urlSync={false}
|
||||
initialSection={initialSection}
|
||||
initialFocus={settingsInitialFocus}
|
||||
extraSections={extraSections}
|
||||
// TODO: opening Keyboard Shortcuts in the portal white-screens
|
||||
// the app (the section expects editor-only context). Hidden here
|
||||
// as a stopgap; fix the section properly and drop this.
|
||||
hiddenSectionKeys={["hotkeys"]}
|
||||
hiddenSectionKeys={PORTAL_HIDDEN_SECTION_KEYS}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</PreferencesProvider>
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
.portal-search__input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.25rem 0.25rem 0.75rem;
|
||||
border-bottom: 1px solid var(--c-border-subtle);
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
.portal-search__input {
|
||||
flex: 1 1 auto;
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.portal-search__input::placeholder {
|
||||
color: var(--color-text-placeholder);
|
||||
}
|
||||
|
||||
.portal-search__esc {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--c-surface-sunken);
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
.portal-search__results {
|
||||
padding-top: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-height: 24rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.portal-search__group-label {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-section-label);
|
||||
padding: 0 0.5rem 0.375rem;
|
||||
}
|
||||
|
||||
.portal-search__item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--c-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.portal-search__item:hover {
|
||||
background: var(--c-hover);
|
||||
}
|
||||
|
||||
.portal-search__item-hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useEffect } from "react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { SearchModal } from "@portal/components/SearchModal";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
|
||||
function ForceOpen() {
|
||||
const { openSearch } = useUI();
|
||||
useEffect(() => {
|
||||
openSearch();
|
||||
}, [openSearch]);
|
||||
return null;
|
||||
}
|
||||
|
||||
const meta: Meta<typeof SearchModal> = {
|
||||
title: "Portal/Header/SearchModal",
|
||||
component: SearchModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ minHeight: "100vh", background: "var(--c-bg)" }}>
|
||||
<ForceOpen />
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SearchModal>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const EmptyCatalogue: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("/v1/search/quick-actions", () => HttpResponse.json([])),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, EmptyState, Modal, Skeleton } from "@app/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import { fetchQuickActions, type QuickAction } from "@portal/api/search";
|
||||
import { SearchIcon } from "@portal/components/icons";
|
||||
import "@portal/components/SearchModal.css";
|
||||
|
||||
export function SearchModal() {
|
||||
const { t } = useTranslation();
|
||||
const { searchOpen, closeSearch } = useUI();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const state = useAsync<QuickAction[]>(() => fetchQuickActions(), []);
|
||||
const { data: actions } = state;
|
||||
const { isLoading } = useSectionFlags(state);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchOpen) {
|
||||
setQuery("");
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
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],
|
||||
);
|
||||
|
||||
const groupKeys = Object.keys(groups);
|
||||
const isEmpty = !isLoading && groupKeys.length === 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={searchOpen}
|
||||
onClose={closeSearch}
|
||||
width="lg"
|
||||
ariaLabel={t("portal.search.ariaLabel")}
|
||||
>
|
||||
<div className="portal-search">
|
||||
<div className="portal-search__input-row">
|
||||
<SearchIcon size={16} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("portal.search.placeholder")}
|
||||
aria-label={t("portal.search.ariaLabel")}
|
||||
className="portal-search__input"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<span className="portal-search__esc" aria-hidden>
|
||||
ESC
|
||||
</span>
|
||||
</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 && (
|
||||
<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")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import type { DocsNavSection } from "@portal/api/docs";
|
||||
* path ("functionality/security" is a child of "functionality"), so sub-sections
|
||||
* nest under their parent. The root "Overview" section is static (always open, no
|
||||
* toggle); every other section collapses, and only the branch leading to the
|
||||
* active doc opens by default. (Search lives in DocsSearch above this.)
|
||||
* active doc opens by default. (Full-text docs search lives in the global
|
||||
* super search.)
|
||||
*/
|
||||
|
||||
// Matches the generator's ROOT_SECTION_ID: the intro section is never collapsible.
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
import type { SearchResult, Segment } from "@portal/docs/search";
|
||||
|
||||
/** Render highlighted segments, wrapping matched runs in <mark>. */
|
||||
function Highlighted({ segments }: { segments: Segment[] }) {
|
||||
return (
|
||||
<>
|
||||
{segments.map((s, i) =>
|
||||
s.hit ? (
|
||||
<mark key={i} className="portal-docs__hl">
|
||||
{s.text}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>{s.text}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Docs search box + results. While a query is active it shows a ranked list of
|
||||
* matching docs — each with its section, a highlighted title, and a content
|
||||
* snippet — that navigates on click (or Enter). Arrow keys move the selection.
|
||||
*/
|
||||
export function DocsSearch({
|
||||
query,
|
||||
onQueryChange,
|
||||
results,
|
||||
onSelect,
|
||||
}: {
|
||||
query: string;
|
||||
onQueryChange: (q: string) => void;
|
||||
results: SearchResult[];
|
||||
onSelect: (docId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// -1 = nothing pre-selected; arrow keys drive this, the mouse uses CSS :hover.
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
const hasQuery = query.trim().length > 0;
|
||||
|
||||
useEffect(() => setActiveIndex(-1), [query]);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current
|
||||
?.querySelector<HTMLElement>('[data-active="true"]')
|
||||
?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [activeIndex]);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
onQueryChange("");
|
||||
return;
|
||||
}
|
||||
if (!results.length) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const hit = results[activeIndex >= 0 ? activeIndex : 0];
|
||||
if (hit) onSelect(hit.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="portal-docs__search">
|
||||
<div className="portal-docs__search-box">
|
||||
<span className="portal-docs__search-icon" aria-hidden>
|
||||
⌕
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
className="portal-docs__search-input"
|
||||
placeholder={t("portal.docs.search.placeholder")}
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
aria-label={t("portal.docs.search.placeholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasQuery && (
|
||||
<div className="portal-docs__results">
|
||||
{results.length === 0 ? (
|
||||
<p className="portal-docs__nav-empty">
|
||||
{t("portal.docs.search.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="portal-docs__results-count">
|
||||
{t("portal.docs.search.results", { count: results.length })}
|
||||
</div>
|
||||
<ul ref={listRef} className="portal-docs__results-list">
|
||||
{results.map((r, i) => (
|
||||
<li key={r.id}>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
fullWidth
|
||||
justify="start"
|
||||
className={
|
||||
"portal-docs__result" +
|
||||
(i === activeIndex ? " is-active" : "")
|
||||
}
|
||||
data-active={i === activeIndex}
|
||||
onClick={() => onSelect(r.id)}
|
||||
>
|
||||
<span className="portal-docs__result-body">
|
||||
<span className="portal-docs__result-head">
|
||||
<span className="portal-docs__result-title">
|
||||
<Highlighted segments={r.titleSegments} />
|
||||
</span>
|
||||
<span className="portal-docs__result-section">
|
||||
{r.sectionLabel}
|
||||
</span>
|
||||
</span>
|
||||
<span className="portal-docs__result-snippet">
|
||||
<Highlighted segments={r.snippet} />
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ const FULL_CAPS: UsersCapabilities = {
|
||||
seats: false,
|
||||
manageGrants: true,
|
||||
removeScope: "org",
|
||||
listingRequiresAdmin: true,
|
||||
};
|
||||
|
||||
/** SaaS team-leader: invite / rename / remove-member only, no org group. */
|
||||
@@ -44,6 +45,7 @@ const SAAS_CAPS: UsersCapabilities = {
|
||||
seats: true,
|
||||
manageGrants: false,
|
||||
removeScope: "team",
|
||||
listingRequiresAdmin: false,
|
||||
};
|
||||
|
||||
/** A full org: one org owner and two teams, each with a leader. */
|
||||
|
||||
@@ -215,7 +215,8 @@ export function UsersDirectory({
|
||||
function renderRow(m: Member) {
|
||||
const access = m.portalAccess ?? "none";
|
||||
return (
|
||||
<div className="portal-users__row" key={m.id}>
|
||||
// data-member-id lets deep links (?member=<id>) scroll to and flash a row.
|
||||
<div className="portal-users__row" key={m.id} data-member-id={m.id}>
|
||||
<div className="portal-users__row-main">
|
||||
<Avatar name={m.name} size="sm" tone={avatarToneForMember(m)} />
|
||||
<div className="portal-users__row-id">
|
||||
|
||||
@@ -7,11 +7,6 @@ import {
|
||||
} from "react";
|
||||
|
||||
interface UIContextValue {
|
||||
searchOpen: boolean;
|
||||
openSearch: () => void;
|
||||
closeSearch: () => void;
|
||||
toggleSearch: () => void;
|
||||
|
||||
/** Off-canvas sidebar drawer on small screens (no-op chrome on desktop). */
|
||||
mobileNavOpen: boolean;
|
||||
openMobileNav: () => void;
|
||||
@@ -32,7 +27,8 @@ interface UIContextValue {
|
||||
* modal pick its own default. Cleared back to `null` on close.
|
||||
*/
|
||||
settingsInitialSection: string | null;
|
||||
openSettings: (section?: string) => void;
|
||||
settingsInitialFocus: string | null;
|
||||
openSettings: (section?: string, focus?: string) => void;
|
||||
closeSettings: () => void;
|
||||
|
||||
/**
|
||||
@@ -81,7 +77,6 @@ function writeSidebarCollapsed(collapsed: boolean): void {
|
||||
}
|
||||
|
||||
export function UIProvider({ children }: { children: ReactNode }) {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] =
|
||||
useState(readSidebarCollapsed);
|
||||
@@ -90,6 +85,9 @@ export function UIProvider({ children }: { children: ReactNode }) {
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [settingsInitialFocus, setSettingsInitialFocus] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [linkModalOpen, setLinkModalOpen] = useState(false);
|
||||
const [trialSetupRequested, setTrialSetupRequested] = useState(false);
|
||||
const [linkModalMode, setLinkModalMode] = useState<"link" | "reauth">("link");
|
||||
@@ -101,16 +99,8 @@ export function UIProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const value = useMemo<UIContextValue>(
|
||||
() => ({
|
||||
// Opening any overlay (search, settings, link modal) dismisses the mobile
|
||||
// nav drawer so overlays never stack on top of it.
|
||||
searchOpen,
|
||||
openSearch: () => {
|
||||
setMobileNavOpen(false);
|
||||
setSearchOpen(true);
|
||||
},
|
||||
closeSearch: () => setSearchOpen(false),
|
||||
toggleSearch: () => setSearchOpen((o) => !o),
|
||||
|
||||
// Opening any overlay (settings, link modal) dismisses the mobile nav
|
||||
// drawer so overlays never stack on top of it.
|
||||
mobileNavOpen,
|
||||
openMobileNav: () => setMobileNavOpen(true),
|
||||
closeMobileNav: () => setMobileNavOpen(false),
|
||||
@@ -131,14 +121,17 @@ export function UIProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
settingsOpen,
|
||||
settingsInitialSection,
|
||||
openSettings: (section?: string) => {
|
||||
settingsInitialFocus,
|
||||
openSettings: (section?: string, focus?: string) => {
|
||||
setMobileNavOpen(false);
|
||||
setSettingsInitialSection(section ?? null);
|
||||
setSettingsInitialFocus(focus ?? null);
|
||||
setSettingsOpen(true);
|
||||
},
|
||||
closeSettings: () => {
|
||||
setSettingsOpen(false);
|
||||
setSettingsInitialSection(null);
|
||||
setSettingsInitialFocus(null);
|
||||
},
|
||||
|
||||
linkModalOpen,
|
||||
@@ -152,6 +145,7 @@ export function UIProvider({ children }: { children: ReactNode }) {
|
||||
setReopenSettingsAfterLink("account-link");
|
||||
setSettingsOpen(false);
|
||||
setSettingsInitialSection(null);
|
||||
setSettingsInitialFocus(null);
|
||||
}
|
||||
setLinkModalOpen(true);
|
||||
},
|
||||
@@ -166,18 +160,19 @@ export function UIProvider({ children }: { children: ReactNode }) {
|
||||
setLinkModalMode("link");
|
||||
if (reopenSettingsAfterLink) {
|
||||
setSettingsInitialSection(reopenSettingsAfterLink);
|
||||
setSettingsInitialFocus(null);
|
||||
setSettingsOpen(true);
|
||||
setReopenSettingsAfterLink(null);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[
|
||||
searchOpen,
|
||||
mobileNavOpen,
|
||||
sidebarCollapsed,
|
||||
assistantOpen,
|
||||
settingsOpen,
|
||||
settingsInitialSection,
|
||||
settingsInitialFocus,
|
||||
linkModalOpen,
|
||||
linkModalMode,
|
||||
reopenSettingsAfterLink,
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (
|
||||
key: string,
|
||||
fallbackOrOptions?: string | Record<string, unknown>,
|
||||
) => {
|
||||
if (key === "portal.policies.defaultName") {
|
||||
return `${(fallbackOrOptions as Record<string, unknown>)?.category as string} Policy`;
|
||||
}
|
||||
if (typeof fallbackOrOptions === "string") return fallbackOrOptions;
|
||||
const labels: Record<string, string> = {
|
||||
"portal.nav.users": "Users",
|
||||
"portal.nav.policies": "Policies",
|
||||
"portal.nav.pipelines": "Pipelines",
|
||||
"portal.nav.sources": "Sources",
|
||||
"portal.nav.editor": "Editor",
|
||||
"superSearch.group.processor": "Processor",
|
||||
"superSearch.group.settings": "Settings",
|
||||
"superSearch.group.tools": "Tools",
|
||||
"settings.email.smtpHost": "SMTP host",
|
||||
"settings.email.title": "Email",
|
||||
};
|
||||
return labels[key] ?? key;
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useNavigate: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
vi.mock("@app/contexts/AppConfigContext", () => ({
|
||||
useAppConfig: vi.fn(() => ({
|
||||
config: {
|
||||
isAdmin: false,
|
||||
enableLogin: true,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@app/contexts/ToolRegistryContext", () => ({
|
||||
useToolRegistry: vi.fn(() => ({
|
||||
allTools: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@portal/contexts/TierContext", () => ({
|
||||
useTier: vi.fn(() => ({
|
||||
tier: "pro",
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockOpenSettings = vi.fn();
|
||||
vi.mock("@portal/contexts/UIContext", () => ({
|
||||
useUI: vi.fn(() => ({
|
||||
openSettings: mockOpenSettings,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@app/data/toolsTaxonomy", () => ({
|
||||
getToolUrlPath: vi.fn((id: string) => `/tools/${id}`),
|
||||
isComingSoonTool: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("@app/data/settingsSearchIndex", () => ({
|
||||
SETTINGS_SEARCH_INDEX: [
|
||||
{
|
||||
section: "email",
|
||||
anchor: "smtp-host",
|
||||
labelKey: "settings.email.smtpHost",
|
||||
labelFallback: "SMTP host",
|
||||
keywords: ["smtp"],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("@app/data/settingsSectionRegistry", () => ({
|
||||
SETTINGS_SECTION_REGISTRY: [
|
||||
{
|
||||
key: "email",
|
||||
labelKey: "settings.email.title",
|
||||
labelFallback: "Email",
|
||||
keywords: ["smtp", "email"],
|
||||
requiresLogin: true,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("@app/data/settingsContentSearch", () => ({
|
||||
findSettingsContentMatch: vi.fn(() => null),
|
||||
buildMatchSnippet: vi.fn(() => ""),
|
||||
}));
|
||||
|
||||
vi.mock("@app/data/processorSearchIndex", () => ({
|
||||
PROCESSOR_SEARCH_INDEX: [
|
||||
{
|
||||
id: "users",
|
||||
labelKey: "portal.nav.users",
|
||||
labelFallback: "Users",
|
||||
path: "/portal/users",
|
||||
keywords: ["members"],
|
||||
},
|
||||
{
|
||||
id: "policies",
|
||||
labelKey: "portal.nav.policies",
|
||||
labelFallback: "Policies",
|
||||
path: "/portal/policies",
|
||||
keywords: ["rules"],
|
||||
},
|
||||
{
|
||||
id: "pipelines",
|
||||
labelKey: "portal.nav.pipelines",
|
||||
labelFallback: "Pipelines",
|
||||
path: "/portal/pipelines",
|
||||
keywords: ["automation"],
|
||||
},
|
||||
{
|
||||
id: "sources",
|
||||
labelKey: "portal.nav.sources",
|
||||
labelFallback: "Sources",
|
||||
path: "/portal/sources",
|
||||
keywords: ["connectors"],
|
||||
},
|
||||
{
|
||||
id: "docs",
|
||||
labelKey: "portal.nav.docs",
|
||||
labelFallback: "Documentation",
|
||||
path: "/portal/docs",
|
||||
keywords: ["docs"],
|
||||
},
|
||||
],
|
||||
// Tests run as an org admin; per-scope access gating has its own coverage
|
||||
// in the stubbed suite.
|
||||
isPortalEntityScopeAccessible: () => true,
|
||||
}));
|
||||
|
||||
// The roster is fetched through the flavor-resolved usersBackend (the same
|
||||
// path the shared users query uses), not @portal/api/users directly.
|
||||
vi.mock("@app/portal/usersBackend", () => ({
|
||||
usersBackend: {
|
||||
fetchUsers: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Keep the real (pure) assemblePolicies; only the network fetchers are mocked.
|
||||
vi.mock("@portal/api/policies", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@portal/api/policies")>()),
|
||||
fetchPoliciesList: vi.fn(),
|
||||
fetchPolicyRuns: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@portal/api/pipelines", () => ({
|
||||
fetchPipelines: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@portal/api/sources", () => ({
|
||||
fetchSources: vi.fn(),
|
||||
}));
|
||||
|
||||
import type { CatalogueEntry } from "@portal/api/policies";
|
||||
import { fetchPoliciesList, fetchPolicyRuns } from "@portal/api/policies";
|
||||
import type { PipelineView } from "@portal/api/pipelines";
|
||||
import { fetchPipelines } from "@portal/api/pipelines";
|
||||
import { fetchSources } from "@portal/api/sources";
|
||||
import type { Member, UsersResponse } from "@portal/api/users";
|
||||
import { usersBackend } from "@app/portal/usersBackend";
|
||||
import {
|
||||
rankDocsResults,
|
||||
rankPortalPipelineResults,
|
||||
rankPortalPolicyResults,
|
||||
} from "@portal/search/entitySearch";
|
||||
import { usePortalSearchResults } from "@portal/hooks/usePortalSearchResults";
|
||||
|
||||
function makePolicyEntry(overrides?: Partial<CatalogueEntry>): CatalogueEntry {
|
||||
return {
|
||||
category: {
|
||||
id: "security",
|
||||
label: "Security",
|
||||
tone: "purple",
|
||||
desc: "Protect sensitive documents",
|
||||
},
|
||||
config: {
|
||||
summary: "",
|
||||
rules: [],
|
||||
scopeLabel: "",
|
||||
fields: [],
|
||||
defaultOperations: [],
|
||||
},
|
||||
policy: {
|
||||
category: {
|
||||
id: "security",
|
||||
label: "Security",
|
||||
tone: "purple",
|
||||
desc: "Protect sensitive documents",
|
||||
},
|
||||
config: {
|
||||
summary: "",
|
||||
rules: [],
|
||||
scopeLabel: "",
|
||||
fields: [],
|
||||
defaultOperations: [],
|
||||
},
|
||||
state: {
|
||||
configured: true,
|
||||
status: "active",
|
||||
sources: [],
|
||||
scopeTypes: [],
|
||||
reviewerEmail: "",
|
||||
fieldValues: {},
|
||||
backendId: "policy-security",
|
||||
},
|
||||
steps: [],
|
||||
stats: {
|
||||
enforced: 0,
|
||||
dataProcessed: "0 B",
|
||||
activeFor: "0d",
|
||||
},
|
||||
activity: [],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePipelineView(
|
||||
id: string,
|
||||
name: string,
|
||||
trigger = "manual",
|
||||
): PipelineView {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
enabled: true,
|
||||
status: "active",
|
||||
trigger,
|
||||
sources: [],
|
||||
steps: [],
|
||||
output: "inline",
|
||||
owner: "alice",
|
||||
};
|
||||
}
|
||||
|
||||
function makeMember(overrides?: Partial<Member>): Member {
|
||||
return {
|
||||
id: "member-1",
|
||||
name: "Alice Admin",
|
||||
email: "alice@example.com",
|
||||
role: "admin",
|
||||
status: "active",
|
||||
lastActive: "1m ago",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeUsersResponse(members: Member[]): UsersResponse {
|
||||
return {
|
||||
summary: {
|
||||
totalMembers: members.length,
|
||||
pendingInvites: 0,
|
||||
seatsUsed: members.length,
|
||||
seatLimit: null,
|
||||
},
|
||||
members,
|
||||
roles: [],
|
||||
access: {
|
||||
tier: "pro",
|
||||
seatsUsed: members.length,
|
||||
seatLimit: null,
|
||||
},
|
||||
mailEnabled: true,
|
||||
emailInvitesEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve: ((value: T) => void) | undefined;
|
||||
let reject: ((reason?: unknown) => void) | undefined;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
resolve: (value: T) => resolve?.(value),
|
||||
reject: (reason?: unknown) => reject?.(reason),
|
||||
};
|
||||
}
|
||||
|
||||
function queryWrapper() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
createElement(QueryClientProvider, { client }, children);
|
||||
}
|
||||
|
||||
describe("usePortalSearchResults helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockOpenSettings.mockReset();
|
||||
vi.mocked(usersBackend.fetchUsers).mockResolvedValue(makeUsersResponse([]));
|
||||
vi.mocked(fetchPoliciesList).mockResolvedValue([]);
|
||||
vi.mocked(fetchPolicyRuns).mockResolvedValue([]);
|
||||
vi.mocked(fetchPipelines).mockResolvedValue({ kpis: [], pipelines: [] });
|
||||
vi.mocked(fetchSources).mockResolvedValue({ kpis: [], sources: [] });
|
||||
});
|
||||
|
||||
it("ranks configured policies under the policies group", () => {
|
||||
const openPolicy = vi.fn();
|
||||
const results = rankPortalPolicyResults(
|
||||
[makePolicyEntry()],
|
||||
"security policy",
|
||||
(key: string, options?: Record<string, unknown>) =>
|
||||
key === "portal.policies.defaultName"
|
||||
? `${options?.category as string} Policy`
|
||||
: key,
|
||||
openPolicy,
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toMatchObject({
|
||||
key: "portal-policy:security",
|
||||
group: "portal-policies",
|
||||
title: "Security Policy",
|
||||
});
|
||||
|
||||
void results[0]?.onSelect();
|
||||
expect(openPolicy).toHaveBeenCalledWith("security");
|
||||
});
|
||||
|
||||
it("filters policy-backed records out of the pipelines group", () => {
|
||||
const openPipeline = vi.fn();
|
||||
const results = rankPortalPipelineResults(
|
||||
[
|
||||
makePipelineView("policy-security", "Security Policy"),
|
||||
makePipelineView("custom-pipeline", "Nightly OCR"),
|
||||
],
|
||||
"nightly",
|
||||
new Set(["policy-security"]),
|
||||
openPipeline,
|
||||
);
|
||||
|
||||
expect(results.map((result) => result.key)).toEqual([
|
||||
"portal-pipeline:custom-pipeline",
|
||||
]);
|
||||
});
|
||||
|
||||
it("full-text searches the bundled docs, not just their titles", () => {
|
||||
const navigate = vi.fn();
|
||||
// "Tesseract" appears in the OCR doc body but in no doc title — a hit
|
||||
// whose snippet contains it proves content search.
|
||||
const results = rankDocsResults("Tesseract", navigate);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]?.subtitle).toMatch(/tesseract/i);
|
||||
|
||||
void results[0]?.onSelect();
|
||||
expect(navigate).toHaveBeenCalledWith(expect.stringMatching(/\/docs#./));
|
||||
});
|
||||
|
||||
it("forwards portal settings row hits with their focus anchor", () => {
|
||||
const { result } = renderHook(
|
||||
() => usePortalSearchResults("smtp", true, { scopeIds: ["settings"] }),
|
||||
{ wrapper: queryWrapper() },
|
||||
);
|
||||
|
||||
const settingHit = result.current.flatResults[0];
|
||||
expect(settingHit?.key).toBe("setting:email:smtp-host");
|
||||
|
||||
void settingHit?.onSelect();
|
||||
expect(mockOpenSettings).toHaveBeenCalledWith("email", "smtp-host");
|
||||
expect(usersBackend.fetchUsers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches only the requested entity scope", async () => {
|
||||
vi.mocked(usersBackend.fetchUsers).mockResolvedValue(
|
||||
makeUsersResponse([makeMember({ id: "member-2", name: "Alice" })]),
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
usePortalSearchResults("alice", true, { scopeIds: ["portal-users"] }),
|
||||
{ wrapper: queryWrapper() },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(usersBackend.fetchUsers).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loadingFiles).toBe(false));
|
||||
|
||||
expect(fetchPoliciesList).not.toHaveBeenCalled();
|
||||
expect(fetchPipelines).not.toHaveBeenCalled();
|
||||
expect(fetchSources).not.toHaveBeenCalled();
|
||||
expect(result.current.groups.map((group) => group.id)).toEqual([
|
||||
"portal-users",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reuses the in-flight query after close/reopen instead of sticking in loading", async () => {
|
||||
const firstUsers = createDeferred<UsersResponse>();
|
||||
vi.mocked(usersBackend.fetchUsers).mockImplementationOnce(
|
||||
() => firstUsers.promise,
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ query }) =>
|
||||
usePortalSearchResults(query, true, { scopeIds: ["portal-users"] }),
|
||||
{
|
||||
initialProps: { query: "alice" },
|
||||
wrapper: queryWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loadingFiles).toBe(true));
|
||||
expect(usersBackend.fetchUsers).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ query: "" });
|
||||
await waitFor(() => expect(result.current.loadingFiles).toBe(false));
|
||||
|
||||
rerender({ query: "alice" });
|
||||
await waitFor(() => expect(result.current.loadingFiles).toBe(true));
|
||||
expect(usersBackend.fetchUsers).toHaveBeenCalledTimes(1);
|
||||
|
||||
firstUsers.resolve(
|
||||
makeUsersResponse([
|
||||
makeMember({ id: "member-3", name: "Alice Reloaded" }),
|
||||
]),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loadingFiles).toBe(false));
|
||||
expect(result.current.groups.map((group) => group.id)).toEqual([
|
||||
"portal-users",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getToolUrlPath } from "@app/data/toolsTaxonomy";
|
||||
import { isPortalEntityScopeAccessible } from "@app/data/processorSearchIndex";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { PORTAL_HIDDEN_SECTION_KEYS } from "@portal/components/PortalSettingsHost";
|
||||
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||
import {
|
||||
assembleSuperSearchGroups,
|
||||
rankSettingsResults,
|
||||
rankToolResults,
|
||||
useSearchScopeFilter,
|
||||
} from "@app/hooks/useSuperSearch";
|
||||
import {
|
||||
PORTAL_ENTITY_SCOPE_DEFS,
|
||||
PORTAL_DOCS_SCOPE_ID,
|
||||
type SuperSearchGates,
|
||||
type SuperSearchGroup,
|
||||
type SuperSearchGroupId,
|
||||
type SuperSearchQueryOptions,
|
||||
type SuperSearchScope,
|
||||
type UseSuperSearchResult,
|
||||
} from "@app/types/superSearch";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
import { assignLocation, openExternalUrl } from "@app/utils/safeNavigation";
|
||||
import { usersBackend } from "@app/portal/usersBackend";
|
||||
import {
|
||||
assemblePolicies,
|
||||
fetchPoliciesList,
|
||||
fetchPolicyRuns,
|
||||
} from "@portal/api/policies";
|
||||
import { fetchPipelines } from "@portal/api/pipelines";
|
||||
import { fetchSources } from "@portal/api/sources";
|
||||
import { EDITOR_IS_SAME_APP, EDITOR_URL } from "@portal/auth/editorUrl";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { qk } from "@portal/queries/keys";
|
||||
import {
|
||||
buildProcessorEntityGroups,
|
||||
defaultPortalEntityScopes,
|
||||
isDocsSearchable,
|
||||
isVisiblePortalScope,
|
||||
withPortalEntityDependencies,
|
||||
type ProcessorEntities,
|
||||
} from "@portal/search/entitySearch";
|
||||
|
||||
const EDITOR_GROUP_ORDER: SuperSearchGroupId[] = ["tools"];
|
||||
const SETTINGS_GROUP_ORDER: SuperSearchGroupId[] = ["settings"];
|
||||
const PROCESSOR_SECTION_LABEL_KEY = "superSearch.group.processor";
|
||||
const PROCESSOR_SECTION_LABEL_FALLBACK = "Processor";
|
||||
const SETTINGS_SECTION_LABEL_KEY = "superSearch.group.settings";
|
||||
const SETTINGS_SECTION_LABEL_FALLBACK = "Settings";
|
||||
const EDITOR_SECTION_LABEL_KEY = "portal.nav.editor";
|
||||
const EDITOR_SECTION_LABEL_FALLBACK = "Editor";
|
||||
|
||||
/**
|
||||
* Cross-origin editor URL for a tool. Only used when a separately-hosted
|
||||
* editor is configured — the same-app case routes client-side instead: on
|
||||
* bundled deploys the backend serves the frontend and 401s unauthenticated
|
||||
* document GETs (the JWT lives in localStorage, so a full page load carries
|
||||
* no credentials), which would bounce every tool hop to /login.
|
||||
*/
|
||||
function externalEditorHref(path: string): string {
|
||||
return EDITOR_URL.replace(/\/$/, "") + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* The portal bar's filter chips — every lane the editor offers except Files
|
||||
* (files only open in the editor) and Pages (the sidebar covers navigation).
|
||||
* Ordered to match the dropdown's section priority. Lanes whose data source
|
||||
* refuses this session (the users roster for non-admins on self-hosted) get
|
||||
* no chip — an offered lane must be able to return results.
|
||||
*/
|
||||
export function usePortalSearchScopes(): SuperSearchScope[] {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const isAdmin = config?.isAdmin ?? false;
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
...PORTAL_ENTITY_SCOPE_DEFS.filter(
|
||||
(def) =>
|
||||
isVisiblePortalScope(def.id) &&
|
||||
isPortalEntityScopeAccessible(def.id, isAdmin),
|
||||
).map((def) => ({
|
||||
id: def.id,
|
||||
label: t(def.labelKey, def.labelFallback),
|
||||
aliases: [...def.aliases],
|
||||
})),
|
||||
...(isDocsSearchable()
|
||||
? [
|
||||
{
|
||||
id: PORTAL_DOCS_SCOPE_ID,
|
||||
label: t("superSearch.group.docs", "Docs"),
|
||||
aliases: ["doc", "docs", "documentation"],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "settings",
|
||||
label: t("superSearch.group.settings", "Settings"),
|
||||
aliases: ["setting", "settings"],
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
label: t("superSearch.group.tools", "Tools"),
|
||||
aliases: ["tool", "tools"],
|
||||
},
|
||||
],
|
||||
[t, isAdmin],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The portal's results provider for the shared super search bar: files stay
|
||||
* editor-only, portal entity results are grouped under a Processor section,
|
||||
* and the shared tools/settings lanes sit under an Editor section. Portal page
|
||||
* routes themselves stay out of the portal search — once you're in the portal,
|
||||
* the entities are the useful targets.
|
||||
*/
|
||||
export function usePortalSearchResults(
|
||||
query: string,
|
||||
active: boolean,
|
||||
options?: SuperSearchQueryOptions,
|
||||
): UseSuperSearchResult {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { openSettings } = useUI();
|
||||
const { allTools } = useToolRegistry();
|
||||
const { config } = useAppConfig();
|
||||
const { tier } = useTier();
|
||||
|
||||
const trimmed = query.trim();
|
||||
const { scopeEnabled } = useSearchScopeFilter(options);
|
||||
const requestedEntityScopes = useMemo(() => {
|
||||
if (!active || trimmed.length === 0) return new Set<string>();
|
||||
const enabled = defaultPortalEntityScopes(config?.isAdmin ?? false).filter(
|
||||
(scopeId) => scopeEnabled(scopeId),
|
||||
);
|
||||
return new Set<string>(withPortalEntityDependencies(enabled));
|
||||
}, [active, scopeEnabled, trimmed, config?.isAdmin]);
|
||||
|
||||
// Entity data rides the portal's shared query layer — the same keys the
|
||||
// views use, so searching warms the view (and vice versa) and the client's
|
||||
// staleTime/retry policy replaces bespoke fetch discipline. `enabled` keeps
|
||||
// each lane's fetch behind its scope chip and the active-query gate.
|
||||
const usersQuery = useQuery({
|
||||
queryKey: qk.usersRoster(tier),
|
||||
queryFn: () => usersBackend.fetchUsers(tier),
|
||||
enabled: requestedEntityScopes.has("portal-users"),
|
||||
});
|
||||
const policiesListQuery = useQuery({
|
||||
queryKey: qk.policiesList(),
|
||||
queryFn: fetchPoliciesList,
|
||||
enabled: requestedEntityScopes.has("portal-policies"),
|
||||
});
|
||||
const policyRunsQuery = useQuery({
|
||||
queryKey: qk.policyRuns(),
|
||||
queryFn: fetchPolicyRuns,
|
||||
enabled: requestedEntityScopes.has("portal-policies"),
|
||||
});
|
||||
const pipelinesQuery = useQuery({
|
||||
queryKey: qk.pipelines(),
|
||||
queryFn: fetchPipelines,
|
||||
enabled: requestedEntityScopes.has("portal-pipelines"),
|
||||
});
|
||||
const sourcesQuery = useQuery({
|
||||
queryKey: qk.sources(),
|
||||
queryFn: fetchSources,
|
||||
enabled: requestedEntityScopes.has("portal-sources"),
|
||||
});
|
||||
|
||||
// Loading only counts for lanes the current search actually requests — a
|
||||
// fetch left in flight after its lane was deselected (or the bar closed)
|
||||
// must not hold the dropdown's no-results gate open.
|
||||
const loadingEntities =
|
||||
(requestedEntityScopes.has("portal-users") && usersQuery.isLoading) ||
|
||||
(requestedEntityScopes.has("portal-policies") &&
|
||||
(policiesListQuery.isLoading || policyRunsQuery.isLoading)) ||
|
||||
(requestedEntityScopes.has("portal-pipelines") &&
|
||||
pipelinesQuery.isLoading) ||
|
||||
(requestedEntityScopes.has("portal-sources") && sourcesQuery.isLoading);
|
||||
|
||||
const entities = useMemo<ProcessorEntities>(
|
||||
() => ({
|
||||
users: usersQuery.data?.members ?? [],
|
||||
policies: policiesListQuery.data
|
||||
? assemblePolicies(policiesListQuery.data, policyRunsQuery.data ?? [])
|
||||
.catalogue
|
||||
: [],
|
||||
pipelines: pipelinesQuery.data?.pipelines ?? [],
|
||||
sources: sourcesQuery.data?.sources ?? [],
|
||||
}),
|
||||
[
|
||||
usersQuery.data,
|
||||
policiesListQuery.data,
|
||||
policyRunsQuery.data,
|
||||
pipelinesQuery.data,
|
||||
sourcesQuery.data,
|
||||
],
|
||||
);
|
||||
|
||||
const openTool = useCallback(
|
||||
(id: ToolId) => {
|
||||
// Link tools have no in-editor UI — navigating to a tool URL for one
|
||||
// lands on a "tool not found" panel. Open their destination directly,
|
||||
// matching how the editor's tool lists treat them.
|
||||
const tool = allTools[id];
|
||||
if (tool?.link) {
|
||||
openExternalUrl(tool.link);
|
||||
return;
|
||||
}
|
||||
const path = getToolUrlPath(id);
|
||||
if (EDITOR_IS_SAME_APP) {
|
||||
// One SPA: swap route-sets through the router. The portal tree
|
||||
// unmounts and the editor mounts fresh at the tool URL, so its
|
||||
// URL-driven tool init runs exactly as it does on a cold load.
|
||||
navigate(path);
|
||||
} else {
|
||||
assignLocation(externalEditorHref(path));
|
||||
}
|
||||
},
|
||||
[allTools, navigate],
|
||||
);
|
||||
|
||||
const openSettingsSection = useCallback(
|
||||
(section: string, anchor?: string) => openSettings(section, anchor),
|
||||
[openSettings],
|
||||
);
|
||||
|
||||
const gates = useMemo<SuperSearchGates | null>(
|
||||
() =>
|
||||
config
|
||||
? {
|
||||
isAdmin: config.isAdmin ?? false,
|
||||
loginEnabled: config.enableLogin ?? false,
|
||||
showSettingsWhenNoLogin: config.showSettingsWhenNoLogin ?? true,
|
||||
}
|
||||
: null,
|
||||
[config],
|
||||
);
|
||||
|
||||
const entityGroups = useMemo<SuperSearchGroup[]>(
|
||||
() =>
|
||||
buildProcessorEntityGroups(entities, trimmed, t, navigate, {
|
||||
scopeEnabled,
|
||||
}),
|
||||
[entities, trimmed, t, navigate, scopeEnabled],
|
||||
);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
// Section order: Processor first, Settings second, Editor last.
|
||||
const settingsGroups = assembleSuperSearchGroups(
|
||||
{
|
||||
settings: scopeEnabled("settings")
|
||||
? rankSettingsResults(
|
||||
trimmed,
|
||||
t,
|
||||
gates,
|
||||
openSettingsSection,
|
||||
undefined,
|
||||
PORTAL_HIDDEN_SECTION_KEYS,
|
||||
)
|
||||
: [],
|
||||
},
|
||||
t,
|
||||
SETTINGS_GROUP_ORDER,
|
||||
).map((group) => ({
|
||||
...group,
|
||||
sectionLabel: t(
|
||||
SETTINGS_SECTION_LABEL_KEY,
|
||||
SETTINGS_SECTION_LABEL_FALLBACK,
|
||||
),
|
||||
}));
|
||||
|
||||
const editorGroups = assembleSuperSearchGroups(
|
||||
{
|
||||
tools: scopeEnabled("tools")
|
||||
? rankToolResults(allTools, trimmed, openTool)
|
||||
: [],
|
||||
},
|
||||
t,
|
||||
EDITOR_GROUP_ORDER,
|
||||
).map((group) => ({
|
||||
...group,
|
||||
sectionLabel: t(EDITOR_SECTION_LABEL_KEY, EDITOR_SECTION_LABEL_FALLBACK),
|
||||
}));
|
||||
|
||||
return [
|
||||
...entityGroups.map((group) => ({
|
||||
...group,
|
||||
sectionLabel: t(
|
||||
PROCESSOR_SECTION_LABEL_KEY,
|
||||
PROCESSOR_SECTION_LABEL_FALLBACK,
|
||||
),
|
||||
})),
|
||||
...settingsGroups,
|
||||
...editorGroups,
|
||||
];
|
||||
}, [
|
||||
entityGroups,
|
||||
gates,
|
||||
openSettingsSection,
|
||||
openTool,
|
||||
scopeEnabled,
|
||||
allTools,
|
||||
t,
|
||||
trimmed,
|
||||
]);
|
||||
|
||||
const flatResults = useMemo(
|
||||
() => groups.flatMap((group) => group.results),
|
||||
[groups],
|
||||
);
|
||||
|
||||
// loadingFiles doubles as "an async source is still loading" for the
|
||||
// dropdown's no-results gate — here that's the entity fetch.
|
||||
return { groups, flatResults, loadingFiles: loadingEntities };
|
||||
}
|
||||
@@ -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";
|
||||
@@ -21,7 +20,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,393 @@
|
||||
import {
|
||||
PROCESSOR_SEARCH_INDEX,
|
||||
isPortalEntityScopeAccessible,
|
||||
} from "@app/data/processorSearchIndex";
|
||||
import {
|
||||
PORTAL_ENTITY_SCOPE_DEFS,
|
||||
PORTAL_DOCS_SCOPE_ID,
|
||||
type SuperSearchGroup,
|
||||
type SuperSearchResult,
|
||||
} from "@app/types/superSearch";
|
||||
import { rankByFuzzy } from "@app/utils/fuzzySearch";
|
||||
import {
|
||||
assemblePolicies,
|
||||
fetchPoliciesList,
|
||||
fetchPolicyRuns,
|
||||
type CatalogueEntry,
|
||||
} from "@portal/api/policies";
|
||||
import { fetchPipelines, type PipelineView } from "@portal/api/pipelines";
|
||||
import { fetchSources, type SourceView } from "@portal/api/sources";
|
||||
import type { Member } from "@portal/api/users";
|
||||
// Flavor-resolved users backend: self-hosted reads the proprietary admin
|
||||
// endpoints, SaaS the invitation-based team endpoints (the admin ones 403
|
||||
// there for the always-ROLE_USER sessions).
|
||||
import { usersBackend } from "@app/portal/usersBackend";
|
||||
import {
|
||||
DocsIcon,
|
||||
PipelinesIcon,
|
||||
PoliciesIcon,
|
||||
SourcesIcon,
|
||||
UsersIcon,
|
||||
} from "@portal/components/icons";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
|
||||
import { allDocs, loadDocsNav } from "@portal/docs/manifest/registry";
|
||||
import { searchDocs, toPlainText, type SearchDoc } from "@portal/docs/search";
|
||||
|
||||
/**
|
||||
* The Processor's entity search: users, policies, pipelines and sources,
|
||||
* fetched per scope and fuzzy-ranked client-side. Shared by both super search
|
||||
* hosts — the portal bar imports it statically, the editor bar loads it on
|
||||
* demand through the processorEntitySearch seam (a static import there would
|
||||
* pull the portal into the main bundle).
|
||||
*/
|
||||
|
||||
export const PORTAL_ENTITY_SCOPE_IDS = PORTAL_ENTITY_SCOPE_DEFS.map(
|
||||
(def) => def.id,
|
||||
);
|
||||
|
||||
export type PortalEntityScopeId =
|
||||
(typeof PORTAL_ENTITY_SCOPE_DEFS)[number]["id"];
|
||||
|
||||
export type PortalEntityItems =
|
||||
| Member[]
|
||||
| CatalogueEntry[]
|
||||
| PipelineView[]
|
||||
| SourceView[];
|
||||
|
||||
export interface ProcessorEntities {
|
||||
users: Member[];
|
||||
policies: CatalogueEntry[];
|
||||
pipelines: PipelineView[];
|
||||
sources: SourceView[];
|
||||
}
|
||||
|
||||
/**
|
||||
* How many results each entity ranker computes — the ceiling the dropdown's
|
||||
* "show more" can reveal (the component shows a small initial slice).
|
||||
*/
|
||||
export const ENTITY_GROUP_LIMIT = 24;
|
||||
|
||||
/** How long a fetched entity scope stays fresh before a search refetches it. */
|
||||
export const ENTITY_REFRESH_MS = 30_000;
|
||||
|
||||
const PORTAL_VIEW_BY_SCOPE_ID = Object.fromEntries(
|
||||
PORTAL_ENTITY_SCOPE_DEFS.map((def) => [def.id, def.viewId]),
|
||||
) as Record<PortalEntityScopeId, string>;
|
||||
|
||||
const VISIBLE_PORTAL_VIEW_IDS = new Set(
|
||||
PROCESSOR_SEARCH_INDEX.map((entry) => entry.id),
|
||||
);
|
||||
|
||||
/** Whether the flavor's portal nav ships the view an entity scope targets. */
|
||||
export function isVisiblePortalScope(scopeId: PortalEntityScopeId): boolean {
|
||||
return VISIBLE_PORTAL_VIEW_IDS.has(PORTAL_VIEW_BY_SCOPE_ID[scopeId]);
|
||||
}
|
||||
|
||||
/** Whether this build ships the in-app developer docs (so they're searchable). */
|
||||
export function isDocsSearchable(): boolean {
|
||||
return VISIBLE_PORTAL_VIEW_IDS.has("docs");
|
||||
}
|
||||
|
||||
// The docs manifest is static (bundled JSON), so the full-text index — the
|
||||
// plaintext strip over every doc — is built once and reused across queries.
|
||||
let docsSearchIndex: SearchDoc[] | null = null;
|
||||
function getDocsSearchIndex(): SearchDoc[] {
|
||||
if (!docsSearchIndex) {
|
||||
const sectionLabels = new Map(loadDocsNav().map((s) => [s.id, s.label]));
|
||||
docsSearchIndex = allDocs().map((doc) => ({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
sectionLabel: sectionLabels.get(doc.section) ?? "",
|
||||
text: toPlainText(doc.markdown),
|
||||
}));
|
||||
}
|
||||
return docsSearchIndex;
|
||||
}
|
||||
|
||||
export function rankDocsResults(
|
||||
trimmed: string,
|
||||
navigate: (path: string) => void,
|
||||
limit = ENTITY_GROUP_LIMIT,
|
||||
): SuperSearchResult[] {
|
||||
if (!isDocsSearchable()) return [];
|
||||
return searchDocs(getDocsSearchIndex(), trimmed, limit).map((result) => ({
|
||||
key: `portal-doc:${result.id}`,
|
||||
group: PORTAL_DOCS_SCOPE_ID,
|
||||
title: result.title,
|
||||
// The matched-content snippet (full text is what makes docs worth
|
||||
// searching), falling back to the doc's section when the hit is title-only.
|
||||
subtitle:
|
||||
result.snippet
|
||||
.map((seg) => seg.text)
|
||||
.join("")
|
||||
.trim() || result.sectionLabel,
|
||||
icon: <DocsIcon />,
|
||||
score: result.score,
|
||||
onSelect: () => navigate(`${toPortalPath(VIEW_PATHS.docs)}#${result.id}`),
|
||||
}));
|
||||
}
|
||||
|
||||
export function withPortalEntityDependencies(
|
||||
scopes: readonly PortalEntityScopeId[],
|
||||
): readonly PortalEntityScopeId[] {
|
||||
// Pipeline rows must exclude policy-backed records, so they depend on the
|
||||
// policy catalogue even when the user only scoped into pipelines.
|
||||
if (
|
||||
!scopes.includes("portal-pipelines") ||
|
||||
scopes.includes("portal-policies")
|
||||
) {
|
||||
return scopes;
|
||||
}
|
||||
return [...scopes, "portal-policies"];
|
||||
}
|
||||
|
||||
/** Every entity scope the flavor ships AND the session can actually query
|
||||
* (see isPortalEntityScopeAccessible), dependencies included — the request
|
||||
* set for an unscoped search. */
|
||||
export function defaultPortalEntityScopes(
|
||||
isAdmin: boolean,
|
||||
): readonly PortalEntityScopeId[] {
|
||||
return withPortalEntityDependencies(
|
||||
PORTAL_ENTITY_SCOPE_IDS.filter(
|
||||
(scopeId) =>
|
||||
isVisiblePortalScope(scopeId) &&
|
||||
isPortalEntityScopeAccessible(scopeId, isAdmin),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** One entity scope's fetch, for the editor seam (which has no QueryClient —
|
||||
* the portal bar reads the shared query layer instead). `tier` shapes only
|
||||
* presentational fields on the users payload, never the lists — hosts without
|
||||
* a TierContext pass "free". */
|
||||
export async function fetchPortalEntityScope(
|
||||
scopeId: PortalEntityScopeId,
|
||||
tier: Tier,
|
||||
): Promise<PortalEntityItems> {
|
||||
switch (scopeId) {
|
||||
case "portal-users":
|
||||
return (await usersBackend.fetchUsers(tier)).members;
|
||||
case "portal-policies": {
|
||||
const [list, runs] = await Promise.all([
|
||||
fetchPoliciesList(),
|
||||
fetchPolicyRuns(),
|
||||
]);
|
||||
return assemblePolicies(list, runs).catalogue;
|
||||
}
|
||||
case "portal-pipelines":
|
||||
return (await fetchPipelines()).pipelines;
|
||||
case "portal-sources":
|
||||
return (await fetchSources()).sources;
|
||||
}
|
||||
}
|
||||
|
||||
/** Assembles per-scope cache values into the typed entity sets. The casts are
|
||||
* sound because fetchPortalEntityScope keys each item type to its scope. */
|
||||
export function toProcessorEntities(
|
||||
values: Partial<Record<PortalEntityScopeId, PortalEntityItems>>,
|
||||
): ProcessorEntities {
|
||||
return {
|
||||
users: (values["portal-users"] as Member[] | undefined) ?? [],
|
||||
policies: (values["portal-policies"] as CatalogueEntry[] | undefined) ?? [],
|
||||
pipelines: (values["portal-pipelines"] as PipelineView[] | undefined) ?? [],
|
||||
sources: (values["portal-sources"] as SourceView[] | undefined) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
type Translate = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function policyResultTitle(entry: CatalogueEntry, t: Translate) {
|
||||
const category = t(entry.category.label);
|
||||
return entry.policy
|
||||
? t("portal.policies.defaultName", { category })
|
||||
: category;
|
||||
}
|
||||
|
||||
export function rankPortalPolicyResults(
|
||||
entries: CatalogueEntry[],
|
||||
trimmed: string,
|
||||
t: Translate,
|
||||
openPolicy: (categoryId: string) => void,
|
||||
limit = ENTITY_GROUP_LIMIT,
|
||||
): SuperSearchResult[] {
|
||||
return rankByFuzzy(
|
||||
entries.filter((entry) => !entry.category.comingSoon),
|
||||
trimmed,
|
||||
[
|
||||
(entry) => policyResultTitle(entry, t),
|
||||
(entry) => t(entry.category.label),
|
||||
(entry) => t(entry.category.desc),
|
||||
],
|
||||
)
|
||||
.slice(0, limit)
|
||||
.map(({ item, score }) => ({
|
||||
key: `portal-policy:${item.category.id}`,
|
||||
group: "portal-policies",
|
||||
title: policyResultTitle(item, t),
|
||||
subtitle: t(item.category.desc),
|
||||
icon: <PoliciesIcon />,
|
||||
score,
|
||||
onSelect: () => openPolicy(item.category.id),
|
||||
}));
|
||||
}
|
||||
|
||||
export function rankPortalPipelineResults(
|
||||
entries: PipelineView[],
|
||||
trimmed: string,
|
||||
excludedIds: ReadonlySet<string>,
|
||||
openPipeline: (pipelineId: string) => void,
|
||||
limit = ENTITY_GROUP_LIMIT,
|
||||
): SuperSearchResult[] {
|
||||
return rankByFuzzy(
|
||||
entries.filter((entry) => !excludedIds.has(entry.id)),
|
||||
trimmed,
|
||||
[(entry) => entry.name, (entry) => entry.trigger],
|
||||
)
|
||||
.slice(0, limit)
|
||||
.map(({ item, score }) => ({
|
||||
key: `portal-pipeline:${item.id}`,
|
||||
group: "portal-pipelines",
|
||||
title: item.name,
|
||||
subtitle: item.trigger,
|
||||
icon: <PipelinesIcon />,
|
||||
score,
|
||||
onSelect: () => openPipeline(item.id),
|
||||
}));
|
||||
}
|
||||
|
||||
export interface BuildEntityGroupsOptions {
|
||||
/** Host scope filter; defaults to every scope enabled. */
|
||||
scopeEnabled?: (scopeId: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks the entity sets into display groups. Selects navigate to the entity's
|
||||
* portal route (deep links where the views support them) — the portal is a
|
||||
* route-set of the same SPA, so this works from either app.
|
||||
*/
|
||||
export function buildProcessorEntityGroups(
|
||||
entities: ProcessorEntities,
|
||||
trimmed: string,
|
||||
t: Translate,
|
||||
navigate: (path: string) => void,
|
||||
options: BuildEntityGroupsOptions = {},
|
||||
): SuperSearchGroup[] {
|
||||
if (!trimmed) return [];
|
||||
const scopeEnabled = options.scopeEnabled ?? (() => true);
|
||||
const groups: SuperSearchGroup[] = [];
|
||||
|
||||
const includeScope = (scopeId: PortalEntityScopeId) =>
|
||||
isVisiblePortalScope(scopeId) && scopeEnabled(scopeId);
|
||||
|
||||
const users = includeScope("portal-users")
|
||||
? rankByFuzzy(entities.users, trimmed, [
|
||||
(member) => member.name,
|
||||
(member) => member.email,
|
||||
])
|
||||
.slice(0, ENTITY_GROUP_LIMIT)
|
||||
.map(({ item, score }) => ({
|
||||
key: `portal-user:${item.id}`,
|
||||
group: "portal-users",
|
||||
title: item.name,
|
||||
subtitle: item.email,
|
||||
icon: <UsersIcon />,
|
||||
score,
|
||||
onSelect: () =>
|
||||
navigate(
|
||||
`${toPortalPath(VIEW_PATHS.users)}?member=${encodeURIComponent(item.id)}`,
|
||||
),
|
||||
}))
|
||||
: [];
|
||||
if (users.length > 0) {
|
||||
groups.push({
|
||||
id: "portal-users",
|
||||
label: t("portal.nav.users"),
|
||||
results: users,
|
||||
});
|
||||
}
|
||||
|
||||
const policies = includeScope("portal-policies")
|
||||
? rankPortalPolicyResults(
|
||||
entities.policies,
|
||||
trimmed,
|
||||
t,
|
||||
(categoryId) =>
|
||||
navigate(
|
||||
`${toPortalPath(VIEW_PATHS.policies)}?category=${encodeURIComponent(categoryId)}`,
|
||||
),
|
||||
ENTITY_GROUP_LIMIT,
|
||||
)
|
||||
: [];
|
||||
if (policies.length > 0) {
|
||||
groups.push({
|
||||
id: "portal-policies",
|
||||
label: t("portal.nav.policies"),
|
||||
results: policies,
|
||||
});
|
||||
}
|
||||
|
||||
// Policy-backed pipelines already surface as policies; listing them twice
|
||||
// under different names would read as duplicates.
|
||||
const policyPipelineIds = new Set(
|
||||
entities.policies.flatMap((entry) =>
|
||||
entry.policy?.state.backendId ? [entry.policy.state.backendId] : [],
|
||||
),
|
||||
);
|
||||
const pipelines = includeScope("portal-pipelines")
|
||||
? rankPortalPipelineResults(
|
||||
entities.pipelines,
|
||||
trimmed,
|
||||
policyPipelineIds,
|
||||
(pipelineId) =>
|
||||
navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipelineId}`),
|
||||
ENTITY_GROUP_LIMIT,
|
||||
)
|
||||
: [];
|
||||
if (pipelines.length > 0) {
|
||||
groups.push({
|
||||
id: "portal-pipelines",
|
||||
label: t("portal.nav.pipelines"),
|
||||
results: pipelines,
|
||||
});
|
||||
}
|
||||
|
||||
const sources = includeScope("portal-sources")
|
||||
? rankByFuzzy(entities.sources, trimmed, [
|
||||
(source) => source.name,
|
||||
(source) => source.type,
|
||||
])
|
||||
.slice(0, ENTITY_GROUP_LIMIT)
|
||||
.map(({ item, score }) => ({
|
||||
key: `portal-source:${item.id}`,
|
||||
group: "portal-sources",
|
||||
title: item.name,
|
||||
subtitle: item.type,
|
||||
icon: <SourcesIcon />,
|
||||
score,
|
||||
onSelect: () =>
|
||||
navigate(`${toPortalPath(VIEW_PATHS.sources)}/${item.id}`),
|
||||
}))
|
||||
: [];
|
||||
if (sources.length > 0) {
|
||||
groups.push({
|
||||
id: "portal-sources",
|
||||
label: t("portal.nav.sources"),
|
||||
results: sources,
|
||||
});
|
||||
}
|
||||
|
||||
const docs =
|
||||
isDocsSearchable() && scopeEnabled(PORTAL_DOCS_SCOPE_ID)
|
||||
? rankDocsResults(trimmed, navigate, ENTITY_GROUP_LIMIT)
|
||||
: [];
|
||||
if (docs.length > 0) {
|
||||
groups.push({
|
||||
id: PORTAL_DOCS_SCOPE_ID,
|
||||
label: t("superSearch.group.docs"),
|
||||
results: docs,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
@@ -45,137 +45,6 @@
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.portal-docs__search {
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.portal-docs__search-box {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.portal-docs__search-icon {
|
||||
position: absolute;
|
||||
left: 0.625rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.9375rem;
|
||||
color: var(--c-text-subtle);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.portal-docs__search-input {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.6rem 0.4rem 1.9rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--c-text);
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--c-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.portal-docs__search-input:focus {
|
||||
border-color: var(--c-primary);
|
||||
}
|
||||
|
||||
.portal-docs__nav-empty {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--c-text-subtle);
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Search results ────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-docs__results {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-docs__results-count {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: var(--c-text-subtle);
|
||||
padding: 0 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
.portal-docs__results-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Hairline divider between results for clear, calm separation. */
|
||||
.portal-docs__results-list li + li {
|
||||
border-top: 1px solid var(--c-border-subtle);
|
||||
}
|
||||
|
||||
.portal-docs__result {
|
||||
height: auto;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.portal-docs__result:hover,
|
||||
.portal-docs__result.is-active {
|
||||
background: var(--c-hover);
|
||||
}
|
||||
|
||||
.portal-docs__result-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Title + section share one line; the title truncates before the section. */
|
||||
.portal-docs__result-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-docs__result-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--c-text);
|
||||
line-height: 1.3;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.portal-docs__result-section {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
.portal-docs__result-snippet {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
color: var(--c-text-subtle);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Subtle match emphasis — coloured text, not a filled block. */
|
||||
.portal-docs__hl {
|
||||
color: var(--c-accent-text);
|
||||
font-weight: 600;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.portal-docs__nav-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -25,7 +25,6 @@ const renderDocs = (ui: ReactElement) =>
|
||||
describe("DeveloperDocs — markdown browser over the generated manifest", () => {
|
||||
it("keeps Overview static (open, no toggle) and other sections collapsed", () => {
|
||||
renderDocs(<DeveloperDocs />);
|
||||
expect(screen.getByRole("searchbox")).toBeInTheDocument();
|
||||
// Overview is static: its items show, and it has no toggle button.
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Production Deployment Guide" }),
|
||||
@@ -50,23 +49,6 @@ describe("DeveloperDocs — markdown browser over the generated manifest", () =>
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("searches doc content (not just titles), shows a snippet, and navigates", async () => {
|
||||
renderDocs(<DeveloperDocs />);
|
||||
// "Tesseract" appears in the OCR doc body but in no doc title — a result
|
||||
// whose snippet contains it proves full-text (content) search.
|
||||
fireEvent.change(screen.getByRole("searchbox"), {
|
||||
target: { value: "Tesseract" },
|
||||
});
|
||||
const hits = await screen.findAllByRole("button", { name: /Tesseract/i });
|
||||
expect(hits.length).toBeGreaterThan(0);
|
||||
fireEvent.click(hits[0]);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText(/locally hosted web application/i),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("follows an internal doc: link inside the rendered markdown", async () => {
|
||||
renderDocs(<DeveloperDocs />);
|
||||
// The Getting Started body links to the Migration guide via the doc: scheme.
|
||||
|
||||
@@ -3,25 +3,22 @@ import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Button, EmptyState } from "@app/ui";
|
||||
import { DocsNav } from "@portal/components/docs/DocsNav";
|
||||
import { DocsSearch } from "@portal/components/docs/DocsSearch";
|
||||
import { DocsSection } from "@portal/components/docs/DocsSection";
|
||||
import { DocsToc } from "@portal/components/docs/DocsToc";
|
||||
import { MarkdownDoc } from "@portal/components/docs/MarkdownDoc";
|
||||
import { extractHeadings } from "@portal/docs/headings";
|
||||
import {
|
||||
allDocs,
|
||||
firstDocId,
|
||||
loadDoc,
|
||||
loadDocsNav,
|
||||
} from "@portal/docs/manifest/registry";
|
||||
import { searchDocs, toPlainText, type SearchDoc } from "@portal/docs/search";
|
||||
import "@portal/views/DeveloperDocs.css";
|
||||
|
||||
/**
|
||||
* Developer Docs — a markdown browser over the docs manifest generated from the
|
||||
* Stirling docs repo (see scripts/sync-portal-docs.mts). The nav is auto-sorted
|
||||
* from the repo's folders + frontmatter; content is the repo markdown, and the
|
||||
* search box does full-text search across every doc.
|
||||
* from the repo's folders + frontmatter; content is the repo markdown. Full-text
|
||||
* search across docs lives in the global super search (Cmd/Ctrl+K).
|
||||
*/
|
||||
export function DeveloperDocs() {
|
||||
const { t } = useTranslation();
|
||||
@@ -29,24 +26,10 @@ export function DeveloperDocs() {
|
||||
const navigate = useNavigate();
|
||||
const contentRef = useRef<HTMLElement>(null);
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const nav = useMemo(() => loadDocsNav(), []);
|
||||
const fallback = useMemo(() => firstDocId(), []);
|
||||
|
||||
// Full-text index over every doc's plaintext body (built once).
|
||||
const index = useMemo<SearchDoc[]>(() => {
|
||||
const labels = new Map(nav.map((s) => [s.id, s.label]));
|
||||
return allDocs().map((d) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
sectionLabel: labels.get(d.section) ?? "",
|
||||
text: toPlainText(d.markdown),
|
||||
}));
|
||||
}, [nav]);
|
||||
const results = useMemo(() => searchDocs(index, query), [index, query]);
|
||||
const searching = query.trim().length > 0;
|
||||
|
||||
// Deep-link support: the active doc id lives in the URL hash.
|
||||
const hashId = decodeURIComponent(hash.replace(/^#/, ""));
|
||||
const activeId = hashId && loadDoc(hashId) ? hashId : fallback;
|
||||
@@ -61,12 +44,11 @@ export function DeveloperDocs() {
|
||||
[doc],
|
||||
);
|
||||
|
||||
// Navigating closes the mobile drawer, clears the search, and resets the pane.
|
||||
// Navigating closes the mobile drawer and resets the pane.
|
||||
const onSelect = useCallback(
|
||||
(id: string) => {
|
||||
navigate({ hash: id });
|
||||
setNavOpen(false);
|
||||
setQuery("");
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
@@ -100,19 +82,11 @@ export function DeveloperDocs() {
|
||||
{t("portal.docs.browse")}
|
||||
</Button>
|
||||
|
||||
{/* Layout columns, not landmarks: the search and the two <nav>s inside them
|
||||
already carry their own named landmarks, and a pair of unlabelled
|
||||
complementary regions is indistinguishable to assistive tech. */}
|
||||
{/* Layout column, not a landmark: the <nav> inside already carries its
|
||||
own named landmark, and an unlabelled complementary region would be
|
||||
indistinguishable to assistive tech. */}
|
||||
<div className={"portal-docs__sidebar" + (navOpen ? " is-open" : "")}>
|
||||
<DocsSearch
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
results={results}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
{!searching && (
|
||||
<DocsNav sections={nav} active={activeId ?? ""} onSelect={onSelect} />
|
||||
)}
|
||||
<DocsNav sections={nav} active={activeId ?? ""} onSelect={onSelect} />
|
||||
</div>
|
||||
|
||||
<main className="portal-docs__content" ref={contentRef}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -75,30 +75,58 @@ export function Policies() {
|
||||
// is configured so a fresh workspace doesn't show a row of zeros.
|
||||
const hasPolicies = !!data && data.summary.active + data.summary.paused > 0;
|
||||
|
||||
const displayCatalogue: CatalogueEntry[] =
|
||||
catalogue.length > 0
|
||||
? catalogue
|
||||
: POLICY_CATEGORIES.map((cat) => ({
|
||||
category: cat,
|
||||
config: POLICY_CONFIG[cat.id] ?? {
|
||||
summary: "",
|
||||
rules: [],
|
||||
scopeLabel: "",
|
||||
fields: [],
|
||||
defaultOperations: [],
|
||||
},
|
||||
policy: null,
|
||||
}));
|
||||
const displayCatalogue: CatalogueEntry[] = useMemo(
|
||||
() =>
|
||||
catalogue.length > 0
|
||||
? catalogue
|
||||
: POLICY_CATEGORIES.map((cat) => ({
|
||||
category: cat,
|
||||
config: POLICY_CONFIG[cat.id] ?? {
|
||||
summary: "",
|
||||
rules: [],
|
||||
scopeLabel: "",
|
||||
fields: [],
|
||||
defaultOperations: [],
|
||||
},
|
||||
policy: null,
|
||||
})),
|
||||
[catalogue],
|
||||
);
|
||||
|
||||
function openEntry(entry: CatalogueEntry) {
|
||||
// Block setup of an AI-required policy until the engine is confirmed on (so a
|
||||
// click during the app-config load can't open a wizard for a disabled
|
||||
// feature); a configured policy stays openable so it can be paused/deleted.
|
||||
if (entry.category.requiresAiEngine && !aiEngineEnabled && !entry.policy)
|
||||
return;
|
||||
if (entry.policy) setDetail(entry);
|
||||
else setWizard(entry);
|
||||
}
|
||||
const openEntry = useCallback(
|
||||
(entry: CatalogueEntry) => {
|
||||
// Block setup of an AI-required policy until the engine is confirmed on (so a
|
||||
// click during the app-config load can't open a wizard for a disabled
|
||||
// feature); a configured policy stays openable so it can be paused/deleted.
|
||||
if (entry.category.requiresAiEngine && !aiEngineEnabled && !entry.policy)
|
||||
return;
|
||||
if (entry.policy) setDetail(entry);
|
||||
else setWizard(entry);
|
||||
},
|
||||
[aiEngineEnabled],
|
||||
);
|
||||
|
||||
// Open a category passed as ?category=<id> (deep link from the super
|
||||
// search), then strip the param so back/reload doesn't re-open it. Waits for
|
||||
// the AI-engine flag too: openEntry refuses AI-gated categories until the
|
||||
// flag is confirmed, and stripping the param before that decision would
|
||||
// drop the deep link silently.
|
||||
useEffect(() => {
|
||||
const categoryId = searchParams.get("category");
|
||||
if (categoryId === null || loading || aiEngineLoading) return;
|
||||
const entry = displayCatalogue.find((e) => e.category.id === categoryId);
|
||||
if (entry) openEntry(entry);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("category");
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
loading,
|
||||
aiEngineLoading,
|
||||
displayCatalogue,
|
||||
openEntry,
|
||||
]);
|
||||
|
||||
async function handleSubmit(
|
||||
entry: CatalogueEntry,
|
||||
|
||||
@@ -355,6 +355,18 @@
|
||||
.portal-users__row + .portal-users__row {
|
||||
border-top: 1px solid var(--c-border-subtle);
|
||||
}
|
||||
/* Deep-link landing flash (?member=<id>). */
|
||||
.portal-users__row--flash {
|
||||
animation: portal-users-row-flash 1.6s ease-out;
|
||||
}
|
||||
@keyframes portal-users-row-flash {
|
||||
from {
|
||||
background: var(--c-hover);
|
||||
}
|
||||
to {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
.portal-users__row-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -70,6 +70,27 @@ export function Users() {
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
// Scroll to and flash the row for ?member=<id> (deep link from the super
|
||||
// search), once the roster has rendered; then strip the param.
|
||||
useEffect(() => {
|
||||
const memberId = searchParams.get("member");
|
||||
if (memberId === null || usersState.loading) return;
|
||||
const row = document.querySelector(
|
||||
`[data-member-id="${CSS.escape(memberId)}"]`,
|
||||
);
|
||||
if (row) {
|
||||
row.scrollIntoView({ block: "center" });
|
||||
row.classList.add("portal-users__row--flash");
|
||||
window.setTimeout(
|
||||
() => row.classList.remove("portal-users__row--flash"),
|
||||
1600,
|
||||
);
|
||||
}
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("member");
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [searchParams, setSearchParams, usersState.loading]);
|
||||
|
||||
// PORTAL grants held by a whole team (principalId = teamId); members inherit these.
|
||||
const grantByTeam = useMemo(() => {
|
||||
const m = new Map<number, ResourceGrant>();
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useScopedFetchCache } from "@app/hooks/useScopedFetchCache";
|
||||
import type { SuperSearchGroup } from "@app/types/superSearch";
|
||||
import type {
|
||||
PortalEntityItems,
|
||||
PortalEntityScopeId,
|
||||
} from "@portal/search/entitySearch";
|
||||
|
||||
type EntitySearchModule = typeof import("@portal/search/entitySearch");
|
||||
|
||||
// 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
|
||||
// fetch or offer entities that have nowhere to open.
|
||||
const includePortal =
|
||||
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
|
||||
|
||||
const NO_GROUPS: SuperSearchGroup[] = [];
|
||||
const NO_SCOPES: readonly PortalEntityScopeId[] = [];
|
||||
|
||||
/**
|
||||
* Processor entity results for the editor's super search. The portal's
|
||||
* entity-search module is imported on demand (first search keystroke) — a
|
||||
* static value import here would pull the portal into the main bundle, the
|
||||
* same constraint the static page index (processorSearchIndex) lives under.
|
||||
* Fetch discipline (TTL, in-flight dedupe, generation guard) comes from
|
||||
* useScopedFetchCache; the scope list is dynamic and the fetcher lives in the
|
||||
* lazily loaded module, which useQuery's static-key shape handles awkwardly.
|
||||
*
|
||||
* `tier` shapes only presentational fields on the users payload, never the
|
||||
* lists (see fetchPortalEntityScope), so the editor passes "free" rather than
|
||||
* mounting the portal's TierContext.
|
||||
*/
|
||||
export function useProcessorEntityGroups(
|
||||
trimmed: string,
|
||||
enabled: boolean,
|
||||
t: TFunction,
|
||||
navigate: (path: string) => void,
|
||||
scopeEnabled: (scopeId: string) => boolean = () => true,
|
||||
isAdmin = false,
|
||||
): SuperSearchGroup[] {
|
||||
const [mod, setMod] = useState<EntitySearchModule | null>(null);
|
||||
const modRef = useRef<EntitySearchModule | null>(null);
|
||||
const active = enabled && includePortal;
|
||||
const hasQuery = trimmed.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || modRef.current) return;
|
||||
let cancelled = false;
|
||||
void import("@portal/search/entitySearch").then((loaded) => {
|
||||
if (cancelled) return;
|
||||
modRef.current = loaded;
|
||||
setMod(loaded);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
const requestedScopes = useMemo<readonly PortalEntityScopeId[]>(() => {
|
||||
if (!active || !hasQuery || !mod) return NO_SCOPES;
|
||||
return mod.withPortalEntityDependencies(
|
||||
mod
|
||||
.defaultPortalEntityScopes(isAdmin)
|
||||
.filter((scopeId) => scopeEnabled(scopeId)),
|
||||
);
|
||||
}, [active, hasQuery, mod, scopeEnabled, isAdmin]);
|
||||
|
||||
const fetchScope = useCallback(
|
||||
async (scopeId: PortalEntityScopeId): Promise<PortalEntityItems> => {
|
||||
const loaded =
|
||||
modRef.current ?? (await import("@portal/search/entitySearch"));
|
||||
return loaded.fetchPortalEntityScope(scopeId, "free");
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { values } = useScopedFetchCache(
|
||||
requestedScopes,
|
||||
fetchScope,
|
||||
// The TTL lives in the (lazily loaded) module; until it arrives no scope
|
||||
// is requested, so the placeholder never gates a real fetch.
|
||||
mod?.ENTITY_REFRESH_MS ?? Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
|
||||
const entities = useMemo(
|
||||
() => (mod ? mod.toProcessorEntities(values) : null),
|
||||
[mod, values],
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!mod || !entities || !active || !hasQuery) return NO_GROUPS;
|
||||
return mod.buildProcessorEntityGroups(entities, trimmed, t, navigate, {
|
||||
scopeEnabled,
|
||||
});
|
||||
}, [mod, entities, active, hasQuery, trimmed, t, navigate, scopeEnabled]);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
// A static leaf module (its portal import is type-only), so it doesn't pull
|
||||
// the lazy portal chunk into the main bundle the way @portal/* values would.
|
||||
import { usersCapabilities } from "@app/portal/usersCapabilities";
|
||||
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: "integrations",
|
||||
labelKey: "portal.nav.integrations",
|
||||
labelFallback: "Integrations",
|
||||
path: `${PORTAL_BASENAME}/integrations`,
|
||||
keywords: ["connections", "external", "api", "webhooks"],
|
||||
},
|
||||
{
|
||||
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", "payg", "bundles"],
|
||||
},
|
||||
{
|
||||
id: "docs",
|
||||
labelKey: "portal.nav.docs",
|
||||
labelFallback: "Documentation",
|
||||
path: `${PORTAL_BASENAME}/docs`,
|
||||
keywords: ["api", "documentation", "reference", "guides"],
|
||||
},
|
||||
];
|
||||
|
||||
export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = includePortal
|
||||
? VIEWS
|
||||
: [];
|
||||
|
||||
/**
|
||||
* Whether an entity scope's data source will serve the current session at
|
||||
* all. The users roster is the one divergent case: portal access alone
|
||||
* doesn't imply the roster endpoint will answer (see
|
||||
* UsersCapabilities.listingRequiresAdmin) — offering the lane anyway renders
|
||||
* a permanently-empty chip that fires a doomed request on every search.
|
||||
*/
|
||||
export function isPortalEntityScopeAccessible(
|
||||
scopeId: string,
|
||||
isAdmin: boolean,
|
||||
): boolean {
|
||||
if (scopeId !== "portal-users") return true;
|
||||
return isAdmin || !usersCapabilities.listingRequiresAdmin;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
SETTINGS_SECTION_REGISTRY as CORE_SETTINGS_SECTION_REGISTRY,
|
||||
type SettingsSectionEntry,
|
||||
} from "@core/data/settingsSectionRegistry";
|
||||
|
||||
export type { SettingsSectionEntry };
|
||||
|
||||
/**
|
||||
* Self-hosted (proprietary) settings sections. Extends the core list with the
|
||||
* account tab, the developer API keys tab, and the admin area — mirroring the
|
||||
* sections added by the proprietary nav builder (`configNavSections`). Keep the
|
||||
* two in sync: a section here that the modal can't render would deep-link to a
|
||||
* dead tab, and a modal section missing here isn't searchable.
|
||||
*
|
||||
* Gating flags mirror the builder: `account`/`api-keys` need login; the admin
|
||||
* sections are surfaced when the user is an admin OR login mode is off.
|
||||
*/
|
||||
export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
|
||||
...CORE_SETTINGS_SECTION_REGISTRY,
|
||||
{
|
||||
key: "account",
|
||||
labelKey: "account.accountSettings",
|
||||
labelFallback: "Account",
|
||||
keywords: ["profile", "email", "password", "user"],
|
||||
requiresLogin: true,
|
||||
groupLabelKey: "settings.preferences.title",
|
||||
groupLabelFallback: "Preferences",
|
||||
},
|
||||
{
|
||||
key: "api-keys",
|
||||
labelKey: "settings.developer.apiKeys",
|
||||
labelFallback: "API Keys",
|
||||
keywords: ["api", "token", "developer", "key"],
|
||||
requiresLogin: true,
|
||||
groupLabelKey: "settings.developer.title",
|
||||
groupLabelFallback: "Developer",
|
||||
},
|
||||
// --- Workspace ---
|
||||
{
|
||||
key: "people",
|
||||
labelKey: "settings.workspace.people",
|
||||
labelFallback: "People",
|
||||
keywords: ["members", "users", "invite", "add member", "roles"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.workspace.title",
|
||||
groupLabelFallback: "Workspace",
|
||||
},
|
||||
{
|
||||
key: "teams",
|
||||
labelKey: "settings.workspace.teams",
|
||||
labelFallback: "Teams",
|
||||
keywords: ["team", "group", "create team"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.workspace.title",
|
||||
groupLabelFallback: "Workspace",
|
||||
},
|
||||
// --- Configuration ---
|
||||
{
|
||||
key: "adminGeneral",
|
||||
labelKey: "settings.configuration.systemSettings",
|
||||
labelFallback: "System Settings",
|
||||
keywords: ["system", "config", "server"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.configuration.title",
|
||||
groupLabelFallback: "Configuration",
|
||||
},
|
||||
{
|
||||
key: "adminFeatures",
|
||||
labelKey: "settings.configuration.features",
|
||||
labelFallback: "Features",
|
||||
keywords: ["features", "toggles", "flags"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.configuration.title",
|
||||
groupLabelFallback: "Configuration",
|
||||
},
|
||||
{
|
||||
key: "adminStorageSharing",
|
||||
labelKey: "settings.configuration.storageSharing",
|
||||
labelFallback: "File Storage & Sharing",
|
||||
keywords: ["storage", "sharing", "files"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.configuration.title",
|
||||
groupLabelFallback: "Configuration",
|
||||
},
|
||||
{
|
||||
key: "adminEndpoints",
|
||||
labelKey: "settings.configuration.endpoints",
|
||||
labelFallback: "Endpoints",
|
||||
keywords: ["endpoints", "api", "routes"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.configuration.title",
|
||||
groupLabelFallback: "Configuration",
|
||||
},
|
||||
{
|
||||
key: "adminMcp",
|
||||
labelKey: "settings.configuration.mcp",
|
||||
labelFallback: "MCP Server",
|
||||
keywords: ["mcp", "server", "ai", "model context protocol"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.configuration.title",
|
||||
groupLabelFallback: "Configuration",
|
||||
},
|
||||
{
|
||||
key: "adminDatabase",
|
||||
labelKey: "settings.configuration.database",
|
||||
labelFallback: "Database",
|
||||
keywords: ["database", "db", "backup"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.configuration.title",
|
||||
groupLabelFallback: "Configuration",
|
||||
},
|
||||
{
|
||||
key: "adminFolderAccess",
|
||||
labelKey: "settings.configuration.folderAccess",
|
||||
labelFallback: "Folder Access",
|
||||
keywords: ["folders", "access", "permissions", "scanning"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.configuration.title",
|
||||
groupLabelFallback: "Configuration",
|
||||
},
|
||||
{
|
||||
key: "adminAdvanced",
|
||||
labelKey: "settings.configuration.advanced",
|
||||
labelFallback: "Advanced",
|
||||
keywords: ["advanced", "expert"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.configuration.title",
|
||||
groupLabelFallback: "Configuration",
|
||||
},
|
||||
// --- AI ---
|
||||
{
|
||||
key: "adminAiGeneral",
|
||||
labelKey: "settings.ai.general",
|
||||
labelFallback: "General",
|
||||
keywords: ["ai", "assistant", "enable"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.ai.title",
|
||||
groupLabelFallback: "AI",
|
||||
},
|
||||
{
|
||||
key: "adminAiModels",
|
||||
labelKey: "settings.ai.models",
|
||||
labelFallback: "Models & Providers",
|
||||
keywords: ["ai", "models", "providers", "llm", "openai", "anthropic"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.ai.title",
|
||||
groupLabelFallback: "AI",
|
||||
},
|
||||
{
|
||||
key: "adminAiDocuments",
|
||||
labelKey: "settings.ai.documents",
|
||||
labelFallback: "Documents & RAG",
|
||||
keywords: ["ai", "rag", "embedding", "retrieval", "documents"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.ai.title",
|
||||
groupLabelFallback: "AI",
|
||||
},
|
||||
{
|
||||
key: "adminAiLimits",
|
||||
labelKey: "settings.ai.limits",
|
||||
labelFallback: "Limits & Performance",
|
||||
keywords: ["ai", "limits", "tokens", "rate", "performance"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.ai.title",
|
||||
groupLabelFallback: "AI",
|
||||
},
|
||||
// --- Security & Authentication ---
|
||||
{
|
||||
key: "adminSecurity",
|
||||
labelKey: "settings.securityAuth.security",
|
||||
labelFallback: "Security",
|
||||
keywords: ["security", "authentication", "auth", "password", "sessions"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.securityAuth.title",
|
||||
groupLabelFallback: "Security & Authentication",
|
||||
},
|
||||
{
|
||||
key: "adminConnections",
|
||||
labelKey: "settings.securityAuth.connections",
|
||||
labelFallback: "Connections",
|
||||
keywords: ["connections", "oauth", "sso", "integrations"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.securityAuth.title",
|
||||
groupLabelFallback: "Security & Authentication",
|
||||
},
|
||||
// --- Licensing & Analytics ---
|
||||
{
|
||||
key: "adminPlan",
|
||||
labelKey: "settings.licensingAnalytics.plan",
|
||||
labelFallback: "Plan",
|
||||
keywords: ["plan", "license", "billing", "subscription", "enterprise"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.licensingAnalytics.title",
|
||||
groupLabelFallback: "Licensing & Analytics",
|
||||
},
|
||||
{
|
||||
key: "adminAudit",
|
||||
labelKey: "settings.licensingAnalytics.audit",
|
||||
labelFallback: "Audit",
|
||||
keywords: ["audit", "logs", "events", "history"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.licensingAnalytics.title",
|
||||
groupLabelFallback: "Licensing & Analytics",
|
||||
},
|
||||
{
|
||||
key: "adminUsage",
|
||||
labelKey: "settings.licensingAnalytics.usageAnalytics",
|
||||
labelFallback: "Usage Analytics",
|
||||
keywords: ["usage", "analytics", "stats", "metrics"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.licensingAnalytics.title",
|
||||
groupLabelFallback: "Licensing & Analytics",
|
||||
},
|
||||
// --- Policies & Privacy ---
|
||||
{
|
||||
key: "adminLegal",
|
||||
labelKey: "settings.policiesPrivacy.legal",
|
||||
labelFallback: "Legal",
|
||||
keywords: ["legal", "terms", "agreement"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.policiesPrivacy.title",
|
||||
groupLabelFallback: "Policies & Privacy",
|
||||
},
|
||||
{
|
||||
key: "adminPrivacy",
|
||||
labelKey: "settings.policiesPrivacy.privacy",
|
||||
labelFallback: "Privacy",
|
||||
keywords: ["privacy", "gdpr", "data"],
|
||||
adminArea: true,
|
||||
groupLabelKey: "settings.policiesPrivacy.title",
|
||||
groupLabelFallback: "Policies & Privacy",
|
||||
},
|
||||
];
|
||||
@@ -22,4 +22,5 @@ export const usersCapabilities: UsersCapabilities = {
|
||||
seats: false,
|
||||
manageGrants: true,
|
||||
removeScope: "org",
|
||||
listingRequiresAdmin: true,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Prototypes builds ship no portal (and have no @portal alias) — shadow the
|
||||
* entity search back to core's empty stub so the super search never resolves
|
||||
* the proprietary implementation's portal imports here.
|
||||
*/
|
||||
export { useProcessorEntityGroups } from "@core/data/processorEntitySearch";
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Prototypes builds ship no portal (and have no @portal alias) — shadow the
|
||||
* index back to core's empty list so the super search never resolves the
|
||||
* proprietary version's portal-flavored imports here.
|
||||
*/
|
||||
export type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
|
||||
export {
|
||||
PROCESSOR_SEARCH_INDEX,
|
||||
isPortalEntityScopeAccessible,
|
||||
} from "@core/data/processorSearchIndex";
|
||||
@@ -73,6 +73,13 @@ interface AuthContextType {
|
||||
refreshProStatus: () => Promise<void>;
|
||||
refreshProfilePicture: () => Promise<void>;
|
||||
refreshProfilePictureMetadata: () => Promise<void>;
|
||||
/**
|
||||
* Session-level permission flags (see the core auth seam). Supabase
|
||||
* sessions don't carry them — consumers treat undefined as "not granted"
|
||||
* and fall back to app-config gates.
|
||||
*/
|
||||
isAdmin?: boolean;
|
||||
portalAccess?: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
|
||||
@@ -31,6 +31,8 @@ interface AppConfigModalProps {
|
||||
urlSync?: boolean;
|
||||
/** Section to land on when opening (used by non-URL hosts like the portal). */
|
||||
initialSection?: NavKey | null;
|
||||
/** Accepted for interface parity with the core shell; this shell ignores it. */
|
||||
initialFocus?: string | null;
|
||||
/** Host-specific sections appended after the saas registry sections. */
|
||||
extraSections?: ConfigNavSection[];
|
||||
/** Registry section keys to drop, for hosts a section can't run in. */
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { type SettingsSectionEntry } from "@core/data/settingsSectionRegistry";
|
||||
|
||||
export type { SettingsSectionEntry };
|
||||
|
||||
/**
|
||||
* SaaS web settings sections. The SaaS modal is composed differently from
|
||||
* self-hosted (see `saasConfigNavSections`): an Account area (Overview,
|
||||
* Passwords & Security, Team), Preferences, a Developer area (API Keys, MCP),
|
||||
* cloud Billing (Plan) and Legal. It deliberately does NOT list the self-hosted
|
||||
* admin sections, so search never deep-links to a tab the SaaS modal can't show.
|
||||
*
|
||||
* The local backend's login/admin flags don't reflect SaaS auth, so entries
|
||||
* avoid `requiresLogin`/`adminArea`. Sections the SaaS nav only mounts for a
|
||||
* signed-in account (Team, Plan) carry `requiresAccount`, which the search
|
||||
* gates on the auth seam's `isAnonymous` — the signal the nav itself uses.
|
||||
*/
|
||||
export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
|
||||
{
|
||||
key: "overview",
|
||||
labelKey: "config.account.overview.label",
|
||||
labelFallback: "Overview",
|
||||
keywords: ["account", "profile", "email", "avatar", "logout"],
|
||||
groupLabelKey: "config.account.overview.title",
|
||||
groupLabelFallback: "Account Settings",
|
||||
},
|
||||
{
|
||||
key: "security",
|
||||
labelKey: "config.account.security.title",
|
||||
labelFallback: "Passwords & Security",
|
||||
keywords: ["password", "2fa", "authentication", "sessions", "security"],
|
||||
groupLabelKey: "config.account.overview.title",
|
||||
groupLabelFallback: "Account Settings",
|
||||
},
|
||||
{
|
||||
key: "teams",
|
||||
labelKey: "config.team",
|
||||
labelFallback: "Team",
|
||||
keywords: ["team", "members", "invite", "seats"],
|
||||
requiresAccount: true,
|
||||
groupLabelKey: "config.account.overview.title",
|
||||
groupLabelFallback: "Account Settings",
|
||||
},
|
||||
{
|
||||
key: "general",
|
||||
labelKey: "settings.general.title",
|
||||
labelFallback: "General",
|
||||
keywords: ["theme", "language", "appearance", "preferences", "startup"],
|
||||
groupLabelKey: "settings.preferences.title",
|
||||
groupLabelFallback: "Preferences",
|
||||
},
|
||||
{
|
||||
key: "hotkeys",
|
||||
labelKey: "settings.hotkeys.title",
|
||||
labelFallback: "Keyboard Shortcuts",
|
||||
keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
|
||||
groupLabelKey: "settings.preferences.title",
|
||||
groupLabelFallback: "Preferences",
|
||||
},
|
||||
{
|
||||
key: "api-keys",
|
||||
labelKey: "settings.developer.apiKeys",
|
||||
labelFallback: "API Keys",
|
||||
keywords: ["api", "token", "developer", "key"],
|
||||
groupLabelKey: "settings.developer.title",
|
||||
groupLabelFallback: "Developer",
|
||||
},
|
||||
{
|
||||
key: "mcp",
|
||||
labelKey: "config.mcp.navLabel",
|
||||
labelFallback: "MCP Server",
|
||||
keywords: ["mcp", "server", "ai", "model context protocol"],
|
||||
groupLabelKey: "settings.developer.title",
|
||||
groupLabelFallback: "Developer",
|
||||
},
|
||||
{
|
||||
key: "plan",
|
||||
labelKey: "config.plan",
|
||||
labelFallback: "Plan",
|
||||
keywords: [
|
||||
"billing",
|
||||
"subscription",
|
||||
"upgrade",
|
||||
"payment",
|
||||
"invoice",
|
||||
"wallet",
|
||||
],
|
||||
requiresAccount: true,
|
||||
groupLabelKey: "settings.planBilling.title",
|
||||
groupLabelFallback: "Plan & Billing",
|
||||
},
|
||||
{
|
||||
key: "legal",
|
||||
labelKey: "settings.legal.label",
|
||||
labelFallback: "Legal",
|
||||
keywords: ["legal", "terms", "privacy", "licenses"],
|
||||
groupLabelKey: "settings.legal.title",
|
||||
groupLabelFallback: "Legal",
|
||||
},
|
||||
];
|
||||
@@ -23,4 +23,5 @@ export const usersCapabilities: UsersCapabilities = {
|
||||
seats: true,
|
||||
manageGrants: false,
|
||||
removeScope: "team",
|
||||
listingRequiresAdmin: false,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user