feat(ui): deep link guardrail detail with ?guardrail= on guardrails pages

The Guardrails and Guardrails Monitor pages kept the selected guardrail
in local React state, so the detail view could not be shared, reloaded,
or reached with the browser back button. Both pages now read and write
the selection through the nuqs `guardrail` query param, matching how the
keys, teams, orgs, projects, users, models and logs pages deep link their
detail views. Opening a guardrail pushes a history entry and closing it
replaces the entry so back returns to the page the user came from
This commit is contained in:
ryan-crabbe-berri 2026-09-05 11:43:52 -07:00
parent 7672399c26
commit 86038318ce
4 changed files with 179 additions and 48 deletions

View file

@ -1,36 +1,61 @@
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { type UrlUpdateEvent } from "nuqs/adapters/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import GuardrailsMonitorView from "./GuardrailsMonitorView";
import * as networking from "@/components/networking";
import { renderWithProviders, screen, testQueryClient, waitFor } from "@/../tests/test-utils";
vi.mock("@/components/networking", () => ({
getGuardrailsUsageOverview: vi.fn(),
getGuardrailsUsageDetail: vi.fn(),
getGuardrailsUsageLogs: vi.fn(),
formatDate: vi.fn((d: Date) => d.toISOString().slice(0, 10)),
}));
const mockGetGuardrailsUsageOverview = vi.mocked(networking.getGuardrailsUsageOverview);
vi.mock("@/components/GuardrailsMonitor/LogViewer", () => ({
LogViewer: ({ guardrailName }: { guardrailName: string }) => <div data-testid="log-viewer">{guardrailName}</div>,
}));
function wrapper({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
const mockGetGuardrailsUsageOverview = vi.mocked(networking.getGuardrailsUsageOverview);
const mockGetGuardrailsUsageDetail = vi.mocked(networking.getGuardrailsUsageDetail);
const mockGetGuardrailsUsageLogs = vi.mocked(networking.getGuardrailsUsageLogs);
const emptyOverview = { rows: [], chart: [], totalRequests: 0, totalBlocked: 0, passRate: 100 };
const piiRow = {
id: "gr-pii",
name: "PII Guard",
type: "pii",
provider: "LiteLLM",
requestsEvaluated: 10,
failRate: 10,
status: "healthy" as const,
trend: "stable" as const,
};
const piiDetail = {
guardrail_name: "PII Guard",
description: "",
status: "healthy",
provider: "LiteLLM",
type: "pii",
requestsEvaluated: 10,
failRate: 10,
avgScore: 0.5,
avgLatency: 20,
};
describe("GuardrailsMonitorView", () => {
it("should render overview and fetch guardrails usage when accessToken is provided", async () => {
mockGetGuardrailsUsageOverview.mockResolvedValue({
rows: [],
chart: [],
totalRequests: 0,
totalBlocked: 0,
passRate: 100,
});
beforeEach(() => {
testQueryClient.clear();
vi.clearAllMocks();
mockGetGuardrailsUsageOverview.mockResolvedValue(emptyOverview);
mockGetGuardrailsUsageDetail.mockResolvedValue(piiDetail);
mockGetGuardrailsUsageLogs.mockResolvedValue({ logs: [], total: 0 });
});
render(<GuardrailsMonitorView accessToken="test-token" />, { wrapper });
it("should render overview and fetch guardrails usage when accessToken is provided", async () => {
renderWithProviders(<GuardrailsMonitorView accessToken="test-token" />);
expect(await screen.findByRole("heading", { name: /Guardrails Monitor/i })).toBeInTheDocument();
await waitFor(() => {
@ -39,7 +64,54 @@ describe("GuardrailsMonitorView", () => {
});
it("should render without crashing when accessToken is null", async () => {
render(<GuardrailsMonitorView accessToken={null} />, { wrapper });
renderWithProviders(<GuardrailsMonitorView accessToken={null} />);
expect(await screen.findByRole("heading", { name: /Guardrails Monitor/i })).toBeInTheDocument();
});
describe("guardrail detail deep link (?guardrail=)", () => {
it("should open the detail view directly from a ?guardrail= deep link", async () => {
renderWithProviders(<GuardrailsMonitorView accessToken="test-token" />, { searchParams: "?guardrail=gr-pii" });
expect(await screen.findByRole("heading", { name: "PII Guard" })).toBeInTheDocument();
expect(mockGetGuardrailsUsageDetail).toHaveBeenCalledWith(
"test-token",
"gr-pii",
expect.any(String),
expect.any(String),
);
expect(screen.queryByRole("heading", { name: /Guardrails Monitor/i })).not.toBeInTheDocument();
});
it("should push ?guardrail= as a new history entry when a guardrail is selected", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
mockGetGuardrailsUsageOverview.mockResolvedValue({ ...emptyOverview, rows: [piiRow] });
renderWithProviders(<GuardrailsMonitorView accessToken="test-token" />, { onUrlUpdate });
await user.click(await screen.findByRole("button", { name: "PII Guard" }));
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
const lastUpdate = onUrlUpdate.mock.calls.at(-1)![0];
expect(lastUpdate.searchParams.get("guardrail")).toBe("gr-pii");
expect(lastUpdate.options.history).toBe("push");
expect(await screen.findByRole("heading", { name: "PII Guard" })).toBeInTheDocument();
});
it("should clear ?guardrail= by replacing history when going back to the overview", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
renderWithProviders(<GuardrailsMonitorView accessToken="test-token" />, {
searchParams: "?guardrail=gr-pii",
onUrlUpdate,
});
await user.click(await screen.findByRole("button", { name: /back to overview/i }));
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
const lastUpdate = onUrlUpdate.mock.calls.at(-1)![0];
expect(lastUpdate.searchParams.has("guardrail")).toBe(false);
expect(lastUpdate.options.history).toBe("replace");
expect(await screen.findByRole("heading", { name: /Guardrails Monitor/i })).toBeInTheDocument();
});
});
});

View file

@ -1,12 +1,11 @@
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import { parseAsString, useQueryState } from "nuqs";
import React, { useCallback, useMemo, useState } from "react";
import { formatDate } from "@/components/networking";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import { GuardrailDetail } from "./GuardrailDetail";
import { GuardrailsOverview } from "./GuardrailsOverview";
type View = { type: "overview" } | { type: "detail"; guardrailId: string };
interface GuardrailsMonitorViewProps {
accessToken?: string | null;
}
@ -16,7 +15,10 @@ const defaultStart = new Date();
defaultStart.setDate(defaultStart.getDate() - 7);
export default function GuardrailsMonitorView({ accessToken = null }: GuardrailsMonitorViewProps) {
const [view, setView] = useState<View>({ type: "overview" });
const [selectedGuardrailId, setSelectedGuardrailId] = useQueryState(
"guardrail",
parseAsString.withOptions({ history: "push" }),
);
const initialFrom = useMemo(() => new Date(defaultStart), []);
const initialTo = useMemo(() => new Date(defaultEnd), []);
@ -34,11 +36,11 @@ export default function GuardrailsMonitorView({ accessToken = null }: Guardrails
}, []);
const handleSelectGuardrail = (id: string) => {
setView({ type: "detail", guardrailId: id });
void setSelectedGuardrailId(id);
};
const handleBack = () => {
setView({ type: "overview" });
void setSelectedGuardrailId(null, { history: "replace" });
};
const dateRangeControl = (
@ -47,7 +49,7 @@ export default function GuardrailsMonitorView({ accessToken = null }: Guardrails
return (
<main className="w-full min-w-0 flex-1 p-8">
{view.type === "overview" ? (
{!selectedGuardrailId ? (
<GuardrailsOverview
accessToken={accessToken}
startDate={startDate}
@ -59,7 +61,7 @@ export default function GuardrailsMonitorView({ accessToken = null }: Guardrails
<>
<div className="mb-4 flex items-center justify-end">{dateRangeControl}</div>
<GuardrailDetail
guardrailId={view.guardrailId}
guardrailId={selectedGuardrailId}
onBack={handleBack}
accessToken={accessToken}
startDate={startDate}

View file

@ -1,7 +1,8 @@
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { type UrlUpdateEvent } from "nuqs/adapters/testing";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import GuardrailsPanel from "./GuardrailsPanel";
import { getGuardrailsList, deleteGuardrailCall } from "@/components/networking";
import { fireEvent, renderWithProviders, screen, waitFor, within } from "@/../tests/test-utils";
vi.mock("@/components/networking", () => ({
getGuardrailsList: vi.fn(),
@ -15,16 +16,21 @@ vi.mock("./add_guardrail_form", () => ({
vi.mock("./guardrail_table", () => ({
__esModule: true,
default: ({ guardrailsList, onDeleteClick }: any) => (
default: ({ guardrailsList, onDeleteClick, onGuardrailClick }: any) => (
<div>
<div>Mock Guardrail Table</div>
{guardrailsList.length > 0 && (
<button
data-testid="delete-button"
onClick={() => onDeleteClick(guardrailsList[0].guardrail_id, guardrailsList[0].guardrail_name)}
>
Delete
</button>
<>
<button
data-testid="delete-button"
onClick={() => onDeleteClick(guardrailsList[0].guardrail_id, guardrailsList[0].guardrail_name)}
>
Delete
</button>
<button data-testid="open-button" onClick={() => onGuardrailClick(guardrailsList[0].guardrail_id)}>
Open
</button>
</>
)}
</div>
),
@ -32,7 +38,12 @@ vi.mock("./guardrail_table", () => ({
vi.mock("./guardrail_info", () => ({
__esModule: true,
default: () => <div>Mock Guardrail Info View</div>,
default: ({ guardrailId, onClose }: { guardrailId: string; onClose: () => void }) => (
<div>
<div data-testid="guardrail-info-view">Mock Guardrail Info View {guardrailId}</div>
<button onClick={onClose}>Close Guardrail Info</button>
</div>
),
}));
vi.mock("./GuardrailTestPlayground", async () => {
@ -112,7 +123,7 @@ describe("GuardrailsPanel", () => {
});
it("should render the component", async () => {
render(<GuardrailsPanel {...defaultProps} />);
renderWithProviders(<GuardrailsPanel {...defaultProps} />);
expect(screen.getByText("Guardrails")).toBeInTheDocument();
// Activate the Guardrails tab so its content (including the Add button) is rendered
fireEvent.click(screen.getByText("Guardrails"));
@ -120,7 +131,7 @@ describe("GuardrailsPanel", () => {
});
it("should delete the clicked guardrail after confirming in the modal", async () => {
render(<GuardrailsPanel {...defaultProps} />);
renderWithProviders(<GuardrailsPanel {...defaultProps} />);
fireEvent.click(screen.getByText("Guardrails"));
fireEvent.click(await screen.findByTestId("delete-button"));
@ -139,14 +150,14 @@ describe("GuardrailsPanel", () => {
});
it("should mount every tab panel up front so panel state survives tab switches", async () => {
render(<GuardrailsPanel {...defaultProps} />);
renderWithProviders(<GuardrailsPanel {...defaultProps} />);
expect(await screen.findByLabelText("playground draft")).toBeInTheDocument();
expect(screen.getByText("Mock Team Guardrails Tab")).toBeInTheDocument();
});
it("should keep test playground state when switching tabs away and back", async () => {
render(<GuardrailsPanel {...defaultProps} />);
renderWithProviders(<GuardrailsPanel {...defaultProps} />);
fireEvent.click(screen.getByText("Test Playground"));
@ -161,7 +172,7 @@ describe("GuardrailsPanel", () => {
});
it("should not delete anything when the modal is cancelled", async () => {
render(<GuardrailsPanel {...defaultProps} />);
renderWithProviders(<GuardrailsPanel {...defaultProps} />);
fireEvent.click(screen.getByText("Guardrails"));
fireEvent.click(await screen.findByTestId("delete-button"));
@ -171,4 +182,42 @@ describe("GuardrailsPanel", () => {
expect(mockDeleteGuardrailCall).not.toHaveBeenCalled();
});
describe("guardrail detail deep link (?guardrail=)", () => {
it("should open the guardrail info view directly from a ?guardrail= deep link", async () => {
renderWithProviders(<GuardrailsPanel {...defaultProps} />, { searchParams: "?guardrail=test-guardrail-1" });
expect(await screen.findByTestId("guardrail-info-view")).toHaveTextContent("test-guardrail-1");
expect(screen.queryByText("Mock Guardrail Table")).not.toBeInTheDocument();
});
it("should push ?guardrail= as a new history entry when a guardrail row is clicked", async () => {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
renderWithProviders(<GuardrailsPanel {...defaultProps} />, { onUrlUpdate });
fireEvent.click(await screen.findByTestId("open-button"));
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
const lastUpdate = onUrlUpdate.mock.calls.at(-1)![0];
expect(lastUpdate.searchParams.get("guardrail")).toBe("test-guardrail-1");
expect(lastUpdate.options.history).toBe("push");
expect(await screen.findByTestId("guardrail-info-view")).toHaveTextContent("test-guardrail-1");
});
it("should clear ?guardrail= by replacing history when the info view is closed", async () => {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
renderWithProviders(<GuardrailsPanel {...defaultProps} />, {
searchParams: "?guardrail=test-guardrail-1",
onUrlUpdate,
});
fireEvent.click(await screen.findByRole("button", { name: "Close Guardrail Info" }));
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
const lastUpdate = onUrlUpdate.mock.calls.at(-1)![0];
expect(lastUpdate.searchParams.has("guardrail")).toBe(false);
expect(lastUpdate.options.history).toBe("replace");
expect(await screen.findByText("Mock Guardrail Table")).toBeInTheDocument();
});
});
});

View file

@ -1,3 +1,4 @@
import { parseAsString, useQueryState } from "nuqs";
import React, { useState, useEffect } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ChevronDown, Code, Plus } from "lucide-react";
@ -40,7 +41,10 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
const [isDeleting, setIsDeleting] = useState(false);
const [guardrailToDelete, setGuardrailToDelete] = useState<Guardrail | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedGuardrailId, setSelectedGuardrailId] = useState<string | null>(null);
const [selectedGuardrailId, setSelectedGuardrailId] = useQueryState(
"guardrail",
parseAsString.withOptions({ history: "push" }),
);
const isAdmin = userRole ? isAdminRole(userRole) : false;
const fetchGuardrails = async () => {
@ -63,16 +67,20 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
fetchGuardrails();
}, [accessToken]);
const closeGuardrailDetail = () => {
void setSelectedGuardrailId(null, { history: "replace" });
};
const handleAddGuardrail = () => {
if (selectedGuardrailId) {
setSelectedGuardrailId(null);
closeGuardrailDetail();
}
setIsAddModalVisible(true);
};
const handleAddCustomCodeGuardrail = () => {
if (selectedGuardrailId) {
setSelectedGuardrailId(null);
closeGuardrailDetail();
}
setIsCustomCodeModalVisible(true);
};
@ -175,7 +183,7 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
{selectedGuardrailId ? (
<GuardrailInfoView
guardrailId={selectedGuardrailId}
onClose={() => setSelectedGuardrailId(null)}
onClose={closeGuardrailDetail}
accessToken={accessToken}
isAdmin={isAdmin}
/>
@ -184,7 +192,7 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
guardrailsList={guardrailsList}
isLoading={isLoading}
onDeleteClick={handleDeleteClick}
onGuardrailClick={(id) => setSelectedGuardrailId(id)}
onGuardrailClick={(id) => void setSelectedGuardrailId(id)}
/>
)}