feat: sidebar search/filter input (#4013) (#6810)

* feat(dashboard): add search/filter input to the dashboard sidebar (#4013)

Adds a search box at the top of the expanded sidebar that filters nav
sections/groups/items client-side by label, so users don't have to
hunt through the growing nav tree. Reuses the existing common.search /
common.noResults i18n keys (no new locale edits needed) and the shared
Input icon="search" pattern. Matching sections auto-expand while
searching and the accordion/pin state is restored once the query is
cleared.

Filtering logic is extracted into a pure filterSidebarSectionsByQuery()
helper (src/shared/utils/sidebarSearch.ts) so it is trivially unit
testable independent of React/next-intl/next-navigation.

* fix(test): move Sidebar.search test to a runner-collected path (test-discovery gate)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-11 05:04:13 -03:00
committed by GitHub
parent 9159b286d0
commit 2c413f2b75
5 changed files with 302 additions and 2 deletions

View File

@@ -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)

View File

@@ -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(<Sidebar />);
});
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(<Sidebar />);
});
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);
});
});

View File

@@ -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<Set<SidebarSectionId>>(new Set());
const [hoveredItem, setHoveredItem] = useState<HoveredItem>(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({
</Link>
</div>
{!collapsed && (
<div className="px-4 pb-2">
<Input
type="search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={tc("search")}
aria-label={tc("search")}
icon="search"
className="gap-0"
inputClassName="py-1.5 text-xs"
/>
</div>
)}
<nav
aria-label="Main navigation"
className={cn(
@@ -525,9 +548,12 @@ export default function Sidebar({
collapsed ? "px-2 space-y-0.5" : "px-3"
)}
>
{visibleSections.map((section, idx) => {
{isSearching && displaySections.length === 0 && (
<p className="px-2 py-3 text-xs text-text-muted/60">{tc("noResults")}</p>
)}
{displaySections.map((section, idx) => {
const sectionId = section.id as SidebarSectionId;
const isExpanded = expandedSections.has(sectionId);
const isExpanded = isSearching || expandedSections.has(sectionId);
const isPinned = pinnedSections.has(sectionId);
const isFirst = idx === 0;
const sectionItems = section.children.flatMap((child: any) =>

View File

@@ -0,0 +1,68 @@
// Pure, framework-free filtering for the sidebar quick-search (#4013).
// Operates on the already-resolved (labeled) section/child shape produced by
// Sidebar.tsx, so it has no dependency on next-intl/React and stays trivially
// unit-testable.
export interface SearchableLabeled {
label: string;
}
export interface SearchableGroup<TItem extends SearchableLabeled> {
type: "group";
items: readonly TItem[];
}
export type SearchableChild<TItem extends SearchableLabeled> =
| TItem
| SearchableGroup<TItem>;
export interface SearchableSection<TItem extends SearchableLabeled> {
children: readonly SearchableChild<TItem>[];
}
function isGroupChild<TItem extends SearchableLabeled>(
child: SearchableChild<TItem>
): child is SearchableGroup<TItem> {
return (
typeof child === "object" &&
child !== null &&
"type" in child &&
(child as { type?: unknown }).type === "group"
);
}
/**
* Filters sidebar sections by a free-text query matched (case-insensitive,
* substring) against each item's resolved label. Groups are kept only if at
* least one of their items still matches; sections are kept only if at least
* one child (flat item or non-empty group) still matches. Passing an empty/
* whitespace-only query returns the input sections unchanged.
*/
export function filterSidebarSectionsByQuery<
TItem extends SearchableLabeled,
TSection extends SearchableSection<TItem>,
>(sections: readonly TSection[], query: string): TSection[] {
const needle = query.trim().toLowerCase();
if (!needle) return [...sections];
const matches = (item: TItem) => item.label.toLowerCase().includes(needle);
const result: TSection[] = [];
for (const section of sections) {
const children: SearchableChild<TItem>[] = [];
for (const child of section.children) {
if (isGroupChild(child)) {
const items = child.items.filter(matches);
if (items.length > 0) {
children.push({ ...child, items });
}
} else if (matches(child)) {
children.push(child);
}
}
if (children.length > 0) {
result.push({ ...section, children } as TSection);
}
}
return result;
}

View File

@@ -0,0 +1,92 @@
import test from "node:test";
import assert from "node:assert/strict";
import { filterSidebarSectionsByQuery } from "../../src/shared/utils/sidebarSearch.ts";
type Item = { id: string; label: string };
type Group = { type: "group"; id: string; items: Item[] };
type Section = { id: string; children: (Item | Group)[] };
function makeSections(): Section[] {
return [
{
id: "omni-proxy",
children: [
{ id: "combos", label: "Combos" },
{ id: "providers", label: "Providers" },
{
type: "group",
id: "tools",
items: [
{ id: "playground", label: "Playground" },
{ id: "logs", label: "Request Logs" },
],
},
],
},
{
id: "configuration",
children: [
{ id: "settings", label: "Settings" },
{ id: "webhooks", label: "Webhooks" },
],
},
];
}
test("empty/whitespace query returns all sections unchanged (identity-ish)", () => {
const sections = makeSections();
assert.deepEqual(filterSidebarSectionsByQuery(sections, ""), sections);
assert.deepEqual(filterSidebarSectionsByQuery(sections, " "), sections);
});
test("filters flat items by case-insensitive substring match on label", () => {
const result = filterSidebarSectionsByQuery(makeSections(), "combo");
assert.equal(result.length, 1);
assert.equal(result[0].id, "omni-proxy");
assert.deepEqual(
result[0].children.map((c) => ("label" in c ? c.id : c.id)),
["combos"]
);
});
test("matches are case-insensitive", () => {
const result = filterSidebarSectionsByQuery(makeSections(), "SETTINGS");
assert.equal(result.length, 1);
assert.equal(result[0].id, "configuration");
});
test("filters items inside a group, dropping non-matching group members", () => {
const result = filterSidebarSectionsByQuery(makeSections(), "logs");
assert.equal(result.length, 1);
const group = result[0].children.find((c) => "type" in c && c.type === "group") as
| Group
| undefined;
assert.ok(group, "expected the tools group to survive filtering");
assert.deepEqual(
group!.items.map((i) => i.id),
["logs"]
);
});
test("drops a group entirely when none of its items match", () => {
const result = filterSidebarSectionsByQuery(makeSections(), "playground-xyz-no-match");
assert.deepEqual(result, []);
});
test("drops sections that have no matching children at all", () => {
const result = filterSidebarSectionsByQuery(makeSections(), "webhooks");
assert.equal(result.length, 1);
assert.equal(result[0].id, "configuration");
});
test("query matching nothing returns an empty array", () => {
const result = filterSidebarSectionsByQuery(makeSections(), "zzz-nonexistent-zzz");
assert.deepEqual(result, []);
});
test("does not mutate the input sections", () => {
const sections = makeSections();
const snapshot = JSON.parse(JSON.stringify(sections));
filterSidebarSectionsByQuery(sections, "combo");
assert.deepEqual(sections, snapshot);
});