mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 13:42:09 +03:00
* 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:
committed by
GitHub
parent
9159b286d0
commit
2c413f2b75
@@ -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)
|
||||
|
||||
113
src/shared/components/Sidebar.search.test.tsx
Normal file
113
src/shared/components/Sidebar.search.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
68
src/shared/utils/sidebarSearch.ts
Normal file
68
src/shared/utils/sidebarSearch.ts
Normal 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;
|
||||
}
|
||||
92
tests/unit/sidebar-search-filter.test.ts
Normal file
92
tests/unit/sidebar-search-filter.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user