diff --git a/changelog/7723-nav-search.yaml b/changelog/7723-nav-search.yaml new file mode 100644 index 00000000000..d5a062b2027 --- /dev/null +++ b/changelog/7723-nav-search.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: [] 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..209a4ecb94e --- /dev/null +++ b/clients/admin-ui/__tests__/features/common/nav/NavSearch.collapsed.test.tsx @@ -0,0 +1,414 @@ +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", +})); + +// 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, + 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 new file mode 100644 index 00000000000..6bd51378332 --- /dev/null +++ b/clients/admin-ui/__tests__/features/common/nav/NavSearch.test.tsx @@ -0,0 +1,635 @@ +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 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", () => ({ + __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("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", () => { + // jsdom has no Mac navigator, so isMac is false and only Ctrl+K is bound + 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 Cmd+K in non-Mac environment", () => { + const NavSearch = getNavSearch(); + render(); + + fireEvent.keyDown(document, { key: "k", metaKey: true }); + + expect(getAutoCompleteProps().open).toBeFalsy(); + }); + + 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("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"); + }); + }); + + 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/cypress/e2e/locations-regulations.cy.ts b/clients/admin-ui/cypress/e2e/locations-regulations.cy.ts index 34dc0a8ea31..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.get("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"); 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 }); diff --git a/clients/admin-ui/src/features/common/NotificationTabs.tsx b/clients/admin-ui/src/features/common/NotificationTabs.tsx index f23b943d85a..03e72ae9087 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: "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 }, +]; + const NotificationTabs = () => { const router = useRouter(); const currentPath = router.pathname; @@ -33,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, 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) => { + - 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 acf6e34d9b6..9e8941d34f3 100644 --- a/clients/admin-ui/src/features/common/nav/NavMenu.module.scss +++ b/clients/admin-ui/src/features/common/nav/NavMenu.module.scss @@ -1,14 +1,40 @@ .collapseToggle { + display: flex; + align-items: center; + justify-content: center; background-color: transparent; border: none; + color: var(--fidesui-neutral-400); + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: + background-color 0.2s ease, + color 0.2s ease; + + &:hover { + background-color: var(--fidesui-neutral-700); + color: var(--fidesui-corinth); + } } -.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; } } @@ -38,6 +64,7 @@ padding-bottom: 24px; padding-right: 16px; display: flex; + align-items: center; justify-content: flex-start; transition: padding-inline-start 0.35s ease; } @@ -50,6 +77,14 @@ padding-inline-start: 28px; } +.logoToggle { + display: inline-flex; + background: none; + border: none; + padding: 0; + cursor: pointer; +} + .logoContainer { position: relative; overflow: hidden; @@ -82,7 +117,7 @@ } .bottomBarExpanded { - justify-content: flex-start; + justify-content: space-between; flex-direction: row; gap: 0; } @@ -93,6 +128,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 new file mode 100644 index 00000000000..7175ed1b368 --- /dev/null +++ b/clients/admin-ui/src/features/common/nav/NavSearch.module.scss @@ -0,0 +1,212 @@ +/* Expanded sidebar search */ +.expandedAutoComplete { + width: 100% !important; +} + +.expandedInput { + 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 { + background-color: var(--fidesui-neutral-800) !important; + border-color: var(--fidesui-neutral-700) !important; + } + + &:focus, + &:focus-within { + background-color: var(--fidesui-neutral-800) !important; + border-color: var(--fidesui-neutral-600) !important; + box-shadow: none !important; + } + + :global(.ant-input) { + background-color: transparent !important; + color: var(--fidesui-corinth) !important; + + &::placeholder { + color: var(--fidesui-neutral-400) !important; + } + } + + :global(.ant-input-clear-icon) { + color: var(--fidesui-neutral-400) !important; + + &:hover { + color: var(--fidesui-corinth) !important; + } + } +} + +/* Spotlight-style modal (collapsed mode) */ +.searchModal { + :global(.ant-modal-content), + :global(.ant-modal-container) { + padding: 0 !important; + border-radius: 12px !important; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2) !important; + overflow: hidden; + } + + :global(.ant-modal-body) { + padding: 0 !important; + } + + :global(.ant-modal-header) { + display: none !important; + } +} + +.modalInput { + border: none !important; + border-radius: 0 !important; + font-size: 16px !important; + height: 48px !important; + min-height: 48px !important; + padding: 0 16px !important; + box-shadow: none !important; + + &:focus, + &:focus-within { + box-shadow: none !important; + } +} + +/* Inline results list */ +.resultsList { + border-top: 1px solid var(--fidesui-neutral-100); + max-height: 360px; + overflow-y: auto; + padding: 6px; + list-style: none; + margin: 0; +} + +.resultsGroup { + list-style: none; + + &:not(:first-child) { + margin-top: 4px; + } +} + +.resultsGroupLabel { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--fidesui-neutral-500); + padding: 8px 10px 4px; +} + +.resultItem { + display: block; + width: 100%; + text-align: left; + padding: 8px 10px; + border: none; + border-radius: 6px; + background: none; + font-size: 14px; + color: var(--fidesui-neutral-900); + cursor: pointer; + line-height: 1.4; + + &:hover { + background-color: var(--fidesui-bg-default); + } +} + +.resultItemActive { + background-color: var(--fidesui-bg-default); +} + +.resultItemParent { + display: block; + font-size: 11px; + color: var(--fidesui-neutral-500); + line-height: 1.3; + margin-top: 1px; +} + +/* Shared dropdown (expanded sidebar) */ +.searchDropdown { + :global(.ant-select-dropdown) { + border-radius: 8px !important; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12) !important; + padding: 4px !important; + } + + :global(.ant-select-item-group) { + font-size: 11px !important; + font-weight: 600 !important; + text-transform: uppercase !important; + letter-spacing: 0.05em !important; + color: var(--fidesui-neutral-500) !important; + padding: 8px 12px 4px !important; + } + + :global(.ant-select-item-option) { + border-radius: 4px !important; + padding: 6px 12px !important; + margin: 1px 0 !important; + } + + :global(.ant-select-item-option-active) { + background-color: var(--fidesui-bg-default) !important; + } +} + +/* Labels (expanded sidebar autocomplete) */ +.groupLabel { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.optionTwoLine { + display: flex; + flex-direction: column; + line-height: 1.3; +} + +.optionParent { + font-size: 11px; + color: var(--fidesui-neutral-500); + margin-top: 1px; +} + +.optionLabel { + font-size: 13px; +} + +/* Keyboard shortcut hint — hidden by default, visible on hover */ +.shortcutHint { + font-size: 11px; + color: var(--fidesui-neutral-500); + background-color: var(--fidesui-neutral-700); + border-radius: 3px; + padding: 1px 5px; + line-height: 1.4; + font-family: inherit; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; +} + +.expandedInput:hover .shortcutHint, +.expandedInput:focus-within .shortcutHint { + opacity: 1; +} diff --git a/clients/admin-ui/src/features/common/nav/NavSearch.tsx b/clients/admin-ui/src/features/common/nav/NavSearch.tsx new file mode 100644 index 00000000000..43dedb1be29 --- /dev/null +++ b/clients/admin-ui/src/features/common/nav/NavSearch.tsx @@ -0,0 +1,175 @@ +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 = + typeof navigator !== "undefined" && + /Mac|iPod|iPhone|iPad/.test(navigator.userAgent); + +const SHORTCUT_LABEL = isMac ? "⌘K" : "Ctrl+K"; + +const SEARCH_ICON_STYLE = { + color: palette.FIDESUI_NEUTRAL_400, + fontSize: 14, +}; +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; + +const NavSearchExpanded = ({ groups }: { groups: NavGroup[] }) => { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); + const inputRef = useRef(null); + const justSelectedRef = useRef(false); + + // 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(); + // 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 = 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}, + 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( + () => new Map(flatItems.map((item) => [itemKey(item), item.path])), + [flatItems], + ); + + const handleSelect = useCallback( + (key: string) => { + const path = keyToPath.get(key) ?? key; + justSelectedRef.current = true; + router.push(path); + setOpen(false); + setSearchValue(""); + inputRef.current?.blur(); + // Reset the guard after the event loop settles + setTimeout(() => { + justSelectedRef.current = false; + }, 0); + }, + [router, keyToPath], + ); + + const handleOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + setSearchValue(""); + } + }, []); + + // Cmd+K on Mac, Ctrl+K elsewhere + useHotkeys( + isMac ? "meta+k" : "ctrl+k", + () => { + setOpen(true); + requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + }, + { enableOnFormTags: true, preventDefault: true }, + ); + + const handleEscapeKey = useCallback((e: React.KeyboardEvent) => { + if (e.key === "Escape") { + setOpen(false); + setSearchValue(""); + inputRef.current?.blur(); + } + }, []); + + return ( +
+ + } + suffix={ + + } + allowClear + onFocus={() => { + if (!justSelectedRef.current) { + setOpen(true); + } + }} + onKeyDown={handleEscapeKey} + data-testid="nav-search-input" + /> + +
+ ); +}; + +interface NavSearchProps { + groups: NavGroup[]; + collapsed?: boolean; +} + +const NavSearch = ({ groups, collapsed = false }: NavSearchProps) => { + if (collapsed) { + return ; + } + + return ; +}; + +export default NavSearch; diff --git a/clients/admin-ui/src/features/common/nav/NavSearchModal.tsx b/clients/admin-ui/src/features/common/nav/NavSearchModal.tsx new file mode 100644 index 00000000000..8bb842bda19 --- /dev/null +++ b/clients/admin-ui/src/features/common/nav/NavSearchModal.tsx @@ -0,0 +1,242 @@ +import { Icons, Input, InputRef, Modal } from "fidesui"; +import palette from "fidesui/src/palette/palette.module.scss"; +import NextLink from "next/link"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +import { pluralize } from "~/features/common/utils"; + +import { NavGroup } from "./nav-config"; +import styles from "./NavSearch.module.scss"; +import useNavSearchItems, { FlatNavItem } from "./useNavSearchItems"; + +const SEARCH_ICON_STYLE = { + color: palette.FIDESUI_NEUTRAL_400, + fontSize: 16, +}; +const COLLAPSED_ICON_STYLE = { + fontSize: 16, + color: palette.FIDESUI_CORINTH, +}; +const isMac = + typeof navigator !== "undefined" && + /Mac|iPod|iPhone|iPad/.test(navigator.userAgent); + +const DEBOUNCE_MS = 200; + +/** Unique key for a nav item, handling duplicate paths via title suffix. */ +const itemKey = (item: FlatNavItem): string => + item.parentTitle ? `${item.path}::${item.title}` : item.path; + +/** Group items by groupTitle, preserving insertion order. */ +const groupByTitle = (items: FlatNavItem[]) => + items.reduce>((acc, item) => { + const existing = acc.get(item.groupTitle) ?? []; + acc.set(item.groupTitle, [...existing, item]); + return acc; + }, new Map()); + +interface NavSearchResultItemProps { + item: FlatNavItem & { idx: number }; + isActive: boolean; + onClose: () => void; + onMouseEnter: () => void; +} + +const NavSearchResultItem = ({ + item, + isActive, + onClose, + onMouseEnter, +}: NavSearchResultItemProps) => ( + + {item.title} + {item.parentTitle && ( + {item.parentTitle} + )} + +); + +interface NavSearchModalProps { + groups: NavGroup[]; +} + +const NavSearchModal = ({ groups }: NavSearchModalProps) => { + const [open, setOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); + const modalInputRef = useRef(null); + + const [debouncedQuery, setDebouncedQuery] = useState(""); + useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(searchValue), DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [searchValue]); + + const flatItems = useNavSearchItems(groups, debouncedQuery); + + const handleClose = useCallback(() => { + setOpen(false); + setSearchValue(""); + }, []); + + // Cmd+K on Mac, Ctrl+K elsewhere + useHotkeys(isMac ? "meta+k" : "ctrl+k", () => setOpen(true), { + enableOnFormTags: true, + preventDefault: true, + }); + + // Keyboard navigation + 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]); + + useEffect(() => { + setActiveIndex(visibleItems.length > 0 ? 0 : -1); + }, [visibleItems]); + + const handleKeyDown = 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(); + // Delegate to the active link element so NextLink handles navigation + document.getElementById(`nav-search-result-${activeIndex}`)?.click(); + } + }, + [visibleItems, activeIndex, handleClose], + ); + + const indexedGroups = useMemo(() => { + const grouped = groupByTitle(visibleItems); + 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]); + + const hasResults = indexedGroups.length > 0; + + return ( +
+ + { + if (isOpen) { + modalInputRef.current?.focus(); + } + }} + > + setSearchValue(e.target.value)} + prefix={} + allowClear + onKeyDown={handleKeyDown} + role="combobox" + aria-label="Search pages" + aria-expanded={hasResults} + aria-controls="nav-search-results-list" + aria-activedescendant={ + activeIndex >= 0 ? `nav-search-result-${activeIndex}` : undefined + } + data-testid="nav-search-modal-input" + /> +
+ {visibleItems.length > 0 && + `${visibleItems.length} ${pluralize(visibleItems.length, "result", "results")} available`} + {visibleItems.length === 0 && + searchValue.trim() && + "No results found"} +
+ {hasResults && ( + + )} +
+
+ ); +}; + +export default NavSearchModal; 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 f8da84e947b..7d54f9a08be 100644 --- a/clients/admin-ui/src/features/common/nav/nav-config.tsx +++ b/clients/admin-ui/src/features/common/nav/nav-config.tsx @@ -2,10 +2,18 @@ import { Icons } from "fidesui"; import { ReactNode } from "react"; import { FlagNames } from "~/features/common/features"; +import { NOTIFICATION_TAB_ITEMS } from "~/features/common/NotificationTabs"; +import { ACTION_CENTER_TAB_ITEMS } from "~/features/data-discovery-and-detection/action-center/hooks/useActionCenterNavigation"; +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 { @@ -54,6 +64,7 @@ export const NAV_CONFIG: NavConfigGroup[] = [ path: routes.ACTION_CENTER_ROUTE, scopes: [ScopeRegistryEnum.DISCOVERY_MONITOR_READ], requiresPlus: true, + tabs: ACTION_CENTER_TAB_ITEMS, }, { title: "Data catalog", @@ -126,9 +137,10 @@ export const NAV_CONFIG: NavConfigGroup[] = [ ScopeRegistryEnum.MANUAL_FIELD_READ_OWN, ScopeRegistryEnum.MANUAL_FIELD_READ_ALL, ], + tabs: PRIVACY_REQUEST_TAB_ITEMS, }, { - title: "Policies", + title: "DSR policies", path: routes.POLICIES_ROUTE, requiresFlag: "policies", scopes: [ScopeRegistryEnum.POLICY_READ], @@ -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", @@ -364,23 +387,23 @@ if (process.env.NEXT_PUBLIC_APP_ENV === "development") { icon: , 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: [], }, @@ -390,12 +413,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: [], }, @@ -409,6 +427,7 @@ export interface NavGroupChild { exact?: boolean; hidden?: boolean; children: Array; + tabs?: NavConfigTab[]; } export interface NavGroup { @@ -566,6 +585,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..5f52932cf7f --- /dev/null +++ b/clients/admin-ui/src/features/common/nav/useNavSearchItems.ts @@ -0,0 +1,168 @@ +import { useMemo } from "react"; + +import { useGetAllDatastoreConnectionsQuery } from "~/features/datastore-connections/datastore-connection.slice"; +import { useGetSystemsQuery } 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, + }, +]; + +/** 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; + groupTitle: string; + parentTitle?: string; +} + +/** + * Builds the full list of searchable nav items, including: + * - 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[], + 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( + () => + groups.flatMap((group) => + group.children + .filter((child) => !child.hidden) + .flatMap((child) => [ + { + title: child.title, + path: child.path, + groupTitle: group.title, + }, + ...(child.tabs?.map((tab) => ({ + title: tab.title, + path: tab.path, + groupTitle: group.title, + parentTitle: child.title, + })) ?? []), + ]), + ), + [groups], + ); + + // Taxonomy types (small fixed set, always available) + 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)); + + return [ + ...CORE_TAXONOMY_ITEMS.map((tax) => ({ + title: tax.title, + path: `/taxonomy/${tax.key}`, + groupTitle: taxonomyGroupTitle, + parentTitle: "Taxonomy", + })), + ...(customTaxonomies ?? []) + .filter((tax) => !coreKeys.has(tax.fides_key)) + .map((tax) => ({ + title: tax.name || tax.fides_key, + path: `/taxonomy/${tax.fides_key}`, + groupTitle: taxonomyGroupTitle, + parentTitle: "Taxonomy", + })), + ]; + }, [customTaxonomies, groups]); + + // 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( + { search: trimmedQuery, page: 1, size: SEARCH_PAGE_SIZE }, + { skip: !shouldSearch }, + ); + + 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]); + + return useMemo( + () => [ + ...staticItems, + ...taxonomyItems, + ...systemItems, + ...integrationItems, + ], + [staticItems, taxonomyItems, systemItems, integrationItems], + ); +}; + +export default useNavSearchItems; 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/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]; 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 ( - + 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. diff --git a/clients/admin-ui/src/pages/privacy-requests/index.tsx b/clients/admin-ui/src/pages/privacy-requests/index.tsx index 990db3d1015..2c98979553c 100644 --- a/clients/admin-ui/src/pages/privacy-requests/index.tsx +++ b/clients/admin-ui/src/pages/privacy-requests/index.tsx @@ -50,12 +50,12 @@ const PrivacyRequests: NextPage = () => { return ( diff --git a/clients/fidesui/src/icons/carbon.ts b/clients/fidesui/src/icons/carbon.ts index 559d2a7c1a7..073fd7f2e89 100644 --- a/clients/fidesui/src/icons/carbon.ts +++ b/clients/fidesui/src/icons/carbon.ts @@ -69,6 +69,8 @@ export { Share, ShowDataCards, Shuffle, + SidePanelClose, + SidePanelOpen, SortAscending, SortDescending, Stamp,