mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
1018d18e6b
commit
1d40f2a707
11 changed files with 1144 additions and 278 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue