diff --git a/CHANGELOG.md b/CHANGELOG.md index 2028f645f3..b5d48a1f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral ### ✨ New Features +- **feat(dashboard):** sidebar quick-filter — a search input at the top of the expanded dashboard sidebar (`src/shared/components/Sidebar.tsx`) filters nav sections/groups/items client-side by label as you type, reusing the existing `common.search`/`common.noResults` i18n keys (zero new locale edits) and the shared `Input` `icon="search"` pattern; matching sections auto-expand while searching (bypassing the accordion/pin state) and collapse back to normal once the query is cleared. Pure filtering logic extracted into `filterSidebarSectionsByQuery()` (`src/shared/utils/sidebarSearch.ts`) for isolated unit testing. Regression guard: `tests/unit/sidebar-search-filter.test.ts`, `src/shared/components/Sidebar.search.test.tsx`. (#4013 — thanks @crochabe-cyber) - **feat(combo):** `auto/*` combos gain a strict budget-cap fallback policy — `X-OmniRoute-Budget-Fallback: strict` (or the persisted `config.budgetFallback: "strict"`) makes an over-budget request fail fast with `HTTP 402` instead of the previous silent fallback to the globally cheapest candidate, which could still exceed the cap. The default (`cheapest`) preserves existing behavior. Builds on the existing `X-OmniRoute-Budget`/`X-OmniRoute-Mode` per-request controls (#6023/#6024/#6025), consolidated into `resolveRequestAutoControls()`. Regression guard: `tests/unit/auto-combo-budget-fallback-3470.test.ts`. (#3470) - **Provider/model param filters**: config-driven parameter denylist/allowlist per provider/model with auto-learn from upstream 400s (#6649 — thanks @ThongAccount, closes #6625) - **Per-combo reasoning token buffer toggle**: the combo builder now exposes an explicit checkbox for the `#3587` reasoning-model `max_tokens` buffer, defaulting to the existing enabled behavior, so a combo can opt out without hand-editing raw JSON config (#6702 — thanks @xz-dev) diff --git a/src/shared/components/Sidebar.search.test.tsx b/src/shared/components/Sidebar.search.test.tsx new file mode 100644 index 0000000000..281330078f --- /dev/null +++ b/src/shared/components/Sidebar.search.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment jsdom +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Skip CloudSyncStatus entirely (it polls /api/sync/cloud + uses next/navigation's +// useRouter, which we don't otherwise need to mock for this component). +process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE = "1"; + +vi.mock("next-intl", () => ({ + useTranslations: () => { + const translate = (key: string) => key; + translate.has = () => false; + return translate; + }, +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/dashboard/combos", +})); + +const cleanupCallbacks: Array<() => void> = []; + +function makeContainer(): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + cleanupCallbacks.push(() => container.remove()); + return container; +} + +function jsonResponse(body: unknown) { + return { ok: true, status: 200, json: async () => body } as Response; +} + +describe("Sidebar search/filter (#4013)", () => { + let root: Root | undefined; + + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (String(url).includes("/api/settings")) return jsonResponse({}); + return jsonResponse({}); + }) + ); + }); + + afterEach(() => { + if (root) { + act(() => root!.unmount()); + root = undefined; + } + while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.(); + document.body.innerHTML = ""; + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("renders a search input at the top of the expanded sidebar", async () => { + // First import in this file pays the one-time cost of compiling Sidebar's + // large transitive dependency graph (sidebarVisibility sections, icons, etc). + const { default: Sidebar } = await import("@/shared/components/Sidebar"); + const container = makeContainer(); + root = createRoot(container); + await act(async () => { + root!.render(); + }); + + const input = container.querySelector('input[type="search"]'); + expect(input).toBeTruthy(); + }, 20000); + + it("filters visible nav items down to those matching the typed query", async () => { + const { default: Sidebar } = await import("@/shared/components/Sidebar"); + const container = makeContainer(); + root = createRoot(container); + await act(async () => { + root!.render(); + }); + + const linksBefore = container.querySelectorAll("nav a"); + expect(linksBefore.length).toBeGreaterThan(1); + + const input = container.querySelector('input[type="search"]') as HTMLInputElement; + expect(input).toBeTruthy(); + + const nativeSetter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value" + )!.set!; + + await act(async () => { + nativeSetter.call(input, "zzz-no-such-nav-item-zzz"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const linksAfterNoMatch = container.querySelectorAll("nav a"); + expect(linksAfterNoMatch.length).toBe(0); + expect(container.querySelector("nav")?.textContent).toBeTruthy(); + + await act(async () => { + nativeSetter.call(input, ""); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const linksAfterClear = container.querySelectorAll("nav a"); + expect(linksAfterClear.length).toBe(linksBefore.length); + }); +}); diff --git a/src/shared/components/Sidebar.tsx b/src/shared/components/Sidebar.tsx index b72636b79c..ead99f0224 100644 --- a/src/shared/components/Sidebar.tsx +++ b/src/shared/components/Sidebar.tsx @@ -5,9 +5,11 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/shared/utils/cn"; import { getActiveSidebarHref } from "@/shared/utils/sidebarRouteMatch"; +import { filterSidebarSectionsByQuery } from "@/shared/utils/sidebarSearch"; import { APP_CONFIG } from "@/shared/constants/appConfig"; import OmniRouteLogo from "./OmniRouteLogo"; import Button from "./Button"; +import Input from "./Input"; import { ConfirmModal } from "./Modal"; import CloudSyncStatus from "./CloudSyncStatus"; import { useTranslations } from "next-intl"; @@ -101,6 +103,7 @@ export default function Sidebar({ ); const [pinnedSections, setPinnedSections] = useState>(new Set()); const [hoveredItem, setHoveredItem] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); // Load persisted state on mount; OmniProxy is pinned by default on first visit useEffect(() => { @@ -266,6 +269,11 @@ export default function Sidebar({ const activeHref = getActiveSidebarHref(pathname, allVisibleItems); + const isSearching = searchQuery.trim().length > 0; + const displaySections = isSearching + ? filterSidebarSectionsByQuery(visibleSections, searchQuery) + : visibleSections; + // Auto-expand the section containing the active page (without closing others) useEffect(() => { if (collapsed) return; @@ -518,6 +526,21 @@ export default function Sidebar({ + {!collapsed && ( +
+ setSearchQuery(e.target.value)} + placeholder={tc("search")} + aria-label={tc("search")} + icon="search" + className="gap-0" + inputClassName="py-1.5 text-xs" + /> +
+ )} +