feat(ui): add sorting, filtering and search to the budgets page

Move the budgets table onto the paged management list route so sorting,
filtering and search happen server-side instead of over whichever rows
happened to be in memory.

Adds useResourceList, a generic hook that owns page, page_size, sort, q
and filters for a server-driven table, folds them into one JSON:API query
and returns exactly the props DataTable's server modes want. Budgets is
its first consumer.

The budget id column now renders in full with a copy button instead of a
fixed-width cell, and the table gains Reset and Created columns.
This commit is contained in:
Yuneng Jiang 2026-07-30 19:22:22 -07:00
parent 1018d18e6b
commit 1d40f2a707
No known key found for this signature in database
11 changed files with 1144 additions and 278 deletions

View file

@ -126,11 +126,6 @@
"count": 2
}
},
"src/app/(dashboard)/budgets/_components/budget_panel.test.tsx": {
"unused-imports/no-unused-imports": {
"count": 2
}
},
"src/app/(dashboard)/budgets/_components/budget_panel.tsx": {
"local/filename-pascal-case": {
"count": 1

View file

@ -1,22 +1,58 @@
import { screen, within } from "@testing-library/react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "@/../tests/test-utils";
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
import BudgetTable from "./BudgetTable";
import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
import type { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
import type { ResourceListResult } from "@/app/(dashboard)/hooks/common/useResourceList";
import { ApiError } from "@/lib/http/client";
const { copyToClipboardMock } = vi.hoisted(() => ({ copyToClipboardMock: vi.fn() }));
vi.mock("@/utils/dataUtils", async (importOriginal) => ({
...(await importOriginal<typeof import("@/utils/dataUtils")>()),
copyToClipboard: copyToClipboardMock,
}));
const makeBudget = (overrides: Partial<budgetItem> = {}): budgetItem => ({
budget_id: "budget-1",
max_budget: 100,
soft_budget: null,
tpm_limit: 1000,
rpm_limit: 10,
budget_duration: "30d",
budget_reset_at: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
...overrides,
});
const defaultProps = {
budgets: [makeBudget()],
const makeList = (overrides: Partial<ResourceListResult<budgetItem>> = {}): ResourceListResult<budgetItem> => ({
rows: [makeBudget()],
rowCount: 1,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
sorting: [{ id: "created_at", desc: true }],
onSortingChange: vi.fn(),
pagination: { pageIndex: 0, pageSize: 50 },
onPaginationChange: vi.fn(),
columnFilters: [],
onColumnFiltersChange: vi.fn(),
searchValue: "",
onSearchChange: vi.fn(),
...overrides,
});
const FORBIDDEN_PROBLEM = {
type: "about:blank",
title: "Forbidden",
status: 403,
detail: "Only proxy admins can view budgets",
};
const defaultProps = {
canModify: true,
onEditClick: vi.fn(),
onDeleteClick: vi.fn(),
@ -25,72 +61,134 @@ const defaultProps = {
describe("BudgetTable", () => {
beforeEach(() => {
vi.clearAllMocks();
testQueryClient.clear();
});
it("should display budget information", () => {
renderWithProviders(<BudgetTable {...defaultProps} />);
renderWithProviders(<BudgetTable {...defaultProps} list={makeList()} />);
expect(screen.getByText("budget-1")).toBeInTheDocument();
expect(screen.getByText("$100.00")).toBeInTheDocument();
expect(screen.getByText("1000")).toBeInTheDocument();
expect(screen.getByText("10")).toBeInTheDocument();
});
it("should render the budget id without a fixed character-count clamp", () => {
it("should render the reset column with the friendly duration label", () => {
renderWithProviders(<BudgetTable {...defaultProps} list={makeList()} />);
expect(screen.getByText("monthly")).toBeInTheDocument();
});
it("should render 'Not set' when a budget has no reset duration", () => {
const list = makeList({ rows: [makeBudget({ budget_duration: null })] });
renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
expect(screen.getByText("Not set")).toBeInTheDocument();
});
it("should render the budget id in full, with no truncation", () => {
const budgetId = "ecc1869c-6231-4380-a56d-1a0be457477d";
renderWithProviders(<BudgetTable {...defaultProps} budgets={[makeBudget({ budget_id: budgetId })]} />);
const list = makeList({ rows: [makeBudget({ budget_id: budgetId })] });
renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
const idCell = screen.getByText(budgetId);
expect(idCell.className).not.toContain("truncate");
expect(idCell.className).not.toMatch(/max-w-\[\d+(ch|rem|px)\]/);
expect(idCell.className).toContain("max-w-full");
expect(idCell.className).toContain("truncate");
});
it("should keep the budget id on a single line", () => {
const budgetId = "ecc1869c-6231-4380-a56d-1a0be457477d";
const list = makeList({ rows: [makeBudget({ budget_id: budgetId })] });
renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
expect(screen.getByText(budgetId).className).toContain("whitespace-nowrap");
});
it("should copy the budget id from the cell's copy button", async () => {
const user = userEvent.setup();
const budgetId = "ecc1869c-6231-4380-a56d-1a0be457477d";
const list = makeList({ rows: [makeBudget({ budget_id: budgetId })] });
renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
await user.click(screen.getByRole("button", { name: "Copy ID" }));
expect(copyToClipboardMock).toHaveBeenCalledWith(budgetId);
});
it("should offer sorting on every backend-sortable column", async () => {
renderWithProviders(<BudgetTable {...defaultProps} list={makeList()} />);
for (const field of ["budget_id", "max_budget", "tpm_limit", "rpm_limit", "created_at"]) {
expect(screen.getByTestId(`sort-header-${field}`)).toBeInTheDocument();
}
});
it("should not make the reset column sortable", () => {
renderWithProviders(<BudgetTable {...defaultProps} list={makeList()} />);
expect(screen.queryByTestId("sort-header-budget_duration")).not.toBeInTheDocument();
expect(screen.getByText("Reset")).toBeInTheDocument();
});
it("should ask the list for a new sort when a sortable header is clicked", async () => {
const user = userEvent.setup();
const onSortingChange = vi.fn();
renderWithProviders(<BudgetTable {...defaultProps} list={makeList({ onSortingChange })} />);
await user.click(screen.getByTestId("sort-header-max_budget"));
expect(onSortingChange).toHaveBeenCalled();
});
it("should show n/a for missing rate limits and Unlimited for a missing max budget", () => {
renderWithProviders(
<BudgetTable {...defaultProps} budgets={[makeBudget({ max_budget: null, tpm_limit: null, rpm_limit: null })]} />,
);
const list = makeList({ rows: [makeBudget({ max_budget: null, tpm_limit: null, rpm_limit: null })] });
renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
expect(screen.getAllByText("n/a")).toHaveLength(2);
expect(screen.getByText("Unlimited")).toBeInTheDocument();
});
it("should sort budgets by updated_at descending", () => {
const budgets = [
makeBudget({ budget_id: "budget-old", updated_at: "2024-01-01T00:00:00Z" }),
makeBudget({ budget_id: "budget-new", updated_at: "2024-06-01T00:00:00Z" }),
];
renderWithProviders(<BudgetTable {...defaultProps} budgets={budgets} />);
const rows = screen.getAllByRole("row").slice(1);
expect(within(rows[0]).getByText("budget-new")).toBeInTheDocument();
expect(within(rows[1]).getByText("budget-old")).toBeInTheDocument();
});
it("should call onEditClick from the actions menu", async () => {
const user = userEvent.setup();
renderWithProviders(<BudgetTable {...defaultProps} />);
const list = makeList();
renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
await user.click(screen.getByTestId("budget-actions-budget-1"));
await user.click(await screen.findByTestId("budget-action-edit"));
expect(defaultProps.onEditClick).toHaveBeenCalledWith(defaultProps.budgets[0]);
expect(defaultProps.onEditClick).toHaveBeenCalledWith(list.rows[0]);
});
it("should call onDeleteClick from the actions menu", async () => {
const user = userEvent.setup();
renderWithProviders(<BudgetTable {...defaultProps} />);
const list = makeList();
renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
await user.click(screen.getByTestId("budget-actions-budget-1"));
await user.click(await screen.findByTestId("budget-action-delete"));
expect(defaultProps.onDeleteClick).toHaveBeenCalledWith(defaultProps.budgets[0]);
expect(defaultProps.onDeleteClick).toHaveBeenCalledWith(list.rows[0]);
});
it("should not render the actions menu when the user cannot modify budgets", () => {
renderWithProviders(<BudgetTable {...defaultProps} canModify={false} />);
renderWithProviders(<BudgetTable {...defaultProps} canModify={false} list={makeList()} />);
expect(screen.queryByTestId("budget-actions-budget-1")).not.toBeInTheDocument();
});
it("should show skeleton rows when loading", () => {
renderWithProviders(<BudgetTable {...defaultProps} budgets={[]} isLoading />);
renderWithProviders(<BudgetTable {...defaultProps} list={makeList({ rows: [], isLoading: true })} />);
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
});
it("should show the empty state when there are no budgets", () => {
renderWithProviders(<BudgetTable {...defaultProps} budgets={[]} />);
renderWithProviders(<BudgetTable {...defaultProps} list={makeList({ rows: [], rowCount: 0 })} />);
expect(screen.getByText("No budgets yet")).toBeInTheDocument();
});
it("should tell the user their search matched nothing rather than that no budgets exist", () => {
const list = makeList({ rows: [], rowCount: 0, searchValue: "nope" });
renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
expect(screen.getByText("No matching budgets")).toBeInTheDocument();
});
it("should render an access-denied state for a 403 instead of an empty table", () => {
const error = new ApiError("Only proxy admins can view budgets", 403, FORBIDDEN_PROBLEM);
const list = makeList({ rows: [], rowCount: 0, error });
const { container } = renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
expect(screen.getByText("You do not have access to budgets")).toBeInTheDocument();
expect(screen.queryByText("No budgets yet")).not.toBeInTheDocument();
expect(container.querySelector(".lucide-shield-alert")).not.toBeNull();
});
it("should surface the problem detail for a non-403 failure", () => {
const error = new ApiError("budget store unavailable", 500, null);
const list = makeList({ rows: [], rowCount: 0, error });
renderWithProviders(<BudgetTable {...defaultProps} list={list} />);
expect(screen.getByText("Could not load budgets")).toBeInTheDocument();
expect(screen.getByText("budget store unavailable")).toBeInTheDocument();
});
});

View file

@ -1,55 +1,269 @@
"use client";
import { Inbox } from "lucide-react";
import React, { useMemo } from "react";
import { Inbox, ShieldAlert } from "lucide-react";
import React, { useMemo, useState } from "react";
import { DataTable } from "@/components/shared/DataTable";
import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
import {
BUDGET_DURATION_FILTER_OPTIONS,
BUDGET_DURATION_UNSET,
type CreatedAtFilterValue,
type MaxBudgetFilterValue,
} from "@/app/(dashboard)/hooks/budgets/budgetFilters";
import type { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
import type { ResourceListResult } from "@/app/(dashboard)/hooks/common/useResourceList";
import {
DataTable,
DataTableFilterDrawer,
DataTableFilterField,
DataTableToolbar,
type FilterDraft,
} from "@/components/shared/DataTable";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ApiError } from "@/lib/http/client";
import { getBudgetTableColumns } from "./BudgetTableColumns";
interface BudgetTableProps {
budgets: budgetItem[];
isLoading: boolean;
list: ResourceListResult<budgetItem>;
canModify: boolean;
onEditClick: (budget: budgetItem) => void;
onDeleteClick: (budget: budgetItem) => void;
}
function EmptyState() {
const PAGE_SIZE_OPTIONS = [25, 50, 100];
const FILTER_LABELS: Record<string, string> = {
budget_duration: "Reset",
max_budget: "Max Budget",
created_at: "Created",
};
const durationLabel = (value: string): string =>
BUDGET_DURATION_FILTER_OPTIONS.find((option) => option.value === value)?.label ?? value;
const formatFilterValue = (columnId: string, value: unknown): string => {
if (columnId === "budget_duration") {
return (Array.isArray(value) ? value : []).map((entry) => durationLabel(String(entry))).join(", ");
}
if (columnId === "max_budget") {
const { min, max, unlimitedOnly } = (value ?? {}) as MaxBudgetFilterValue;
return unlimitedOnly === true ? "Unlimited only" : `${min ? `$${min}` : "any"} to ${max ? `$${max}` : "any"}`;
}
if (columnId === "created_at") {
const { from, to } = (value ?? {}) as CreatedAtFilterValue;
return `${from || "any"} to ${to || "any"}`;
}
return String(value);
};
/** The drawer keeps any non-empty object as an active filter, so collapse a blank draft to nothing. */
const normalizeMaxBudget = (draft: MaxBudgetFilterValue): MaxBudgetFilterValue | undefined => {
if (draft.unlimitedOnly === true) {
return { unlimitedOnly: true };
}
const min = draft.min?.trim() ?? "";
const max = draft.max?.trim() ?? "";
if (min === "" && max === "") {
return undefined;
}
return { ...(min === "" ? {} : { min }), ...(max === "" ? {} : { max }) };
};
const normalizeCreatedAt = (draft: CreatedAtFilterValue): CreatedAtFilterValue | undefined => {
const from = draft.from ?? "";
const to = draft.to ?? "";
if (from === "" && to === "") {
return undefined;
}
return { ...(from === "" ? {} : { from }), ...(to === "" ? {} : { to }) };
};
function EmptyState({ hasQuery }: { hasQuery: boolean }) {
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Inbox className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No budgets yet</div>
<div className="text-sm font-medium text-foreground">{hasQuery ? "No matching budgets" : "No budgets yet"}</div>
<div className="text-sm text-muted-foreground">
Create a budget to set spend, TPM and RPM limits for customers.
{hasQuery
? "No budget matches your search or filters."
: "Create a budget to set spend, TPM and RPM limits for customers."}
</div>
</div>
);
}
const BudgetTable: React.FC<BudgetTableProps> = ({ budgets, isLoading, canModify, onEditClick, onDeleteClick }) => {
const rows = useMemo(
() => [...budgets].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()),
[budgets],
function ErrorState({ error }: { error: Error }) {
const forbidden = error instanceof ApiError && error.status === 403;
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<ShieldAlert className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">
{forbidden ? "You do not have access to budgets" : "Could not load budgets"}
</div>
<div className="text-sm text-muted-foreground">
{forbidden ? "Ask a proxy admin to grant you the admin viewer role." : error.message}
</div>
</div>
);
}
/** "Not set" and the concrete durations are exclusive; see serializeBudgetFilters for why. */
function DurationFilter({ selected, onChange }: { selected: string[]; onChange: (selected: string[]) => void }) {
const toggle = (value: string, checked: boolean): void => {
if (!checked) {
onChange(selected.filter((entry) => entry !== value));
return;
}
const kept = value === BUDGET_DURATION_UNSET ? [] : selected.filter((entry) => entry !== BUDGET_DURATION_UNSET);
onChange([...kept, value]);
};
return (
<div className="flex flex-col gap-2">
{BUDGET_DURATION_FILTER_OPTIONS.map((option) => (
<Label key={option.value} className="font-normal">
<Checkbox
checked={selected.includes(option.value)}
onCheckedChange={(checked) => toggle(option.value, checked === true)}
data-testid={`budget-filter-duration-${option.value}`}
/>
{option.label}
</Label>
))}
</div>
);
}
function BudgetFilterFields({ get, set }: FilterDraft) {
const maxBudget = (get("max_budget") as MaxBudgetFilterValue | undefined) ?? {};
const created = (get("created_at") as CreatedAtFilterValue | undefined) ?? {};
const unlimitedOnly = maxBudget.unlimitedOnly === true;
return (
<>
<DataTableFilterField label="Reset">
<DurationFilter
selected={(get("budget_duration") as string[] | undefined) ?? []}
onChange={(selected) => set("budget_duration", selected)}
/>
</DataTableFilterField>
<DataTableFilterField label="Max Budget (USD)">
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
step="0.01"
value={maxBudget.min ?? ""}
disabled={unlimitedOnly}
onChange={(event) => set("max_budget", normalizeMaxBudget({ ...maxBudget, min: event.target.value }))}
placeholder="Min"
aria-label="Minimum max budget"
data-testid="budget-filter-max-budget-min"
/>
<Input
type="number"
min={0}
step="0.01"
value={maxBudget.max ?? ""}
disabled={unlimitedOnly}
onChange={(event) => set("max_budget", normalizeMaxBudget({ ...maxBudget, max: event.target.value }))}
placeholder="Max"
aria-label="Maximum max budget"
data-testid="budget-filter-max-budget-max"
/>
</div>
<Label className="mt-1 font-normal">
<Checkbox
checked={unlimitedOnly}
onCheckedChange={(checked) => set("max_budget", normalizeMaxBudget({ unlimitedOnly: checked === true }))}
data-testid="budget-filter-max-budget-unlimited"
/>
Unlimited only
</Label>
</DataTableFilterField>
<DataTableFilterField label="Created">
<div className="flex items-center gap-2">
<Input
type="date"
value={created.from ?? ""}
onChange={(event) => set("created_at", normalizeCreatedAt({ ...created, from: event.target.value }))}
aria-label="Created from"
data-testid="budget-filter-created-from"
/>
<Input
type="date"
value={created.to ?? ""}
onChange={(event) => set("created_at", normalizeCreatedAt({ ...created, to: event.target.value }))}
aria-label="Created to"
data-testid="budget-filter-created-to"
/>
</div>
</DataTableFilterField>
</>
);
}
const BudgetTable: React.FC<BudgetTableProps> = ({ list, canModify, onEditClick, onDeleteClick }) => {
const [filtersOpen, setFiltersOpen] = useState(false);
const columns = useMemo(
() => getBudgetTableColumns({ canModify, onEditClick, onDeleteClick }),
[canModify, onEditClick, onDeleteClick],
);
const hasQuery = list.searchValue.trim() !== "" || list.columnFilters.length > 0;
const emptyMessage = list.error === null ? <EmptyState hasQuery={hasQuery} /> : <ErrorState error={list.error} />;
return (
<DataTable
data={rows}
data={list.rows}
columns={columns}
getRowId={(budget, index) => budget.budget_id || String(index)}
isLoading={isLoading}
sortingMode="server"
sorting={list.sorting}
onSortingChange={list.onSortingChange}
paginationMode="server"
pagination={list.pagination}
onPaginationChange={list.onPaginationChange}
rowCount={list.rowCount}
pageSizeOptions={PAGE_SIZE_OPTIONS}
filterMode="server"
columnFilters={list.columnFilters}
onColumnFiltersChange={list.onColumnFiltersChange}
isLoading={list.isLoading}
loadingMessage="Loading budgets…"
noDataMessage={<EmptyState />}
noDataMessage={emptyMessage}
size="compact"
toolbar={(table) => (
<>
<DataTableToolbar
table={table}
searchValue={list.searchValue}
onSearchChange={list.onSearchChange}
searchPlaceholder="Search by budget ID…"
onOpenFilters={() => setFiltersOpen(true)}
onRefresh={list.refetch}
isRefreshing={list.isFetching}
filterLabels={FILTER_LABELS}
formatFilterValue={formatFilterValue}
/>
<DataTableFilterDrawer
table={table}
open={filtersOpen}
onOpenChange={setFiltersOpen}
title="Filters"
description="Narrow down your budgets"
>
{(draft) => <BudgetFilterFields {...draft} />}
</DataTableFilterDrawer>
</>
)}
/>
);
};

View file

@ -1,11 +1,13 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ColumnDef, FilterFn } from "@tanstack/react-table";
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { IdCell, MoneyCell } from "@/components/shared/table_cells";
import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
import type { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
import { buttonVariants } from "@/components/ui/button";
import { getBudgetDurationLabel } from "@/components/common_components/budget_duration_dropdown";
import {
DropdownMenu,
DropdownMenuContent,
@ -15,6 +17,15 @@ import {
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
/**
* Filtering happens on the server, so this never runs as a predicate. It exists to override
* TanStack's auto-remove heuristic, which infers a filter shape from the column's first cell
* and silently discards a filter whose value is not that shape (a range object on a numeric
* column, for instance).
*/
const serverFilter: FilterFn<budgetItem> = () => true;
serverFilter.autoRemove = () => false;
function RateLimitCell({ value }: { value: number | null }) {
if (value == null) {
return <span className="text-muted-foreground">n/a</span>;
@ -22,6 +33,13 @@ function RateLimitCell({ value }: { value: number | null }) {
return <span className="tabular-nums">{value}</span>;
}
function BudgetDurationCell({ value }: { value: string | null }) {
if (!value) {
return <span className="text-muted-foreground">Not set</span>;
}
return <span className="whitespace-nowrap">{getBudgetDurationLabel(value)}</span>;
}
interface BudgetRowActionsProps {
budget: budgetItem;
onEditClick: (budget: budgetItem) => void;
@ -72,38 +90,56 @@ export const getBudgetTableColumns = ({
id: "budget_id",
accessorKey: "budget_id",
meta: { title: "Budget ID" },
header: "Budget ID",
size: 220,
enableSorting: false,
cell: ({ row }) => <IdCell value={row.original.budget_id} variant="plain" className="max-w-full" />,
header: ({ column }) => <DataTableSortHeader column={column} title="Budget ID" />,
cell: ({ row }) => (
<IdCell value={row.original.budget_id} variant="plain" truncate={false} copyable className="whitespace-nowrap" />
),
},
{
id: "max_budget",
accessorKey: "max_budget",
filterFn: serverFilter,
meta: { title: "Max Budget", numeric: true },
header: "Max Budget",
header: ({ column }) => <DataTableSortHeader column={column} title="Max Budget" />,
size: 120,
enableSorting: false,
cell: ({ row }) => <MoneyCell value={row.original.max_budget} decimals={2} showZero emptyText="Unlimited" />,
},
{
id: "tpm_limit",
accessorKey: "tpm_limit",
meta: { title: "TPM", numeric: true },
header: "TPM",
header: ({ column }) => <DataTableSortHeader column={column} title="TPM" />,
size: 100,
enableSorting: false,
cell: ({ row }) => <RateLimitCell value={row.original.tpm_limit} />,
},
{
id: "rpm_limit",
accessorKey: "rpm_limit",
meta: { title: "RPM", numeric: true },
header: "RPM",
header: ({ column }) => <DataTableSortHeader column={column} title="RPM" />,
size: 100,
enableSorting: false,
cell: ({ row }) => <RateLimitCell value={row.original.rpm_limit} />,
},
{
id: "budget_duration",
accessorKey: "budget_duration",
filterFn: serverFilter,
meta: { title: "Reset" },
// "7d"/"30d" sort lexicographically, not chronologically, so the route does not offer it.
enableSorting: false,
header: ({ column }) => <DataTableSortHeader column={column} title="Reset" />,
size: 110,
cell: ({ row }) => <BudgetDurationCell value={row.original.budget_duration} />,
},
{
id: "created_at",
accessorKey: "created_at",
filterFn: serverFilter,
meta: { title: "Created" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
size: 160,
cell: ({ row }) => <DateCell value={row.original.created_at} />,
},
...(canModify
? [
{

View file

@ -1,217 +1,254 @@
import { fireEvent, render, waitFor, screen } from "@testing-library/react";
import { act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@/lib/http/client";
import BudgetPanel from "./budget_panel";
const mockBudgets = [
{
budget_id: "budget-1",
max_budget: 100,
rpm_limit: 10,
tpm_limit: 1000,
updated_at: "2024-01-01T00:00:00Z",
},
];
vi.mock("@/app/(dashboard)/hooks/budgets/useBudgets", () => ({
useBudgets: vi.fn().mockReturnValue({ data: [], isLoading: false }),
useDeleteBudget: vi.fn().mockReturnValue({ mutateAsync: vi.fn(), isPending: false }),
useCreateBudget: vi.fn().mockReturnValue({ mutateAsync: vi.fn() }),
useUpdateBudget: vi.fn().mockReturnValue({ mutateAsync: vi.fn() }),
const { getMock, budgetDeleteMock } = vi.hoisted(() => ({
getMock: vi.fn(),
budgetDeleteMock: vi.fn(),
}));
import {
useBudgets,
useDeleteBudget,
useCreateBudget,
useUpdateBudget,
} from "@/app/(dashboard)/hooks/budgets/useBudgets";
vi.mock("@/components/networking", () => ({
apiClient: { get: getMock },
budgetCreateCall: vi.fn(),
budgetUpdateCall: vi.fn(),
budgetDeleteCall: budgetDeleteMock,
getProxyBaseUrl: () => "",
}));
const createQueryClient = () =>
new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({ accessToken: "sk-test", userRole: "Admin", userId: "u1" }),
}));
function renderWithProviders(ui: React.ReactElement) {
const qc = createQueryClient();
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
vi.mock("@/components/molecules/notifications_manager", () => ({
default: { success: vi.fn(), info: vi.fn(), fromBackend: vi.fn() },
}));
interface BudgetSeed {
budget_id: string;
max_budget: number | null;
budget_duration: string | null;
}
const budgetRow = (seed: BudgetSeed) => ({
soft_budget: null,
tpm_limit: 1000,
rpm_limit: 10,
budget_reset_at: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
...seed,
});
const FORBIDDEN_PROBLEM = {
type: "about:blank",
title: "Forbidden",
status: 403,
detail: "Only proxy admins can view budgets",
};
const DEFAULT_ROWS = [
budgetRow({ budget_id: "ecc1869c-6231-4380-a56d-1a0be457477d", max_budget: 100, budget_duration: "30d" }),
];
const respondWith = (rows: ReturnType<typeof budgetRow>[], totalCount: number) => {
getMock.mockResolvedValue({
data: rows,
meta: { total_count: totalCount, page: 1, page_size: 50, total_pages: Math.ceil(totalCount / 50) },
});
};
type QueryRecord = Record<string, string | number>;
const queries = (): QueryRecord[] => getMock.mock.calls.map((call) => (call[1] as { query: QueryRecord }).query);
const lastQuery = (): QueryRecord => queries()[queries().length - 1];
const paths = (): string[] => getMock.mock.calls.map((call) => String(call[0]));
const renderPanel = () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return render(
<QueryClientProvider client={client}>
<BudgetPanel accessToken="sk-test" />
</QueryClientProvider>,
);
};
const openFilters = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByTestId("datatable-filters-trigger"));
await screen.findByTestId("filter-drawer-body");
};
describe("Budget Panel", () => {
afterEach(() => {
beforeEach(() => {
vi.clearAllMocks();
respondWith(DEFAULT_ROWS, 1);
});
it("should render the budget panel and load budgets", async () => {
vi.mocked(useBudgets).mockReturnValue({
data: mockBudgets,
isLoading: false,
} as any);
renderWithProviders(<BudgetPanel accessToken="token-123" />);
await waitFor(() => {
expect(screen.getByText("Create a budget to assign to customers.")).toBeInTheDocument();
expect(screen.getByText("budget-1")).toBeInTheDocument();
});
it("loads the first page of budgets, newest first", async () => {
renderPanel();
await waitFor(() => expect(getMock).toHaveBeenCalled());
expect(paths()[0]).toBe("/management/v1/budgets");
expect(queries()[0]).toEqual({ page: 1, page_size: 50, sort: "-created_at" });
expect(await screen.findByText("ecc1869c-6231-4380-a56d-1a0be457477d")).toBeInTheDocument();
});
it("should open delete modal from the actions menu", async () => {
it("asks the server to sort when a sortable header is clicked", async () => {
const user = userEvent.setup();
vi.mocked(useBudgets).mockReturnValue({
data: [
{
budget_id: "budget-to-delete",
max_budget: 200,
rpm_limit: 20,
tpm_limit: 2000,
updated_at: "2024-01-02T00:00:00Z",
},
],
isLoading: false,
} as any);
renderPanel();
await waitFor(() => expect(getMock).toHaveBeenCalled());
renderWithProviders(<BudgetPanel accessToken="token-123" />);
await user.click(screen.getByTestId("sort-header-max_budget"));
await waitFor(() => expect(lastQuery().sort).toBe("-max_budget"));
await waitFor(() => {
expect(screen.getByText("budget-to-delete")).toBeInTheDocument();
});
await user.click(screen.getByTestId("sort-header-max_budget"));
await waitFor(() => expect(lastQuery().sort).toBe("max_budget"));
await user.click(screen.getByTestId("budget-actions-budget-to-delete"));
await user.click(screen.getByTestId("sort-header-budget_id"));
await waitFor(() => expect(lastQuery().sort).toBe("budget_id"));
});
it("searches on budget_id with a debounced q", async () => {
const user = userEvent.setup();
renderPanel();
await waitFor(() => expect(getMock).toHaveBeenCalled());
await user.type(screen.getByTestId("datatable-search"), "ecc");
await waitFor(() => expect(lastQuery().q).toBe("ecc"));
expect(queries().some((query) => query.q === "e" || query.q === "ec")).toBe(false);
});
it("filters by reset duration and clears it again", async () => {
const user = userEvent.setup();
renderPanel();
await waitFor(() => expect(getMock).toHaveBeenCalled());
await openFilters(user);
await user.click(screen.getByTestId("budget-filter-duration-7d"));
await user.click(screen.getByTestId("budget-filter-duration-30d"));
await user.click(screen.getByTestId("filter-drawer-apply"));
await waitFor(() => expect(lastQuery()["filter[budget_duration][in]"]).toBe("7d,30d"));
await user.click(screen.getByTestId("filter-chip-remove-budget_duration"));
await waitFor(() => expect(lastQuery()).not.toHaveProperty("filter[budget_duration][in]"));
});
it("filters by budgets with no reset duration", async () => {
const user = userEvent.setup();
renderPanel();
await waitFor(() => expect(getMock).toHaveBeenCalled());
await openFilters(user);
await user.click(screen.getByTestId("budget-filter-duration-__unset__"));
await user.click(screen.getByTestId("filter-drawer-apply"));
await waitFor(() => expect(lastQuery()["filter[budget_duration][is_null]"]).toBe("true"));
expect(lastQuery()).not.toHaveProperty("filter[budget_duration][in]");
});
it("filters by a max budget range and clears it again", async () => {
const user = userEvent.setup();
renderPanel();
await waitFor(() => expect(getMock).toHaveBeenCalled());
await openFilters(user);
await user.type(screen.getByTestId("budget-filter-max-budget-min"), "10");
await user.type(screen.getByTestId("budget-filter-max-budget-max"), "500");
await user.click(screen.getByTestId("filter-drawer-apply"));
await waitFor(() => expect(lastQuery()["filter[max_budget][gte]"]).toBe("10"));
expect(lastQuery()["filter[max_budget][lte]"]).toBe("500");
await user.click(screen.getByTestId("datatable-clear-filters"));
await waitFor(() => expect(lastQuery()).not.toHaveProperty("filter[max_budget][gte]"));
expect(lastQuery()).not.toHaveProperty("filter[max_budget][lte]");
});
it("filters to unlimited budgets only", async () => {
const user = userEvent.setup();
renderPanel();
await waitFor(() => expect(getMock).toHaveBeenCalled());
await openFilters(user);
await user.type(screen.getByTestId("budget-filter-max-budget-min"), "10");
await user.click(screen.getByTestId("budget-filter-max-budget-unlimited"));
await user.click(screen.getByTestId("filter-drawer-apply"));
await waitFor(() => expect(lastQuery()["filter[max_budget][is_null]"]).toBe("true"));
expect(lastQuery()).not.toHaveProperty("filter[max_budget][gte]");
});
it("filters by a created date range covering whole local days", async () => {
const user = userEvent.setup();
renderPanel();
await waitFor(() => expect(getMock).toHaveBeenCalled());
await openFilters(user);
await user.type(screen.getByTestId("budget-filter-created-from"), "2026-01-05");
await user.type(screen.getByTestId("budget-filter-created-to"), "2026-01-06");
await user.click(screen.getByTestId("filter-drawer-apply"));
await waitFor(() =>
expect(lastQuery()["filter[created_at][gte]"]).toBe(new Date("2026-01-05T00:00:00.000").toISOString()),
);
expect(lastQuery()["filter[created_at][lte]"]).toBe(new Date("2026-01-06T23:59:59.999").toISOString());
});
it("pages through the results and changes page size", async () => {
const user = userEvent.setup();
respondWith(DEFAULT_ROWS, 400);
renderPanel();
await waitFor(() => expect(getMock).toHaveBeenCalled());
await user.click(screen.getByTestId("pagination-next"));
await waitFor(() => expect(lastQuery().page).toBe(2));
expect(lastQuery().page_size).toBe(50);
await user.click(screen.getByTestId("pagination-page-size"));
await user.click(await screen.findByRole("option", { name: "25" }));
await waitFor(() => expect(lastQuery().page_size).toBe(25));
});
it("renders an access-denied state when the route rejects the caller", async () => {
getMock.mockRejectedValue(new ApiError("Only proxy admins can view budgets", 403, FORBIDDEN_PROBLEM));
renderPanel();
expect(await screen.findByText("You do not have access to budgets")).toBeInTheDocument();
expect(screen.queryByText("No budgets yet")).not.toBeInTheDocument();
});
it("deletes a budget from the actions menu", async () => {
const user = userEvent.setup();
budgetDeleteMock.mockResolvedValue(undefined);
renderPanel();
await screen.findByText("ecc1869c-6231-4380-a56d-1a0be457477d");
await user.click(screen.getByTestId("budget-actions-ecc1869c-6231-4380-a56d-1a0be457477d"));
await user.click(await screen.findByTestId("budget-action-delete"));
await screen.findByText("Delete Budget?");
await user.click(screen.getByRole("button", { name: /^delete$/i }));
await waitFor(() => {
expect(screen.getByText("Delete Budget?")).toBeInTheDocument();
});
await waitFor(() =>
expect(budgetDeleteMock).toHaveBeenCalledWith("sk-test", "ecc1869c-6231-4380-a56d-1a0be457477d"),
);
});
it("should successfully delete a budget", async () => {
it("refetches the current page after a delete", async () => {
const user = userEvent.setup();
const deleteMutateAsync = vi.fn().mockResolvedValue(undefined);
vi.mocked(useBudgets).mockReturnValue({
data: [
{
budget_id: "budget-to-delete",
max_budget: 200,
rpm_limit: 20,
tpm_limit: 2000,
updated_at: "2024-01-02T00:00:00Z",
},
],
isLoading: false,
} as any);
vi.mocked(useDeleteBudget).mockReturnValue({
mutateAsync: deleteMutateAsync,
isPending: false,
} as any);
budgetDeleteMock.mockResolvedValue(undefined);
renderPanel();
await screen.findByText("ecc1869c-6231-4380-a56d-1a0be457477d");
const before = getMock.mock.calls.length;
renderWithProviders(<BudgetPanel accessToken="token-123" />);
await waitFor(() => {
expect(screen.getByText("budget-to-delete")).toBeInTheDocument();
});
await user.click(screen.getByTestId("budget-actions-budget-to-delete"));
await user.click(screen.getByTestId("budget-actions-ecc1869c-6231-4380-a56d-1a0be457477d"));
await user.click(await screen.findByTestId("budget-action-delete"));
await screen.findByText("Delete Budget?");
await user.click(screen.getByRole("button", { name: /^delete$/i }));
await waitFor(() => {
expect(screen.getByText("Delete Budget?")).toBeInTheDocument();
});
const confirmButton = screen.getByRole("button", { name: /delete/i });
act(() => {
fireEvent.click(confirmButton);
});
await waitFor(() => {
expect(deleteMutateAsync).toHaveBeenCalledWith("budget-to-delete");
});
});
it("should render empty state without crashing", async () => {
vi.mocked(useBudgets).mockReturnValue({
data: [],
isLoading: false,
} as any);
renderWithProviders(<BudgetPanel accessToken="token-123" />);
await waitFor(() => {
expect(screen.getByText("Create a budget to assign to customers.")).toBeInTheDocument();
});
});
it("should handle delete error", async () => {
const user = userEvent.setup();
const deleteMutateAsync = vi.fn().mockRejectedValue(new Error("Delete failed"));
vi.mocked(useBudgets).mockReturnValue({
data: [
{
budget_id: "budget-to-delete",
max_budget: 200,
rpm_limit: 20,
tpm_limit: 2000,
updated_at: "2024-01-02T00:00:00Z",
},
],
isLoading: false,
} as any);
vi.mocked(useDeleteBudget).mockReturnValue({
mutateAsync: deleteMutateAsync,
isPending: false,
} as any);
renderWithProviders(<BudgetPanel accessToken="token-123" />);
await waitFor(() => {
expect(screen.getByText("budget-to-delete")).toBeInTheDocument();
});
await user.click(screen.getByTestId("budget-actions-budget-to-delete"));
await user.click(await screen.findByTestId("budget-action-delete"));
await waitFor(() => {
expect(screen.getByText("Delete Budget?")).toBeInTheDocument();
});
const confirmButton = screen.getByRole("button", { name: /delete/i });
act(() => {
fireEvent.click(confirmButton);
});
await waitFor(() => {
expect(deleteMutateAsync).toHaveBeenCalledWith("budget-to-delete");
});
});
it("should open edit modal from the actions menu", async () => {
const user = userEvent.setup();
vi.mocked(useBudgets).mockReturnValue({
data: [
{
budget_id: "budget-to-edit",
max_budget: 300,
rpm_limit: 30,
tpm_limit: 3000,
updated_at: "2024-01-03T00:00:00Z",
},
],
isLoading: false,
} as any);
renderWithProviders(<BudgetPanel accessToken="token-123" />);
await waitFor(() => {
expect(screen.getByText("budget-to-edit")).toBeInTheDocument();
});
await user.click(screen.getByTestId("budget-actions-budget-to-edit"));
await user.click(await screen.findByTestId("budget-action-edit"));
await waitFor(() => {
expect(screen.getByText("Edit Budget")).toBeInTheDocument();
});
await waitFor(() => expect(getMock.mock.calls.length).toBeGreaterThan(before));
});
});

View file

@ -3,13 +3,13 @@
*
*/
import React, { useState } from "react";
import React, { useCallback, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { useBudgets, useDeleteBudget, budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
import { useBudgetList, useDeleteBudget, budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
import BudgetModal from "./budget_modal";
import BudgetTable from "./BudgetTable";
import EditBudgetModal from "./edit_budget_modal";
@ -31,21 +31,25 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
// Admin Viewer follows the read-parity rule: see budgets, no writes.
const canModify = isProxyAdminRole(userRole ?? "");
const { data: budgetList = [], isLoading } = useBudgets();
const budgetList = useBudgetList();
const deleteBudget = useDeleteBudget();
const handleEditCall = async (budget: budgetItem) => {
if (accessToken == null) {
return;
}
setSelectedBudget(budget);
setIsEditModalVisible(true);
};
// Stable identities keep the memoized column defs stable; new ones remount every header and cell.
const handleEditCall = useCallback(
(budget: budgetItem) => {
if (accessToken == null) {
return;
}
setSelectedBudget(budget);
setIsEditModalVisible(true);
},
[accessToken],
);
const handleDeleteClick = (budget: budgetItem) => {
const handleDeleteClick = useCallback((budget: budgetItem) => {
setSelectedBudget(budget);
setIsDeleteModalVisible(true);
};
}, []);
const handleDeleteConfirm = async () => {
if (!selectedBudget || accessToken == null) {
@ -99,8 +103,7 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
)}
<p className="mb-4 text-sm text-muted-foreground">Create a budget to assign to customers.</p>
<BudgetTable
budgets={budgetList}
isLoading={isLoading}
list={budgetList}
canModify={canModify}
onEditClick={handleEditCall}
onDeleteClick={handleDeleteClick}

View file

@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { BUDGET_DURATION_UNSET, serializeBudgetFilters } from "./budgetFilters";
describe("serializeBudgetFilters", () => {
it("sends nothing when no filter is active", () => {
expect(serializeBudgetFilters([])).toEqual({});
});
it("maps selected durations onto the in operator", () => {
expect(serializeBudgetFilters([{ id: "budget_duration", value: ["7d", "30d"] }])).toEqual({
"filter[budget_duration][in]": "7d,30d",
});
});
it("maps 'Not set' onto is_null instead of in", () => {
expect(serializeBudgetFilters([{ id: "budget_duration", value: [BUDGET_DURATION_UNSET] }])).toEqual({
"filter[budget_duration][is_null]": "true",
});
});
it("never sends in alongside is_null for the same field", () => {
const params = serializeBudgetFilters([{ id: "budget_duration", value: ["7d", BUDGET_DURATION_UNSET] }]);
expect(params["filter[budget_duration][in]"]).toBeUndefined();
expect(params["filter[budget_duration][is_null]"]).toBe("true");
});
it("maps a max budget range onto gte and lte", () => {
expect(serializeBudgetFilters([{ id: "max_budget", value: { min: "10", max: "250.5" } }])).toEqual({
"filter[max_budget][gte]": "10",
"filter[max_budget][lte]": "250.5",
});
});
it("sends only the bound that was filled in", () => {
expect(serializeBudgetFilters([{ id: "max_budget", value: { min: "10", max: "" } }])).toEqual({
"filter[max_budget][gte]": "10",
});
});
it("maps 'Unlimited only' onto is_null and drops the range", () => {
const params = serializeBudgetFilters([{ id: "max_budget", value: { min: "10", unlimitedOnly: true } }]);
expect(params).toEqual({ "filter[max_budget][is_null]": "true" });
});
it("widens a created-at day range to cover the whole local days", () => {
const params = serializeBudgetFilters([{ id: "created_at", value: { from: "2026-01-05", to: "2026-01-06" } }]);
expect(params["filter[created_at][gte]"]).toBe(new Date("2026-01-05T00:00:00.000").toISOString());
expect(params["filter[created_at][lte]"]).toBe(new Date("2026-01-06T23:59:59.999").toISOString());
});
it("ignores an unparseable date rather than sending a broken bound", () => {
expect(serializeBudgetFilters([{ id: "created_at", value: { from: "not-a-date" } }])).toEqual({});
});
it("ignores filter ids the route does not declare", () => {
expect(serializeBudgetFilters([{ id: "spend", value: "5" }])).toEqual({});
});
});

View file

@ -0,0 +1,91 @@
import type { ColumnFilter, ColumnFiltersState } from "@tanstack/react-table";
export const BUDGET_DURATION_UNSET = "__unset__";
export const BUDGET_DURATION_FILTER_OPTIONS: readonly { value: string; label: string }[] = [
{ value: "1h", label: "hourly" },
{ value: "24h", label: "daily" },
{ value: "7d", label: "weekly" },
{ value: "30d", label: "monthly" },
{ value: BUDGET_DURATION_UNSET, label: "Not set" },
];
export interface MaxBudgetFilterValue {
min?: string;
max?: string;
unlimitedOnly?: boolean;
}
export interface CreatedAtFilterValue {
from?: string;
to?: string;
}
type QueryEntry = readonly [string, string];
const entries = (key: string, value: string): QueryEntry[] => (value === "" ? [] : [[key, value]]);
const asStringArray = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
const asRecord = (value: unknown): Record<string, unknown> =>
typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
const asTrimmed = (value: unknown): string => (typeof value === "string" ? value.trim() : "");
/** The date inputs give a calendar day; the route wants an instant, so widen to the viewer's whole local day. */
const isoAt = (day: string, time: string): string => {
if (day === "") {
return "";
}
const parsed = new Date(`${day}T${time}`);
return Number.isNaN(parsed.getTime()) ? "" : parsed.toISOString();
};
/**
* "Not set" is exclusive with the concrete durations. The route's contract does not say how it
* combines `in` with `is_null` on one field, and under AND semantics that pair can only match
* nothing, so we never send both.
*/
const durationParams = (value: unknown): QueryEntry[] => {
const selected = asStringArray(value);
if (selected.includes(BUDGET_DURATION_UNSET)) {
return [["filter[budget_duration][is_null]", "true"]];
}
return entries("filter[budget_duration][in]", selected.join(","));
};
const maxBudgetParams = (value: unknown): QueryEntry[] => {
const draft = asRecord(value);
if (draft.unlimitedOnly === true) {
return [["filter[max_budget][is_null]", "true"]];
}
return [
...entries("filter[max_budget][gte]", asTrimmed(draft.min)),
...entries("filter[max_budget][lte]", asTrimmed(draft.max)),
];
};
const createdAtParams = (value: unknown): QueryEntry[] => {
const draft = asRecord(value);
return [
...entries("filter[created_at][gte]", isoAt(asTrimmed(draft.from), "00:00:00.000")),
...entries("filter[created_at][lte]", isoAt(asTrimmed(draft.to), "23:59:59.999")),
];
};
const filterParams = (filter: ColumnFilter): QueryEntry[] => {
switch (filter.id) {
case "budget_duration":
return durationParams(filter.value);
case "max_budget":
return maxBudgetParams(filter.value);
case "created_at":
return createdAtParams(filter.value);
default:
return [];
}
};
export const serializeBudgetFilters = (filters: ColumnFiltersState): Readonly<Record<string, string>> =>
Object.fromEntries(filters.flatMap(filterParams));

View file

@ -1,28 +1,58 @@
import { useQuery, useMutation, useQueryClient, UseQueryResult } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { getBudgetList, budgetCreateCall, budgetUpdateCall, budgetDeleteCall } from "@/components/networking";
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { SortingState } from "@tanstack/react-table";
import { useCallback } from "react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { apiClient, budgetCreateCall, budgetUpdateCall, budgetDeleteCall } from "@/components/networking";
import { createQueryKeys } from "../common/queryKeysFactory";
import {
useResourceList,
type ResourceListPage,
type ResourceListQuery,
type ResourceListResult,
} from "../common/useResourceList";
import { serializeBudgetFilters } from "./budgetFilters";
export interface budgetItem {
budget_id: string;
max_budget: number | null;
soft_budget: number | null;
rpm_limit: number | null;
tpm_limit: number | null;
budget_duration: string | null;
budget_reset_at: string | null;
created_at: string;
updated_at: string;
}
export const BUDGET_LIST_PATH = "/management/v1/budgets";
export const budgetKeys = createQueryKeys("budgets");
export const useBudgets = (): UseQueryResult<budgetItem[]> => {
const DEFAULT_PAGE_SIZE = 50;
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
export const useBudgetList = (): ResourceListResult<budgetItem> => {
const { accessToken } = useAuthorized();
return useQuery<budgetItem[]>({
queryKey: budgetKeys.list({}),
queryFn: async () => {
const data = await getBudgetList(accessToken!);
return (data ?? []).filter((item: budgetItem | null): item is budgetItem => item != null);
},
const fetchPage = useCallback(
(query: ResourceListQuery, signal: AbortSignal): Promise<ResourceListPage<budgetItem>> =>
apiClient.get<ResourceListPage<budgetItem>>(BUDGET_LIST_PATH, { accessToken, query, signal }),
[accessToken],
);
const listOptions = {
queryKey: budgetKeys.lists(),
fetchPage,
serializeFilters: serializeBudgetFilters,
defaultSorting: DEFAULT_SORTING,
defaultPageSize: DEFAULT_PAGE_SIZE,
enabled: Boolean(accessToken),
});
};
return useResourceList<budgetItem>(listOptions);
};
export const useCreateBudget = () => {

View file

@ -0,0 +1,161 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ColumnFiltersState } from "@tanstack/react-table";
import { act, renderHook, waitFor } from "@testing-library/react";
import React, { type PropsWithChildren } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
toSortParam,
useResourceList,
type ResourceListPage,
type ResourceListQuery,
type UseResourceListOptions,
} from "./useResourceList";
interface Row {
id: string;
}
const page = (rows: Row[], totalCount: number): ResourceListPage<Row> => ({
data: rows,
meta: { total_count: totalCount, page: 1, page_size: 50, total_pages: 1 },
});
const noFilters = (): Readonly<Record<string, string>> => ({});
const calls: ResourceListQuery[] = [];
const renderList = (overrides: Partial<UseResourceListOptions<Row>> = {}) => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
const fetchPage = vi.fn((query: ResourceListQuery) => {
calls.push(query);
return Promise.resolve(page([{ id: "a" }], 3));
});
const options: UseResourceListOptions<Row> = {
queryKey: ["widgets", "list"],
fetchPage,
serializeFilters: noFilters,
defaultSorting: [{ id: "created_at", desc: true }],
defaultPageSize: 50,
enabled: true,
...overrides,
};
return renderHook(() => useResourceList<Row>(options), { wrapper });
};
const lastCall = (): ResourceListQuery => calls[calls.length - 1];
describe("toSortParam", () => {
it("prefixes descending fields with a minus and joins with commas", () => {
expect(toSortParam([{ id: "created_at", desc: true }])).toBe("-created_at");
expect(toSortParam([{ id: "max_budget", desc: false }])).toBe("max_budget");
expect(
toSortParam([
{ id: "a", desc: false },
{ id: "b", desc: true },
]),
).toBe("a,-b");
});
});
describe("useResourceList", () => {
beforeEach(() => {
calls.length = 0;
});
it("requests the first page with the default sort", async () => {
const { result } = renderList();
await waitFor(() => expect(result.current.rowCount).toBe(3));
expect(lastCall()).toEqual({ page: 1, page_size: 50, sort: "-created_at" });
});
it("exposes the returned rows and total count", async () => {
const { result } = renderList();
await waitFor(() => expect(result.current.rows).toEqual([{ id: "a" }]));
expect(result.current.rowCount).toBe(3);
});
it("does not fetch while disabled", async () => {
const { result } = renderList({ enabled: false });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(calls).toHaveLength(0);
});
it("sends the new sort and returns to the first page", async () => {
const { result } = renderList();
await waitFor(() => expect(calls).toHaveLength(1));
act(() => result.current.onPaginationChange({ pageIndex: 2, pageSize: 50 }));
await waitFor(() => expect(lastCall().page).toBe(3));
act(() => result.current.onSortingChange([{ id: "max_budget", desc: false }]));
await waitFor(() => expect(lastCall().sort).toBe("max_budget"));
expect(lastCall().page).toBe(1);
});
it("omits sort entirely when nothing is sorted", async () => {
const { result } = renderList({ defaultSorting: [] });
await waitFor(() => expect(calls).toHaveLength(1));
expect(result.current.sorting).toEqual([]);
expect(lastCall()).not.toHaveProperty("sort");
});
it("debounces the search into a single trimmed q and returns to the first page", async () => {
const { result } = renderList();
await waitFor(() => expect(calls).toHaveLength(1));
act(() => result.current.onPaginationChange({ pageIndex: 1, pageSize: 50 }));
await waitFor(() => expect(lastCall().page).toBe(2));
act(() => result.current.onSearchChange("bud"));
act(() => result.current.onSearchChange("budg "));
await waitFor(() => expect(lastCall().q).toBe("budg"));
expect(lastCall().page).toBe(1);
expect(calls.some((call) => call.q === "bud")).toBe(false);
});
it("stops sending q once the search box is cleared", async () => {
const { result } = renderList();
act(() => result.current.onSearchChange("budget"));
await waitFor(() => expect(lastCall().q).toBe("budget"));
act(() => result.current.onSearchChange(""));
await waitFor(() => expect(lastCall()).not.toHaveProperty("q"));
});
it("merges serialized filters into the request and returns to the first page", async () => {
const serializeFilters = (filters: ColumnFiltersState): Readonly<Record<string, string>> =>
filters.length === 0 ? {} : { "filter[colour][in]": String(filters[0].value) };
const { result } = renderList({ serializeFilters });
await waitFor(() => expect(calls).toHaveLength(1));
act(() => result.current.onPaginationChange({ pageIndex: 3, pageSize: 50 }));
await waitFor(() => expect(lastCall().page).toBe(4));
act(() => result.current.onColumnFiltersChange([{ id: "colour", value: "red" }]));
await waitFor(() => expect(lastCall()["filter[colour][in]"]).toBe("red"));
expect(lastCall().page).toBe(1);
act(() => result.current.onColumnFiltersChange([]));
await waitFor(() => expect(lastCall()).not.toHaveProperty("filter[colour][in]"));
});
it("sends the requested page size", async () => {
const { result } = renderList();
await waitFor(() => expect(calls).toHaveLength(1));
act(() => result.current.onPaginationChange({ pageIndex: 0, pageSize: 25 }));
await waitFor(() => expect(lastCall().page_size).toBe(25));
});
it("surfaces a failed page as an error instead of empty rows", async () => {
const fetchPage = vi.fn(() => Promise.reject(new Error("boom")));
const { result } = renderList({ fetchPage });
await waitFor(() => expect(result.current.error?.message).toBe("boom"));
expect(result.current.rows).toEqual([]);
});
});

View file

@ -0,0 +1,142 @@
"use client";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import type { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
import { useCallback, useMemo, useState } from "react";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
export type ResourceListQuery = Readonly<Record<string, string | number>>;
export interface ResourceListMeta {
total_count: number;
page: number;
page_size: number;
total_pages: number;
}
export interface ResourceListPage<TRow> {
data: TRow[];
meta: ResourceListMeta;
}
export interface UseResourceListOptions<TRow> {
/** Prefix every list variant hangs off, so invalidating the resource root refetches whichever page is on screen. */
queryKey: readonly unknown[];
fetchPage: (query: ResourceListQuery, signal: AbortSignal) => Promise<ResourceListPage<TRow>>;
/** Must be referentially stable; it feeds the query key. */
serializeFilters: (filters: ColumnFiltersState) => Readonly<Record<string, string>>;
defaultSorting: SortingState;
defaultPageSize: number;
enabled: boolean;
}
export interface ResourceListResult<TRow> {
rows: TRow[];
rowCount: number;
isLoading: boolean;
isFetching: boolean;
error: Error | null;
refetch: () => void;
sorting: SortingState;
onSortingChange: OnChangeFn<SortingState>;
pagination: PaginationState;
onPaginationChange: OnChangeFn<PaginationState>;
columnFilters: ColumnFiltersState;
onColumnFiltersChange: OnChangeFn<ColumnFiltersState>;
searchValue: string;
onSearchChange: (value: string) => void;
}
/** JSON:API sort form: comma separated fields, `-` prefix for descending. */
export const toSortParam = (sorting: SortingState): string =>
sorting.map((entry) => (entry.desc ? `-${entry.id}` : entry.id)).join(",");
/**
* State container for a table whose sorting, paging, search and filtering all run
* on the server. It owns those four pieces of state, folds them into one JSON:API
* query, and returns the exact props DataTable's server modes want.
*
* Empty parameters are dropped rather than sent blank because the management
* routes reject query params they do not declare.
*/
export function useResourceList<TRow>(options: UseResourceListOptions<TRow>): ResourceListResult<TRow> {
const { queryKey, fetchPage, serializeFilters, defaultSorting, defaultPageSize, enabled } = options;
const [sorting, setSorting] = useState<SortingState>(defaultSorting);
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: defaultPageSize });
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [searchValue, setSearchValue] = useState("");
const [debouncedSearch] = useDebouncedValue(searchValue, { wait: DEBOUNCE_WAIT_MS });
const query = useMemo<ResourceListQuery>(() => {
const sort = toSortParam(sorting);
const search = debouncedSearch.trim();
return {
page: pagination.pageIndex + 1,
page_size: pagination.pageSize,
...(sort === "" ? {} : { sort }),
...(search === "" ? {} : { q: search }),
...serializeFilters(columnFilters),
};
}, [sorting, pagination.pageIndex, pagination.pageSize, debouncedSearch, columnFilters, serializeFilters]);
const queryOptions: UseQueryOptions<ResourceListPage<TRow>, Error, ResourceListPage<TRow>, readonly unknown[]> = {
queryKey: [...queryKey, query],
queryFn: ({ signal }) => fetchPage(query, signal),
enabled,
placeholderData: (previous) => previous,
};
const { data, isLoading, isFetching, error, refetch: refetchQuery } = useQuery(queryOptions);
const toFirstPage = useCallback(() => setPagination((previous) => ({ ...previous, pageIndex: 0 })), []);
const onSortingChange = useCallback<OnChangeFn<SortingState>>(
(updater) => {
setSorting(updater);
toFirstPage();
},
[toFirstPage],
);
const onColumnFiltersChange = useCallback<OnChangeFn<ColumnFiltersState>>(
(updater) => {
setColumnFilters(updater);
toFirstPage();
},
[toFirstPage],
);
const onSearchChange = useCallback(
(value: string) => {
setSearchValue(value);
toFirstPage();
},
[toFirstPage],
);
const refetch = useCallback(() => {
void refetchQuery();
}, [refetchQuery]);
const rows = useMemo(() => data?.data ?? [], [data]);
return {
rows,
rowCount: data?.meta.total_count ?? 0,
isLoading,
isFetching,
error,
refetch,
sorting,
onSortingChange,
pagination,
onPaginationChange: setPagination,
columnFilters,
onColumnFiltersChange,
searchValue,
onSearchChange,
};
}