refactor(ui): convert projects page chart to shadcn/recharts (#32722)

This commit is contained in:
ryan-crabbe-berri 2026-07-11 17:22:42 -07:00 committed by GitHub
parent 4baeb283f3
commit d37ba79ebd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 68 additions and 1 deletions

View file

@ -43,6 +43,14 @@ const mockProject: ProjectResponse = {
litellm_budget_table: null,
};
const rectangleFills = (container: HTMLElement) =>
new Set(Array.from(container.querySelectorAll("path.recharts-rectangle")).map((rect) => rect.getAttribute("fill")));
const yAxisTickLabels = (container: HTMLElement) =>
Array.from(container.querySelectorAll(".recharts-yAxis-tick-labels .recharts-cartesian-axis-tick-value")).map(
(tick) => tick.textContent,
);
describe("ProjectDetail", () => {
const onBack = vi.fn();
@ -159,6 +167,65 @@ describe("ProjectDetail", () => {
expect(screen.getByText("No team assigned")).toBeInTheDocument();
});
describe("Spend by Model chart", () => {
const multiModelProject: ProjectResponse = {
...mockProject,
model_spend: {
"claude-sonnet-5": 0.5,
"gpt-5.2": 10,
"claude-opus-4-8": 2.75,
"gpt-5.2-codex": 5.5,
},
};
it("should render one cyan bar per model without a legend", () => {
mockUseProjectDetails.mockReturnValue({ data: multiModelProject, isLoading: false });
const { container } = renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(container.querySelectorAll(".recharts-bar")).toHaveLength(1);
expect(container.querySelectorAll("path.recharts-rectangle")).toHaveLength(4);
expect(rectangleFills(container)).toEqual(new Set(["var(--color-cyan-500, #06b6d4)"]));
expect(container.querySelector(".recharts-legend-wrapper")).toBeNull();
});
it("should list models on the category axis sorted by spend descending", () => {
mockUseProjectDetails.mockReturnValue({ data: multiModelProject, isLoading: false });
const { container } = renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(yAxisTickLabels(container)).toEqual(["gpt-5.2", "gpt-5.2-codex", "claude-opus-4-8", "claude-sonnet-5"]);
});
it("should format value axis ticks as dollars with four decimals", () => {
mockUseProjectDetails.mockReturnValue({ data: multiModelProject, isLoading: false });
const { container } = renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(container.querySelector(".recharts-xAxis-tick-labels")?.textContent).toMatch(/\$\d+\.\d{4}/);
});
it("should scale the chart height at 40px per model with a 120px floor", () => {
mockUseProjectDetails.mockReturnValue({ data: multiModelProject, isLoading: false });
const { container } = renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(container.querySelector<HTMLElement>('[data-slot="chart"]')?.style.height).toBe("160px");
mockUseProjectDetails.mockReturnValue({ data: mockProject, isLoading: false });
const { container: singleModelContainer } = renderWithProviders(
<ProjectDetail projectId="proj-1" onBack={onBack} />,
);
expect(singleModelContainer.querySelector<HTMLElement>('[data-slot="chart"]')?.style.height).toBe("120px");
});
it("should show the empty state when no model spend is recorded", () => {
mockUseProjectDetails.mockReturnValue({
data: { ...mockProject, model_spend: {} },
isLoading: false,
});
const { container } = renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(screen.getByText("No model spend recorded yet")).toBeInTheDocument();
expect(container.querySelector('[data-slot="chart"]')).toBeNull();
});
});
it("should show team information when team data is available", () => {
mockUseTeam.mockReturnValue({
data: {

View file

@ -16,7 +16,7 @@ import {
Typography,
} from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import { BarChart } from "@tremor/react";
import { BarChart } from "@/components/shared/charts";
import { ArrowLeftIcon, DollarSignIcon, EditIcon, KeyIcon, UsersIcon } from "lucide-react";
import { useMemo, useState } from "react";
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";