mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
refactor(ui): migrate usage tables to shared DataTable (#36707)
* test(ui): decouple usage table test from antd * refactor(ui): migrate usage tables to shared DataTable * test(ui): preserve data utility exports in usage tests
This commit is contained in:
parent
09889e1986
commit
bfa34e2b10
13 changed files with 298 additions and 712 deletions
|
|
@ -1660,7 +1660,7 @@
|
|||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx": {
|
||||
|
|
@ -2270,11 +2270,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/UsagePage/components/KeyModelUsageView.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/UsagePage/utils/value_formatters.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
@ -3928,14 +3923,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/table.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/view_model/model_name_display.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -1,36 +1,7 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders, screen } from "@/../tests/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import EndpointUsageTable from "./EndpointUsageTable";
|
||||
|
||||
vi.mock("antd", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
function Table({ columns, dataSource }: any) {
|
||||
return React.createElement(
|
||||
"div",
|
||||
{ "data-testid": "antd-table" },
|
||||
columns?.map((col: any) =>
|
||||
React.createElement("div", { key: col.key, "data-testid": `column-${col.key}` }, col.title),
|
||||
),
|
||||
dataSource?.map((row: any) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
{ key: row.key, "data-testid": `row-${row.key}` },
|
||||
React.createElement("div", null, row.endpoint),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
(Table as any).displayName = "Table";
|
||||
|
||||
function Progress({ percent }: any) {
|
||||
return React.createElement("div", { "data-testid": "antd-progress", "data-percent": percent });
|
||||
}
|
||||
(Progress as any).displayName = "Progress";
|
||||
|
||||
return { Table, Progress };
|
||||
});
|
||||
|
||||
describe("EndpointUsageTable", () => {
|
||||
it("should render", () => {
|
||||
const mockEndpointData = {
|
||||
|
|
@ -51,14 +22,18 @@ describe("EndpointUsageTable", () => {
|
|||
},
|
||||
};
|
||||
|
||||
render(<EndpointUsageTable endpointData={mockEndpointData} />);
|
||||
renderWithProviders(<EndpointUsageTable endpointData={mockEndpointData} />);
|
||||
|
||||
expect(screen.getByTestId("antd-table")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("column-endpoint")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("column-requests")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("column-api_requests")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("column-successRate")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("column-total_tokens")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("column-spend")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("columnheader").map((header) => header.textContent)).toEqual([
|
||||
"Endpoint",
|
||||
"Successful / Failed",
|
||||
"Total Request",
|
||||
"Success Rate",
|
||||
"Total Tokens",
|
||||
"Spend",
|
||||
]);
|
||||
expect(screen.getByText("endpoint-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("95.00%")).toBeInTheDocument();
|
||||
expect(screen.getByText("$100.50")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from "react";
|
||||
import { Table, Progress } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { Progress } from "antd";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { MetricWithMetadata } from "@/components/UsagePage/types";
|
||||
|
||||
|
|
@ -36,17 +37,17 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData })
|
|||
successRate: calculateSuccessRate(data.metrics.successful_requests, data.metrics.api_requests),
|
||||
}));
|
||||
|
||||
const columns: ColumnsType<EndpointRow> = [
|
||||
const columns: ColumnDef<EndpointRow>[] = [
|
||||
{
|
||||
title: "Endpoint",
|
||||
dataIndex: "endpoint",
|
||||
key: "endpoint",
|
||||
render: (text: string) => <span className="font-medium">{text}</span>,
|
||||
header: "Endpoint",
|
||||
accessorKey: "endpoint",
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.endpoint}</span>,
|
||||
},
|
||||
{
|
||||
title: "Successful / Failed",
|
||||
key: "requests",
|
||||
render: (_: any, record: EndpointRow) => {
|
||||
header: "Successful / Failed",
|
||||
id: "requests",
|
||||
cell: ({ row }) => {
|
||||
const record = row.original;
|
||||
const successPercentage =
|
||||
record.api_requests > 0 ? (record.successful_requests / record.api_requests) * 100 : 0;
|
||||
const failurePercentage = record.api_requests > 0 ? (record.failed_requests / record.api_requests) * 100 : 0;
|
||||
|
|
@ -76,16 +77,17 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData })
|
|||
},
|
||||
},
|
||||
{
|
||||
title: "Total Request",
|
||||
dataIndex: "api_requests",
|
||||
key: "api_requests",
|
||||
render: (value: number) => value.toLocaleString(),
|
||||
header: "Total Request",
|
||||
accessorKey: "api_requests",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => row.original.api_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: "Success Rate",
|
||||
dataIndex: "successRate",
|
||||
key: "successRate",
|
||||
render: (value: number) => {
|
||||
header: "Success Rate",
|
||||
accessorKey: "successRate",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => {
|
||||
const value = row.original.successRate;
|
||||
const successRateStr = value.toFixed(2);
|
||||
return (
|
||||
<span
|
||||
|
|
@ -103,20 +105,28 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData })
|
|||
},
|
||||
},
|
||||
{
|
||||
title: "Total Tokens",
|
||||
dataIndex: "total_tokens",
|
||||
key: "total_tokens",
|
||||
render: (value: number) => value.toLocaleString(),
|
||||
header: "Total Tokens",
|
||||
accessorKey: "total_tokens",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => row.original.total_tokens.toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: "Spend",
|
||||
dataIndex: "spend",
|
||||
key: "spend",
|
||||
render: (value: number) => <MoneyCell value={value} decimals={2} />,
|
||||
header: "Spend",
|
||||
accessorKey: "spend",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={2} />,
|
||||
},
|
||||
];
|
||||
|
||||
return <Table columns={columns} dataSource={dataSource} pagination={false} />;
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={dataSource}
|
||||
getRowId={(row) => row.key}
|
||||
noDataMessage="No endpoint usage data"
|
||||
size="compact"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EndpointUsageTable;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import useTeams from "@/app/(dashboard)/hooks/useTeams";
|
||||
import { BarChart, DonutChart } from "@/components/shared/charts";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import {
|
||||
getProviderSpend,
|
||||
getTopAgents,
|
||||
getTopAPIKeys,
|
||||
getTopModels,
|
||||
type ExtendedDailyData,
|
||||
type ProviderSpendRow,
|
||||
} from "./entityUsageAggregations";
|
||||
import { buildCostBreakdownTiles, buildSummaryTiles, hasFlatCost, type SummaryTile } from "./entityUsageSummary";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
|
|
@ -20,12 +22,6 @@ import {
|
|||
Subtitle,
|
||||
Tab,
|
||||
TabGroup,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
|
|
@ -33,6 +29,7 @@ import {
|
|||
Title,
|
||||
} from "@tremor/react";
|
||||
import { DownOutlined, ExportOutlined, InfoCircleOutlined, LoadingOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Alert, Button, Tooltip } from "antd";
|
||||
import React, { type ReactNode, useMemo, useState } from "react";
|
||||
import TeamMultiSelect from "@/components/common_components/team_multi_select";
|
||||
|
|
@ -269,6 +266,80 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
|
||||
const capitalizedEntityLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1);
|
||||
const showFlatCost = entityType === "team" && hasFlatCost(spendData.metadata);
|
||||
const providerSpend = useMemo(() => getProviderSpend(spendData.results), [spendData.results]);
|
||||
const entityBreakdownColumns = useMemo<ColumnDef<EntityMetricWithMetadata>[]>(
|
||||
() => [
|
||||
{
|
||||
header: capitalizedEntityLabel,
|
||||
accessorKey: "metadata.alias",
|
||||
cell: ({ row }) => row.original.metadata.alias,
|
||||
},
|
||||
{
|
||||
header: "Spend",
|
||||
accessorKey: "metrics.spend",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <MoneyCell value={row.original.metrics.spend} decimals={4} />,
|
||||
},
|
||||
{
|
||||
header: "Successful",
|
||||
accessorKey: "metrics.successful_requests",
|
||||
meta: { numeric: true, className: "text-green-600" },
|
||||
cell: ({ row }) => row.original.metrics.successful_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Failed",
|
||||
accessorKey: "metrics.failed_requests",
|
||||
meta: { numeric: true, className: "text-red-600" },
|
||||
cell: ({ row }) => row.original.metrics.failed_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Tokens",
|
||||
accessorKey: "metrics.total_tokens",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => row.original.metrics.total_tokens.toLocaleString(),
|
||||
},
|
||||
],
|
||||
[capitalizedEntityLabel],
|
||||
);
|
||||
const providerSpendColumns = useMemo<ColumnDef<ProviderSpendRow>[]>(
|
||||
() => [
|
||||
{
|
||||
header: "Provider",
|
||||
accessorKey: "provider",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
{row.original.provider && <Logo provider={row.original.provider} className="size-4" />}
|
||||
<span>{row.original.provider}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Spend",
|
||||
accessorKey: "spend",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={2} />,
|
||||
},
|
||||
{
|
||||
header: "Successful",
|
||||
accessorKey: "successful_requests",
|
||||
meta: { numeric: true, className: "text-green-600" },
|
||||
cell: ({ row }) => row.original.successful_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Failed",
|
||||
accessorKey: "failed_requests",
|
||||
meta: { numeric: true, className: "text-red-600" },
|
||||
cell: ({ row }) => row.original.failed_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Tokens",
|
||||
accessorKey: "tokens",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => row.original.tokens.toLocaleString(),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const chev = "text-gray-400 text-xs";
|
||||
const expandIcon = showCostBreakdown ? <DownOutlined className={chev} /> : <RightOutlined className={chev} />;
|
||||
|
|
@ -430,38 +501,14 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
/>
|
||||
</Col>
|
||||
<Col numColSpan={1}>
|
||||
<div className="h-52 overflow-y-auto">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>{capitalizedEntityLabel}</TableHeaderCell>
|
||||
<TableHeaderCell>Spend</TableHeaderCell>
|
||||
<TableHeaderCell className="text-green-600">Successful</TableHeaderCell>
|
||||
<TableHeaderCell className="text-red-600">Failed</TableHeaderCell>
|
||||
<TableHeaderCell>Tokens</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{getEntityBreakdown()
|
||||
.filter((entity) => entity.metrics.spend > 0)
|
||||
.map((entity) => (
|
||||
<TableRow key={entity.metadata.id}>
|
||||
<TableCell>{entity.metadata.alias}</TableCell>
|
||||
<TableCell>
|
||||
<MoneyCell value={entity.metrics.spend} decimals={4} />
|
||||
</TableCell>
|
||||
<TableCell className="text-green-600">
|
||||
{entity.metrics.successful_requests.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-red-600">
|
||||
{entity.metrics.failed_requests.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>{entity.metrics.total_tokens.toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={entityBreakdownColumns}
|
||||
data={getEntityBreakdown().filter((entity) => entity.metrics.spend > 0)}
|
||||
getRowId={(row) => row.metadata.id}
|
||||
maxBodyHeight={208}
|
||||
noDataMessage={`No ${entityType} spend data`}
|
||||
size="compact"
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
</div>
|
||||
|
|
@ -519,7 +566,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
<Col numColSpan={1}>
|
||||
<DonutChart
|
||||
className="mt-4 h-40"
|
||||
data={getProviderSpend(spendData.results)}
|
||||
data={providerSpend}
|
||||
index="provider"
|
||||
category="spend"
|
||||
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
|
||||
|
|
@ -530,37 +577,13 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
/>
|
||||
</Col>
|
||||
<Col numColSpan={1}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Provider</TableHeaderCell>
|
||||
<TableHeaderCell>Spend</TableHeaderCell>
|
||||
<TableHeaderCell className="text-green-600">Successful</TableHeaderCell>
|
||||
<TableHeaderCell className="text-red-600">Failed</TableHeaderCell>
|
||||
<TableHeaderCell>Tokens</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{getProviderSpend(spendData.results).map((provider) => (
|
||||
<TableRow key={provider.provider}>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
{provider.provider && <Logo provider={provider.provider} className="w-4 h-4" />}
|
||||
<span>{provider.provider}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MoneyCell value={provider.spend} decimals={2} />
|
||||
</TableCell>
|
||||
<TableCell className="text-green-600">
|
||||
{provider.successful_requests.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-red-600">{provider.failed_requests.toLocaleString()}</TableCell>
|
||||
<TableCell>{provider.tokens.toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<DataTable
|
||||
columns={providerSpendColumns}
|
||||
data={providerSpend}
|
||||
getRowId={(row) => row.provider}
|
||||
noDataMessage="No provider usage data"
|
||||
size="compact"
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,10 @@
|
|||
import { DonutChart } from "@/components/shared/charts";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
Grid,
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Title,
|
||||
} from "@tremor/react";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Card, Col, Grid, Switch, Title } from "@tremor/react";
|
||||
import { Tooltip } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
|
||||
|
|
@ -35,6 +25,43 @@ interface SpendByProviderProps {
|
|||
providerSpend: ProviderSpendData[];
|
||||
}
|
||||
|
||||
const columns: ColumnDef<ProviderSpendData>[] = [
|
||||
{
|
||||
header: "Provider",
|
||||
accessorKey: "provider",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
{row.original.provider && <ProviderLogo provider={row.original.provider} className="size-4" />}
|
||||
<span>{row.original.provider}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Spend",
|
||||
accessorKey: "spend",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={2} />,
|
||||
},
|
||||
{
|
||||
header: "Successful",
|
||||
accessorKey: "successful_requests",
|
||||
meta: { numeric: true, className: "text-green-600" },
|
||||
cell: ({ row }) => row.original.successful_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Failed",
|
||||
accessorKey: "failed_requests",
|
||||
meta: { numeric: true, className: "text-red-600" },
|
||||
cell: ({ row }) => row.original.failed_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Tokens",
|
||||
accessorKey: "tokens",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => row.original.tokens.toLocaleString(),
|
||||
},
|
||||
];
|
||||
|
||||
const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChanging, providerSpend }) => {
|
||||
const [includeZeroSpend, setIncludeZeroSpend] = useState(false);
|
||||
const [includeUnknown, setIncludeUnknown] = useState(false);
|
||||
|
|
@ -94,35 +121,13 @@ const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChangi
|
|||
/>
|
||||
</Col>
|
||||
<Col numColSpan={1}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Provider</TableHeaderCell>
|
||||
<TableHeaderCell>Spend</TableHeaderCell>
|
||||
<TableHeaderCell className="text-green-600">Successful</TableHeaderCell>
|
||||
<TableHeaderCell className="text-red-600">Failed</TableHeaderCell>
|
||||
<TableHeaderCell>Tokens</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredProviderSpend.map((provider) => (
|
||||
<TableRow key={provider.provider}>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
{provider.provider && <ProviderLogo provider={provider.provider} className="w-4 h-4" />}
|
||||
<span>{provider.provider}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MoneyCell value={provider.spend} decimals={2} />
|
||||
</TableCell>
|
||||
<TableCell className="text-green-600">{provider.successful_requests.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-red-600">{provider.failed_requests.toLocaleString()}</TableCell>
|
||||
<TableCell>{provider.tokens.toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredProviderSpend}
|
||||
getRowId={(row) => row.provider}
|
||||
noDataMessage="No provider usage data"
|
||||
size="compact"
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { BarChart } from "@/components/shared/charts";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { Segmented } from "antd";
|
||||
import { useState } from "react";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { DataTable } from "@/components/view_logs/table";
|
||||
|
||||
type TopModel = {
|
||||
key: string;
|
||||
|
|
@ -100,9 +100,7 @@ export default function TopModelView({ topModels, topModelsLimit, setTopModelsLi
|
|||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-hidden max-h-[600px] overflow-y-auto">
|
||||
<DataTable columns={columns} data={processedTopModels} isLoading={false} />
|
||||
</div>
|
||||
<DataTable columns={columns} data={processedTopModels} isLoading={false} maxBodyHeight={600} size="compact" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,15 @@ export type ExtendedDailyData = DailyData & {
|
|||
|
||||
export type ModelBreakdownKey = "models" | "model_groups";
|
||||
|
||||
export interface ProviderSpendRow extends Record<string, unknown> {
|
||||
provider: string;
|
||||
spend: number;
|
||||
requests: number;
|
||||
successful_requests: number;
|
||||
failed_requests: number;
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
export const getTopModels = (
|
||||
results: ExtendedDailyData[],
|
||||
modelBreakdownKey: ModelBreakdownKey,
|
||||
|
|
@ -136,8 +145,8 @@ export const getTopAPIKeys = (results: ExtendedDailyData[], topKeysLimit: number
|
|||
.slice(0, topKeysLimit);
|
||||
};
|
||||
|
||||
export const getProviderSpend = (results: ExtendedDailyData[]) => {
|
||||
const providerSpend: { [key: string]: any } = {};
|
||||
export const getProviderSpend = (results: ExtendedDailyData[]): ProviderSpendRow[] => {
|
||||
const providerSpend: Record<string, ProviderSpendRow> = {};
|
||||
results.forEach((day) => {
|
||||
Object.entries(day.breakdown.providers || {}).forEach(([provider, metrics]) => {
|
||||
if (!providerSpend[provider]) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { BarChart } from "@/components/shared/charts";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { IdCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/outline";
|
||||
import { Segmented, Tooltip } from "antd";
|
||||
|
|
@ -8,7 +9,6 @@ import { formatNumberWithCommas } from "../../../../utils/dataUtils";
|
|||
import { transformKeyInfo } from "../../../key_team_helpers/transform_key_info";
|
||||
import { keyInfoV1Call } from "../../../networking";
|
||||
import KeyInfoView from "../../../templates/key_info_view";
|
||||
import { DataTable } from "../../../view_logs/table";
|
||||
import { TagUsage } from "../../types";
|
||||
|
||||
interface TopKeyViewProps {
|
||||
|
|
@ -232,9 +232,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-hidden max-h-[600px] overflow-y-auto">
|
||||
<DataTable columns={columns} data={topKeys} isLoading={false} />
|
||||
</div>
|
||||
<DataTable columns={columns} data={topKeys} isLoading={false} maxBodyHeight={600} size="compact" />
|
||||
)}
|
||||
|
||||
{isModalOpen && selectedKey && keyData && (
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { BarChart } from "@/components/shared/charts";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { Table } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import React, { useState } from "react";
|
||||
import { TopModelData } from "../types";
|
||||
|
||||
|
|
@ -12,39 +12,40 @@ interface KeyModelUsageViewProps {
|
|||
}
|
||||
|
||||
const VISIBLE_ROWS = 5;
|
||||
// antd Table with size="small" has a row height of ~39px
|
||||
const ANTD_SMALL_TABLE_ROW_HEIGHT = 39;
|
||||
const COMPACT_TABLE_HEADER_HEIGHT = 33;
|
||||
const COMPACT_TABLE_ROW_HEIGHT = 32;
|
||||
|
||||
const columns: ColumnsType<TopModelData> = [
|
||||
const columns: ColumnDef<TopModelData>[] = [
|
||||
{
|
||||
title: "Model",
|
||||
dataIndex: "model",
|
||||
key: "model",
|
||||
render: (value) => value || "-",
|
||||
header: "Model",
|
||||
accessorKey: "model",
|
||||
cell: ({ row }) => row.original.model || "-",
|
||||
},
|
||||
{
|
||||
title: "Spend (USD)",
|
||||
dataIndex: "spend",
|
||||
key: "spend",
|
||||
render: (value) => <MoneyCell value={value} decimals={2} />,
|
||||
header: "Spend (USD)",
|
||||
accessorKey: "spend",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={2} />,
|
||||
},
|
||||
{
|
||||
title: "Successful",
|
||||
dataIndex: "successful_requests",
|
||||
key: "successful_requests",
|
||||
render: (value) => <span className="text-green-600">{value?.toLocaleString() || 0}</span>,
|
||||
header: "Successful",
|
||||
accessorKey: "successful_requests",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => (
|
||||
<span className="text-green-600">{row.original.successful_requests?.toLocaleString() || 0}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Failed",
|
||||
dataIndex: "failed_requests",
|
||||
key: "failed_requests",
|
||||
render: (value) => <span className="text-red-600">{value?.toLocaleString() || 0}</span>,
|
||||
header: "Failed",
|
||||
accessorKey: "failed_requests",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <span className="text-red-600">{row.original.failed_requests?.toLocaleString() || 0}</span>,
|
||||
},
|
||||
{
|
||||
title: "Tokens",
|
||||
dataIndex: "tokens",
|
||||
key: "tokens",
|
||||
render: (value) => value?.toLocaleString() || 0,
|
||||
header: "Tokens",
|
||||
accessorKey: "tokens",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => row.original.tokens?.toLocaleString() || 0,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -93,13 +94,12 @@ const KeyModelUsageView: React.FC<KeyModelUsageViewProps> = ({ topModels }) => {
|
|||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
<DataTable
|
||||
columns={columns}
|
||||
dataSource={topModels}
|
||||
rowKey="model"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={topModels.length > VISIBLE_ROWS ? { y: VISIBLE_ROWS * ANTD_SMALL_TABLE_ROW_HEIGHT } : undefined}
|
||||
data={topModels}
|
||||
getRowId={(row) => row.model}
|
||||
maxBodyHeight={COMPACT_TABLE_HEADER_HEIGHT + VISIBLE_ROWS * COMPACT_TABLE_ROW_HEIGHT}
|
||||
size="compact"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -48,11 +48,14 @@ vi.mock("antd", () => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock("@/utils/dataUtils", () => ({
|
||||
formatNumberWithCommas: (value: number, decimals?: number) => {
|
||||
return value.toFixed(decimals || 0);
|
||||
},
|
||||
}));
|
||||
vi.mock("@/utils/dataUtils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/utils/dataUtils")>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
formatNumberWithCommas: (value: number, decimals?: number) => value.toFixed(decimals || 0),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/utils/teamUtils", () => ({
|
||||
resolveTeamAliasFromTeamID: (teamID: string, teams: any[]) => {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,8 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Title,
|
||||
Subtitle,
|
||||
Table,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableHeaderCell,
|
||||
TableBody,
|
||||
TableCell,
|
||||
Text,
|
||||
Button,
|
||||
Tab,
|
||||
TabGroup,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
} from "@tremor/react";
|
||||
import { Title, Subtitle, Text, Button, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { BarChart } from "@/components/shared/charts";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { perUserAnalyticsCall } from "./networking";
|
||||
|
||||
interface PerUserMetrics {
|
||||
|
|
@ -89,6 +75,48 @@ const PerUserUsage: React.FC<PerUserUsageProps> = ({ accessToken, selectedTags,
|
|||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<PerUserMetrics>[] = [
|
||||
{
|
||||
header: "User ID",
|
||||
accessorKey: "user_id",
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.user_id}</span>,
|
||||
},
|
||||
{
|
||||
header: "User Email",
|
||||
accessorKey: "user_email",
|
||||
cell: ({ row }) => row.original.user_email || "N/A",
|
||||
},
|
||||
{
|
||||
header: "User Agent",
|
||||
accessorKey: "user_agent",
|
||||
cell: ({ row }) => row.original.user_agent || "Unknown",
|
||||
},
|
||||
{
|
||||
header: "Success Generations",
|
||||
accessorKey: "successful_requests",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => formatAbbreviatedNumber(row.original.successful_requests),
|
||||
},
|
||||
{
|
||||
header: "Total Tokens",
|
||||
accessorKey: "total_tokens",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => formatAbbreviatedNumber(row.original.total_tokens),
|
||||
},
|
||||
{
|
||||
header: "Failed Requests",
|
||||
accessorKey: "failed_requests",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => formatAbbreviatedNumber(row.original.failed_requests),
|
||||
},
|
||||
{
|
||||
header: "Total Cost",
|
||||
accessorKey: "spend",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => `$${formatAbbreviatedNumber(row.original.spend, 4)}`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<Title>Per User Usage</Title>
|
||||
|
|
@ -103,46 +131,13 @@ const PerUserUsage: React.FC<PerUserUsageProps> = ({ accessToken, selectedTags,
|
|||
<TabPanels>
|
||||
{/* Tab 1: Existing User Details Table */}
|
||||
<TabPanel>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>User ID</TableHeaderCell>
|
||||
<TableHeaderCell>User Email</TableHeaderCell>
|
||||
<TableHeaderCell>User Agent</TableHeaderCell>
|
||||
<TableHeaderCell className="text-right">Success Generations</TableHeaderCell>
|
||||
<TableHeaderCell className="text-right">Total Tokens</TableHeaderCell>
|
||||
<TableHeaderCell className="text-right">Failed Requests</TableHeaderCell>
|
||||
<TableHeaderCell className="text-right">Total Cost</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{perUserData.results.slice(0, 10).map((item: PerUserMetrics, index: number) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Text className="font-medium">{item.user_id}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text>{item.user_email || "N/A"}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text>{item.user_agent || "Unknown"}</Text>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Text>{formatAbbreviatedNumber(item.successful_requests)}</Text>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Text>{formatAbbreviatedNumber(item.total_tokens)}</Text>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Text>{formatAbbreviatedNumber(item.failed_requests)}</Text>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Text>${formatAbbreviatedNumber(item.spend, 4)}</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={perUserData.results.slice(0, 10)}
|
||||
getRowId={(row) => row.user_id}
|
||||
noDataMessage="No per-user usage data"
|
||||
size="compact"
|
||||
/>
|
||||
|
||||
{perUserData.results.length > 10 && (
|
||||
<div className="mt-4 flex justify-between items-center">
|
||||
|
|
|
|||
|
|
@ -1,258 +0,0 @@
|
|||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DataTable } from "./table";
|
||||
|
||||
type Row = { request_id: string; a: string; b: string };
|
||||
|
||||
const data: Row[] = [{ request_id: "r1", a: "alpha", b: "beta" }];
|
||||
|
||||
const sizedColumns: ColumnDef<Row>[] = [
|
||||
{ header: "A", accessorKey: "a", size: 120 },
|
||||
{ header: "B", accessorKey: "b", size: 80 },
|
||||
];
|
||||
|
||||
const unsizedColumns: ColumnDef<Row>[] = [
|
||||
{ header: "A", accessorKey: "a" },
|
||||
{ header: "B", accessorKey: "b" },
|
||||
];
|
||||
|
||||
const expanderColumn: ColumnDef<Row> = {
|
||||
id: "expander",
|
||||
header: () => null,
|
||||
cell: ({ row }) =>
|
||||
row.getCanExpand() ? (
|
||||
<button
|
||||
onClick={row.getToggleExpandedHandler()}
|
||||
aria-label={`${row.getIsExpanded() ? "collapse" : "expand"} ${row.original.request_id}`}
|
||||
>
|
||||
{row.getIsExpanded() ? "collapse" : "expand"}
|
||||
</button>
|
||||
) : null,
|
||||
};
|
||||
|
||||
describe("DataTable column sizing", () => {
|
||||
it("min-widths the table to the column total and sizes every cell when columns declare sizes", () => {
|
||||
render(<DataTable data={data} columns={sizedColumns} />);
|
||||
|
||||
const table = screen.getByRole("table");
|
||||
expect(table.style.minWidth).toBe("200px");
|
||||
expect(table.style.width).toBe("");
|
||||
|
||||
const headers = screen.getAllByRole("columnheader");
|
||||
expect(headers.map((h) => h.style.width)).toEqual(["120px", "80px"]);
|
||||
|
||||
const cells = screen.getAllByRole("cell");
|
||||
expect(cells.map((c) => c.style.width)).toEqual(["120px", "80px"]);
|
||||
});
|
||||
|
||||
it("leaves cells unsized and keeps the fluid table when no column declares a size", () => {
|
||||
render(<DataTable data={data} columns={unsizedColumns} />);
|
||||
|
||||
const table = screen.getByRole("table");
|
||||
expect(table.style.width).toBe("");
|
||||
expect(table.style.minWidth).toBe("400px");
|
||||
|
||||
for (const cell of [...screen.getAllByRole("columnheader"), ...screen.getAllByRole("cell")]) {
|
||||
expect(cell.style.width).toBe("");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataTable states", () => {
|
||||
it("shows the loading message instead of rows while loading", () => {
|
||||
render(<DataTable data={data} columns={unsizedColumns} isLoading loadingMessage="Fetching things" />);
|
||||
|
||||
expect(screen.getByText("Fetching things")).toBeInTheDocument();
|
||||
expect(screen.queryByText("alpha")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the no-data message when there are no rows", () => {
|
||||
render(<DataTable data={[]} columns={unsizedColumns} noDataMessage="Nothing here" />);
|
||||
|
||||
expect(screen.getByText("Nothing here")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to generic loading and empty defaults", () => {
|
||||
const { rerender } = render(<DataTable data={data} columns={unsizedColumns} isLoading />);
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
|
||||
rerender(<DataTable data={[]} columns={unsizedColumns} />);
|
||||
expect(screen.getByText("No results")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("suppresses the primitive's row hover on loading, empty, and expansion placeholder rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<DataTable data={data} columns={unsizedColumns} isLoading />);
|
||||
expect(screen.getByText("Loading...").closest("tr")).toHaveClass("hover:bg-transparent");
|
||||
|
||||
rerender(<DataTable data={[]} columns={unsizedColumns} />);
|
||||
expect(screen.getByText("No results").closest("tr")).toHaveClass("hover:bg-transparent");
|
||||
|
||||
rerender(
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={[expanderColumn, ...unsizedColumns]}
|
||||
getRowCanExpand={() => true}
|
||||
renderSubComponent={({ row }) => <div>details for {row.original.request_id}</div>}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "expand r1" }));
|
||||
expect(screen.getByText("details for r1").closest("tr")).toHaveClass("hover:bg-transparent");
|
||||
expect(screen.getByText("alpha").closest("tr")).not.toHaveClass("hover:bg-transparent");
|
||||
});
|
||||
|
||||
it("renders row data through plain TanStack column defs, including custom cell renderers", () => {
|
||||
const columns: ColumnDef<Row>[] = [
|
||||
{ header: "A", accessorKey: "a" },
|
||||
{ header: "B", cell: ({ row }) => <span>custom:{row.original.b}</span> },
|
||||
];
|
||||
render(<DataTable data={data} columns={columns} />);
|
||||
|
||||
expect(screen.getByText("alpha")).toBeInTheDocument();
|
||||
expect(screen.getByText("custom:beta")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clips the table to the rounded wrapper so the header band cannot bleed past the corners", () => {
|
||||
const { container } = render(<DataTable data={data} columns={unsizedColumns} />);
|
||||
|
||||
const wrapper = container.firstElementChild;
|
||||
expect(wrapper).toHaveClass("rounded-lg", "overflow-hidden");
|
||||
});
|
||||
|
||||
it("right-aligns headers and cells with tabular figures for numeric meta columns", () => {
|
||||
const columns: ColumnDef<Row>[] = [
|
||||
{ header: "A", accessorKey: "a" },
|
||||
{ header: "B", accessorKey: "b", meta: { numeric: true } },
|
||||
];
|
||||
render(<DataTable data={data} columns={columns} />);
|
||||
|
||||
const headers = screen.getAllByRole("columnheader");
|
||||
expect(headers[1].querySelector("div")).toHaveClass("justify-end");
|
||||
expect(headers[0].querySelector("div")).not.toHaveClass("justify-end");
|
||||
|
||||
const cells = screen.getAllByRole("cell");
|
||||
expect(cells[1]).toHaveClass("text-right", "tabular-nums");
|
||||
expect(cells[0]).not.toHaveClass("text-right");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataTable row interaction", () => {
|
||||
it("fires onRowClick with the row's original data", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = vi.fn();
|
||||
render(<DataTable data={data} columns={unsizedColumns} onRowClick={onRowClick} />);
|
||||
|
||||
await user.click(screen.getByText("alpha"));
|
||||
|
||||
expect(onRowClick).toHaveBeenCalledExactlyOnceWith(data[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataTable expansion", () => {
|
||||
const rows: Row[] = [
|
||||
{ request_id: "r1", a: "alpha", b: "beta" },
|
||||
{ request_id: "r2", a: "gamma", b: "delta" },
|
||||
];
|
||||
|
||||
it("toggles the sub-component in a full-width cell (colspan path)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTable
|
||||
data={rows}
|
||||
columns={[expanderColumn, ...unsizedColumns]}
|
||||
getRowCanExpand={() => true}
|
||||
renderSubComponent={({ row }) => <div>details for {row.original.request_id}</div>}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("details for r1")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "expand r1" }));
|
||||
const details = screen.getByText("details for r1");
|
||||
expect(details).toBeInTheDocument();
|
||||
expect(screen.queryByText("details for r2")).not.toBeInTheDocument();
|
||||
|
||||
const detailCell = details.closest("td");
|
||||
expect(detailCell).toHaveAttribute("colspan", "3");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "collapse r1" }));
|
||||
expect(screen.queryByText("details for r1")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps expansion attached to the same row through data reorders when getRowId is injected", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<DataTable
|
||||
data={rows}
|
||||
columns={[expanderColumn, ...unsizedColumns]}
|
||||
getRowId={(row) => row.request_id}
|
||||
getRowCanExpand={() => true}
|
||||
renderSubComponent={({ row }) => <div>details for {row.original.request_id}</div>}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "expand r1" }));
|
||||
expect(screen.getByText("details for r1")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<DataTable
|
||||
data={[...rows].reverse()}
|
||||
columns={[expanderColumn, ...unsizedColumns]}
|
||||
getRowId={(row) => row.request_id}
|
||||
getRowCanExpand={() => true}
|
||||
renderSubComponent={({ row }) => <div>details for {row.original.request_id}</div>}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("details for r1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("details for r2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not expand rows when getRowCanExpand is missing even if a renderer is provided", () => {
|
||||
render(
|
||||
<DataTable
|
||||
data={rows}
|
||||
columns={[expanderColumn, ...unsizedColumns]}
|
||||
renderSubComponent={({ row }) => <div>details for {row.original.request_id}</div>}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "expand r1" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataTable sorting", () => {
|
||||
const rows: Row[] = [
|
||||
{ request_id: "r1", a: "bravo", b: "2" },
|
||||
{ request_id: "r2", a: "alpha", b: "1" },
|
||||
{ request_id: "r3", a: "charlie", b: "3" },
|
||||
];
|
||||
|
||||
const firstColumnValues = () =>
|
||||
screen
|
||||
.getAllByRole("row")
|
||||
.slice(1)
|
||||
.map((row) => within(row).getAllByRole("cell")[0].textContent);
|
||||
|
||||
it("leaves row order untouched when sorting is disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DataTable data={rows} columns={unsizedColumns} />);
|
||||
|
||||
await user.click(screen.getByText("A"));
|
||||
|
||||
expect(firstColumnValues()).toEqual(["bravo", "alpha", "charlie"]);
|
||||
});
|
||||
|
||||
it("sorts ascending then descending on header clicks when enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DataTable data={rows} columns={unsizedColumns} enableSorting />);
|
||||
|
||||
await user.click(screen.getByText("A"));
|
||||
expect(firstColumnValues()).toEqual(["alpha", "bravo", "charlie"]);
|
||||
|
||||
await user.click(screen.getByText("A"));
|
||||
expect(firstColumnValues()).toEqual(["charlie", "bravo", "alpha"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
import { Fragment, useState } from "react";
|
||||
import {
|
||||
ColumnDef,
|
||||
RowData,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
Row,
|
||||
useReactTable,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { Table, TableHeader, TableHead, TableBody, TableRow, TableCell } from "@/components/ui/table";
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- declaration merging requires the type parameters to match the upstream ColumnMeta signature exactly (TS2428)
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
numeric?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
data: TData[];
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
getRowId?: (row: TData, index: number) => string;
|
||||
onRowClick?: (row: TData) => void;
|
||||
/** Renders inside a single colspan cell */
|
||||
renderSubComponent?: (props: { row: Row<TData> }) => React.ReactElement;
|
||||
getRowCanExpand?: (row: Row<TData>) => boolean;
|
||||
isLoading?: boolean;
|
||||
loadingMessage?: string;
|
||||
noDataMessage?: string;
|
||||
/** Enable client-side column sorting (defaults to false to avoid conflicts with server-side sorting) */
|
||||
enableSorting?: boolean;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
data = [],
|
||||
columns,
|
||||
getRowId,
|
||||
onRowClick,
|
||||
renderSubComponent,
|
||||
getRowCanExpand,
|
||||
isLoading = false,
|
||||
loadingMessage = "Loading...",
|
||||
noDataMessage = "No results",
|
||||
enableSorting = false,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const supportsExpansion = !!renderSubComponent && !!getRowCanExpand;
|
||||
const hasExplicitColumnSizes = columns.some((column) => column.size !== undefined);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const table = useReactTable<TData>({
|
||||
data,
|
||||
columns,
|
||||
...(enableSorting && {
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
enableSortingRemoval: false,
|
||||
}),
|
||||
...(supportsExpansion && { getRowCanExpand }),
|
||||
...(getRowId && { getRowId }),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
...(enableSorting && { getSortedRowModel: getSortedRowModel() }),
|
||||
...(supportsExpansion && { getExpandedRowModel: getExpandedRowModel() }),
|
||||
});
|
||||
|
||||
const tableClassName = hasExplicitColumnSizes ? "table-fixed" : "table-fixed w-full box-border";
|
||||
const tableStyle = hasExplicitColumnSizes ? { minWidth: table.getCenterTotalSize() } : { minWidth: "400px" };
|
||||
|
||||
return (
|
||||
<div className="rounded-lg custom-border overflow-hidden w-full max-w-full box-border">
|
||||
<Table className={tableClassName} style={tableStyle}>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="bg-muted/50 hover:bg-muted/50">
|
||||
{headerGroup.headers.map((header) => {
|
||||
const canSort = enableSorting && header.column.getCanSort();
|
||||
const isSorted = header.column.getIsSorted();
|
||||
const numeric = header.column.columnDef.meta?.numeric;
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={`py-1 h-8 text-xs font-medium text-muted-foreground first:pl-4 last:pr-4 ${
|
||||
canSort ? "cursor-pointer select-none hover:bg-muted" : ""
|
||||
}`}
|
||||
style={hasExplicitColumnSizes ? { width: header.getSize() } : undefined}
|
||||
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<div className={`flex items-center gap-1 ${numeric ? "justify-end" : ""}`}>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{canSort && (
|
||||
<span className="text-muted-foreground">
|
||||
{isSorted === "asc" ? "↑" : isSorted === "desc" ? "↓" : "⇅"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>{loadingMessage}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : table.getRowModel().rows.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<Fragment key={row.id}>
|
||||
<TableRow
|
||||
className={`h-8 ${onRowClick ? "cursor-pointer" : ""}`}
|
||||
onClick={() => onRowClick?.(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap first:pl-4 last:pr-4 ${
|
||||
cell.column.columnDef.meta?.numeric ? "text-right tabular-nums" : ""
|
||||
}`}
|
||||
style={hasExplicitColumnSizes ? { width: cell.column.getSize() } : undefined}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
|
||||
{supportsExpansion && row.getIsExpanded() && renderSubComponent && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={row.getVisibleCells().length} className="p-0">
|
||||
<div className="w-full max-w-full overflow-hidden box-border">{renderSubComponent({ row })}</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
))
|
||||
) : (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center align-middle">
|
||||
<p className="text-sm text-muted-foreground">{noDataMessage}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue