refactor: extract a shared spend-by-category panel from Project and Provider usage

SpendByProvider and ProjectSpendBreakdown rendered the same donut chart,
table, and loading state with only the columns and title differing. Pulls
that shell into SpendByCategoryPanel and has both consumers pass in their
own columns and data, keeping provider-specific filtering where it belongs.
This commit is contained in:
Aryan Gupta 2026-09-10 17:52:07 +05:30
parent e395744038
commit 05ef5627ae
4 changed files with 224 additions and 91 deletions

View file

@ -1,15 +1,12 @@
import { DonutChart } from "@/components/shared/charts";
import { DataTable } from "@/components/shared/DataTable";
import { MoneyCell } from "@/components/shared/table_cells";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { Info } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import React, { useState } from "react";
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
import { ChartLoader } from "@/components/shared/chart_loader";
import { SpendByCategoryPanel } from "../SpendByCategoryPanel";
type ProviderSpendData = {
provider: string;
@ -70,13 +67,10 @@ const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChangi
const filteredProviderSpend = providerSpend.filter((provider) => {
const isUnknown = provider.provider?.toLowerCase() === "unknown";
// If includeUnknown is true, always include unknown provider
if (isUnknown) {
return includeUnknown;
}
// If includeZeroSpend is true, include all providers (including those with 0 spend)
// Otherwise, only include providers with spend > 0
if (includeZeroSpend) {
return true;
}
@ -84,54 +78,37 @@ const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChangi
return provider.spend > 0;
});
const headerAction = (
<>
<div className="flex items-center gap-2">
<label className="text-sm text-foreground">Show Zero Spend</label>
<Switch checked={includeZeroSpend} onCheckedChange={setIncludeZeroSpend} />
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<label className="text-sm text-foreground">Show Unknown</label>
<Tooltip>
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
<TooltipContent>Requests that failed to route to a provider</TooltipContent>
</Tooltip>
</div>
<Switch checked={includeUnknown} onCheckedChange={setIncludeUnknown} />
</div>
</>
);
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Spend by Provider</CardTitle>
<CardAction className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-foreground">Show Zero Spend</label>
<Switch checked={includeZeroSpend} onCheckedChange={setIncludeZeroSpend} />
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<label className="text-sm text-foreground">Show Unknown</label>
<Tooltip>
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
<TooltipContent>Requests that failed to route to a provider</TooltipContent>
</Tooltip>
</div>
<Switch checked={includeUnknown} onCheckedChange={setIncludeUnknown} />
</div>
</CardAction>
</CardHeader>
<CardContent>
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<div className="grid grid-cols-2">
<DonutChart
className="mt-4 h-40"
data={filteredProviderSpend}
index="provider"
category="spend"
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
colors={["cyan"]}
showLabel
startAngle={90}
endAngle={-270}
/>
<DataTable
columns={columns}
data={filteredProviderSpend}
getRowId={(row) => row.provider}
noDataMessage="No provider usage data"
size="compact"
/>
</div>
)}
</CardContent>
</Card>
<SpendByCategoryPanel
title="Spend by Provider"
headerAction={headerAction}
loading={loading}
isDateChanging={isDateChanging}
data={filteredProviderSpend}
indexKey="provider"
columns={columns}
getRowId={(row) => row.provider}
noDataMessage="No provider usage data"
/>
);
};

View file

@ -1,13 +1,9 @@
import React from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { DonutChart } from "@/components/shared/charts";
import { DataTable } from "@/components/shared/DataTable";
import { MoneyCell } from "@/components/shared/table_cells";
import { ChartLoader } from "@/components/shared/chart_loader";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { SpendByCategoryPanel } from "../SpendByCategoryPanel";
import type { ProjectSpendRow } from "./projectUsageAggregations";
interface ProjectSpendBreakdownProps {
@ -48,37 +44,16 @@ const columns: ColumnDef<ProjectSpendRow>[] = [
];
const ProjectSpendBreakdown: React.FC<ProjectSpendBreakdownProps> = ({ loading, isDateChanging, projectSpend }) => (
<Card className="h-full">
<CardHeader>
<CardTitle>Spend by Project</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<div className="grid grid-cols-2">
<DonutChart
className="mt-4 h-40"
data={projectSpend}
index="project_alias"
category="spend"
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
colors={["cyan"]}
showLabel
startAngle={90}
endAngle={-270}
/>
<DataTable
columns={columns}
data={projectSpend}
getRowId={(row) => row.project_id}
noDataMessage="No project usage data"
size="compact"
/>
</div>
)}
</CardContent>
</Card>
<SpendByCategoryPanel
title="Spend by Project"
loading={loading}
isDateChanging={isDateChanging}
data={projectSpend}
indexKey="project_alias"
columns={columns}
getRowId={(row) => row.project_id}
noDataMessage="No project usage data"
/>
);
export default ProjectSpendBreakdown;

View file

@ -0,0 +1,118 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ColumnDef } from "@tanstack/react-table";
import { SpendByCategoryPanel } from "./SpendByCategoryPanel";
vi.mock("@/components/shared/chart_loader", () => ({
ChartLoader: ({ isDateChanging }: { isDateChanging: boolean }) => (
<div data-testid="chart-loader">{isDateChanging ? "Processing date selection..." : "Loading chart data..."}</div>
),
}));
interface TestRow extends Record<string, unknown> {
key: string;
spend: number;
}
const columns: ColumnDef<TestRow>[] = [{ header: "Key", accessorKey: "key" }];
const rows: TestRow[] = [{ key: "row-1", spend: 5 }];
describe("SpendByCategoryPanel", () => {
it("displays the given title", () => {
render(
<SpendByCategoryPanel
title="Spend by Widget"
loading={false}
isDateChanging={false}
data={[]}
indexKey="key"
columns={columns}
getRowId={(row) => row.key}
noDataMessage="No data"
/>,
);
expect(screen.getByText("Spend by Widget")).toBeInTheDocument();
});
it("shows the loader instead of chart content while loading", () => {
render(
<SpendByCategoryPanel
title="Spend by Widget"
loading
isDateChanging={false}
data={rows}
indexKey="key"
columns={columns}
getRowId={(row) => row.key}
noDataMessage="No data"
/>,
);
expect(screen.getByTestId("chart-loader")).toBeInTheDocument();
expect(screen.queryByText("row-1")).not.toBeInTheDocument();
});
it("renders the table rows once loaded", () => {
render(
<SpendByCategoryPanel
title="Spend by Widget"
loading={false}
isDateChanging={false}
data={rows}
indexKey="key"
columns={columns}
getRowId={(row) => row.key}
noDataMessage="No data"
/>,
);
expect(screen.getAllByText("row-1").length).toBeGreaterThan(0);
});
it("shows the empty message when there is no data", () => {
render(
<SpendByCategoryPanel
title="Spend by Widget"
loading={false}
isDateChanging={false}
data={[]}
indexKey="key"
columns={columns}
getRowId={(row) => row.key}
noDataMessage="No data for this range"
/>,
);
expect(screen.getByText("No data for this range")).toBeInTheDocument();
});
it("renders a header action only when one is given", () => {
const { rerender } = render(
<SpendByCategoryPanel
title="Spend by Widget"
loading={false}
isDateChanging={false}
data={[]}
indexKey="key"
columns={columns}
getRowId={(row) => row.key}
noDataMessage="No data"
/>,
);
expect(screen.queryByText("Toggle")).not.toBeInTheDocument();
rerender(
<SpendByCategoryPanel
title="Spend by Widget"
headerAction={<button type="button">Toggle</button>}
loading={false}
isDateChanging={false}
data={[]}
indexKey="key"
columns={columns}
getRowId={(row) => row.key}
noDataMessage="No data"
/>,
);
expect(screen.getByText("Toggle")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,63 @@
import React from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { DonutChart } from "@/components/shared/charts";
import { DataTable } from "@/components/shared/DataTable";
import { ChartLoader } from "@/components/shared/chart_loader";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatNumberWithCommas } from "@/utils/dataUtils";
interface SpendByCategoryPanelProps<TRow extends Record<string, unknown>> {
title: string;
headerAction?: React.ReactNode;
loading: boolean;
isDateChanging: boolean;
data: TRow[];
indexKey: keyof TRow & string;
columns: ColumnDef<TRow>[];
getRowId: (row: TRow) => string;
noDataMessage: string;
}
export function SpendByCategoryPanel<TRow extends Record<string, unknown>>({
title,
headerAction,
loading,
isDateChanging,
data,
indexKey,
columns,
getRowId,
noDataMessage,
}: SpendByCategoryPanelProps<TRow>) {
return (
<Card className="h-full">
<CardHeader>
<CardTitle>{title}</CardTitle>
{headerAction && <CardAction className="flex items-center gap-4">{headerAction}</CardAction>}
</CardHeader>
<CardContent>
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<div className="grid grid-cols-2">
<DonutChart
className="mt-4 h-40"
data={data}
index={indexKey}
category="spend"
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
colors={["cyan"]}
showLabel
startAngle={90}
endAngle={-270}
/>
<DataTable columns={columns} data={data} getRowId={getRowId} noDataMessage={noDataMessage} size="compact" />
</div>
)}
</CardContent>
</Card>
);
}
export default SpendByCategoryPanel;