feat(ui): drive the models page model group filter from ?model_group=

The All Models model-group filter was in-memory React state, so a
filtered view could not be linked to or bookmarked. Deriving it from a
?model_group= query param makes the filter shareable and gives other
pages (for example an organization's model list) a URL to point at

Selecting a group writes the param with history.replaceState so the
filter does not spam browser history; choosing All Model Groups removes
it
This commit is contained in:
ryan-crabbe-berri 2026-07-29 11:49:25 -07:00
parent c56e657097
commit da439f7b70
2 changed files with 76 additions and 2 deletions

View file

@ -0,0 +1,61 @@
/* @vitest-environment jsdom */
import { act, render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AllModelsPanel from "./AllModelsPanel";
interface CapturedTabProps {
selectedModelGroup: string | null;
setSelectedModelGroup: (modelGroup: string) => void;
}
const captured: { props: CapturedTabProps | null } = { props: null };
vi.mock("@/app/(dashboard)/models-and-endpoints/components/AllModelsTab", () => ({
default: (props: CapturedTabProps) => {
captured.props = props;
return <div data-testid="all-models-tab" />;
},
}));
vi.mock("@/app/(dashboard)/models-and-endpoints/components/AllModelsTable", () => ({
ALL_MODEL_GROUPS_VALUE: "all",
}));
vi.mock("@/app/(dashboard)/models-and-endpoints/useModelDashboardData", () => ({
useModelDashboardData: () => ({ availableModelGroups: [], availableModelAccessGroups: [] }),
}));
vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) }));
describe("AllModelsPanel ?model_group= filter", () => {
beforeEach(() => {
captured.props = null;
window.history.pushState(null, "", "/models-and-endpoints/");
});
it("seeds the model group filter from the URL", () => {
window.history.pushState(null, "", "/models-and-endpoints/?model_group=gpt-4o");
render(<AllModelsPanel />);
expect(captured.props?.selectedModelGroup).toBe("gpt-4o");
});
it("defaults to no filter when the param is absent", () => {
render(<AllModelsPanel />);
expect(captured.props?.selectedModelGroup).toBeNull();
});
it("writes the selected group to the URL via replaceState", () => {
render(<AllModelsPanel />);
const spy = vi.spyOn(window.history, "replaceState");
act(() => captured.props?.setSelectedModelGroup("claude-opus-5"));
expect(spy.mock.calls.at(-1)?.[2]).toContain("model_group=claude-opus-5");
spy.mockRestore();
});
it("removes the param when the filter resets to all groups", () => {
window.history.pushState(null, "", "/models-and-endpoints/?model_group=gpt-4o");
render(<AllModelsPanel />);
const spy = vi.spyOn(window.history, "replaceState");
act(() => captured.props?.setSelectedModelGroup("all"));
expect(spy.mock.calls.at(-1)?.[2] as string).not.toContain("model_group");
spy.mockRestore();
});
});

View file

@ -1,12 +1,25 @@
"use client";
import { useState } from "react";
import { useCallback } from "react";
import { useSearchParams } from "next/navigation";
import AllModelsTab from "@/app/(dashboard)/models-and-endpoints/components/AllModelsTab";
import { ALL_MODEL_GROUPS_VALUE } from "@/app/(dashboard)/models-and-endpoints/components/AllModelsTable";
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
import { navigateWithParams } from "@/app/(dashboard)/navigateWithParams";
export default function AllModelsPanel() {
const [selectedModelGroup, setSelectedModelGroup] = useState<string | null>(null);
const searchParams = useSearchParams();
const selectedModelGroup = searchParams?.get("model_group") ?? null;
const setSelectedModelGroup = useCallback((modelGroup: string) => {
navigateWithParams((params) => {
if (modelGroup && modelGroup !== ALL_MODEL_GROUPS_VALUE) {
params.set("model_group", modelGroup);
} else {
params.delete("model_group");
}
}, "replace");
}, []);
const { availableModelGroups, availableModelAccessGroups } = useModelDashboardData();
const { openModel, openTeam } = useModelDetailRouting();