From bbcbc65f208baa980315f0561f9f43a35a72f027 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Fri, 20 Mar 2026 16:47:41 -0700 Subject: [PATCH 01/28] ENG-3044: Add search to Admin UI sidebar navigation Add a search component to the sidebar nav that lets users quickly find and navigate to any page, tab, system, integration, or taxonomy type. Supports Cmd+K shortcut, Spotlight-style modal when collapsed, and full keyboard/screen-reader accessibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../features/common/nav/NavSearch.test.tsx | 788 ++++++++++++++++++ .../access-control/AccessControlTabs.tsx | 24 +- .../src/features/common/NotificationTabs.tsx | 9 + .../src/features/common/nav/MainSideNav.tsx | 2 + .../features/common/nav/NavSearch.module.scss | 219 +++++ .../src/features/common/nav/NavSearch.tsx | 360 ++++++++ .../src/features/common/nav/nav-config.tsx | 25 + .../features/common/nav/useNavSearchItems.ts | 164 ++++ .../hooks/usePrivacyRequestTabs.ts | 14 + 9 files changed, 1593 insertions(+), 12 deletions(-) create mode 100644 clients/admin-ui/__tests__/features/common/nav/NavSearch.test.tsx create mode 100644 clients/admin-ui/src/features/common/nav/NavSearch.module.scss create mode 100644 clients/admin-ui/src/features/common/nav/NavSearch.tsx create mode 100644 clients/admin-ui/src/features/common/nav/useNavSearchItems.ts diff --git a/clients/admin-ui/__tests__/features/common/nav/NavSearch.test.tsx b/clients/admin-ui/__tests__/features/common/nav/NavSearch.test.tsx new file mode 100644 index 00000000000..3483dd394b6 --- /dev/null +++ b/clients/admin-ui/__tests__/features/common/nav/NavSearch.test.tsx @@ -0,0 +1,788 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { NavGroup } from "~/features/common/nav/nav-config"; +import { FlatNavItem } from "~/features/common/nav/useNavSearchItems"; + +// Mock fidesui to avoid jsdom incompatibilities with Ant Design components. +// NavSearch imports: AutoComplete, Icons, Input, InputRef, Modal +jest.mock("fidesui", () => { + // eslint-disable-next-line global-require + const MockReact = require("react"); + return { + __esModule: true, + AutoComplete: (props: any) => { + (global as any).mockAutoCompleteProps = props; + const { options, onSelect, open, children, value, onSearch } = props; + return MockReact.createElement( + "div", + { "data-testid": "mock-autocomplete", "data-open": String(open) }, + MockReact.cloneElement(children, { + value, + onChange: (e: any) => onSearch?.(e.target.value), + }), + open + ? MockReact.createElement( + "ul", + { "data-testid": "mock-autocomplete-dropdown" }, + options?.map((group: any, gi: number) => + MockReact.createElement( + "li", + { key: gi, "data-testid": "option-group" }, + MockReact.createElement( + "span", + { "data-testid": "group-label" }, + group.label, + ), + MockReact.createElement( + "ul", + null, + group.options?.map((opt: any) => + MockReact.createElement( + "li", + { key: opt.value }, + MockReact.createElement( + "button", + { + type: "button", + "data-testid": `option-${opt.value}`, + onClick: () => onSelect?.(opt.value), + }, + opt.label, + ), + ), + ), + ), + ), + ), + ) + : null, + ); + }, + Input: MockReact.forwardRef((props: any, ref: any) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { prefix, suffix, allowClear, autoFocus, ...rest } = props; + return MockReact.createElement("input", { ...rest, ref }); + }), + Modal: ({ open: modalOpen, children, onCancel, destroyOnClose }: any) => { + if (!modalOpen) { + if (destroyOnClose) { + return null; + } + return MockReact.createElement( + "div", + { "data-testid": "mock-modal", style: { display: "none" } }, + children, + ); + } + return MockReact.createElement( + "div", + { "data-testid": "mock-modal" }, + children, + MockReact.createElement("button", { + "data-testid": "mock-modal-close", + onClick: onCancel, + }), + ); + }, + Icons: { + Search: () => MockReact.createElement("span", null, "search-icon"), + }, + }; +}); + +// Mock palette to avoid SCSS import issues +jest.mock("fidesui/src/palette/palette.module.scss", () => ({ + FIDESUI_CORINTH: "#fafafa", + FIDESUI_NEUTRAL_400: "#a8aaad", +})); + +// Mock useNavSearchItems to return static + dynamic items without Redux +const mockDynamicItems: FlatNavItem[] = []; +jest.mock("~/features/common/nav/useNavSearchItems", () => ({ + __esModule: true, + default: (groups: any[]) => { + const items: any[] = []; + groups.forEach((group: any) => { + group.children + .filter((child: any) => !child.hidden) + .forEach((child: any) => { + items.push({ + title: child.title, + path: child.path, + groupTitle: group.title, + }); + child.tabs?.forEach((tab: any) => { + items.push({ + title: tab.title, + path: tab.path, + groupTitle: group.title, + parentTitle: child.title, + }); + }); + }); + }); + return [...items, ...mockDynamicItems]; + }, +})); + +const mockPush = jest.fn(); + +jest.mock("next/router", () => ({ + useRouter: () => ({ + push: mockPush, + pathname: "/", + route: "/", + query: {}, + asPath: "/", + }), +})); + +// Must import NavSearch after mocks are set up +// eslint-disable-next-line global-require +const getNavSearch = () => require("~/features/common/nav/NavSearch").default; + +const MOCK_GROUPS: NavGroup[] = [ + { + title: "Overview", + icon: "icon" as unknown as React.ReactNode, + children: [{ title: "Home", path: "/", exact: true, children: [] }], + }, + { + title: "Data inventory", + icon: "icon" as unknown as React.ReactNode, + children: [ + { title: "System inventory", path: "/systems", children: [] }, + { title: "Manage datasets", path: "/dataset", children: [] }, + ], + }, + { + title: "Privacy requests", + icon: "icon" as unknown as React.ReactNode, + children: [ + { + title: "Request manager", + path: "/privacy-requests", + children: [], + tabs: [ + { title: "Requests", path: "/privacy-requests?tab=request" }, + { + title: "Manual tasks", + path: "/privacy-requests?tab=manual-tasks", + }, + ], + }, + { + title: "Hidden route", + path: "/hidden", + hidden: true, + children: [], + }, + ], + }, + { + title: "Settings", + icon: "icon" as unknown as React.ReactNode, + children: [ + { + title: "Privacy requests", + path: "/settings/privacy-requests", + children: [], + tabs: [ + { + title: "Redaction patterns", + path: "/settings/privacy-requests", + }, + { + title: "Duplicate detection", + path: "/settings/privacy-requests", + }, + ], + }, + ], + }, +]; + +const getAutoCompleteProps = (): Record => + (global as any).mockAutoCompleteProps ?? {}; + +const getOptionValues = (): string[] => { + const options = getAutoCompleteProps().options ?? []; + return options.flatMap((g: any) => g.options.map((o: any) => o.value)); +}; + +const getGroupLabels = (): string[] => { + const options = getAutoCompleteProps().options ?? []; + return options.map((g: any) => g.label.props.children as string); +}; + +beforeEach(() => { + jest.clearAllMocks(); + (global as any).mockAutoCompleteProps = {}; +}); + +afterEach(() => { + mockDynamicItems.length = 0; +}); + +describe("NavSearch", () => { + describe("expanded mode", () => { + it("renders the search input", () => { + const NavSearch = getNavSearch(); + render(); + expect(screen.getByTestId("nav-search-input")).toBeInTheDocument(); + }); + + it("does not render the collapsed toggle button", () => { + const NavSearch = getNavSearch(); + render(); + expect(screen.queryByTestId("nav-search-toggle")).not.toBeInTheDocument(); + }); + + it("sets defaultActiveFirstOption for Enter key selection", () => { + const NavSearch = getNavSearch(); + render(); + fireEvent.focus(screen.getByTestId("nav-search-input")); + + expect(getAutoCompleteProps().defaultActiveFirstOption).toBe(true); + }); + }); + + describe("collapsed mode", () => { + it("renders the search toggle button", () => { + const NavSearch = getNavSearch(); + render(); + expect(screen.getByTestId("nav-search-toggle")).toBeInTheDocument(); + }); + + it("does not render the expanded search input", () => { + const NavSearch = getNavSearch(); + render(); + expect(screen.queryByTestId("nav-search-input")).not.toBeInTheDocument(); + }); + + it("has an accessible label on the toggle button", () => { + const NavSearch = getNavSearch(); + render(); + expect(screen.getByLabelText("Search navigation")).toBeInTheDocument(); + }); + + it("opens a modal when the search toggle is clicked", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + await waitFor(() => { + expect(screen.getByTestId("mock-modal")).toBeInTheDocument(); + expect( + screen.getByTestId("nav-search-modal-input"), + ).toBeInTheDocument(); + }); + }); + + it("does not show results until user starts typing", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + await waitFor(() => { + expect( + screen.getByTestId("nav-search-modal-input"), + ).toBeInTheDocument(); + }); + + expect( + screen.queryByTestId("nav-search-results"), + ).not.toBeInTheDocument(); + }); + + it("shows inline results after typing", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + const input = await screen.findByTestId("nav-search-modal-input"); + fireEvent.change(input, { target: { value: "System" } }); + + await waitFor(() => { + expect(screen.getByTestId("nav-search-results")).toBeInTheDocument(); + expect(screen.getByTestId("result-/systems")).toBeInTheDocument(); + }); + }); + + it("navigates on result click", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + const input = await screen.findByTestId("nav-search-modal-input"); + fireEvent.change(input, { target: { value: "dataset" } }); + + await waitFor(() => { + expect(screen.getByTestId("result-/dataset")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId("result-/dataset")); + + expect(mockPush).toHaveBeenCalledWith("/dataset"); + }); + + it("closes the modal on Escape key", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + const input = await screen.findByTestId("nav-search-modal-input"); + fireEvent.change(input, { target: { value: "System" } }); + + await waitFor(() => { + expect(screen.getByTestId("nav-search-results")).toBeInTheDocument(); + }); + + fireEvent.keyDown(input, { key: "Escape" }); + + await waitFor(() => { + expect( + screen.queryByTestId("nav-search-results"), + ).not.toBeInTheDocument(); + }); + }); + + it("navigates with ArrowDown + Enter in modal", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + const input = await screen.findByTestId("nav-search-modal-input"); + // Type something that matches multiple items + fireEvent.change(input, { target: { value: "System" } }); + + await waitFor(() => { + expect(screen.getByTestId("nav-search-results")).toBeInTheDocument(); + }); + + // First item is active by default, press Enter to navigate + fireEvent.keyDown(input, { key: "Enter" }); + + expect(mockPush).toHaveBeenCalled(); + }); + + it("wraps ArrowDown from last to first item", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + const input = await screen.findByTestId("nav-search-modal-input"); + // "Home" matches only 1 item + fireEvent.change(input, { target: { value: "Home" } }); + + await waitFor(() => { + expect(screen.getByTestId("nav-search-results")).toBeInTheDocument(); + }); + + // ArrowDown from the only item should wrap to index 0 + fireEvent.keyDown(input, { key: "ArrowDown" }); + // Should still have the first item active (wrapped) + const firstResult = screen.getByTestId("result-/"); + expect(firstResult.getAttribute("aria-selected")).toBe("true"); + }); + + it("sets aria-activedescendant on the input for screen readers", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + const input = await screen.findByTestId("nav-search-modal-input"); + fireEvent.change(input, { target: { value: "System" } }); + + await waitFor(() => { + expect(input.getAttribute("aria-activedescendant")).toBe( + "nav-search-result-0", + ); + }); + }); + }); + + describe("filtering", () => { + it("excludes hidden routes from the options", () => { + const NavSearch = getNavSearch(); + render(); + fireEvent.focus(screen.getByTestId("nav-search-input")); + + expect(getOptionValues()).not.toContain("/hidden"); + }); + + it("includes all visible routes when no search query", () => { + const NavSearch = getNavSearch(); + render(); + fireEvent.focus(screen.getByTestId("nav-search-input")); + + const values = getOptionValues(); + expect(values).toContain("/"); + expect(values).toContain("/systems"); + expect(values).toContain("/dataset"); + expect(values).toContain("/privacy-requests"); + }); + + it("filters options when typing a search query", () => { + const NavSearch = getNavSearch(); + render(); + const input = screen.getByTestId("nav-search-input"); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "System" } }); + + const values = getOptionValues(); + expect(values).toContain("/systems"); + expect(values).not.toContain("/dataset"); + }); + + it("is case-insensitive", () => { + const NavSearch = getNavSearch(); + render(); + const input = screen.getByTestId("nav-search-input"); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "system" } }); + + expect(getOptionValues()).toContain("/systems"); + }); + + it("returns no options when nothing matches", () => { + const NavSearch = getNavSearch(); + render(); + const input = screen.getByTestId("nav-search-input"); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "zzzznonexistent" } }); + + expect(getOptionValues()).toHaveLength(0); + }); + + it("groups options by nav group", () => { + const NavSearch = getNavSearch(); + render(); + fireEvent.focus(screen.getByTestId("nav-search-input")); + + const labels = getGroupLabels(); + expect(labels).toContain("Overview"); + expect(labels).toContain("Data inventory"); + expect(labels).toContain("Privacy requests"); + }); + + it("only shows matching groups when filtering", () => { + const NavSearch = getNavSearch(); + render(); + const input = screen.getByTestId("nav-search-input"); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "Home" } }); + + const labels = getGroupLabels(); + expect(labels).toContain("Overview"); + expect(labels).not.toContain("Data inventory"); + }); + }); + + describe("keyboard shortcuts", () => { + it("opens search on Cmd+K", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.keyDown(document, { key: "k", metaKey: true }); + + await waitFor(() => { + expect(getAutoCompleteProps().open).toBe(true); + }); + }); + + it("opens search on Ctrl+K", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + + await waitFor(() => { + expect(getAutoCompleteProps().open).toBe(true); + }); + }); + + it("does not open on K without modifier key", () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.keyDown(document, { key: "k" }); + + expect(getAutoCompleteProps().open).toBeFalsy(); + }); + }); + + describe("navigation", () => { + it("navigates to the selected route", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.focus(screen.getByTestId("nav-search-input")); + await waitFor(() => { + expect(getAutoCompleteProps().open).toBe(true); + }); + + fireEvent.click(screen.getByTestId("option-/systems")); + + expect(mockPush).toHaveBeenCalledWith("/systems"); + }); + + it("clears the search value after selection", () => { + const NavSearch = getNavSearch(); + render(); + const input = screen.getByTestId("nav-search-input"); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "System" } }); + + fireEvent.click(screen.getByTestId("option-/systems")); + + expect(getAutoCompleteProps().value).toBe(""); + }); + + it("closes the dropdown after selection", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.focus(screen.getByTestId("nav-search-input")); + await waitFor(() => { + expect(getAutoCompleteProps().open).toBe(true); + }); + + fireEvent.click(screen.getByTestId("option-/systems")); + + expect(getAutoCompleteProps().open).toBe(false); + }); + }); + + describe("tabs", () => { + it("includes tab items in search results", () => { + const NavSearch = getNavSearch(); + render(); + const input = screen.getByTestId("nav-search-input"); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "Manual" } }); + + const values = getOptionValues(); + expect(values).toContain( + "/privacy-requests?tab=manual-tasks::Manual tasks", + ); + }); + + it("navigates to tab path on selection", async () => { + const NavSearch = getNavSearch(); + render(); + const input = screen.getByTestId("nav-search-input"); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "Manual" } }); + + await waitFor(() => { + expect(getAutoCompleteProps().open).toBe(true); + }); + + fireEvent.click( + screen.getByTestId( + "option-/privacy-requests?tab=manual-tasks::Manual tasks", + ), + ); + + expect(mockPush).toHaveBeenCalledWith( + "/privacy-requests?tab=manual-tasks", + ); + }); + + it("shows tab items in modal results", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + const input = await screen.findByTestId("nav-search-modal-input"); + fireEvent.change(input, { target: { value: "Manual" } }); + + await waitFor(() => { + expect( + screen.getByTestId("result-/privacy-requests?tab=manual-tasks"), + ).toBeInTheDocument(); + }); + }); + + it("includes both parent page and its tabs in results", () => { + const NavSearch = getNavSearch(); + render(); + const input = screen.getByTestId("nav-search-input"); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "Request" } }); + + const values = getOptionValues(); + expect(values).toContain("/privacy-requests"); + expect(values).toContain("/privacy-requests?tab=request::Requests"); + }); + }); + + describe("duplicate paths", () => { + it("renders both items when two tabs share the same path", () => { + const NavSearch = getNavSearch(); + render(); + const input = screen.getByTestId("nav-search-input"); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "tion" } }); + + const values = getOptionValues(); + expect(values).toContain( + "/settings/privacy-requests::Redaction patterns", + ); + expect(values).toContain( + "/settings/privacy-requests::Duplicate detection", + ); + }); + + it("navigates correctly when selecting a duplicate-path item", async () => { + const NavSearch = getNavSearch(); + render(); + const input = screen.getByTestId("nav-search-input"); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "Redaction" } }); + + await waitFor(() => { + expect(getAutoCompleteProps().open).toBe(true); + }); + + fireEvent.click( + screen.getByTestId( + "option-/settings/privacy-requests::Redaction patterns", + ), + ); + + expect(mockPush).toHaveBeenCalledWith("/settings/privacy-requests"); + }); + + it("renders duplicate-path items in modal without key warnings", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + const input = await screen.findByTestId("nav-search-modal-input"); + fireEvent.change(input, { target: { value: "tion" } }); + + await waitFor(() => { + // Both items should be rendered + expect(screen.getByText("Redaction patterns")).toBeInTheDocument(); + expect(screen.getByText("Duplicate detection")).toBeInTheDocument(); + }); + }); + }); + + describe("sub-items visibility", () => { + it("does not show tabs when there is no search query", () => { + const NavSearch = getNavSearch(); + render(); + fireEvent.focus(screen.getByTestId("nav-search-input")); + + const values = getOptionValues(); + expect(values).toContain("/privacy-requests"); + const tabRequest = "/privacy-requests?tab=request::Requests"; + const tabManual = "/privacy-requests?tab=manual-tasks::Manual tasks"; + expect(values).not.toContain(tabRequest); + expect(values).not.toContain(tabManual); + }); + + it("shows tabs only when the search query matches them", () => { + const NavSearch = getNavSearch(); + render(); + const input = screen.getByTestId("nav-search-input"); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "Manual" } }); + + const values = getOptionValues(); + const tabManual = "/privacy-requests?tab=manual-tasks::Manual tasks"; + const tabRequest = "/privacy-requests?tab=request::Requests"; + expect(values).toContain(tabManual); + expect(values).not.toContain(tabRequest); + }); + }); + + describe("dynamic items", () => { + it("includes dynamic taxonomy items when search matches", () => { + mockDynamicItems.push({ + title: "Marketing", + path: "/taxonomy/data_use", + groupTitle: "Core configuration", + parentTitle: "Taxonomy", + }); + + const NavSearch = getNavSearch(); + render(); + const input = screen.getByTestId("nav-search-input"); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "Marketing" } }); + + expect(getOptionValues()).toContain("/taxonomy/data_use::Marketing"); + }); + + it("includes dynamic integration items when search matches", () => { + mockDynamicItems.push({ + title: "Stripe Production", + path: "/integrations/stripe-prod", + groupTitle: "Core configuration", + parentTitle: "Integrations", + }); + + const NavSearch = getNavSearch(); + render(); + const input = screen.getByTestId("nav-search-input"); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "Stripe" } }); + + expect(getOptionValues()).toContain( + "/integrations/stripe-prod::Stripe Production", + ); + }); + + it("does not show dynamic items when there is no search query", () => { + mockDynamicItems.push({ + title: "Marketing", + path: "/taxonomy/data_use", + groupTitle: "Core configuration", + parentTitle: "Taxonomy", + }); + + const NavSearch = getNavSearch(); + render(); + fireEvent.focus(screen.getByTestId("nav-search-input")); + + expect(getOptionValues()).not.toContain("/taxonomy/data_use::Marketing"); + }); + }); + + describe("with empty groups", () => { + it("renders with no options when groups are empty", () => { + const NavSearch = getNavSearch(); + render(); + fireEvent.focus(screen.getByTestId("nav-search-input")); + + expect(getOptionValues()).toHaveLength(0); + }); + }); +}); diff --git a/clients/admin-ui/src/features/access-control/AccessControlTabs.tsx b/clients/admin-ui/src/features/access-control/AccessControlTabs.tsx index bf2d600cbea..fa5966d88a6 100644 --- a/clients/admin-ui/src/features/access-control/AccessControlTabs.tsx +++ b/clients/admin-ui/src/features/access-control/AccessControlTabs.tsx @@ -1,11 +1,18 @@ import { Menu } from "fidesui"; import { useRouter } from "next/router"; +import type { NavConfigTab } from "~/features/common/nav/nav-config"; import { ACCESS_CONTROL_REQUEST_LOG_ROUTE, ACCESS_CONTROL_SUMMARY_ROUTE, } from "~/features/common/nav/routes"; +/** Shared tab definitions used by both the tab bar and nav search. */ +export const ACCESS_CONTROL_TAB_ITEMS: NavConfigTab[] = [ + { title: "Summary", path: ACCESS_CONTROL_SUMMARY_ROUTE }, + { title: "Request log", path: ACCESS_CONTROL_REQUEST_LOG_ROUTE }, +]; + const AccessControlTabs = () => { const router = useRouter(); const currentPath = router.pathname; @@ -17,18 +24,11 @@ const AccessControlTabs = () => { selectedKey = "summary"; } - const menuItems = [ - { - key: "summary", - label: "Summary", - path: ACCESS_CONTROL_SUMMARY_ROUTE, - }, - { - key: "request-log", - label: "Request log", - path: ACCESS_CONTROL_REQUEST_LOG_ROUTE, - }, - ]; + const menuItems = ACCESS_CONTROL_TAB_ITEMS.map((tab) => ({ + key: tab.path === ACCESS_CONTROL_SUMMARY_ROUTE ? "summary" : "request-log", + label: tab.title, + path: tab.path, + })); const handleMenuClick = (key: string) => { const item = menuItems.find((i) => i.key === key); diff --git a/clients/admin-ui/src/features/common/NotificationTabs.tsx b/clients/admin-ui/src/features/common/NotificationTabs.tsx index f23b943d85a..3d59d60d605 100644 --- a/clients/admin-ui/src/features/common/NotificationTabs.tsx +++ b/clients/admin-ui/src/features/common/NotificationTabs.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; import { useAppSelector } from "~/app/hooks"; import { useFeatures } from "~/features/common/features"; +import type { NavConfigTab } from "~/features/common/nav/nav-config"; import { CHAT_PROVIDERS_ROUTE, MESSAGING_PROVIDERS_ROUTE, @@ -13,6 +14,14 @@ import { ScopeRegistryEnum } from "~/types/api"; import { selectThisUsersScopes } from "../user-management"; +/** Shared tab definitions used by both the tab bar and nav search. */ +export const NOTIFICATION_TAB_ITEMS: NavConfigTab[] = [ + { title: "Templates", path: NOTIFICATIONS_TEMPLATES_ROUTE }, + { title: "Digests", path: NOTIFICATIONS_DIGESTS_ROUTE }, + { title: "Email providers", path: MESSAGING_PROVIDERS_ROUTE }, + { title: "Chat providers", path: CHAT_PROVIDERS_ROUTE }, +]; + const NotificationTabs = () => { const router = useRouter(); const currentPath = router.pathname; diff --git a/clients/admin-ui/src/features/common/nav/MainSideNav.tsx b/clients/admin-ui/src/features/common/nav/MainSideNav.tsx index 60fab96564c..e50475a69a0 100644 --- a/clients/admin-ui/src/features/common/nav/MainSideNav.tsx +++ b/clients/admin-ui/src/features/common/nav/MainSideNav.tsx @@ -22,6 +22,7 @@ import { useNav } from "./hooks"; import { ActiveNav, NavGroup } from "./nav-config"; import { NavMenu } from "./NavMenu"; import styles from "./NavMenu.module.scss"; +import NavSearch from "./NavSearch"; const NAV_BACKGROUND_COLOR = palette.FIDESUI_MINOS; const NAV_WIDTH = "240px"; @@ -206,6 +207,7 @@ export const UnconnectedMainSideNav = ({ + + item.parentTitle ? `${item.path}::${item.title}` : item.path; + +interface NavSearchProps { + groups: NavGroup[]; + collapsed?: boolean; +} + +const NavSearch = ({ groups, collapsed = false }: NavSearchProps) => { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); + const inputRef = useRef(null); + const modalInputRef = useRef(null); + const justSelectedRef = useRef(false); + + const flatItems: FlatNavItem[] = useNavSearchItems(groups); + + const filteredOptions = useMemo(() => { + const query = searchValue.trim().toLowerCase(); + // When no query, only show top-level pages (not tabs/dynamic items). + // Sub-items only appear when the search text matches them. + const items = query + ? flatItems.filter((item) => item.title.toLowerCase().includes(query)) + : flatItems.filter((item) => !item.parentTitle); + + // Group by nav group + const grouped = new Map(); + items.forEach((item) => { + const list = grouped.get(item.groupTitle) ?? []; + list.push(item); + grouped.set(item.groupTitle, list); + }); + + return Array.from(grouped.entries()).map(([groupTitle, groupItems]) => ({ + label: {groupTitle}, + options: groupItems.map((item) => ({ + value: itemKey(item), + label: item.parentTitle ? ( +
+ {item.title} + {item.parentTitle} +
+ ) : ( + {item.title} + ), + })), + })); + }, [flatItems, searchValue]); + + // Build a lookup from unique key back to path for navigation + const keyToPath = useMemo(() => { + const map = new Map(); + flatItems.forEach((item) => { + map.set(itemKey(item), item.path); + }); + return map; + }, [flatItems]); + + const handleSelect = useCallback( + (key: string) => { + const path = keyToPath.get(key) ?? key; + justSelectedRef.current = true; + router.push(path); + setOpen(false); + setSearchValue(""); + inputRef.current?.blur(); + modalInputRef.current?.blur(); + // Reset the guard after the event loop settles + setTimeout(() => { + justSelectedRef.current = false; + }, 0); + }, + [router, keyToPath], + ); + + const handleClose = useCallback(() => { + setOpen(false); + setSearchValue(""); + }, []); + + const handleOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + setSearchValue(""); + } + }, []); + + // Cmd+K / Ctrl+K global shortcut + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + setOpen(true); + // In expanded mode, focus immediately; in collapsed mode, + // the Modal's afterOpenChange callback handles focus. + if (!collapsed) { + requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + } + } + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [collapsed]); + + // Focus input when opened in expanded mode + useEffect(() => { + if (open && !collapsed) { + requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + } + }, [open, collapsed]); + + const handleEscapeKey = useCallback((e: React.KeyboardEvent) => { + if (e.key === "Escape") { + setOpen(false); + setSearchValue(""); + inputRef.current?.blur(); + modalInputRef.current?.blur(); + } + }, []); + + // Keyboard navigation for inline results in the modal + const [activeIndex, setActiveIndex] = useState(-1); + + const visibleItems = useMemo(() => { + const query = searchValue.trim().toLowerCase(); + return query + ? flatItems.filter((item) => item.title.toLowerCase().includes(query)) + : []; + }, [flatItems, searchValue]); + + // Reset active index when results change + useEffect(() => { + setActiveIndex(visibleItems.length > 0 ? 0 : -1); + }, [visibleItems]); + + const handleModalKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + handleClose(); + return; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((prev) => + prev < visibleItems.length - 1 ? prev + 1 : 0, + ); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((prev) => + prev > 0 ? prev - 1 : visibleItems.length - 1, + ); + } else if ( + e.key === "Enter" && + activeIndex >= 0 && + visibleItems[activeIndex] + ) { + e.preventDefault(); + handleSelect(visibleItems[activeIndex].path); + } + }, + [visibleItems, activeIndex, handleSelect, handleClose], + ); + + // Pre-compute flat indexed items for the modal to avoid mutable counter in render + const indexedModalItems = useMemo(() => { + const grouped = new Map(); + visibleItems.forEach((item) => { + const list = grouped.get(item.groupTitle) ?? []; + list.push(item); + grouped.set(item.groupTitle, list); + }); + + let idx = 0; + return Array.from(grouped.entries()).map(([groupTitle, items]) => ({ + groupTitle, + items: items.map((item) => { + const currentIdx = idx; + idx += 1; + return { ...item, idx: currentIdx }; + }), + })); + }, [visibleItems]); + + if (collapsed) { + return ( +
+ + { + if (isOpen) { + modalInputRef.current?.focus(); + } + }} + > + setSearchValue(e.target.value)} + prefix={} + allowClear + onKeyDown={handleModalKeyDown} + role="combobox" + aria-label="Search pages" + aria-expanded={indexedModalItems.length > 0} + aria-controls={ + indexedModalItems.length > 0 + ? "nav-search-results-list" + : undefined + } + aria-activedescendant={ + activeIndex >= 0 ? `nav-search-result-${activeIndex}` : undefined + } + data-testid="nav-search-modal-input" + /> +
+ {visibleItems.length > 0 && + `${visibleItems.length} result${visibleItems.length === 1 ? "" : "s"} available`} + {visibleItems.length === 0 && + searchValue.trim() && + "No results found"} +
+ {indexedModalItems.length > 0 && ( + + )} +
+
+ ); + } + + return ( +
+ + } + suffix={ + + } + allowClear + onFocus={() => { + if (!justSelectedRef.current) { + setOpen(true); + } + }} + onKeyDown={handleEscapeKey} + data-testid="nav-search-input" + /> + +
+ ); +}; + +export default NavSearch; diff --git a/clients/admin-ui/src/features/common/nav/nav-config.tsx b/clients/admin-ui/src/features/common/nav/nav-config.tsx index 87c229459f7..af893f59c57 100644 --- a/clients/admin-ui/src/features/common/nav/nav-config.tsx +++ b/clients/admin-ui/src/features/common/nav/nav-config.tsx @@ -1,11 +1,19 @@ import { Icons } from "fidesui"; import { ReactNode } from "react"; +import { ACCESS_CONTROL_TAB_ITEMS } from "~/features/access-control/AccessControlTabs"; import { FlagNames } from "~/features/common/features"; +import { NOTIFICATION_TAB_ITEMS } from "~/features/common/NotificationTabs"; +import { PRIVACY_REQUEST_TAB_ITEMS } from "~/features/privacy-requests/hooks/usePrivacyRequestTabs"; import { ScopeRegistryEnum } from "~/types/api"; import * as routes from "./routes"; +export interface NavConfigTab { + title: string; + path: string; +} + export interface NavConfigRoute { title?: string; path: string; @@ -24,6 +32,8 @@ export interface NavConfigRoute { scopes: ScopeRegistryEnum[]; /** Child routes which will be rendered in the side nav */ routes?: NavConfigRoute[]; + /** Tabs within this page that should appear in search */ + tabs?: NavConfigTab[]; } export interface NavConfigGroup { @@ -68,6 +78,7 @@ export const NAV_CONFIG: NavConfigGroup[] = [ scopes: [ScopeRegistryEnum.DISCOVERY_MONITOR_READ], requiresFlag: "alphaPurposeBasedAccessControl", requiresPlus: true, + tabs: ACCESS_CONTROL_TAB_ITEMS, }, ], }, @@ -126,6 +137,7 @@ export const NAV_CONFIG: NavConfigGroup[] = [ ScopeRegistryEnum.MANUAL_FIELD_READ_OWN, ScopeRegistryEnum.MANUAL_FIELD_READ_ALL, ], + tabs: PRIVACY_REQUEST_TAB_ITEMS, }, { title: "Policies", @@ -211,6 +223,7 @@ export const NAV_CONFIG: NavConfigGroup[] = [ ScopeRegistryEnum.DIGEST_CONFIG_READ, ScopeRegistryEnum.MESSAGING_CREATE_OR_UPDATE, ], + tabs: NOTIFICATION_TAB_ITEMS, }, { title: "Custom fields", @@ -282,6 +295,16 @@ export const NAV_CONFIG: NavConfigGroup[] = [ title: "Privacy requests", path: routes.PRIVACY_REQUESTS_SETTINGS_ROUTE, scopes: [ScopeRegistryEnum.PRIVACY_REQUEST_REDACTION_PATTERNS_UPDATE], + tabs: [ + { + title: "Redaction patterns", + path: routes.PRIVACY_REQUESTS_SETTINGS_ROUTE, + }, + { + title: "Duplicate detection", + path: routes.PRIVACY_REQUESTS_SETTINGS_ROUTE, + }, + ], }, { title: "Users", @@ -399,6 +422,7 @@ export interface NavGroupChild { exact?: boolean; hidden?: boolean; children: Array; + tabs?: NavConfigTab[]; } export interface NavGroup { @@ -556,6 +580,7 @@ const configureNavRoute = ({ exact: route.exact, hidden: route.hidden, children, + tabs: route.tabs, }; return groupChild; diff --git a/clients/admin-ui/src/features/common/nav/useNavSearchItems.ts b/clients/admin-ui/src/features/common/nav/useNavSearchItems.ts new file mode 100644 index 00000000000..def2cc4e670 --- /dev/null +++ b/clients/admin-ui/src/features/common/nav/useNavSearchItems.ts @@ -0,0 +1,164 @@ +import { useMemo } from "react"; + +import { + INITIAL_CONNECTIONS_FILTERS, + useGetAllDatastoreConnectionsQuery, +} from "~/features/datastore-connections/datastore-connection.slice"; +import { useGetAllSystemsQuery } from "~/features/system/system.slice"; +import { + CoreTaxonomiesEnum, + TaxonomyTypeEnum, +} from "~/features/taxonomy/constants"; +import { useGetCustomTaxonomiesQuery } from "~/features/taxonomy/taxonomy.slice"; + +import { NavGroup } from "./nav-config"; + +/** Core taxonomy types that are always present regardless of Plus. */ +const CORE_TAXONOMY_ITEMS = [ + { + title: CoreTaxonomiesEnum.DATA_CATEGORIES, + key: TaxonomyTypeEnum.DATA_CATEGORY, + }, + { + title: CoreTaxonomiesEnum.DATA_USES, + key: TaxonomyTypeEnum.DATA_USE, + }, + { + title: CoreTaxonomiesEnum.DATA_SUBJECTS, + key: TaxonomyTypeEnum.DATA_SUBJECT, + }, + { + title: CoreTaxonomiesEnum.SYSTEM_GROUPS, + key: TaxonomyTypeEnum.SYSTEM_GROUP, + }, +]; + +export interface FlatNavItem { + title: string; + path: string; + groupTitle: string; + parentTitle?: string; +} + +/** + * Builds the full list of searchable nav items, including: + * - Static pages and their tabs (from nav groups) + * - Taxonomy type names (core + custom from API) + * - Dynamic integration items (from API) + * - Dynamic system names (from API) + * + * Queries run eagerly so data is available when the user opens the search. + */ +const useNavSearchItems = (groups: NavGroup[]): FlatNavItem[] => { + // Static items from nav config + const staticItems: FlatNavItem[] = useMemo(() => { + const items: FlatNavItem[] = []; + groups.forEach((group) => { + group.children + .filter((child) => !child.hidden) + .forEach((child) => { + items.push({ + title: child.title, + path: child.path, + groupTitle: group.title, + }); + child.tabs?.forEach((tab) => { + items.push({ + title: tab.title, + path: tab.path, + groupTitle: group.title, + parentTitle: child.title, + }); + }); + }); + }); + return items; + }, [groups]); + + // Core taxonomy types are always available + + // Custom taxonomy types from API (Plus-only, may be undefined) + const { data: customTaxonomies } = useGetCustomTaxonomiesQuery(); + + const taxonomyItems: FlatNavItem[] = useMemo(() => { + const taxonomyGroupTitle = + groups.find((g) => g.children.some((c) => c.path === "/taxonomy")) + ?.title ?? "Core configuration"; + + const coreKeys = new Set(CORE_TAXONOMY_ITEMS.map((c) => c.key)); + const items: FlatNavItem[] = CORE_TAXONOMY_ITEMS.map((tax) => ({ + title: tax.title, + path: `/taxonomy/${tax.key}`, + groupTitle: taxonomyGroupTitle, + parentTitle: "Taxonomy", + })); + + // Append any custom taxonomy types not already covered by core + customTaxonomies + ?.filter((tax) => !coreKeys.has(tax.fides_key)) + .forEach((tax) => { + items.push({ + title: tax.name || tax.fides_key, + path: `/taxonomy/${tax.fides_key}`, + groupTitle: taxonomyGroupTitle, + parentTitle: "Taxonomy", + }); + }); + + return items; + }, [customTaxonomies, groups]); + + // Integration data + const { data: connectionsData } = useGetAllDatastoreConnectionsQuery( + INITIAL_CONNECTIONS_FILTERS, + ); + + const integrationItems: FlatNavItem[] = useMemo(() => { + if (!connectionsData?.items) { + return []; + } + const integrationGroup = groups.find((g) => + g.children.some((c) => c.path === "/integrations"), + ); + const groupTitle = integrationGroup?.title ?? "Core configuration"; + + return connectionsData.items.map((conn) => ({ + title: conn.name || conn.key, + path: `/integrations/${conn.key}`, + groupTitle, + parentTitle: "Integrations", + })); + }, [connectionsData, groups]); + + // System names from API + const { data: systems } = useGetAllSystemsQuery(); + + const systemItems: FlatNavItem[] = useMemo(() => { + if (!systems) { + return []; + } + const systemGroup = groups.find((g) => + g.children.some((c) => c.path === "/systems"), + ); + const groupTitle = systemGroup?.title ?? "Data inventory"; + + return systems.map((sys) => ({ + title: sys.name || sys.fides_key, + path: `/systems/configure/${sys.fides_key}`, + groupTitle, + parentTitle: "System inventory", + })); + }, [systems, groups]); + + return useMemo( + () => [ + ...staticItems, + ...taxonomyItems, + ...integrationItems, + ...systemItems, + ], + [staticItems, taxonomyItems, integrationItems, systemItems], + ); +}; + +export default useNavSearchItems; diff --git a/clients/admin-ui/src/features/privacy-requests/hooks/usePrivacyRequestTabs.ts b/clients/admin-ui/src/features/privacy-requests/hooks/usePrivacyRequestTabs.ts index dc74a8b0d40..560ae81755f 100644 --- a/clients/admin-ui/src/features/privacy-requests/hooks/usePrivacyRequestTabs.ts +++ b/clients/admin-ui/src/features/privacy-requests/hooks/usePrivacyRequestTabs.ts @@ -1,6 +1,8 @@ import { useRouter } from "next/router"; import { useCallback, useEffect, useMemo, useState } from "react"; +import type { NavConfigTab } from "~/features/common/nav/nav-config"; +import { PRIVACY_REQUESTS_ROUTE } from "~/features/common/nav/routes"; import { useHasPermission } from "~/features/common/Restrict"; import { ScopeRegistryEnum } from "~/types/api"; @@ -9,6 +11,18 @@ export const PRIVACY_REQUEST_TABS = { MANUAL_TASK: "manual-tasks", } as const; +/** Shared tab definitions used by both the tab bar and nav search. */ +export const PRIVACY_REQUEST_TAB_ITEMS: NavConfigTab[] = [ + { + title: "Requests", + path: `${PRIVACY_REQUESTS_ROUTE}?tab=${PRIVACY_REQUEST_TABS.REQUEST}`, + }, + { + title: "Manual tasks", + path: `${PRIVACY_REQUESTS_ROUTE}?tab=${PRIVACY_REQUEST_TABS.MANUAL_TASK}`, + }, +]; + export type PrivacyRequestTabKey = (typeof PRIVACY_REQUEST_TABS)[keyof typeof PRIVACY_REQUEST_TABS]; From de7467cb63228add02d6139a9b4b73c04770ce38 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Fri, 20 Mar 2026 16:48:06 -0700 Subject: [PATCH 02/28] Add changelog for ENG-3044 Co-Authored-By: Claude Opus 4.6 (1M context) --- changelog/7723.yaml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog/7723.yaml diff --git a/changelog/7723.yaml b/changelog/7723.yaml new file mode 100644 index 00000000000..d5a062b2027 --- /dev/null +++ b/changelog/7723.yaml @@ -0,0 +1,4 @@ +type: Added +description: Add search to the Admin UI sidebar navigation for quickly finding pages, tabs, systems, integrations, and taxonomy types +pr: 7723 +labels: [] From 39fb91f2bf2492c9e3403ac123a0399420609ab2 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Fri, 20 Mar 2026 16:49:11 -0700 Subject: [PATCH 03/28] Rename Privacy Requests > Policies to DSR Policies Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/admin-ui/src/features/common/nav/nav-config.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/admin-ui/src/features/common/nav/nav-config.tsx b/clients/admin-ui/src/features/common/nav/nav-config.tsx index af893f59c57..5a0d0bbfac3 100644 --- a/clients/admin-ui/src/features/common/nav/nav-config.tsx +++ b/clients/admin-ui/src/features/common/nav/nav-config.tsx @@ -140,7 +140,7 @@ export const NAV_CONFIG: NavConfigGroup[] = [ tabs: PRIVACY_REQUEST_TAB_ITEMS, }, { - title: "Policies", + title: "DSR Policies", path: routes.POLICIES_ROUTE, requiresFlag: "policies", scopes: [ScopeRegistryEnum.POLICY_READ], From f61f0e20d638f901be13403b8af2c5aa8ebc6295 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Fri, 20 Mar 2026 16:50:20 -0700 Subject: [PATCH 04/28] Fix sentence case: DSR policies Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/admin-ui/src/features/common/nav/nav-config.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/admin-ui/src/features/common/nav/nav-config.tsx b/clients/admin-ui/src/features/common/nav/nav-config.tsx index 5a0d0bbfac3..0b473e9a63b 100644 --- a/clients/admin-ui/src/features/common/nav/nav-config.tsx +++ b/clients/admin-ui/src/features/common/nav/nav-config.tsx @@ -140,7 +140,7 @@ export const NAV_CONFIG: NavConfigGroup[] = [ tabs: PRIVACY_REQUEST_TAB_ITEMS, }, { - title: "DSR Policies", + title: "DSR policies", path: routes.POLICIES_ROUTE, requiresFlag: "policies", scopes: [ScopeRegistryEnum.POLICY_READ], From 0ed09fcd238c81181374efaea4ffd343ef4295b8 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Fri, 20 Mar 2026 16:52:40 -0700 Subject: [PATCH 05/28] Rename Notifications > Templates to Messaging templates Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/admin-ui/src/features/common/NotificationTabs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/admin-ui/src/features/common/NotificationTabs.tsx b/clients/admin-ui/src/features/common/NotificationTabs.tsx index 3d59d60d605..5016b2ae34b 100644 --- a/clients/admin-ui/src/features/common/NotificationTabs.tsx +++ b/clients/admin-ui/src/features/common/NotificationTabs.tsx @@ -16,7 +16,7 @@ import { selectThisUsersScopes } from "../user-management"; /** Shared tab definitions used by both the tab bar and nav search. */ export const NOTIFICATION_TAB_ITEMS: NavConfigTab[] = [ - { title: "Templates", path: NOTIFICATIONS_TEMPLATES_ROUTE }, + { title: "Messaging templates", path: NOTIFICATIONS_TEMPLATES_ROUTE }, { title: "Digests", path: NOTIFICATIONS_DIGESTS_ROUTE }, { title: "Email providers", path: MESSAGING_PROVIDERS_ROUTE }, { title: "Chat providers", path: CHAT_PROVIDERS_ROUTE }, From f92ee1e9eb10cb40ce19a6441501651d8bca98c6 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Fri, 20 Mar 2026 16:54:01 -0700 Subject: [PATCH 06/28] Fix: also rename hardcoded tab label to Messaging templates Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/admin-ui/src/features/common/NotificationTabs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/admin-ui/src/features/common/NotificationTabs.tsx b/clients/admin-ui/src/features/common/NotificationTabs.tsx index 5016b2ae34b..03e72ae9087 100644 --- a/clients/admin-ui/src/features/common/NotificationTabs.tsx +++ b/clients/admin-ui/src/features/common/NotificationTabs.tsx @@ -42,7 +42,7 @@ const NotificationTabs = () => { let menuItems = [ { key: "templates", - label: "Templates", + label: "Messaging templates", requiresPlus: true, scopes: [ScopeRegistryEnum.MESSAGING_TEMPLATE_UPDATE], path: NOTIFICATIONS_TEMPLATES_ROUTE, From f2e2842a205c58caebc7739d057a69a0412e733e Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Fri, 20 Mar 2026 17:27:33 -0700 Subject: [PATCH 07/28] Fix CI: rename changelog, change nav search placeholder to avoid Cypress collision The expanded nav search placeholder "Search" matched existing Cypress selectors (input[placeholder='Search']). Changed to "Go to..." to avoid the collision. Co-Authored-By: Claude Opus 4.6 (1M context) --- changelog/{7723.yaml => 7723-nav-search.yaml} | 0 clients/admin-ui/src/features/common/nav/NavSearch.tsx | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename changelog/{7723.yaml => 7723-nav-search.yaml} (100%) diff --git a/changelog/7723.yaml b/changelog/7723-nav-search.yaml similarity index 100% rename from changelog/7723.yaml rename to changelog/7723-nav-search.yaml diff --git a/clients/admin-ui/src/features/common/nav/NavSearch.tsx b/clients/admin-ui/src/features/common/nav/NavSearch.tsx index 3caf469a2b9..2b2f1a7a955 100644 --- a/clients/admin-ui/src/features/common/nav/NavSearch.tsx +++ b/clients/admin-ui/src/features/common/nav/NavSearch.tsx @@ -335,7 +335,7 @@ const NavSearch = ({ groups, collapsed = false }: NavSearchProps) => { } suffix={ From a219a619ab68044bdbb3ad1f2ff1e6f3c6aca2ad Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Fri, 20 Mar 2026 17:28:23 -0700 Subject: [PATCH 08/28] Update nav search placeholder to "Search pages..." Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/admin-ui/src/features/common/nav/NavSearch.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/admin-ui/src/features/common/nav/NavSearch.tsx b/clients/admin-ui/src/features/common/nav/NavSearch.tsx index 2b2f1a7a955..6b74f82ca6f 100644 --- a/clients/admin-ui/src/features/common/nav/NavSearch.tsx +++ b/clients/admin-ui/src/features/common/nav/NavSearch.tsx @@ -335,7 +335,7 @@ const NavSearch = ({ groups, collapsed = false }: NavSearchProps) => { } suffix={ From 58fd34af3834113bdecf9f2da60e338c587ee4cc Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Fri, 20 Mar 2026 17:36:27 -0700 Subject: [PATCH 09/28] Use server-side search for systems and integrations Replace eager prefetch of all systems/integrations with server-side search that fires after 2+ characters with a 300ms debounce. This scales to thousands of systems and integrations without loading them all into memory on page load. Static items (pages, tabs, taxonomy types) remain client-side filtered. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/features/common/nav/NavSearch.tsx | 10 ++- .../features/common/nav/useNavSearchItems.ts | 90 ++++++++++--------- 2 files changed, 58 insertions(+), 42 deletions(-) diff --git a/clients/admin-ui/src/features/common/nav/NavSearch.tsx b/clients/admin-ui/src/features/common/nav/NavSearch.tsx index 6b74f82ca6f..b21d705d63c 100644 --- a/clients/admin-ui/src/features/common/nav/NavSearch.tsx +++ b/clients/admin-ui/src/features/common/nav/NavSearch.tsx @@ -26,6 +26,7 @@ const COLLAPSED_ICON_STYLE = { color: palette.FIDESUI_CORINTH, }; const MODAL_POSITION = { top: "calc(50vh - 24px)" }; +const DEBOUNCE_MS = 300; /** Create a unique key for an item to avoid collisions when multiple items share the same path. */ const itemKey = (item: FlatNavItem): string => @@ -44,7 +45,14 @@ const NavSearch = ({ groups, collapsed = false }: NavSearchProps) => { const modalInputRef = useRef(null); const justSelectedRef = useRef(false); - const flatItems: FlatNavItem[] = useNavSearchItems(groups); + // Debounce the search value for server-side queries (systems, integrations) + const [debouncedQuery, setDebouncedQuery] = useState(""); + useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(searchValue), DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [searchValue]); + + const flatItems: FlatNavItem[] = useNavSearchItems(groups, debouncedQuery); const filteredOptions = useMemo(() => { const query = searchValue.trim().toLowerCase(); diff --git a/clients/admin-ui/src/features/common/nav/useNavSearchItems.ts b/clients/admin-ui/src/features/common/nav/useNavSearchItems.ts index def2cc4e670..214ac3e43fe 100644 --- a/clients/admin-ui/src/features/common/nav/useNavSearchItems.ts +++ b/clients/admin-ui/src/features/common/nav/useNavSearchItems.ts @@ -1,10 +1,7 @@ import { useMemo } from "react"; -import { - INITIAL_CONNECTIONS_FILTERS, - useGetAllDatastoreConnectionsQuery, -} from "~/features/datastore-connections/datastore-connection.slice"; -import { useGetAllSystemsQuery } from "~/features/system/system.slice"; +import { useGetAllDatastoreConnectionsQuery } from "~/features/datastore-connections/datastore-connection.slice"; +import { useGetSystemsQuery } from "~/features/system/system.slice"; import { CoreTaxonomiesEnum, TaxonomyTypeEnum, @@ -33,6 +30,12 @@ const CORE_TAXONOMY_ITEMS = [ }, ]; +/** Minimum characters before server-side search fires. */ +const MIN_SEARCH_LENGTH = 2; + +/** Max results to fetch per dynamic source. */ +const SEARCH_PAGE_SIZE = 10; + export interface FlatNavItem { title: string; path: string; @@ -42,15 +45,19 @@ export interface FlatNavItem { /** * Builds the full list of searchable nav items, including: - * - Static pages and their tabs (from nav groups) - * - Taxonomy type names (core + custom from API) - * - Dynamic integration items (from API) - * - Dynamic system names (from API) - * - * Queries run eagerly so data is available when the user opens the search. + * - Static pages and their tabs (from nav groups, always available) + * - Taxonomy type names (core + custom from API, always available) + * - Dynamic system names (server-side search, fires after 2+ characters) + * - Dynamic integration names (server-side search, fires after 2+ characters) */ -const useNavSearchItems = (groups: NavGroup[]): FlatNavItem[] => { - // Static items from nav config +const useNavSearchItems = ( + groups: NavGroup[], + searchQuery: string, +): FlatNavItem[] => { + const trimmedQuery = searchQuery.trim(); + const shouldSearch = trimmedQuery.length >= MIN_SEARCH_LENGTH; + + // Static items from nav config (always available) const staticItems: FlatNavItem[] = useMemo(() => { const items: FlatNavItem[] = []; groups.forEach((group) => { @@ -75,9 +82,7 @@ const useNavSearchItems = (groups: NavGroup[]): FlatNavItem[] => { return items; }, [groups]); - // Core taxonomy types are always available - - // Custom taxonomy types from API (Plus-only, may be undefined) + // Taxonomy types (small fixed set, always available) const { data: customTaxonomies } = useGetCustomTaxonomiesQuery(); const taxonomyItems: FlatNavItem[] = useMemo(() => { @@ -93,7 +98,6 @@ const useNavSearchItems = (groups: NavGroup[]): FlatNavItem[] => { parentTitle: "Taxonomy", })); - // Append any custom taxonomy types not already covered by core customTaxonomies ?.filter((tax) => !coreKeys.has(tax.fides_key)) .forEach((tax) => { @@ -108,9 +112,33 @@ const useNavSearchItems = (groups: NavGroup[]): FlatNavItem[] => { return items; }, [customTaxonomies, groups]); - // Integration data + // Systems - server-side search, skipped until user types 2+ chars + const { data: systemsData } = useGetSystemsQuery( + { search: trimmedQuery, page: 1, size: SEARCH_PAGE_SIZE }, + { skip: !shouldSearch }, + ); + + const systemItems: FlatNavItem[] = useMemo(() => { + if (!systemsData?.items) { + return []; + } + const systemGroup = groups.find((g) => + g.children.some((c) => c.path === "/systems"), + ); + const groupTitle = systemGroup?.title ?? "Data inventory"; + + return systemsData.items.map((sys) => ({ + title: sys.name || sys.fides_key, + path: `/systems/configure/${sys.fides_key}`, + groupTitle, + parentTitle: "System inventory", + })); + }, [systemsData, groups]); + + // Integrations - server-side search, skipped until user types 2+ chars const { data: connectionsData } = useGetAllDatastoreConnectionsQuery( - INITIAL_CONNECTIONS_FILTERS, + { search: trimmedQuery, page: 1, size: SEARCH_PAGE_SIZE }, + { skip: !shouldSearch }, ); const integrationItems: FlatNavItem[] = useMemo(() => { @@ -130,34 +158,14 @@ const useNavSearchItems = (groups: NavGroup[]): FlatNavItem[] => { })); }, [connectionsData, groups]); - // System names from API - const { data: systems } = useGetAllSystemsQuery(); - - const systemItems: FlatNavItem[] = useMemo(() => { - if (!systems) { - return []; - } - const systemGroup = groups.find((g) => - g.children.some((c) => c.path === "/systems"), - ); - const groupTitle = systemGroup?.title ?? "Data inventory"; - - return systems.map((sys) => ({ - title: sys.name || sys.fides_key, - path: `/systems/configure/${sys.fides_key}`, - groupTitle, - parentTitle: "System inventory", - })); - }, [systems, groups]); - return useMemo( () => [ ...staticItems, ...taxonomyItems, - ...integrationItems, ...systemItems, + ...integrationItems, ], - [staticItems, taxonomyItems, integrationItems, systemItems], + [staticItems, taxonomyItems, systemItems, integrationItems], ); }; From e9ec72503c5ae6e0b7bd07c43b952ecb538391ff Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Fri, 20 Mar 2026 17:40:33 -0700 Subject: [PATCH 10/28] Reduce search debounce to 200ms Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/admin-ui/src/features/common/nav/NavSearch.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/admin-ui/src/features/common/nav/NavSearch.tsx b/clients/admin-ui/src/features/common/nav/NavSearch.tsx index b21d705d63c..fe9598cf505 100644 --- a/clients/admin-ui/src/features/common/nav/NavSearch.tsx +++ b/clients/admin-ui/src/features/common/nav/NavSearch.tsx @@ -26,7 +26,7 @@ const COLLAPSED_ICON_STYLE = { color: palette.FIDESUI_CORINTH, }; const MODAL_POSITION = { top: "calc(50vh - 24px)" }; -const DEBOUNCE_MS = 300; +const DEBOUNCE_MS = 200; /** Create a unique key for an item to avoid collisions when multiple items share the same path. */ const itemKey = (item: FlatNavItem): string => From e2a2331d6d67b3968380a543c74d115221dad044 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Fri, 20 Mar 2026 17:44:48 -0700 Subject: [PATCH 11/28] Fix modal padding in production Ant theme Override Ant Design CSS variables for modal content/body padding to ensure zero padding regardless of theme configuration. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../admin-ui/src/features/common/nav/NavSearch.module.scss | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/clients/admin-ui/src/features/common/nav/NavSearch.module.scss b/clients/admin-ui/src/features/common/nav/NavSearch.module.scss index da7138c1d34..8375234e7db 100644 --- a/clients/admin-ui/src/features/common/nav/NavSearch.module.scss +++ b/clients/admin-ui/src/features/common/nav/NavSearch.module.scss @@ -76,6 +76,7 @@ .searchModal { :global(.ant-modal-content) { padding: 0 !important; + --ant-modal-content-padding: 0 !important; border-radius: 12px !important; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2) !important; overflow: hidden; @@ -83,6 +84,11 @@ :global(.ant-modal-body) { padding: 0 !important; + --ant-modal-body-padding: 0 !important; + } + + :global(.ant-modal-header) { + display: none !important; } } From 3a38c4101dd03ac6bf6a7703fd5c30d418ec2904 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Fri, 20 Mar 2026 17:50:58 -0700 Subject: [PATCH 12/28] Fix modal padding: target .ant-modal-container Ant Design v5 wraps content in .ant-modal-container (not .ant-modal-content) which adds 20px 24px padding. Target both selectors for compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../admin-ui/src/features/common/nav/NavSearch.module.scss | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/clients/admin-ui/src/features/common/nav/NavSearch.module.scss b/clients/admin-ui/src/features/common/nav/NavSearch.module.scss index 8375234e7db..f6ef86a7ca5 100644 --- a/clients/admin-ui/src/features/common/nav/NavSearch.module.scss +++ b/clients/admin-ui/src/features/common/nav/NavSearch.module.scss @@ -74,9 +74,9 @@ /* Spotlight-style modal (collapsed mode) */ .searchModal { - :global(.ant-modal-content) { + :global(.ant-modal-content), + :global(.ant-modal-container) { padding: 0 !important; - --ant-modal-content-padding: 0 !important; border-radius: 12px !important; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2) !important; overflow: hidden; @@ -84,7 +84,6 @@ :global(.ant-modal-body) { padding: 0 !important; - --ant-modal-body-padding: 0 !important; } :global(.ant-modal-header) { From 1e0a0da945545bde1200b6ee2f7275e43f40547c Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Fri, 20 Mar 2026 18:01:14 -0700 Subject: [PATCH 13/28] Address PR review: semantic HTML, split tests, fix styling - Use ul/li/span instead of div for modal results list (semantic HTML) - Split collapsed mode tests into NavSearch.collapsed.test.tsx - Add list-style reset for ul/li elements in results Co-Authored-By: Claude Opus 4.6 (1M context) --- .../common/nav/NavSearch.collapsed.test.tsx | 341 ++++++++++++++++++ .../features/common/nav/NavSearch.test.tsx | 195 ---------- .../features/common/nav/NavSearch.module.scss | 4 + .../src/features/common/nav/NavSearch.tsx | 12 +- 4 files changed, 351 insertions(+), 201 deletions(-) create mode 100644 clients/admin-ui/__tests__/features/common/nav/NavSearch.collapsed.test.tsx diff --git a/clients/admin-ui/__tests__/features/common/nav/NavSearch.collapsed.test.tsx b/clients/admin-ui/__tests__/features/common/nav/NavSearch.collapsed.test.tsx new file mode 100644 index 00000000000..017ae69bb7a --- /dev/null +++ b/clients/admin-ui/__tests__/features/common/nav/NavSearch.collapsed.test.tsx @@ -0,0 +1,341 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { NavGroup } from "~/features/common/nav/nav-config"; +import { FlatNavItem } from "~/features/common/nav/useNavSearchItems"; + +// Mock fidesui (same setup as NavSearch.test.tsx) +jest.mock("fidesui", () => { + // eslint-disable-next-line global-require + const MockReact = require("react"); + return { + __esModule: true, + AutoComplete: (props: any) => { + const { open, children, value, onSearch } = props; + return MockReact.createElement( + "div", + { "data-testid": "mock-autocomplete", "data-open": String(open) }, + MockReact.cloneElement(children, { + value, + onChange: (e: any) => onSearch?.(e.target.value), + }), + ); + }, + Input: MockReact.forwardRef((props: any, ref: any) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { prefix, suffix, allowClear, autoFocus, ...rest } = props; + return MockReact.createElement("input", { ...rest, ref }); + }), + Modal: ({ open: modalOpen, children, onCancel, destroyOnClose }: any) => { + if (!modalOpen) { + if (destroyOnClose) { + return null; + } + return MockReact.createElement( + "div", + { "data-testid": "mock-modal", style: { display: "none" } }, + children, + ); + } + return MockReact.createElement( + "div", + { "data-testid": "mock-modal" }, + children, + MockReact.createElement("button", { + "data-testid": "mock-modal-close", + onClick: onCancel, + }), + ); + }, + Icons: { + Search: () => MockReact.createElement("span", null, "search-icon"), + }, + }; +}); + +jest.mock("fidesui/src/palette/palette.module.scss", () => ({ + FIDESUI_CORINTH: "#fafafa", + FIDESUI_NEUTRAL_400: "#a8aaad", +})); + +const mockDynamicItems: FlatNavItem[] = []; +jest.mock("~/features/common/nav/useNavSearchItems", () => ({ + __esModule: true, + default: (groups: any[]) => { + const items: any[] = []; + groups.forEach((group: any) => { + group.children + .filter((child: any) => !child.hidden) + .forEach((child: any) => { + items.push({ + title: child.title, + path: child.path, + groupTitle: group.title, + }); + child.tabs?.forEach((tab: any) => { + items.push({ + title: tab.title, + path: tab.path, + groupTitle: group.title, + parentTitle: child.title, + }); + }); + }); + }); + return [...items, ...mockDynamicItems]; + }, +})); + +const mockPush = jest.fn(); + +jest.mock("next/router", () => ({ + useRouter: () => ({ + push: mockPush, + pathname: "/", + route: "/", + query: {}, + asPath: "/", + }), +})); + +// eslint-disable-next-line global-require +const getNavSearch = () => require("~/features/common/nav/NavSearch").default; + +const MOCK_GROUPS: NavGroup[] = [ + { + title: "Overview", + icon: "icon" as unknown as React.ReactNode, + children: [{ title: "Home", path: "/", exact: true, children: [] }], + }, + { + title: "Data inventory", + icon: "icon" as unknown as React.ReactNode, + children: [ + { title: "System inventory", path: "/systems", children: [] }, + { title: "Manage datasets", path: "/dataset", children: [] }, + ], + }, + { + title: "Privacy requests", + icon: "icon" as unknown as React.ReactNode, + children: [ + { + title: "Request manager", + path: "/privacy-requests", + children: [], + tabs: [ + { title: "Requests", path: "/privacy-requests?tab=request" }, + { + title: "Manual tasks", + path: "/privacy-requests?tab=manual-tasks", + }, + ], + }, + ], + }, +]; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +afterEach(() => { + mockDynamicItems.length = 0; +}); + +describe("NavSearch collapsed mode", () => { + it("renders the search toggle button", () => { + const NavSearch = getNavSearch(); + render(); + expect(screen.getByTestId("nav-search-toggle")).toBeInTheDocument(); + }); + + it("does not render the expanded search input", () => { + const NavSearch = getNavSearch(); + render(); + expect(screen.queryByTestId("nav-search-input")).not.toBeInTheDocument(); + }); + + it("has an accessible label on the toggle button", () => { + const NavSearch = getNavSearch(); + render(); + expect(screen.getByLabelText("Search navigation")).toBeInTheDocument(); + }); + + it("opens a modal when the search toggle is clicked", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + await waitFor(() => { + expect(screen.getByTestId("mock-modal")).toBeInTheDocument(); + expect(screen.getByTestId("nav-search-modal-input")).toBeInTheDocument(); + }); + }); + + it("does not show results until user starts typing", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + await waitFor(() => { + expect(screen.getByTestId("nav-search-modal-input")).toBeInTheDocument(); + }); + + expect(screen.queryByTestId("nav-search-results")).not.toBeInTheDocument(); + }); + + it("shows inline results after typing", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + const input = await screen.findByTestId("nav-search-modal-input"); + fireEvent.change(input, { target: { value: "System" } }); + + await waitFor(() => { + expect(screen.getByTestId("nav-search-results")).toBeInTheDocument(); + expect(screen.getByTestId("result-/systems")).toBeInTheDocument(); + }); + }); + + it("navigates on result click", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + const input = await screen.findByTestId("nav-search-modal-input"); + fireEvent.change(input, { target: { value: "dataset" } }); + + await waitFor(() => { + expect(screen.getByTestId("result-/dataset")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId("result-/dataset")); + + expect(mockPush).toHaveBeenCalledWith("/dataset"); + }); + + it("closes the modal on Escape key", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + const input = await screen.findByTestId("nav-search-modal-input"); + fireEvent.change(input, { target: { value: "System" } }); + + await waitFor(() => { + expect(screen.getByTestId("nav-search-results")).toBeInTheDocument(); + }); + + fireEvent.keyDown(input, { key: "Escape" }); + + await waitFor(() => { + expect( + screen.queryByTestId("nav-search-results"), + ).not.toBeInTheDocument(); + }); + }); + + it("navigates with ArrowDown + Enter in modal", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + const input = await screen.findByTestId("nav-search-modal-input"); + fireEvent.change(input, { target: { value: "System" } }); + + await waitFor(() => { + expect(screen.getByTestId("nav-search-results")).toBeInTheDocument(); + }); + + fireEvent.keyDown(input, { key: "Enter" }); + + expect(mockPush).toHaveBeenCalled(); + }); + + it("wraps ArrowDown from last to first item", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + const input = await screen.findByTestId("nav-search-modal-input"); + fireEvent.change(input, { target: { value: "Home" } }); + + await waitFor(() => { + expect(screen.getByTestId("nav-search-results")).toBeInTheDocument(); + }); + + fireEvent.keyDown(input, { key: "ArrowDown" }); + const firstResult = screen.getByTestId("result-/"); + expect(firstResult.getAttribute("aria-selected")).toBe("true"); + }); + + it("sets aria-activedescendant on the input for screen readers", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + const input = await screen.findByTestId("nav-search-modal-input"); + fireEvent.change(input, { target: { value: "System" } }); + + await waitFor(() => { + expect(input.getAttribute("aria-activedescendant")).toBe( + "nav-search-result-0", + ); + }); + }); + + it("shows tab items in modal results", async () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + const input = await screen.findByTestId("nav-search-modal-input"); + fireEvent.change(input, { target: { value: "Manual" } }); + + await waitFor(() => { + expect( + screen.getByTestId("result-/privacy-requests?tab=manual-tasks"), + ).toBeInTheDocument(); + }); + }); + + it("renders duplicate-path items in modal without key warnings", async () => { + mockDynamicItems.push( + { + title: "Redaction patterns", + path: "/settings/privacy-requests", + groupTitle: "Settings", + parentTitle: "Privacy requests", + }, + { + title: "Duplicate detection", + path: "/settings/privacy-requests", + groupTitle: "Settings", + parentTitle: "Privacy requests", + }, + ); + + const NavSearch = getNavSearch(); + render(); + + fireEvent.click(screen.getByTestId("nav-search-toggle")); + + const input = await screen.findByTestId("nav-search-modal-input"); + fireEvent.change(input, { target: { value: "tion" } }); + + await waitFor(() => { + expect(screen.getByText("Redaction patterns")).toBeInTheDocument(); + expect(screen.getByText("Duplicate detection")).toBeInTheDocument(); + }); + }); +}); diff --git a/clients/admin-ui/__tests__/features/common/nav/NavSearch.test.tsx b/clients/admin-ui/__tests__/features/common/nav/NavSearch.test.tsx index 3483dd394b6..d6cfcc0568f 100644 --- a/clients/admin-ui/__tests__/features/common/nav/NavSearch.test.tsx +++ b/clients/admin-ui/__tests__/features/common/nav/NavSearch.test.tsx @@ -247,169 +247,6 @@ describe("NavSearch", () => { }); }); - describe("collapsed mode", () => { - it("renders the search toggle button", () => { - const NavSearch = getNavSearch(); - render(); - expect(screen.getByTestId("nav-search-toggle")).toBeInTheDocument(); - }); - - it("does not render the expanded search input", () => { - const NavSearch = getNavSearch(); - render(); - expect(screen.queryByTestId("nav-search-input")).not.toBeInTheDocument(); - }); - - it("has an accessible label on the toggle button", () => { - const NavSearch = getNavSearch(); - render(); - expect(screen.getByLabelText("Search navigation")).toBeInTheDocument(); - }); - - it("opens a modal when the search toggle is clicked", async () => { - const NavSearch = getNavSearch(); - render(); - - fireEvent.click(screen.getByTestId("nav-search-toggle")); - - await waitFor(() => { - expect(screen.getByTestId("mock-modal")).toBeInTheDocument(); - expect( - screen.getByTestId("nav-search-modal-input"), - ).toBeInTheDocument(); - }); - }); - - it("does not show results until user starts typing", async () => { - const NavSearch = getNavSearch(); - render(); - - fireEvent.click(screen.getByTestId("nav-search-toggle")); - - await waitFor(() => { - expect( - screen.getByTestId("nav-search-modal-input"), - ).toBeInTheDocument(); - }); - - expect( - screen.queryByTestId("nav-search-results"), - ).not.toBeInTheDocument(); - }); - - it("shows inline results after typing", async () => { - const NavSearch = getNavSearch(); - render(); - - fireEvent.click(screen.getByTestId("nav-search-toggle")); - - const input = await screen.findByTestId("nav-search-modal-input"); - fireEvent.change(input, { target: { value: "System" } }); - - await waitFor(() => { - expect(screen.getByTestId("nav-search-results")).toBeInTheDocument(); - expect(screen.getByTestId("result-/systems")).toBeInTheDocument(); - }); - }); - - it("navigates on result click", async () => { - const NavSearch = getNavSearch(); - render(); - - fireEvent.click(screen.getByTestId("nav-search-toggle")); - - const input = await screen.findByTestId("nav-search-modal-input"); - fireEvent.change(input, { target: { value: "dataset" } }); - - await waitFor(() => { - expect(screen.getByTestId("result-/dataset")).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByTestId("result-/dataset")); - - expect(mockPush).toHaveBeenCalledWith("/dataset"); - }); - - it("closes the modal on Escape key", async () => { - const NavSearch = getNavSearch(); - render(); - - fireEvent.click(screen.getByTestId("nav-search-toggle")); - - const input = await screen.findByTestId("nav-search-modal-input"); - fireEvent.change(input, { target: { value: "System" } }); - - await waitFor(() => { - expect(screen.getByTestId("nav-search-results")).toBeInTheDocument(); - }); - - fireEvent.keyDown(input, { key: "Escape" }); - - await waitFor(() => { - expect( - screen.queryByTestId("nav-search-results"), - ).not.toBeInTheDocument(); - }); - }); - - it("navigates with ArrowDown + Enter in modal", async () => { - const NavSearch = getNavSearch(); - render(); - - fireEvent.click(screen.getByTestId("nav-search-toggle")); - - const input = await screen.findByTestId("nav-search-modal-input"); - // Type something that matches multiple items - fireEvent.change(input, { target: { value: "System" } }); - - await waitFor(() => { - expect(screen.getByTestId("nav-search-results")).toBeInTheDocument(); - }); - - // First item is active by default, press Enter to navigate - fireEvent.keyDown(input, { key: "Enter" }); - - expect(mockPush).toHaveBeenCalled(); - }); - - it("wraps ArrowDown from last to first item", async () => { - const NavSearch = getNavSearch(); - render(); - - fireEvent.click(screen.getByTestId("nav-search-toggle")); - - const input = await screen.findByTestId("nav-search-modal-input"); - // "Home" matches only 1 item - fireEvent.change(input, { target: { value: "Home" } }); - - await waitFor(() => { - expect(screen.getByTestId("nav-search-results")).toBeInTheDocument(); - }); - - // ArrowDown from the only item should wrap to index 0 - fireEvent.keyDown(input, { key: "ArrowDown" }); - // Should still have the first item active (wrapped) - const firstResult = screen.getByTestId("result-/"); - expect(firstResult.getAttribute("aria-selected")).toBe("true"); - }); - - it("sets aria-activedescendant on the input for screen readers", async () => { - const NavSearch = getNavSearch(); - render(); - - fireEvent.click(screen.getByTestId("nav-search-toggle")); - - const input = await screen.findByTestId("nav-search-modal-input"); - fireEvent.change(input, { target: { value: "System" } }); - - await waitFor(() => { - expect(input.getAttribute("aria-activedescendant")).toBe( - "nav-search-result-0", - ); - }); - }); - }); - describe("filtering", () => { it("excludes hidden routes from the options", () => { const NavSearch = getNavSearch(); @@ -605,22 +442,6 @@ describe("NavSearch", () => { ); }); - it("shows tab items in modal results", async () => { - const NavSearch = getNavSearch(); - render(); - - fireEvent.click(screen.getByTestId("nav-search-toggle")); - - const input = await screen.findByTestId("nav-search-modal-input"); - fireEvent.change(input, { target: { value: "Manual" } }); - - await waitFor(() => { - expect( - screen.getByTestId("result-/privacy-requests?tab=manual-tasks"), - ).toBeInTheDocument(); - }); - }); - it("includes both parent page and its tabs in results", () => { const NavSearch = getNavSearch(); render(); @@ -673,22 +494,6 @@ describe("NavSearch", () => { expect(mockPush).toHaveBeenCalledWith("/settings/privacy-requests"); }); - - it("renders duplicate-path items in modal without key warnings", async () => { - const NavSearch = getNavSearch(); - render(); - - fireEvent.click(screen.getByTestId("nav-search-toggle")); - - const input = await screen.findByTestId("nav-search-modal-input"); - fireEvent.change(input, { target: { value: "tion" } }); - - await waitFor(() => { - // Both items should be rendered - expect(screen.getByText("Redaction patterns")).toBeInTheDocument(); - expect(screen.getByText("Duplicate detection")).toBeInTheDocument(); - }); - }); }); describe("sub-items visibility", () => { diff --git a/clients/admin-ui/src/features/common/nav/NavSearch.module.scss b/clients/admin-ui/src/features/common/nav/NavSearch.module.scss index f6ef86a7ca5..a793a63a56f 100644 --- a/clients/admin-ui/src/features/common/nav/NavSearch.module.scss +++ b/clients/admin-ui/src/features/common/nav/NavSearch.module.scss @@ -112,9 +112,13 @@ max-height: 360px; overflow-y: auto; padding: 6px; + list-style: none; + margin: 0; } .resultsGroup { + list-style: none; + &:not(:first-child) { margin-top: 4px; } diff --git a/clients/admin-ui/src/features/common/nav/NavSearch.tsx b/clients/admin-ui/src/features/common/nav/NavSearch.tsx index fe9598cf505..ef594bde3f9 100644 --- a/clients/admin-ui/src/features/common/nav/NavSearch.tsx +++ b/clients/admin-ui/src/features/common/nav/NavSearch.tsx @@ -282,22 +282,22 @@ const NavSearch = ({ groups, collapsed = false }: NavSearchProps) => { "No results found"} {indexedModalItems.length > 0 && ( - + )} From 6cfa3a9b8d908aaa51c56d7a0e86a0ae39ca0564 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Fri, 20 Mar 2026 21:03:20 -0700 Subject: [PATCH 14/28] Increase search page size to 50 for better coverage The backend LIKE search on 'ab' matches many systems containing those letters anywhere in the name. With size=10 relevant results like 'AB InBev' were pushed off the first page. Size=50 gives better coverage while the client-side filter still narrows to exact matches. Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/admin-ui/src/features/common/nav/useNavSearchItems.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/admin-ui/src/features/common/nav/useNavSearchItems.ts b/clients/admin-ui/src/features/common/nav/useNavSearchItems.ts index 214ac3e43fe..8608fcbc89f 100644 --- a/clients/admin-ui/src/features/common/nav/useNavSearchItems.ts +++ b/clients/admin-ui/src/features/common/nav/useNavSearchItems.ts @@ -34,7 +34,7 @@ const CORE_TAXONOMY_ITEMS = [ const MIN_SEARCH_LENGTH = 2; /** Max results to fetch per dynamic source. */ -const SEARCH_PAGE_SIZE = 10; +const SEARCH_PAGE_SIZE = 50; export interface FlatNavItem { title: string; From 0bda55d9e99f34df20925a97431ea3ef5149bbcf Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Fri, 20 Mar 2026 21:05:52 -0700 Subject: [PATCH 15/28] Revert "Increase search page size to 50 for better coverage" This reverts commit 6cfa3a9b8d908aaa51c56d7a0e86a0ae39ca0564. --- clients/admin-ui/src/features/common/nav/useNavSearchItems.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/admin-ui/src/features/common/nav/useNavSearchItems.ts b/clients/admin-ui/src/features/common/nav/useNavSearchItems.ts index 8608fcbc89f..214ac3e43fe 100644 --- a/clients/admin-ui/src/features/common/nav/useNavSearchItems.ts +++ b/clients/admin-ui/src/features/common/nav/useNavSearchItems.ts @@ -34,7 +34,7 @@ const CORE_TAXONOMY_ITEMS = [ const MIN_SEARCH_LENGTH = 2; /** Max results to fetch per dynamic source. */ -const SEARCH_PAGE_SIZE = 50; +const SEARCH_PAGE_SIZE = 10; export interface FlatNavItem { title: string; From 68fee7094fd79816692ce882719ada9a0db07eac Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Fri, 20 Mar 2026 21:10:50 -0700 Subject: [PATCH 16/28] Position search modal at one-third from top Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/admin-ui/src/features/common/nav/NavSearch.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/admin-ui/src/features/common/nav/NavSearch.tsx b/clients/admin-ui/src/features/common/nav/NavSearch.tsx index ef594bde3f9..7a047d2ab77 100644 --- a/clients/admin-ui/src/features/common/nav/NavSearch.tsx +++ b/clients/admin-ui/src/features/common/nav/NavSearch.tsx @@ -25,7 +25,7 @@ const COLLAPSED_ICON_STYLE = { fontSize: 16, color: palette.FIDESUI_CORINTH, }; -const MODAL_POSITION = { top: "calc(50vh - 24px)" }; +const MODAL_POSITION = { top: "calc(33vh - 24px)" }; const DEBOUNCE_MS = 200; /** Create a unique key for an item to avoid collisions when multiple items share the same path. */ From 4b98d69e7e95771565e7f2aade21587916f3143e Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Fri, 20 Mar 2026 21:47:26 -0700 Subject: [PATCH 17/28] Sidebar collapse toggle, Action center tabs, sentence case, cleanup - Replace logo-click collapse with SidePanelClose/SidePanelOpen icons - Collapsed mode: Fides icon crossfades to expand icon on hover - Add Action center tabs (Attention required, Activity) to search - Sentence-case developer page titles and nav labels - Remove dead Table migration POC nav entry Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/features/common/nav/MainSideNav.tsx | 69 ++++++++++--------- .../features/common/nav/NavMenu.module.scss | 58 +++++++++++++++- .../src/features/common/nav/nav-config.tsx | 17 ++--- .../hooks/useActionCenterNavigation.tsx | 11 +++ .../admin-ui/src/pages/poc/ant-components.tsx | 2 +- .../src/pages/poc/prompt-explorer.tsx | 2 +- .../admin-ui/src/pages/poc/test-monitors.tsx | 2 +- 7 files changed, 114 insertions(+), 47 deletions(-) diff --git a/clients/admin-ui/src/features/common/nav/MainSideNav.tsx b/clients/admin-ui/src/features/common/nav/MainSideNav.tsx index e50475a69a0..9ad7f6858d8 100644 --- a/clients/admin-ui/src/features/common/nav/MainSideNav.tsx +++ b/clients/admin-ui/src/features/common/nav/MainSideNav.tsx @@ -162,50 +162,55 @@ export const UnconnectedMainSideNav = ({
- + ) : ( + <>
Fides Logo
-
- + + + )} , routes: [ { - title: "Prompt Explorer", + title: "Prompt explorer", path: routes.PROMPT_EXPLORER_ROUTE, scopes: [ScopeRegistryEnum.DEVELOPER_READ], requiresPlus: true, }, { - title: "Test Monitors", + title: "Test monitors", path: routes.TEST_MONITORS_ROUTE, scopes: [ScopeRegistryEnum.DEVELOPER_READ], }, { - title: "Ant Design POC", + title: "Ant design POC", path: routes.ANT_POC_ROUTE, scopes: [], }, { - title: "Fides JS Docs", + title: "Fides JS docs", path: routes.FIDES_JS_DOCS, scopes: [], }, @@ -403,12 +405,7 @@ if (process.env.NEXT_PUBLIC_APP_ENV === "development") { scopes: [], }, { - title: "Table Migration POC", - path: routes.TABLE_MIGRATION_POC_ROUTE, - scopes: [], - }, - { - title: "Error Test", + title: "Error test", path: routes.ERRORS_POC_ROUTE, scopes: [], }, diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/hooks/useActionCenterNavigation.tsx b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/hooks/useActionCenterNavigation.tsx index eba75bb8bfd..079806dff4f 100644 --- a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/hooks/useActionCenterNavigation.tsx +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/hooks/useActionCenterNavigation.tsx @@ -1,6 +1,11 @@ import { Icons, MenuProps } from "fidesui"; import useMenuNavigation from "~/features/common/hooks/useMenuNavigation"; +import type { NavConfigTab } from "~/features/common/nav/nav-config"; +import { + ACTION_CENTER_ACTIVITY_ROUTE, + ACTION_CENTER_ROUTE, +} from "~/features/common/nav/routes"; export enum ActionCenterRoute { ATTENTION_REQUIRED = "attention-required", @@ -25,6 +30,12 @@ export const ACTION_CENTER_CONFIG: Record< }, } as const; +/** Shared tab definitions used by both the tab bar and nav search. */ +export const ACTION_CENTER_TAB_ITEMS: NavConfigTab[] = [ + { title: "Attention required", path: ACTION_CENTER_ROUTE }, + { title: "Activity", path: ACTION_CENTER_ACTIVITY_ROUTE }, +]; + export type ActionCenterRouteConfig = Record; const useActionCenterNavigation = (routeConfig: ActionCenterRouteConfig) => { diff --git a/clients/admin-ui/src/pages/poc/ant-components.tsx b/clients/admin-ui/src/pages/poc/ant-components.tsx index 5af88d0bf87..211893bc639 100644 --- a/clients/admin-ui/src/pages/poc/ant-components.tsx +++ b/clients/admin-ui/src/pages/poc/ant-components.tsx @@ -52,7 +52,7 @@ const AntPOC: NextPage = () => { return ( - + diff --git a/clients/admin-ui/src/pages/poc/prompt-explorer.tsx b/clients/admin-ui/src/pages/poc/prompt-explorer.tsx index 4a52f45c444..9fd12ec3570 100644 --- a/clients/admin-ui/src/pages/poc/prompt-explorer.tsx +++ b/clients/admin-ui/src/pages/poc/prompt-explorer.tsx @@ -275,7 +275,7 @@ const PromptExplorer: NextPage = () => { return ( - + Developer tool for exploring and testing LLM prompts used in assessments and questionnaires. diff --git a/clients/admin-ui/src/pages/poc/test-monitors.tsx b/clients/admin-ui/src/pages/poc/test-monitors.tsx index 606fd6dca50..26dd19904cb 100644 --- a/clients/admin-ui/src/pages/poc/test-monitors.tsx +++ b/clients/admin-ui/src/pages/poc/test-monitors.tsx @@ -11,7 +11,7 @@ const { Paragraph } = Typography; const TestMonitors: NextPage = () => { return ( - + Developer tool for seeding test data via the configurable test monitors. From bfd6b00d5ef021586853549ba34712366675973e Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Fri, 20 Mar 2026 21:48:15 -0700 Subject: [PATCH 18/28] Auto-formatting changes Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/admin-ui/src/pages/consent/privacy-notices/[id].tsx | 2 +- clients/admin-ui/src/pages/consent/privacy-notices/index.tsx | 2 +- clients/admin-ui/src/pages/consent/privacy-notices/new.tsx | 2 +- clients/admin-ui/src/pages/privacy-requests/index.tsx | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/clients/admin-ui/src/pages/consent/privacy-notices/[id].tsx b/clients/admin-ui/src/pages/consent/privacy-notices/[id].tsx index 1359deb7422..e7c9b81916b 100644 --- a/clients/admin-ui/src/pages/consent/privacy-notices/[id].tsx +++ b/clients/admin-ui/src/pages/consent/privacy-notices/[id].tsx @@ -50,7 +50,7 @@ const PrivacyNoticeDetailPage = () => { return ( ( - + Manage the privacy notices and mechanisms that are displayed to your users based on their location, what information you collect about them, diff --git a/clients/admin-ui/src/pages/consent/privacy-notices/new.tsx b/clients/admin-ui/src/pages/consent/privacy-notices/new.tsx index 06a96c49229..f2ddc972b30 100644 --- a/clients/admin-ui/src/pages/consent/privacy-notices/new.tsx +++ b/clients/admin-ui/src/pages/consent/privacy-notices/new.tsx @@ -8,7 +8,7 @@ import PrivacyNoticeForm from "~/features/privacy-notices/PrivacyNoticeForm"; const NewPrivacyNoticePage = () => ( { return ( From 3a9e0acc88590468118517d6d1fc8c6c84d92893 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Mon, 23 Mar 2026 09:14:08 -0700 Subject: [PATCH 19/28] Fix Prettier formatting in NavMenu.module.scss Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/admin-ui/src/features/common/nav/NavMenu.module.scss | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/clients/admin-ui/src/features/common/nav/NavMenu.module.scss b/clients/admin-ui/src/features/common/nav/NavMenu.module.scss index 91e257fbd65..0877aa53f98 100644 --- a/clients/admin-ui/src/features/common/nav/NavMenu.module.scss +++ b/clients/admin-ui/src/features/common/nav/NavMenu.module.scss @@ -8,7 +8,9 @@ cursor: pointer; padding: 4px; border-radius: 4px; - transition: background-color 0.2s ease, color 0.2s ease; + transition: + background-color 0.2s ease, + color 0.2s ease; &:hover { background-color: var(--fidesui-neutral-700); From ab2a053c9c02cd02f91cfba843c84c45835588d1 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Mon, 23 Mar 2026 09:38:57 -0700 Subject: [PATCH 20/28] Fix antSelect Cypress command using global selector instead of element ref The antSelect command used cy.get(subject.selector) which re-queries the DOM globally with just ".ant-select", matching the first ant-select on the page rather than the intended element. With the NavSearch AutoComplete now rendering in the sidebar, it became the first .ant-select, causing the messaging template selector test to click the wrong element. Replace cy.get(subject.selector) with cy.wrap(subject) to preserve the actual element reference throughout the command. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../admin-ui/cypress/support/ant-support.ts | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/clients/admin-ui/cypress/support/ant-support.ts b/clients/admin-ui/cypress/support/ant-support.ts index 6b6f7a12dd3..2a21472fe46 100644 --- a/clients/admin-ui/cypress/support/ant-support.ts +++ b/clients/admin-ui/cypress/support/ant-support.ts @@ -211,8 +211,8 @@ Cypress.Commands.add( prevSubject: "element", }, (subject, option, clickOptions = { force: true }) => { - cy.get(subject.selector).first().should("have.class", "ant-select"); - cy.get(subject.selector) + cy.wrap(subject).first().should("have.class", "ant-select"); + cy.wrap(subject) .first() .invoke("attr", "class") .then((classes) => { @@ -221,19 +221,15 @@ Cypress.Commands.add( } if (!classes.includes("ant-select-open")) { if (classes.includes("ant-select-multiple")) { - cy.get(subject.selector).first().find("input").focus().click(); + cy.wrap(subject).first().find("input").focus().click(); } else { - cy.get(subject.selector) - .first() - .find("input") - .focus() - .click(clickOptions); + cy.wrap(subject).first().find("input").focus().click(clickOptions); } } cy.antSelectDropdownVisible(); cy.getAntSelectOption(option).should("be.visible").click(clickOptions); if (classes.includes("ant-select-multiple")) { - cy.get(subject.selector).first().find("input").blur(); + cy.wrap(subject).first().find("input").blur(); } cy.get("body").should(() => { const dropdown = Cypress.$(".ant-select-dropdown:visible"); @@ -249,8 +245,8 @@ Cypress.Commands.add( prevSubject: "element", }, (subject) => { - cy.get(subject.selector).should("have.class", "ant-select-allow-clear"); - cy.get(subject.selector).find(".ant-select-clear").click({ force: true }); + cy.wrap(subject).should("have.class", "ant-select-allow-clear"); + cy.wrap(subject).find(".ant-select-clear").click({ force: true }); }, ); @@ -260,7 +256,7 @@ Cypress.Commands.add( prevSubject: "element", }, (subject, option) => { - cy.get(subject.selector) + cy.wrap(subject) .find(`.ant-select-selection-item[title="${option}"]`) .find(".ant-select-selection-item-remove") .click({ force: true }); From 71ff974a146def78c6f425c9f16ddbfcf439a0a1 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Mon, 23 Mar 2026 09:51:03 -0700 Subject: [PATCH 21/28] Scope clear-icon click in locations test to avoid NavSearch collision The locations-regulations Cypress test used a global selector for button.ant-input-clear-icon which now matches both the page search bar and the NavSearch component's input. Scope it to the search-bar testid. Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/admin-ui/cypress/e2e/locations-regulations.cy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/admin-ui/cypress/e2e/locations-regulations.cy.ts b/clients/admin-ui/cypress/e2e/locations-regulations.cy.ts index 34dc0a8ea31..b8954ba8d36 100644 --- a/clients/admin-ui/cypress/e2e/locations-regulations.cy.ts +++ b/clients/admin-ui/cypress/e2e/locations-regulations.cy.ts @@ -108,7 +108,7 @@ describe("Locations and regulations", () => { }); // Clear search should reset to initial state - cy.get("button.ant-input-clear-icon").click(); + cy.getByTestId("search-bar").find("button.ant-input-clear-icon").click(); cy.getByTestId("picker-card-Europe"); cy.getByTestId("picker-card-North America"); cy.getByTestId("picker-card-South America"); From 479678b73224bcd305a5ed729d346a620cb0ad87 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Mon, 23 Mar 2026 10:02:32 -0700 Subject: [PATCH 22/28] Use .clear() instead of clicking ant-input-clear-icon in locations test The data-testid is on the inner , so .find() can't reach sibling elements like the clear icon. Use Cypress .clear() instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/admin-ui/cypress/e2e/locations-regulations.cy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/admin-ui/cypress/e2e/locations-regulations.cy.ts b/clients/admin-ui/cypress/e2e/locations-regulations.cy.ts index b8954ba8d36..4340cbf6752 100644 --- a/clients/admin-ui/cypress/e2e/locations-regulations.cy.ts +++ b/clients/admin-ui/cypress/e2e/locations-regulations.cy.ts @@ -108,7 +108,7 @@ describe("Locations and regulations", () => { }); // Clear search should reset to initial state - cy.getByTestId("search-bar").find("button.ant-input-clear-icon").click(); + cy.getByTestId("search-bar").clear(); cy.getByTestId("picker-card-Europe"); cy.getByTestId("picker-card-North America"); cy.getByTestId("picker-card-South America"); From 7d4595c078b1942b7771be8e4ef041f10c5fe471 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Mon, 23 Mar 2026 14:08:59 -0700 Subject: [PATCH 23/28] Sidebar collapse toggle, Action center tabs, sentence case, cleanup Move collapse/expand toggle from logo area to bottom bar footer. Logo is now a static display. In expanded mode the toggle is right-aligned; in collapsed mode it stacks below Help and Account. Restyle nav search input to blend into sidebar: minos fill with subtle neutral-700 border, neutral-800 on hover/focus, Cmd+K hint only visible on focus. Standardize bottom bar button styles across Help, Account, and collapse toggle with shared navBottomButton class. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../common/nav/AccountDropdownMenu.tsx | 8 +- .../src/features/common/nav/MainSideNav.tsx | 119 +++++++++--------- .../features/common/nav/NavMenu.module.scss | 76 +++++------ .../features/common/nav/NavSearch.module.scss | 22 +++- 4 files changed, 113 insertions(+), 112 deletions(-) diff --git a/clients/admin-ui/src/features/common/nav/AccountDropdownMenu.tsx b/clients/admin-ui/src/features/common/nav/AccountDropdownMenu.tsx index 1a1754f307f..e83d8dbdc69 100644 --- a/clients/admin-ui/src/features/common/nav/AccountDropdownMenu.tsx +++ b/clients/admin-ui/src/features/common/nav/AccountDropdownMenu.tsx @@ -8,9 +8,13 @@ import { USER_DETAIL_ROUTE } from "./routes"; interface AccountDropdownMenuProps { onLogout: () => void; + className?: string; } -const AccountDropdownMenu = ({ onLogout }: AccountDropdownMenuProps) => { +const AccountDropdownMenu = ({ + onLogout, + className, +}: AccountDropdownMenuProps) => { const user = useAppSelector(selectUser); const userId = user?.id; const username = user?.username; @@ -49,7 +53,7 @@ const AccountDropdownMenu = ({ onLogout }: AccountDropdownMenuProps) => { - ) : ( - <> -
- Fides Logo -
- - - )} + Fides Logo + +
+ Fides Logo +
+ - diff --git a/clients/admin-ui/src/features/common/nav/NavMenu.module.scss b/clients/admin-ui/src/features/common/nav/NavMenu.module.scss index 0877aa53f98..6ab6d423ba4 100644 --- a/clients/admin-ui/src/features/common/nav/NavMenu.module.scss +++ b/clients/admin-ui/src/features/common/nav/NavMenu.module.scss @@ -18,12 +18,23 @@ } } -.helpButton { - background-color: transparent; - border: none; +.navBottomButton { + display: flex; + align-items: center; + justify-content: center; + background-color: transparent !important; + border: none !important; + color: var(--fidesui-neutral-400) !important; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: + background-color 0.2s ease, + color 0.2s ease; &:hover { - background-color: var(--fidesui-neutral-700); + background-color: var(--fidesui-neutral-700) !important; + color: var(--fidesui-corinth) !important; } } @@ -51,10 +62,10 @@ .logoWrapper { padding-bottom: 24px; - padding-right: 8px; + padding-right: 16px; display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; transition: padding-inline-start 0.35s ease; } @@ -66,46 +77,6 @@ padding-inline-start: 28px; } -.collapsedLogoToggle { - display: flex; - align-items: center; - justify-content: center; - position: relative; - width: 24px; - height: 24px; - background: none; - border: none; - cursor: pointer; - padding: 0; -} - -.collapsedLogoIcon, -.collapsedExpandIcon { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - transition: opacity 0.2s ease; -} - -.collapsedLogoIcon { - opacity: 1; -} - -.collapsedExpandIcon { - opacity: 0; - color: var(--fidesui-corinth); -} - -.collapsedLogoToggle:hover .collapsedLogoIcon { - opacity: 0; -} - -.collapsedLogoToggle:hover .collapsedExpandIcon { - opacity: 1; -} - .logoContainer { position: relative; overflow: hidden; @@ -138,7 +109,7 @@ } .bottomBarExpanded { - justify-content: flex-start; + justify-content: space-between; flex-direction: row; gap: 0; } @@ -149,6 +120,17 @@ gap: 8px; } +.bottomBarLeft { + display: flex; + align-items: center; + gap: 0; +} + +.bottomBarCollapsed .bottomBarLeft { + flex-direction: column; + gap: 8px; +} + .menu { --ant-menu-item-margin-block: 0 !important; --ant-menu-item-margin-inline: 0 !important; diff --git a/clients/admin-ui/src/features/common/nav/NavSearch.module.scss b/clients/admin-ui/src/features/common/nav/NavSearch.module.scss index a793a63a56f..f9065421695 100644 --- a/clients/admin-ui/src/features/common/nav/NavSearch.module.scss +++ b/clients/admin-ui/src/features/common/nav/NavSearch.module.scss @@ -8,25 +8,31 @@ } .expandedInput { - background-color: var(--fidesui-neutral-800) !important; + background-color: var(--fidesui-minos) !important; border: 1px solid var(--fidesui-neutral-700) !important; color: var(--fidesui-corinth) !important; border-radius: 6px !important; font-size: 13px !important; height: 32px !important; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease !important; &::placeholder { color: var(--fidesui-neutral-400) !important; } &:hover { - border-color: var(--fidesui-neutral-600) !important; + background-color: var(--fidesui-neutral-800) !important; + border-color: var(--fidesui-neutral-700) !important; } &:focus, &:focus-within { - border-color: var(--fidesui-sandstone) !important; - box-shadow: 0 0 0 1px var(--fidesui-sandstone) !important; + background-color: var(--fidesui-neutral-800) !important; + border-color: var(--fidesui-neutral-600) !important; + box-shadow: none !important; } :global(.ant-input) { @@ -215,7 +221,7 @@ font-size: 13px; } -/* Keyboard shortcut hint */ +/* Keyboard shortcut hint — hidden by default, visible on focus */ .shortcutHint { font-size: 11px; color: var(--fidesui-neutral-500); @@ -225,4 +231,10 @@ line-height: 1.4; font-family: inherit; pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; +} + +.expandedInput:focus-within .shortcutHint { + opacity: 1; } From 11f9b8b7083190679783abd81f5a9072579124bd Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Mon, 23 Mar 2026 14:11:05 -0700 Subject: [PATCH 24/28] Restore logo click as collapse/expand toggle Keep the logo clickable for users accustomed to that behavior. The footer button remains as a secondary affordance for discoverability. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/features/common/nav/MainSideNav.tsx | 67 +++++++++++-------- .../features/common/nav/NavMenu.module.scss | 8 +++ 2 files changed, 47 insertions(+), 28 deletions(-) diff --git a/clients/admin-ui/src/features/common/nav/MainSideNav.tsx b/clients/admin-ui/src/features/common/nav/MainSideNav.tsx index 67e454c23a2..895e5b09f85 100644 --- a/clients/admin-ui/src/features/common/nav/MainSideNav.tsx +++ b/clients/admin-ui/src/features/common/nav/MainSideNav.tsx @@ -162,38 +162,49 @@ export const UnconnectedMainSideNav = ({
-
- Fides Logo +
+ Fides Logo +
+
+ Fides Logo +
-
- Fides Logo -
-
+
Date: Mon, 23 Mar 2026 14:24:42 -0700 Subject: [PATCH 25/28] Show Cmd+K hint on hover and focus instead of focus-only Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/admin-ui/src/features/common/nav/NavSearch.module.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/clients/admin-ui/src/features/common/nav/NavSearch.module.scss b/clients/admin-ui/src/features/common/nav/NavSearch.module.scss index f9065421695..0e76d224ff1 100644 --- a/clients/admin-ui/src/features/common/nav/NavSearch.module.scss +++ b/clients/admin-ui/src/features/common/nav/NavSearch.module.scss @@ -221,7 +221,7 @@ font-size: 13px; } -/* Keyboard shortcut hint — hidden by default, visible on focus */ +/* Keyboard shortcut hint — hidden by default, visible on hover */ .shortcutHint { font-size: 11px; color: var(--fidesui-neutral-500); @@ -235,6 +235,7 @@ transition: opacity 0.15s ease; } +.expandedInput:hover .shortcutHint, .expandedInput:focus-within .shortcutHint { opacity: 1; } From d11cdb3b87c75346a5824e9a2ae188e2346691f7 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Tue, 24 Mar 2026 12:48:04 -0700 Subject: [PATCH 26/28] Address PR review feedback for nav search - Split NavSearch into NavSearchExpanded + NavSearchModal components - Extract NavSearchResultItem as separate component - Use NextLink for modal result items instead of router.push callback - Switch to react-hotkeys-hook for Cmd+K/Ctrl+K shortcut - Rewrite grouping/item construction with reduce/flatMap (no mutations) - Use pluralize utility for result count announcement - Use path as key in AccessControlTabs - Convert layout wrappers to Tailwind, keep Ant overrides in SCSS - Fix aria-controls to always reference the listbox ID Co-Authored-By: Claude Opus 4.6 (1M context) --- .../common/nav/NavSearch.collapsed.test.tsx | 73 +++++ .../features/common/nav/NavSearch.test.tsx | 43 +++ .../access-control/AccessControlTabs.tsx | 25 +- .../features/common/nav/NavSearch.module.scss | 29 -- .../src/features/common/nav/NavSearch.tsx | 265 +++--------------- .../features/common/nav/NavSearchModal.tsx | 238 ++++++++++++++++ .../features/common/nav/useNavSearchItems.ts | 62 ++-- 7 files changed, 424 insertions(+), 311 deletions(-) create mode 100644 clients/admin-ui/src/features/common/nav/NavSearchModal.tsx diff --git a/clients/admin-ui/__tests__/features/common/nav/NavSearch.collapsed.test.tsx b/clients/admin-ui/__tests__/features/common/nav/NavSearch.collapsed.test.tsx index 017ae69bb7a..209a4ecb94e 100644 --- a/clients/admin-ui/__tests__/features/common/nav/NavSearch.collapsed.test.tsx +++ b/clients/admin-ui/__tests__/features/common/nav/NavSearch.collapsed.test.tsx @@ -57,6 +57,79 @@ jest.mock("fidesui/src/palette/palette.module.scss", () => ({ FIDESUI_NEUTRAL_400: "#a8aaad", })); +// Mock react-hotkeys-hook so fireEvent.keyDown works in tests +jest.mock("react-hotkeys-hook", () => { + // eslint-disable-next-line global-require + const MockReact = require("react"); + return { + useHotkeys: ( + keys: string, + callback: () => void, + options?: Record, + ) => { + MockReact.useEffect(() => { + const handler = (e: KeyboardEvent) => { + keys + .split(",") + .map((k: string) => k.trim().toLowerCase()) + .some((combo: string) => { + const parts = combo.split("+"); + const key = parts.pop()!; + const needsMeta = parts.includes("meta"); + const needsCtrl = parts.includes("ctrl"); + if ( + e.key.toLowerCase() === key && + (!needsMeta || e.metaKey) && + (!needsCtrl || e.ctrlKey) + ) { + if (options?.preventDefault) { + e.preventDefault(); + } + callback(); + return true; + } + return false; + }); + }; + globalThis.document.addEventListener("keydown", handler); + return () => + globalThis.document.removeEventListener("keydown", handler); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + }, + }; +}); + +// Mock next/link to simulate NextLink navigation via the mocked router +jest.mock("next/link", () => { + // eslint-disable-next-line global-require + const MockReact = require("react"); + return { + __esModule: true, + default: MockReact.forwardRef( + ({ href, onClick, children, ...props }: any, ref: any) => { + // eslint-disable-next-line global-require, @typescript-eslint/no-shadow + const { useRouter } = require("next/router"); + const router = useRouter(); + return MockReact.createElement( + "a", + { + ...props, + href, + ref, + onClick: (e: any) => { + e.preventDefault(); + router.push(href); + onClick?.(e); + }, + }, + children, + ); + }, + ), + }; +}); + const mockDynamicItems: FlatNavItem[] = []; jest.mock("~/features/common/nav/useNavSearchItems", () => ({ __esModule: true, diff --git a/clients/admin-ui/__tests__/features/common/nav/NavSearch.test.tsx b/clients/admin-ui/__tests__/features/common/nav/NavSearch.test.tsx index d6cfcc0568f..bc6b8b76389 100644 --- a/clients/admin-ui/__tests__/features/common/nav/NavSearch.test.tsx +++ b/clients/admin-ui/__tests__/features/common/nav/NavSearch.test.tsx @@ -96,6 +96,49 @@ jest.mock("fidesui/src/palette/palette.module.scss", () => ({ FIDESUI_NEUTRAL_400: "#a8aaad", })); +// Mock react-hotkeys-hook so fireEvent.keyDown works in tests +jest.mock("react-hotkeys-hook", () => { + // eslint-disable-next-line global-require + const MockReact = require("react"); + return { + useHotkeys: ( + keys: string, + callback: () => void, + options?: Record, + ) => { + MockReact.useEffect(() => { + const handler = (e: KeyboardEvent) => { + keys + .split(",") + .map((k: string) => k.trim().toLowerCase()) + .some((combo: string) => { + const parts = combo.split("+"); + const key = parts.pop()!; + const needsMeta = parts.includes("meta"); + const needsCtrl = parts.includes("ctrl"); + if ( + e.key.toLowerCase() === key && + (!needsMeta || e.metaKey) && + (!needsCtrl || e.ctrlKey) + ) { + if (options?.preventDefault) { + e.preventDefault(); + } + callback(); + return true; + } + return false; + }); + }; + globalThis.document.addEventListener("keydown", handler); + return () => + globalThis.document.removeEventListener("keydown", handler); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + }, + }; +}); + // Mock useNavSearchItems to return static + dynamic items without Redux const mockDynamicItems: FlatNavItem[] = []; jest.mock("~/features/common/nav/useNavSearchItems", () => ({ diff --git a/clients/admin-ui/src/features/access-control/AccessControlTabs.tsx b/clients/admin-ui/src/features/access-control/AccessControlTabs.tsx index fa5966d88a6..36ecfb08909 100644 --- a/clients/admin-ui/src/features/access-control/AccessControlTabs.tsx +++ b/clients/admin-ui/src/features/access-control/AccessControlTabs.tsx @@ -17,29 +17,14 @@ const AccessControlTabs = () => { const router = useRouter(); const currentPath = router.pathname; - let selectedKey = "summary"; + let selectedKey = ACCESS_CONTROL_SUMMARY_ROUTE; if (currentPath.startsWith(ACCESS_CONTROL_REQUEST_LOG_ROUTE)) { - selectedKey = "request-log"; - } else if (currentPath.startsWith(ACCESS_CONTROL_SUMMARY_ROUTE)) { - selectedKey = "summary"; + selectedKey = ACCESS_CONTROL_REQUEST_LOG_ROUTE; } - const menuItems = ACCESS_CONTROL_TAB_ITEMS.map((tab) => ({ - key: tab.path === ACCESS_CONTROL_SUMMARY_ROUTE ? "summary" : "request-log", + const items = ACCESS_CONTROL_TAB_ITEMS.map((tab) => ({ + key: tab.path, label: tab.title, - path: tab.path, - })); - - const handleMenuClick = (key: string) => { - const item = menuItems.find((i) => i.key === key); - if (item) { - router.push(item.path); - } - }; - - const items = menuItems.map((item) => ({ - label: item.label, - key: item.key, })); return ( @@ -47,7 +32,7 @@ const AccessControlTabs = () => { handleMenuClick(key)} + onClick={({ key }) => router.push(key)} items={items} /> diff --git a/clients/admin-ui/src/features/common/nav/NavSearch.module.scss b/clients/admin-ui/src/features/common/nav/NavSearch.module.scss index 0e76d224ff1..7175ed1b368 100644 --- a/clients/admin-ui/src/features/common/nav/NavSearch.module.scss +++ b/clients/admin-ui/src/features/common/nav/NavSearch.module.scss @@ -1,8 +1,4 @@ /* Expanded sidebar search */ -.expandedWrapper { - padding: 0 8px 12px; -} - .expandedAutoComplete { width: 100% !important; } @@ -53,31 +49,6 @@ } } -/* Collapsed sidebar search */ -.collapsedWrapper { - display: flex; - flex-direction: column; - align-items: center; - padding-bottom: 12px; -} - -.collapsedButton { - display: flex; - align-items: center; - justify-content: center; - width: 36px; - height: 36px; - border-radius: 6px; - border: none; - background-color: transparent; - cursor: pointer; - transition: background-color 0.2s ease; - - &:hover { - background-color: var(--fidesui-neutral-700); - } -} - /* Spotlight-style modal (collapsed mode) */ .searchModal { :global(.ant-modal-content), diff --git a/clients/admin-ui/src/features/common/nav/NavSearch.tsx b/clients/admin-ui/src/features/common/nav/NavSearch.tsx index 7a047d2ab77..e95af95a827 100644 --- a/clients/admin-ui/src/features/common/nav/NavSearch.tsx +++ b/clients/admin-ui/src/features/common/nav/NavSearch.tsx @@ -1,10 +1,12 @@ -import { AutoComplete, Icons, Input, InputRef, Modal } from "fidesui"; +import { AutoComplete, Icons, Input, InputRef } from "fidesui"; import palette from "fidesui/src/palette/palette.module.scss"; import { useRouter } from "next/router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; import { NavGroup } from "./nav-config"; import styles from "./NavSearch.module.scss"; +import NavSearchModal from "./NavSearchModal"; import useNavSearchItems, { FlatNavItem } from "./useNavSearchItems"; const isMac = @@ -13,36 +15,21 @@ const isMac = const SHORTCUT_LABEL = isMac ? "⌘K" : "Ctrl+K"; -const SEARCH_ICON_STYLE_SM = { +const SEARCH_ICON_STYLE = { color: palette.FIDESUI_NEUTRAL_400, fontSize: 14, }; -const SEARCH_ICON_STYLE_LG = { - color: palette.FIDESUI_NEUTRAL_400, - fontSize: 16, -}; -const COLLAPSED_ICON_STYLE = { - fontSize: 16, - color: palette.FIDESUI_CORINTH, -}; -const MODAL_POSITION = { top: "calc(33vh - 24px)" }; const DEBOUNCE_MS = 200; /** Create a unique key for an item to avoid collisions when multiple items share the same path. */ const itemKey = (item: FlatNavItem): string => item.parentTitle ? `${item.path}::${item.title}` : item.path; -interface NavSearchProps { - groups: NavGroup[]; - collapsed?: boolean; -} - -const NavSearch = ({ groups, collapsed = false }: NavSearchProps) => { +const NavSearchExpanded = ({ groups }: { groups: NavGroup[] }) => { const router = useRouter(); const [open, setOpen] = useState(false); const [searchValue, setSearchValue] = useState(""); const inputRef = useRef(null); - const modalInputRef = useRef(null); const justSelectedRef = useRef(false); // Debounce the search value for server-side queries (systems, integrations) @@ -63,12 +50,11 @@ const NavSearch = ({ groups, collapsed = false }: NavSearchProps) => { : flatItems.filter((item) => !item.parentTitle); // Group by nav group - const grouped = new Map(); - items.forEach((item) => { - const list = grouped.get(item.groupTitle) ?? []; - list.push(item); - grouped.set(item.groupTitle, list); - }); + const grouped = items.reduce>((acc, item) => { + const existing = acc.get(item.groupTitle) ?? []; + acc.set(item.groupTitle, [...existing, item]); + return acc; + }, new Map()); return Array.from(grouped.entries()).map(([groupTitle, groupItems]) => ({ label: {groupTitle}, @@ -87,13 +73,10 @@ const NavSearch = ({ groups, collapsed = false }: NavSearchProps) => { }, [flatItems, searchValue]); // Build a lookup from unique key back to path for navigation - const keyToPath = useMemo(() => { - const map = new Map(); - flatItems.forEach((item) => { - map.set(itemKey(item), item.path); - }); - return map; - }, [flatItems]); + const keyToPath = useMemo( + () => new Map(flatItems.map((item) => [itemKey(item), item.path])), + [flatItems], + ); const handleSelect = useCallback( (key: string) => { @@ -103,7 +86,6 @@ const NavSearch = ({ groups, collapsed = false }: NavSearchProps) => { setOpen(false); setSearchValue(""); inputRef.current?.blur(); - modalInputRef.current?.blur(); // Reset the guard after the event loop settles setTimeout(() => { justSelectedRef.current = false; @@ -112,11 +94,6 @@ const NavSearch = ({ groups, collapsed = false }: NavSearchProps) => { [router, keyToPath], ); - const handleClose = useCallback(() => { - setOpen(false); - setSearchValue(""); - }, []); - const handleOpenChange = useCallback((nextOpen: boolean) => { setOpen(nextOpen); if (!nextOpen) { @@ -125,210 +102,27 @@ const NavSearch = ({ groups, collapsed = false }: NavSearchProps) => { }, []); // Cmd+K / Ctrl+K global shortcut - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "k") { - e.preventDefault(); - setOpen(true); - // In expanded mode, focus immediately; in collapsed mode, - // the Modal's afterOpenChange callback handles focus. - if (!collapsed) { - requestAnimationFrame(() => { - inputRef.current?.focus(); - }); - } - } - }; - document.addEventListener("keydown", handler); - return () => document.removeEventListener("keydown", handler); - }, [collapsed]); - - // Focus input when opened in expanded mode - useEffect(() => { - if (open && !collapsed) { + useHotkeys( + "meta+k, ctrl+k", + () => { + setOpen(true); requestAnimationFrame(() => { inputRef.current?.focus(); }); - } - }, [open, collapsed]); + }, + { enableOnFormTags: true, preventDefault: true }, + ); const handleEscapeKey = useCallback((e: React.KeyboardEvent) => { if (e.key === "Escape") { setOpen(false); setSearchValue(""); inputRef.current?.blur(); - modalInputRef.current?.blur(); } }, []); - // Keyboard navigation for inline results in the modal - const [activeIndex, setActiveIndex] = useState(-1); - - const visibleItems = useMemo(() => { - const query = searchValue.trim().toLowerCase(); - return query - ? flatItems.filter((item) => item.title.toLowerCase().includes(query)) - : []; - }, [flatItems, searchValue]); - - // Reset active index when results change - useEffect(() => { - setActiveIndex(visibleItems.length > 0 ? 0 : -1); - }, [visibleItems]); - - const handleModalKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - handleClose(); - return; - } - if (e.key === "ArrowDown") { - e.preventDefault(); - setActiveIndex((prev) => - prev < visibleItems.length - 1 ? prev + 1 : 0, - ); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setActiveIndex((prev) => - prev > 0 ? prev - 1 : visibleItems.length - 1, - ); - } else if ( - e.key === "Enter" && - activeIndex >= 0 && - visibleItems[activeIndex] - ) { - e.preventDefault(); - handleSelect(visibleItems[activeIndex].path); - } - }, - [visibleItems, activeIndex, handleSelect, handleClose], - ); - - // Pre-compute flat indexed items for the modal to avoid mutable counter in render - const indexedModalItems = useMemo(() => { - const grouped = new Map(); - visibleItems.forEach((item) => { - const list = grouped.get(item.groupTitle) ?? []; - list.push(item); - grouped.set(item.groupTitle, list); - }); - - let idx = 0; - return Array.from(grouped.entries()).map(([groupTitle, items]) => ({ - groupTitle, - items: items.map((item) => { - const currentIdx = idx; - idx += 1; - return { ...item, idx: currentIdx }; - }), - })); - }, [visibleItems]); - - if (collapsed) { - return ( -
- - { - if (isOpen) { - modalInputRef.current?.focus(); - } - }} - > - setSearchValue(e.target.value)} - prefix={} - allowClear - onKeyDown={handleModalKeyDown} - role="combobox" - aria-label="Search pages" - aria-expanded={indexedModalItems.length > 0} - aria-controls={ - indexedModalItems.length > 0 - ? "nav-search-results-list" - : undefined - } - aria-activedescendant={ - activeIndex >= 0 ? `nav-search-result-${activeIndex}` : undefined - } - data-testid="nav-search-modal-input" - /> -
- {visibleItems.length > 0 && - `${visibleItems.length} result${visibleItems.length === 1 ? "" : "s"} available`} - {visibleItems.length === 0 && - searchValue.trim() && - "No results found"} -
- {indexedModalItems.length > 0 && ( - - )} -
-
- ); - } - return ( -
+
{ className={styles.expandedInput} placeholder="Search pages..." aria-label="Search pages" - prefix={} + prefix={} suffix={