mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
fix(ui): right-align money and count columns across tables (#37889)
* fix(ui): right-align money and count columns across tables Make numeric the one alignment token for the three shared table wrappers (DataTable, MemberTable, SimpleTable) via NUMERIC_CELL_CLASS, and flag every money, cost, and bare-count column that was still left-aligned. Raw ui/table usages that render spend or budgets get the same class on their header and cell. * test(ui): render the organizations alignment test with providers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(ui): query alignment cells by role instead of DOM traversal Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
7204942756
commit
0736a14326
32 changed files with 217 additions and 39 deletions
|
|
@ -33,6 +33,12 @@ describe("AgentsTable", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("right-aligns the Spend (USD) column", () => {
|
||||
render(<AgentsTable agents={[]} {...baseProps} />);
|
||||
expect(screen.getByRole("columnheader", { name: "Spend (USD)" })).toHaveClass("text-right");
|
||||
expect(screen.getByRole("columnheader", { name: "Agent Name" })).not.toHaveClass("text-right");
|
||||
});
|
||||
|
||||
it("renders the agent's model and opens the detail view when the ID cell is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAgentClick = vi.fn();
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export const getAgentsTableColumns = ({
|
|||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
meta: { title: "Spend (USD)" },
|
||||
meta: { title: "Spend (USD)", numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Spend (USD)" />,
|
||||
size: 130,
|
||||
enableSorting: true,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ describe("ProviderDiscountTable", () => {
|
|||
expect(screen.getByRole("columnheader", { name: "Provider" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Discount Percentage" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Actions" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Discount Percentage" })).toHaveClass("text-right");
|
||||
expect(screen.getByRole("columnheader", { name: "Provider" })).not.toHaveClass("text-right");
|
||||
});
|
||||
|
||||
it("should display provider display names in the table", () => {
|
||||
|
|
|
|||
|
|
@ -80,10 +80,11 @@ const ProviderDiscountTable: React.FC<ProviderDiscountTableProps> = ({
|
|||
},
|
||||
{
|
||||
header: "Discount Percentage",
|
||||
numeric: true,
|
||||
cell: (row) => {
|
||||
const { displayName } = getProviderLogoAndName(row.provider);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{editingProvider === row.provider ? (
|
||||
<>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ describe("ProviderMarginTable", () => {
|
|||
expect(screen.getByRole("columnheader", { name: "Provider" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Margin" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Actions" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Margin" })).toHaveClass("text-right");
|
||||
expect(screen.getByRole("columnheader", { name: "Provider" })).not.toHaveClass("text-right");
|
||||
});
|
||||
|
||||
it("should display the provider display name", () => {
|
||||
|
|
|
|||
|
|
@ -123,10 +123,11 @@ const ProviderMarginTable: React.FC<ProviderMarginTableProps> = ({
|
|||
},
|
||||
{
|
||||
header: "Margin",
|
||||
numeric: true,
|
||||
cell: (row) => {
|
||||
const displayName = marginRowDisplayName(row.provider);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{editingProvider === row.provider ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -174,6 +174,8 @@ describe("AllModelsTable", () => {
|
|||
const { rerender } = render(<AllModelsTable {...baseProps} />);
|
||||
expect(screen.getByText("$30")).toBeInTheDocument();
|
||||
expect(screen.getByText("$60")).toBeInTheDocument();
|
||||
expect(screen.getByRole("cell", { name: /\$30/ })).toHaveClass("text-right");
|
||||
expect(screen.getByRole("columnheader", { name: /costs/i })).toHaveClass("text-right");
|
||||
|
||||
rerender(<AllModelsTable {...baseProps} data={[makeModel({ input_cost: null, output_cost: null })]} />);
|
||||
expect(screen.queryByText(/^\$/)).not.toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -437,7 +437,7 @@ export const getModelsTableColumns = ({
|
|||
{
|
||||
id: COSTS_COLUMN_ID,
|
||||
accessorFn: (row) => row.input_cost,
|
||||
meta: { title: "Costs" },
|
||||
meta: { title: "Costs", numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Costs" />,
|
||||
enableSorting: true,
|
||||
size: 130,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,15 @@ import {
|
|||
} from "@/components/ui/combobox";
|
||||
import { Meter, MeterIndicator, MeterTrack } from "@/components/shared/Meter";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
NUMERIC_CELL_CLASS,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { AreaChart, BarChart, DonutChart } from "@/components/shared/charts";
|
||||
|
||||
|
|
@ -651,14 +659,14 @@ const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, use
|
|||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Provider</TableHead>
|
||||
<TableHead>Spend</TableHead>
|
||||
<TableHead className={NUMERIC_CELL_CLASS}>Spend</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{spendByProvider.map((provider) => (
|
||||
<TableRow key={provider.provider}>
|
||||
<TableCell>{provider.provider}</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className={NUMERIC_CELL_CLASS}>
|
||||
<MoneyCell value={provider.spend} decimals={2} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -840,8 +848,8 @@ const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, use
|
|||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead>Spend</TableHead>
|
||||
<TableHead>Total Events</TableHead>
|
||||
<TableHead className={NUMERIC_CELL_CLASS}>Spend</TableHead>
|
||||
<TableHead className={NUMERIC_CELL_CLASS}>Total Events</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
|
|
@ -849,10 +857,10 @@ const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, use
|
|||
{topUsers?.map((user: any, index: number) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{user.end_user}</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className={NUMERIC_CELL_CLASS}>
|
||||
<MoneyCell value={user.total_spend} decimals={2} />
|
||||
</TableCell>
|
||||
<TableCell>{user.total_count}</TableCell>
|
||||
<TableCell className={NUMERIC_CELL_CLASS}>{user.total_count}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
|
|
|||
|
|
@ -82,6 +82,16 @@ describe("OrganizationsTable", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("right-aligns the money and count columns only", () => {
|
||||
renderWithProviders(<OrganizationsTable {...baseProps} organizations={[]} />);
|
||||
for (const header of ["Spend (USD)", "Budget (USD)", "Members"]) {
|
||||
expect(screen.getByRole("columnheader", { name: header })).toHaveClass("text-right");
|
||||
}
|
||||
for (const header of ["Organization Name", "TPM / RPM Limits"]) {
|
||||
expect(screen.getByRole("columnheader", { name: header })).not.toHaveClass("text-right");
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the detail view when the organization ID cell is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOrganizationClick = vi.fn();
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ export const getOrganizationsTableColumns = ({
|
|||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
meta: { title: "Spend (USD)" },
|
||||
meta: { title: "Spend (USD)", numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Spend (USD)" />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
|
|
@ -137,7 +137,7 @@ export const getOrganizationsTableColumns = ({
|
|||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
meta: { title: "Budget (USD)" },
|
||||
meta: { title: "Budget (USD)", numeric: true },
|
||||
header: "Budget (USD)",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
|
|
@ -163,7 +163,7 @@ export const getOrganizationsTableColumns = ({
|
|||
},
|
||||
{
|
||||
id: "members",
|
||||
meta: { title: "Members" },
|
||||
meta: { title: "Members", numeric: true },
|
||||
header: "Members",
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,16 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
NUMERIC_CELL_CLASS,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
interface BulkEditUserModalProps {
|
||||
|
|
@ -250,7 +259,7 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
|
|||
<TableHead className="w-[30%]">User ID</TableHead>
|
||||
<TableHead className="w-[25%]">Email</TableHead>
|
||||
<TableHead className="w-[25%]">Current Role</TableHead>
|
||||
<TableHead className="w-[20%]">Budget</TableHead>
|
||||
<TableHead className={cn("w-[20%]", NUMERIC_CELL_CLASS)}>Budget</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -263,7 +272,7 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
|
|||
<TableCell className="text-xs text-foreground">
|
||||
{possibleUIRoles?.[user.user_role]?.ui_label || user.user_role}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className={NUMERIC_CELL_CLASS}>
|
||||
<MoneyCell value={user.max_budget} decimals={2} emptyText="Unlimited" showZero />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ describe("getModelHubTableColumns", () => {
|
|||
expect(screen.getByText("128.0K / 16.4K")).toBeInTheDocument();
|
||||
expect(screen.getByText("$2.50")).toBeInTheDocument();
|
||||
expect(screen.getByText("$10.00")).toBeInTheDocument();
|
||||
expect(screen.getByRole("cell", { name: "128.0K / 16.4K" })).toHaveClass("text-right");
|
||||
expect(screen.getByRole("cell", { name: /\$2\.50/ })).toHaveClass("text-right");
|
||||
expect(screen.getByRole("cell", { name: "gpt-4o" })).not.toHaveClass("text-right");
|
||||
});
|
||||
|
||||
it("shows capability badges only for supported features", () => {
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export const getModelHubTableColumns = ({ onModelClick }: ModelHubTableColumnsDe
|
|||
{
|
||||
id: "max_input_tokens",
|
||||
accessorKey: "max_input_tokens",
|
||||
meta: { title: "Tokens", className: "hidden lg:table-cell" },
|
||||
meta: { title: "Tokens", className: "hidden lg:table-cell", numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Tokens" />,
|
||||
size: 110,
|
||||
enableSorting: true,
|
||||
|
|
@ -165,7 +165,7 @@ export const getModelHubTableColumns = ({ onModelClick }: ModelHubTableColumnsDe
|
|||
{
|
||||
id: "input_cost_per_token",
|
||||
accessorKey: "input_cost_per_token",
|
||||
meta: { title: "Cost/1M", skeleton: "twoLine" },
|
||||
meta: { title: "Cost/1M", skeleton: "twoLine", numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Cost/1M" />,
|
||||
size: 110,
|
||||
enableSorting: true,
|
||||
|
|
|
|||
|
|
@ -161,6 +161,12 @@ describe("sort contract – only backend-sortable columns are sortable", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("right-aligns Spend / Budget but not Team", () => {
|
||||
renderTable();
|
||||
expect(screen.getByRole("columnheader", { name: "Spend / Budget" })).toHaveClass("text-right");
|
||||
expect(screen.getByRole("columnheader", { name: "Team" })).not.toHaveClass("text-right");
|
||||
});
|
||||
|
||||
it("does not make Spend / Budget sortable (the backend rejects sort_by=spend)", () => {
|
||||
renderTable();
|
||||
expect(screen.queryByText("Spend / Budget").closest("button")).toBeNull();
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ export const getTeamTableColumns = ({
|
|||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
meta: { title: "Spend / Budget", skeleton: "meter" },
|
||||
meta: { title: "Spend / Budget", skeleton: "meter", numeric: true },
|
||||
header: "Spend / Budget",
|
||||
size: 200,
|
||||
enableSorting: false,
|
||||
|
|
@ -234,7 +234,7 @@ export const getTeamTableColumns = ({
|
|||
},
|
||||
{
|
||||
id: "members",
|
||||
meta: { title: "Members" },
|
||||
meta: { title: "Members", numeric: true },
|
||||
header: "Members",
|
||||
size: 110,
|
||||
enableSorting: false,
|
||||
|
|
@ -242,7 +242,7 @@ export const getTeamTableColumns = ({
|
|||
},
|
||||
{
|
||||
id: "models",
|
||||
meta: { title: "Models" },
|
||||
meta: { title: "Models", numeric: true },
|
||||
header: "Models",
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
|
|
|
|||
|
|
@ -207,6 +207,12 @@ it("should render VirtualKeysTable component", () => {
|
|||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("right-aligns the Spend / Budget column", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
expect(await screen.findByRole("columnheader", { name: /^Spend/ })).toHaveClass("text-right");
|
||||
expect(screen.getByRole("columnheader", { name: /^Key$/ })).not.toHaveClass("text-right");
|
||||
});
|
||||
|
||||
it("shows the Budget Reset column by default", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
await waitFor(() => {
|
||||
|
|
|
|||
|
|
@ -267,7 +267,7 @@ export const getKeyTableColumns = ({
|
|||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
meta: { title: "Spend / Budget", skeleton: "meter" },
|
||||
meta: { title: "Spend / Budget", skeleton: "meter", numeric: true },
|
||||
header: ({ table }) => <DataTableMultiSortHeader table={table} fields={SPEND_BUDGET_SORT_FIELDS} />,
|
||||
size: 180,
|
||||
enableSorting: true,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
NUMERIC_CELL_CLASS,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Download, FileText, FileWarning, Trash2, TriangleAlert, Upload } from "lucide-react";
|
||||
import { userCreateCall, invitationCreateCall, getProxyUISettings } from "./networking";
|
||||
import Papa from "papaparse";
|
||||
|
|
@ -798,7 +806,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
|
|||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Teams</TableHead>
|
||||
<TableHead>Budget</TableHead>
|
||||
<TableHead className={NUMERIC_CELL_CLASS}>Budget</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -809,7 +817,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
|
|||
<TableCell className="whitespace-normal break-words">{record.user_email}</TableCell>
|
||||
<TableCell className="whitespace-normal break-words">{record.user_role}</TableCell>
|
||||
<TableCell className="whitespace-normal break-words">{record.teams}</TableCell>
|
||||
<TableCell>{record.max_budget}</TableCell>
|
||||
<TableCell className={NUMERIC_CELL_CLASS}>{record.max_budget}</TableCell>
|
||||
<TableCell className="whitespace-normal break-words">{renderStatusCell(record)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,16 @@ import { Label } from "@/components/ui/label";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
NUMERIC_CELL_CLASS,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { keyListCall, regenerateKeyCall } from "../networking";
|
||||
import { KeyResponse } from "../key_team_helpers/key_list";
|
||||
|
|
@ -176,7 +185,9 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
|
|||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="text-xs font-semibold uppercase tracking-wide">Key</TableHead>
|
||||
<TableHead className="text-xs font-semibold uppercase tracking-wide">Spend</TableHead>
|
||||
<TableHead className={cn("text-xs font-semibold uppercase tracking-wide", NUMERIC_CELL_CLASS)}>
|
||||
Spend
|
||||
</TableHead>
|
||||
<TableHead className="text-xs font-semibold uppercase tracking-wide">Expires</TableHead>
|
||||
<TableHead className="text-xs font-semibold uppercase tracking-wide">Created</TableHead>
|
||||
{premiumUser && (
|
||||
|
|
@ -220,7 +231,9 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
|
|||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="text-xs font-semibold uppercase tracking-wide">Key</TableHead>
|
||||
<TableHead className="text-xs font-semibold uppercase tracking-wide">Spend</TableHead>
|
||||
<TableHead className={cn("text-xs font-semibold uppercase tracking-wide", NUMERIC_CELL_CLASS)}>
|
||||
Spend
|
||||
</TableHead>
|
||||
<TableHead className="text-xs font-semibold uppercase tracking-wide">Expires</TableHead>
|
||||
<TableHead className="text-xs font-semibold uppercase tracking-wide">Created</TableHead>
|
||||
{premiumUser && (
|
||||
|
|
@ -237,7 +250,7 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
|
|||
<span className="font-mono text-[13px]">{maskKey(record.key_name)}</span>
|
||||
{record.key_alias && <div className="text-xs text-muted-foreground">{record.key_alias}</div>}
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px]">
|
||||
<TableCell className={cn("text-[13px]", NUMERIC_CELL_CLASS)}>
|
||||
${record.spend?.toFixed(2) ?? "0.00"}
|
||||
{record.max_budget != null && record.max_budget > 0 && (
|
||||
<span className="text-muted-foreground"> / ${record.max_budget.toFixed(2)}</span>
|
||||
|
|
|
|||
|
|
@ -213,3 +213,20 @@ describe("MemberTable actions", () => {
|
|||
expect(screen.getByText("No members found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemberTable numeric columns", () => {
|
||||
it("right-aligns the header and cells of a numeric extra column only", () => {
|
||||
renderTable({
|
||||
members: [MEMBERS[0]],
|
||||
extraColumns: [
|
||||
{ title: "Spend (USD)", key: "spend", numeric: true, render: () => <span>$1.50</span> },
|
||||
{ title: "Joined", key: "joined", render: () => <span>Aug 1</span> },
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.getByRole("columnheader", { name: "Spend (USD)" })).toHaveClass("text-right", "tabular-nums");
|
||||
expect(screen.getByRole("cell", { name: "$1.50" })).toHaveClass("text-right", "tabular-nums");
|
||||
expect(screen.getByRole("columnheader", { name: "Joined" })).not.toHaveClass("text-right");
|
||||
expect(screen.getByRole("cell", { name: "Aug 1" })).not.toHaveClass("text-right");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export interface MemberTableColumn {
|
|||
key: string;
|
||||
render: (member: Member) => React.ReactNode;
|
||||
sortValue?: (member: Member) => MemberTableSortValue;
|
||||
numeric?: boolean;
|
||||
}
|
||||
|
||||
export interface MemberTableProps {
|
||||
|
|
@ -87,6 +88,7 @@ const extraColumnDef = (column: MemberTableColumn): ColumnDef<Member> => {
|
|||
header: () => <span className="font-medium">{column.title}</span>,
|
||||
enableSorting: false,
|
||||
enableGlobalFilter: false,
|
||||
meta: { numeric: column.numeric },
|
||||
cell: ({ row }) => column.render(row.original),
|
||||
};
|
||||
}
|
||||
|
|
@ -97,6 +99,7 @@ const extraColumnDef = (column: MemberTableColumn): ColumnDef<Member> => {
|
|||
sortDescFirst: false,
|
||||
sortUndefined: "last",
|
||||
enableGlobalFilter: false,
|
||||
meta: { numeric: column.numeric },
|
||||
cell: ({ row }) => column.render(row.original),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SimpleTable, type SimpleTableColumn } from "./simple_table";
|
||||
|
||||
interface Row {
|
||||
name: string;
|
||||
spend: number;
|
||||
}
|
||||
|
||||
const columns: SimpleTableColumn<Row>[] = [
|
||||
{ header: "Name", accessor: "name" },
|
||||
{ header: "Spend", accessor: "spend", numeric: true },
|
||||
];
|
||||
|
||||
describe("SimpleTable numeric columns", () => {
|
||||
it("right-aligns the header and cells of a numeric column only", () => {
|
||||
render(<SimpleTable data={[{ name: "Alice", spend: 42 }]} columns={columns} />);
|
||||
|
||||
expect(screen.getByRole("columnheader", { name: "Spend" })).toHaveClass("text-right", "tabular-nums");
|
||||
expect(screen.getByRole("cell", { name: "42" })).toHaveClass("text-right", "tabular-nums");
|
||||
expect(screen.getByRole("columnheader", { name: "Name" })).not.toHaveClass("text-right");
|
||||
expect(screen.getByRole("cell", { name: "Alice" })).not.toHaveClass("text-right");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +1,20 @@
|
|||
import React from "react";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||
import {
|
||||
NUMERIC_CELL_CLASS,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export interface SimpleTableColumn<T> {
|
||||
header: string;
|
||||
accessor?: keyof T;
|
||||
cell?: (row: T) => React.ReactNode;
|
||||
width?: string;
|
||||
numeric?: boolean;
|
||||
}
|
||||
|
||||
interface SimpleTableProps<T> {
|
||||
|
|
@ -34,7 +43,11 @@ export function SimpleTable<T>({
|
|||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column, index) => (
|
||||
<TableHead key={index} style={{ width: column.width }}>
|
||||
<TableHead
|
||||
key={index}
|
||||
style={{ width: column.width }}
|
||||
className={column.numeric ? NUMERIC_CELL_CLASS : undefined}
|
||||
>
|
||||
{column.header}
|
||||
</TableHead>
|
||||
))}
|
||||
|
|
@ -51,7 +64,7 @@ export function SimpleTable<T>({
|
|||
data.map((row, rowIndex) => (
|
||||
<TableRow key={getRowKey ? getRowKey(row, rowIndex) : rowIndex}>
|
||||
{columns.map((column, colIndex) => (
|
||||
<TableCell key={colIndex}>
|
||||
<TableCell key={colIndex} className={column.numeric ? NUMERIC_CELL_CLASS : undefined}>
|
||||
{column.cell ? column.cell(row) : String(row[column.accessor as keyof T] ?? "")}
|
||||
</TableCell>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
{
|
||||
title: "Spend (USD)",
|
||||
key: "spend",
|
||||
numeric: true,
|
||||
sortValue: (record: Member) => orgMemberFor(record)?.spend ?? null,
|
||||
render: (record: Member) => <MoneyCell value={orgMemberFor(record)?.spend} decimals={4} />,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -141,8 +141,33 @@ const expansionColumns: ColumnDef<Person, unknown>[] = [
|
|||
},
|
||||
];
|
||||
|
||||
const numericColumns: ColumnDef<Person, unknown>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
|
||||
},
|
||||
{
|
||||
id: "spend",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Spend" />,
|
||||
meta: { numeric: true },
|
||||
cell: () => <span>$1.50</span>,
|
||||
},
|
||||
];
|
||||
|
||||
const CHARLIE_ALICE_BOB: Person[] = [person("c", "Charlie"), person("a", "Alice"), person("b", "Bob")];
|
||||
|
||||
describe("DataTable numeric columns", () => {
|
||||
it("right-aligns the header and cells of a numeric column only", () => {
|
||||
render(<DataTable data={[person("a", "Alice")]} columns={numericColumns} sortingMode="client" />);
|
||||
|
||||
expect(screen.getByRole("columnheader", { name: "Spend" })).toHaveClass("text-right", "tabular-nums");
|
||||
expect(screen.getByRole("cell", { name: "$1.50" })).toHaveClass("text-right", "tabular-nums");
|
||||
expect(screen.getByRole("columnheader", { name: "Name" })).not.toHaveClass("text-right");
|
||||
expect(screen.getByRole("cell", { name: "Alice" })).not.toHaveClass("text-right");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataTable sorting", () => {
|
||||
it("client mode reorders rows when the sort header is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { Fragment, useEffect, useState } from "react";
|
|||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
NUMERIC_CELL_CLASS,
|
||||
Table as TableRoot,
|
||||
TableBody,
|
||||
TableCell,
|
||||
|
|
@ -193,7 +194,7 @@ function DataTableHeadCell<TData>({ header, size, stickyHeader, enableColumnResi
|
|||
className={cn(
|
||||
"relative text-muted-foreground",
|
||||
size === "compact" ? "h-8 px-2 py-1 text-xs" : "",
|
||||
meta?.numeric ? "text-right" : "",
|
||||
meta?.numeric ? NUMERIC_CELL_CLASS : "",
|
||||
meta?.className,
|
||||
meta?.headerClassName,
|
||||
sticky.className,
|
||||
|
|
@ -238,7 +239,7 @@ function DataTableBodyCell<TData>({ cell, size, stickyHeader, enableColumnResizi
|
|||
className={cn(
|
||||
"overflow-hidden text-ellipsis",
|
||||
size === "compact" ? "px-2 py-1 text-xs" : "",
|
||||
meta?.numeric ? "text-right tabular-nums" : "",
|
||||
meta?.numeric ? NUMERIC_CELL_CLASS : "",
|
||||
meta?.className,
|
||||
sticky.className,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -292,6 +292,9 @@ describe("TeamMembersComponent", () => {
|
|||
|
||||
expect(screen.getByText("$100.50")).toBeInTheDocument();
|
||||
expect(screen.getByText("$1,538.26")).toBeInTheDocument();
|
||||
expect(screen.getByRole("cell", { name: "$100.50" })).toHaveClass("text-right");
|
||||
expect(screen.getByRole("columnheader", { name: /^Team Member Budget \(USD\)/ })).toHaveClass("text-right");
|
||||
expect(screen.getByRole("columnheader", { name: "User Email" })).not.toHaveClass("text-right");
|
||||
expect(screen.getByText(/100 RPM/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/10000 TPM/)).toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -186,6 +186,7 @@ export default function TeamMemberTab({
|
|||
</span>
|
||||
),
|
||||
key: "spend",
|
||||
numeric: true,
|
||||
sortValue: (record: Member) => getUserCurrentCycleSpend(record.user_id),
|
||||
render: (record: Member) => <MoneyCell value={getUserCurrentCycleSpend(record.user_id)} decimals={2} />,
|
||||
},
|
||||
|
|
@ -199,6 +200,7 @@ export default function TeamMemberTab({
|
|||
</span>
|
||||
),
|
||||
key: "total_spend",
|
||||
numeric: true,
|
||||
sortValue: (record: Member) => getUserTotalSpend(record.user_id),
|
||||
render: (record: Member) => <MoneyCell value={getUserTotalSpend(record.user_id)} decimals={2} />,
|
||||
},
|
||||
|
|
@ -212,11 +214,12 @@ export default function TeamMemberTab({
|
|||
</span>
|
||||
),
|
||||
key: "budget",
|
||||
numeric: true,
|
||||
sortValue: (record: Member) => getUserBudget(record.user_id),
|
||||
render: (record: Member) => {
|
||||
const source = getUserBudgetSource(record.user_id);
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex items-center justify-end gap-2">
|
||||
<MoneyCell value={getUserBudget(record.user_id)} decimals={2} emptyText="Unlimited" showZero />
|
||||
{source !== "none" && (
|
||||
<Badge variant={source === "custom" ? "outline" : "secondary"} data-testid="member-budget-source">
|
||||
|
|
|
|||
|
|
@ -131,6 +131,14 @@ describe("TeamVirtualKeysTable", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("right-aligns the Spend (USD) and Budget (USD) columns", async () => {
|
||||
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByRole("columnheader", { name: "Spend (USD)" })).toHaveClass("text-right");
|
||||
expect(screen.getByRole("columnheader", { name: "Budget (USD)" })).toHaveClass("text-right");
|
||||
expect(screen.getByRole("columnheader", { name: "Key ID" })).not.toHaveClass("text-right");
|
||||
});
|
||||
|
||||
it("should display keys in table when data is loaded", async () => {
|
||||
mockUseKeys.mockReturnValue({
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
meta: { title: "Spend (USD)" },
|
||||
meta: { title: "Spend (USD)", numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Spend (USD)" variant="header-cycle" />,
|
||||
size: 100,
|
||||
enableSorting: true,
|
||||
|
|
@ -294,7 +294,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
{
|
||||
id: "max_budget",
|
||||
accessorKey: "max_budget",
|
||||
meta: { title: "Budget (USD)" },
|
||||
meta: { title: "Budget (USD)", numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Budget (USD)" variant="header-cycle" />,
|
||||
size: 110,
|
||||
enableSorting: true,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import * as React from "react";
|
|||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
const NUMERIC_CELL_CLASS = "text-right tabular-nums";
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.ComponentPropsWithoutRef<"table">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div data-slot="table-container" className="relative w-full overflow-x-auto">
|
||||
|
|
@ -96,4 +98,4 @@ const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.ComponentPr
|
|||
);
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||
export { NUMERIC_CELL_CLASS, Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue