fix(ui): consistent row activation and keyboard access for tables with a detail view

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
jesus 2026-10-10 20:31:32 +00:00
parent 3981228d70
commit e50d78760b
26 changed files with 650 additions and 96 deletions

View file

@ -10,6 +10,9 @@ model_list:
api_base: os.environ/MOCK_LLM_URL
api_key: fake-key
litellm_settings:
store_audit_logs: true
general_settings:
master_key: os.environ/LITELLM_MASTER_KEY
database_url: os.environ/DATABASE_URL

View file

@ -0,0 +1,254 @@
import { test, expect, type APIRequestContext, type Locator, type Page as PlaywrightPage } from "@playwright/test";
import { ADMIN_STORAGE_PATH } from "../../constants";
import { Page } from "../../fixtures/pages";
import { navigateToPage } from "../../helpers/navigation";
import { proxyIsPremium } from "../../helpers/premium";
import { CHAT_MODEL_A, deleteVirtualKey, masterKey, sendChatCompletion, waitForSpendLog } from "../../helpers/traffic";
interface GeneratedKeyResponse {
key: string;
token?: string;
token_id?: string;
}
interface GeneratedKey {
key: string;
token: string;
}
interface CreatedTeam {
team_id: string;
}
interface SpendLog {
request_id: string;
}
const uniqueSuffix = (): string => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const adminHeaders = (): Record<string, string> => ({ Authorization: `Bearer ${masterKey()}` });
const visibleTestId = (page: PlaywrightPage, id: string): Locator => page.getByTestId(id).filter({ visible: true });
const visibleDataTable = (page: PlaywrightPage): Locator => visibleTestId(page, "data-table-root").first();
const visibleRows = (page: PlaywrightPage): Locator => visibleDataTable(page).locator("tbody tr");
const rowWithText = (page: PlaywrightPage, text: string): Locator => visibleRows(page).filter({ hasText: text }).first();
async function postOk<T>(request: APIRequestContext, path: string, data: Record<string, unknown>): Promise<T> {
const response = await request.post(path, { headers: adminHeaders(), data });
expect(response.ok(), `POST ${path} failed (${response.status()}): ${await response.text()}`).toBe(true);
return (await response.json()) as T;
}
async function createKey(
request: APIRequestContext,
alias: string,
teamId?: string,
): Promise<GeneratedKey> {
const response = await postOk<GeneratedKeyResponse>(request, "/key/generate", {
key_alias: alias,
...(teamId ? { team_id: teamId } : {}),
});
const token = response.token ?? response.token_id;
if (!token) {
throw new Error("POST /key/generate did not return a key token");
}
return { key: response.key, token };
}
async function cellForHeader(page: PlaywrightPage, row: Locator, title: string): Promise<Locator> {
const headers = await visibleDataTable(page).getByRole("columnheader").allTextContents();
const index = headers.findIndex((header) => header.includes(title));
if (index < 0) {
throw new Error(`Could not find the ${title} table column`);
}
return row.getByRole("cell").nth(index);
}
async function createAuditTeam(request: APIRequestContext): Promise<CreatedTeam> {
return postOk<CreatedTeam>(request, "/team/new", { team_alias: `e2e-row-audit-${uniqueSuffix()}` });
}
async function locateAuditRow(page: PlaywrightPage, teamId: string): Promise<Locator> {
await navigateToPage(page, Page.Logs);
await page.getByRole("tab", { name: "Audit Logs" }).click();
await page.getByPlaceholder("Search audit logs by ID…").fill(teamId);
const row = rowWithText(page, teamId);
await expect(row, `audit row for team ${teamId} should be visible before activation checks`).toBeVisible();
return row;
}
async function locateRequestRow(page: PlaywrightPage, requestId: string): Promise<Locator> {
await navigateToPage(page, Page.Logs);
await page.getByPlaceholder("Search logs by ID…").fill(requestId);
const row = rowWithText(page, requestId);
await expect(row, `request row ${requestId} should be visible before activation checks`).toBeVisible();
return row;
}
async function readSpendLogRequestId(request: APIRequestContext, requestId: string): Promise<string> {
const response = await request.get(`/spend/logs?request_id=${encodeURIComponent(requestId)}`, {
headers: adminHeaders(),
});
expect(response.ok(), `GET /spend/logs for ${requestId} failed (${response.status()})`).toBe(true);
const body = (await response.json()) as SpendLog[] | { data: SpendLog[] };
const rows = Array.isArray(body) ? body : body.data;
const spendLog = rows.find((row) => row.request_id === requestId);
if (!spendLog) {
throw new Error(`Spend log for request ${requestId} was not returned after waitForSpendLog`);
}
return spendLog.request_id;
}
test.describe("Virtual Keys row activation", () => {
test.use({ storageState: ADMIN_STORAGE_PATH });
test("a non-interactive cell opens that key's details", async ({ page, request }) => {
const alias = `e2e-row-key-cell-${uniqueSuffix()}`;
const key = await createKey(request, alias);
try {
await navigateToPage(page, Page.ApiKeys);
const row = rowWithText(page, alias);
await expect(row, `key row ${alias} should be visible before clicking Created At`).toBeVisible();
await (await cellForHeader(page, row, "Created At")).click();
await expect(page).toHaveURL((url) => url.searchParams.get("key") === key.token);
await expect(page.getByText("Back to Keys", { exact: true })).toBeVisible();
} finally {
await deleteVirtualKey(request, key.token);
}
});
test("the row is focusable, named, and opens details with Enter and Space", async ({ page, request }) => {
const alias = `e2e-row-key-keyboard-${uniqueSuffix()}`;
const key = await createKey(request, alias);
try {
await navigateToPage(page, Page.ApiKeys);
const row = rowWithText(page, alias);
await expect(row, `key row ${alias} should be visible before keyboard checks`).toBeVisible();
await expect.soft(row).toHaveAccessibleName(`Open key ${alias}`);
await row.focus();
await expect.soft(row).toBeFocused();
await page.keyboard.press("Enter");
await expect.soft(page).toHaveURL((url) => url.searchParams.get("key") === key.token);
await expect.soft(page.getByText("Back to Keys", { exact: true })).toBeVisible();
const backToKeys = page.getByText("Back to Keys", { exact: true });
if (await backToKeys.isVisible()) {
await backToKeys.click();
}
await expect(row).toBeVisible();
await row.focus();
await page.keyboard.press("Space");
await expect.soft(page).toHaveURL((url) => url.searchParams.get("key") === key.token);
await expect.soft(page.getByText("Back to Keys", { exact: true })).toBeVisible();
} finally {
await deleteVirtualKey(request, key.token);
}
});
test("the team link does not open key details", async ({ page, request }) => {
const alias = `e2e-row-key-link-${uniqueSuffix()}`;
const key = await createKey(request, alias, "e2e-team-crud");
try {
await navigateToPage(page, Page.ApiKeys);
const row = rowWithText(page, alias);
await expect(row, `key row ${alias} should be visible before checking its team link`).toBeVisible();
const teamLink = row.getByRole("link", { name: "E2E Team CRUD" });
await expect(teamLink).toBeVisible();
await teamLink.click();
await expect(page).not.toHaveURL((url) => url.searchParams.has("key"));
await expect(page.getByText("Back to Keys", { exact: true })).not.toBeVisible();
} finally {
await deleteVirtualKey(request, key.token);
}
});
});
test.describe("Request Logs row activation", () => {
test.use({ storageState: ADMIN_STORAGE_PATH });
test("clicking a non-interactive cell opens the single-request drawer", async ({ page, request }) => {
const suffix = uniqueSuffix();
const key = await createKey(request, `e2e-row-log-mouse-${suffix}`);
try {
const requestId = await sendChatCompletion(request, {
model: CHAT_MODEL_A,
prompt: `row activation mouse ${suffix}`,
apiKey: key.key,
});
await waitForSpendLog(request, requestId);
const spendLogRequestId = await readSpendLogRequestId(request, requestId);
const row = await locateRequestRow(page, spendLogRequestId);
await (await cellForHeader(page, row, "Time")).click();
await expect(page.getByRole("dialog").filter({ hasText: spendLogRequestId })).toBeVisible();
} finally {
await deleteVirtualKey(request, key.token);
}
});
test("the row is focusable, named, and opens the drawer with Enter", async ({ page, request }) => {
const suffix = uniqueSuffix();
const key = await createKey(request, `e2e-row-log-keyboard-${suffix}`);
try {
const requestId = await sendChatCompletion(request, {
model: CHAT_MODEL_A,
prompt: `row activation keyboard ${suffix}`,
apiKey: key.key,
});
await waitForSpendLog(request, requestId);
const spendLogRequestId = await readSpendLogRequestId(request, requestId);
const row = await locateRequestRow(page, spendLogRequestId);
const drawer = page.getByRole("dialog").filter({ hasText: spendLogRequestId });
await expect.soft(row).toHaveAccessibleName(`Open request ${spendLogRequestId}`);
await row.focus();
await expect.soft(row).toBeFocused();
await page.keyboard.press("Enter");
await expect.soft(drawer).toBeVisible();
} finally {
await deleteVirtualKey(request, key.token);
}
});
});
test.describe("Audit Logs row activation", () => {
test.use({ storageState: ADMIN_STORAGE_PATH });
test("clicking Timestamp opens that audit log", async ({ page, request }) => {
test.skip(!proxyIsPremium(), "Audit Logs require a premium proxy license");
const team = await createAuditTeam(request);
try {
const row = await locateAuditRow(page, team.team_id);
await (await cellForHeader(page, row, "Timestamp")).click();
await expect(page.getByRole("dialog").filter({ hasText: team.team_id })).toBeVisible();
} finally {
await request.post("/team/delete", { headers: adminHeaders(), data: { team_ids: [team.team_id] } });
}
});
test("the row is focusable, named, and opens the drawer with Enter", async ({ page, request }) => {
test.skip(!proxyIsPremium(), "Audit Logs require a premium proxy license");
const team = await createAuditTeam(request);
try {
const row = await locateAuditRow(page, team.team_id);
const action = (await row.getByRole("cell").nth(1).innerText()).trim();
const drawer = page.getByRole("dialog").filter({ hasText: team.team_id });
await expect.soft(row).toHaveAccessibleName(`Open audit log ${action} ${team.team_id}`);
await row.focus();
await expect.soft(row).toBeFocused();
await page.keyboard.press("Enter");
await expect.soft(drawer).toBeVisible();
} finally {
await request.post("/team/delete", { headers: adminHeaders(), data: { team_ids: [team.team_id] } });
}
});
});

View file

@ -351,6 +351,7 @@ export function GuardrailsOverview({
isLoading={isLoading}
noDataMessage="No data for this period"
onRowClick={(row) => onSelectGuardrail(row.id)}
getRowLabel={(row) => `Open guardrail ${row.name}`}
rowClassName={() => "cursor-pointer"}
sortingMode="server"
sorting={sorting}

View file

@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen } from "../../../../../tests/test-utils";
import { renderWithProviders, screen, within } from "../../../../../tests/test-utils";
import { ProjectKeysTable } from "./ProjectKeysTable";
import { KeyResponse } from "@/components/key_team_helpers/key_list";
@ -106,38 +106,56 @@ describe("ProjectKeysTable", () => {
expect(screen.getByText("—")).toBeInTheDocument();
});
it("should link the key name to that key's detail on the Virtual Keys page", () => {
it("renders the key alias as plain text and gives its row an accessible name", () => {
renderWithProviders(
<ProjectKeysTable {...defaultProps} keys={[makeKey({ token: "tok-abc123", key_alias: "My API Key" })]} />,
);
expect(screen.getByRole("link", { name: "My API Key" })).toHaveAttribute("href", "/ui/api-keys?key=tok-abc123");
const row = screen.getByRole("row", { name: "Open key My API Key" });
expect(within(row).getByText("My API Key")).toBeInTheDocument();
expect(within(row).queryByRole("link", { name: "My API Key" })).not.toBeInTheDocument();
});
it("should navigate to the key detail without a full page load when the key name is clicked", async () => {
it("navigates to key details when plain row content is clicked", async () => {
const user = userEvent.setup();
push.mockClear();
renderWithProviders(
<ProjectKeysTable {...defaultProps} keys={[makeKey({ token: "tok-abc123", key_alias: "My API Key" })]} />,
);
await user.click(screen.getByRole("link", { name: "My API Key" }));
await user.click(screen.getByText("My API Key"));
expect(push).toHaveBeenCalledWith("/ui/api-keys?key=tok-abc123");
});
it("should still link a key that has no alias", () => {
it("opens a focused row with Enter and labels an alias-less key with its ID", async () => {
const user = userEvent.setup();
push.mockClear();
renderWithProviders(
<ProjectKeysTable
{...defaultProps}
keys={[makeKey({ token: "tok-no-alias", key_alias: "", user_id: "owner-1" })]}
keys={[
makeKey({ token: "tok-no-alias", token_id: "tid-no-alias", key_alias: null as any, user_id: "owner-1" }),
]}
/>,
);
expect(screen.getByRole("link", { name: "—" })).toHaveAttribute("href", "/ui/api-keys?key=tok-no-alias");
const row = screen.getByRole("row", { name: "Open key tid-no-alias" });
row.focus();
expect(row).toHaveFocus();
await user.keyboard("{Enter}");
expect(push).toHaveBeenCalledWith("/ui/api-keys?key=tok-no-alias");
});
it("should give each row a link to its own key", () => {
it("routes each row to its own key", async () => {
const user = userEvent.setup();
push.mockClear();
const keys = [makeKey({ token: "tok-1", key_alias: "Key One" }), makeKey({ token: "tok-2", key_alias: "Key Two" })];
renderWithProviders(<ProjectKeysTable {...defaultProps} keys={keys} />);
expect(screen.getByRole("link", { name: "Key One" })).toHaveAttribute("href", "/ui/api-keys?key=tok-1");
expect(screen.getByRole("link", { name: "Key Two" })).toHaveAttribute("href", "/ui/api-keys?key=tok-2");
await user.click(screen.getByText("Key One"));
expect(push).toHaveBeenLastCalledWith("/ui/api-keys?key=tok-1");
const secondRow = screen.getByRole("row", { name: "Open key Key Two" });
secondRow.focus();
await user.keyboard("{Enter}");
expect(push).toHaveBeenLastCalledWith("/ui/api-keys?key=tok-2");
});
it("should display the owner using user.user_email when available", () => {

View file

@ -2,10 +2,12 @@
import { OnChangeFn, PaginationState } from "@tanstack/react-table";
import { KeyRound } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { KeyResponse } from "@/components/key_team_helpers/key_list";
import { DataTable } from "@/components/shared/DataTable";
import { keyDetailHref } from "@/utils/entityLinks";
import { getProjectKeysTableColumns } from "./ProjectKeysTableColumns";
import { PROJECT_KEYS_PAGE_SIZE_OPTIONS } from "./useProjectsUrlState";
@ -39,6 +41,7 @@ export function ProjectKeysTable({
pagination,
onPaginationChange,
}: ProjectKeysTableProps) {
const router = useRouter();
const columns = useMemo(() => getProjectKeysTableColumns(), []);
return (
@ -56,6 +59,10 @@ export function ProjectKeysTable({
loadingMessage="Loading keys…"
noDataMessage={<EmptyState />}
size="compact"
onRowClick={(key) => {
if (key.token) router.push(keyDetailHref(key.token));
}}
getRowLabel={(key) => `Open key ${key.key_alias ?? key.token_id}`}
/>
);
}

View file

@ -4,8 +4,7 @@ import { ColumnDef } from "@tanstack/react-table";
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
import { KeyResponse } from "@/components/key_team_helpers/key_list";
import { CellTooltip, DateCell, IdentityCell } from "@/components/shared/table_cells";
import { keyDetailHref } from "@/utils/entityLinks";
import { CellTooltip, DateCell } from "@/components/shared/table_cells";
function OwnerCell({ record }: { record: KeyResponse }) {
const email = record.user?.user_email ?? record.user_id ?? null;
@ -29,13 +28,7 @@ export const getProjectKeysTableColumns = (): ColumnDef<KeyResponse>[] => [
meta: { title: "Key Name" },
header: "Key Name",
enableSorting: false,
cell: ({ row }) => (
<IdentityCell
title={<span title={row.original.key_alias ?? undefined}>{row.original.key_alias || "—"}</span>}
href={row.original.token ? keyDetailHref(row.original.token) : undefined}
className="max-w-60"
/>
),
cell: ({ row }) => <span className="text-sm">{row.original.key_alias || "—"}</span>,
},
{
id: "owner",

View file

@ -167,3 +167,41 @@ describe("ProjectsTable pagination URL state", () => {
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 2");
});
});
describe("ProjectsTable row activation", () => {
const renderProject = (onProjectClick: (projectId: string) => void) =>
renderWithProviders(
<ProjectsTable
projects={[makeProject(1)]}
isLoading={false}
isFiltered={false}
onProjectClick={onProjectClick}
teamAliasMap={new Map()}
isTeamsLoading={false}
/>,
);
it("opens project details from a plain cell and exposes the accessible row name", async () => {
const user = userEvent.setup();
const onProjectClick = vi.fn();
renderProject(onProjectClick);
const row = screen.getByRole("row", { name: "Open project Project 01" });
await user.click(within(row).getByText("proj-01"));
expect(onProjectClick).toHaveBeenCalledWith("proj-01");
});
it("opens project details when Enter is pressed on the focused row", async () => {
const user = userEvent.setup();
const onProjectClick = vi.fn();
renderProject(onProjectClick);
const row = screen.getByRole("row", { name: "Open project Project 01" });
row.focus();
expect(row).toHaveFocus();
await user.keyboard("{Enter}");
expect(onProjectClick).toHaveBeenCalledWith("proj-01");
});
});

View file

@ -50,9 +50,9 @@ export function ProjectsTable({
const pageSize = PAGE_SIZE_OPTIONS.includes(pagination.pageSize) ? pagination.pageSize : PROJECTS_DEFAULT_PAGE_SIZE;
const columns = useMemo(() => {
const deps = { onProjectClick, teamAliasMap, isTeamsLoading };
const deps = { teamAliasMap, isTeamsLoading };
return getProjectsTableColumns(deps);
}, [onProjectClick, teamAliasMap, isTeamsLoading]);
}, [teamAliasMap, isTeamsLoading]);
const pageCount = Math.max(Math.ceil(projects.length / pageSize), 1);
const pageIndex = pagination.pageIndex < pageCount ? pagination.pageIndex : 0;
@ -83,6 +83,8 @@ export function ProjectsTable({
loadingMessage="Loading projects…"
noDataMessage={<EmptyState isFiltered={isFiltered} />}
size="compact"
onRowClick={(project) => onProjectClick(project.project_id)}
getRowLabel={(project) => `Open project ${project.project_alias ?? project.project_id}`}
/>
);
}

View file

@ -51,13 +51,11 @@ function ProjectModelsCell({ project }: { project: ProjectResponse }) {
}
interface ProjectsTableColumnsDeps {
onProjectClick: (projectId: string) => void;
teamAliasMap: Map<string, string>;
isTeamsLoading: boolean;
}
export const getProjectsTableColumns = ({
onProjectClick,
teamAliasMap,
isTeamsLoading,
}: ProjectsTableColumnsDeps): ColumnDef<ProjectResponse>[] => [
@ -68,13 +66,7 @@ export const getProjectsTableColumns = ({
header: "ID",
size: 190,
enableSorting: false,
cell: ({ row }) => (
<IdentityCell
title={row.original.project_id}
titleClassName="font-mono text-xs font-normal"
onClick={() => onProjectClick(row.original.project_id)}
/>
),
cell: ({ row }) => <IdentityCell title={row.original.project_id} titleClassName="font-mono text-xs font-normal" />,
},
{
id: "project_alias",

View file

@ -530,6 +530,7 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
globalFilter={globalFilter}
onGlobalFilterChange={setGlobalFilter}
onRowClick={fetchRunDetail}
getRowLabel={(run) => `Open workflow run ${run.run_id}`}
size="compact"
toolbar={(table) => (
<>

View file

@ -160,11 +160,7 @@ function OverflowChips({ items }: { items: string[] }) {
);
}
interface PublicModelHubColumnsDeps {
onModelClick: (model: ModelGroupInfo) => void;
}
export const getPublicModelHubColumns = ({ onModelClick }: PublicModelHubColumnsDeps): ColumnDef<ModelGroupInfo>[] => {
export const getPublicModelHubColumns = (): ColumnDef<ModelGroupInfo>[] => {
const columns: ColumnDef<ModelGroupInfo>[] = [
{
id: "model_group",
@ -178,7 +174,6 @@ export const getPublicModelHubColumns = ({ onModelClick }: PublicModelHubColumns
title={row.original.model_group}
titleClassName="font-mono text-xs font-normal"
className="max-w-72"
onClick={() => onModelClick(row.original)}
/>
),
},
@ -309,11 +304,7 @@ export const getPublicModelHubColumns = ({ onModelClick }: PublicModelHubColumns
}));
};
interface PublicAgentHubColumnsDeps {
onAgentClick: (agent: AgentCard) => void;
}
export const getPublicAgentHubColumns = ({ onAgentClick }: PublicAgentHubColumnsDeps): ColumnDef<AgentCard>[] => [
export const getPublicAgentHubColumns = (): ColumnDef<AgentCard>[] => [
{
id: "name",
accessorKey: "name",
@ -323,12 +314,7 @@ export const getPublicAgentHubColumns = ({ onAgentClick }: PublicAgentHubColumns
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<IdentityCell
title={row.original.name}
titleClassName="font-mono text-xs font-normal"
className="max-w-72"
onClick={() => onAgentClick(row.original)}
/>
<IdentityCell title={row.original.name} titleClassName="font-mono text-xs font-normal" className="max-w-72" />
),
},
{
@ -401,11 +387,7 @@ export const getPublicAgentHubColumns = ({ onAgentClick }: PublicAgentHubColumns
},
];
interface PublicMCPHubColumnsDeps {
onServerClick: (server: MCPServerData) => void;
}
export const getPublicMCPHubColumns = ({ onServerClick }: PublicMCPHubColumnsDeps): ColumnDef<MCPServerData>[] => [
export const getPublicMCPHubColumns = (): ColumnDef<MCPServerData>[] => [
{
id: "server_name",
accessorKey: "server_name",
@ -419,7 +401,6 @@ export const getPublicMCPHubColumns = ({ onServerClick }: PublicMCPHubColumnsDep
title={row.original.server_name}
titleClassName="font-mono text-xs font-normal"
className="max-w-72"
onClick={() => onServerClick(row.original)}
/>
),
},

View file

@ -427,20 +427,33 @@ it("sorts by spend ascending when 'Spend ascending' is chosen from the Spend / B
});
});
it("clicking the key cell deep-links via ?key=", async () => {
it("clicking a plain key cell opens the key details", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<VirtualKeysTable />, { onUrlUpdate });
await waitFor(() => {
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Test Key Alias"));
const row = await screen.findByRole("row", { name: "Open key Test Key Alias" });
await user.click(within(row).getByText("Test Key Alias"));
await waitFor(() => {
expect(lastKeyParam(onUrlUpdate)).toBe(mockKey.token);
});
expect(lastHistoryMode(onUrlUpdate)).toBe("push");
expect(screen.getByText("Back to Keys")).toBeInTheDocument();
});
it("opens a focused key row with Enter", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<VirtualKeysTable />, { onUrlUpdate });
const row = await screen.findByRole("row", { name: "Open key Test Key Alias" });
row.focus();
expect(row).toHaveFocus();
await user.keyboard("{Enter}");
await waitFor(() => expect(lastKeyParam(onUrlUpdate)).toBe(mockKey.token));
expect(screen.getByText("Back to Keys")).toBeInTheDocument();
});
it("renders KeyInfoView when the URL has ?key= for a key on the current page, without refetching it", async () => {
@ -556,6 +569,18 @@ describe("entity links out of the key rows", () => {
expect(within(row).getByRole("link", { name: "Test Team" })).toHaveAttribute("href", "/ui/teams?team=team-1");
});
it("does not open key details when the Team link is clicked", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<VirtualKeysTable />, { onUrlUpdate });
const row = await keyRow();
await user.click(within(row).getByRole("link", { name: "Test Team" }));
expect(onUrlUpdate).not.toHaveBeenCalled();
expect(screen.queryByText("Back to Keys")).not.toBeInTheDocument();
});
it("points the Organization cell at the org's detail page", async () => {
mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, org_id: "org-1" }]));
const user = userEvent.setup();

View file

@ -157,10 +157,9 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
allTeams,
organizations,
teamMemberBudgets,
onSelectKey: (key: KeyResponse) => void setSelectedKeyId(key.token),
applyUserBudgetToTeamKeys,
}),
[allTeams, organizations, teamMemberBudgets, setSelectedKeyId, applyUserBudgetToTeamKeys],
[allTeams, organizations, teamMemberBudgets, applyUserBudgetToTeamKeys],
);
const columns = useMemo(() => getKeyTableColumns(columnDeps), [columnDeps]);
@ -253,6 +252,10 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
data={keyList}
columns={columns}
getRowId={(row) => row.token}
onRowClick={(key) => {
void setSelectedKeyId(key.token);
}}
getRowLabel={(key) => `Open key ${key.key_alias ?? key.key_name ?? key.token}`}
columnVisibility={columnVisibility}
onColumnVisibilityChange={onColumnVisibilityChange}
sortingMode="server"

View file

@ -91,7 +91,6 @@ interface KeyTableColumnsDeps {
allTeams: Team[];
organizations: Organization[];
teamMemberBudgets: Readonly<Record<string, TeamMemberBudgetSource>>;
onSelectKey: (key: KeyResponse) => void;
applyUserBudgetToTeamKeys: boolean;
}
@ -99,7 +98,6 @@ export const getKeyTableColumns = ({
allTeams,
organizations,
teamMemberBudgets,
onSelectKey,
applyUserBudgetToTeamKeys,
}: KeyTableColumnsDeps): ColumnDef<KeyResponse>[] => [
{
@ -134,7 +132,6 @@ export const getKeyTableColumns = ({
dataTestId={`key-status-${row.original.token_id}`}
/>
}
onClick={() => onSelectKey(row.original)}
/>
);
},
@ -146,7 +143,7 @@ export const getKeyTableColumns = ({
header: ({ column }) => <DataTableSortHeader column={column} title="Key ID" variant="header-cycle" />,
size: 120,
enableSorting: true,
cell: (info) => <IdCell value={info.getValue() as string | null} onClick={() => onSelectKey(info.row.original)} />,
cell: (info) => <IdCell value={info.getValue() as string | null} />,
},
{
id: "team_alias",

View file

@ -1,5 +1,5 @@
import type { ColumnFiltersState, PaginationState } from "@tanstack/react-table";
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
@ -69,11 +69,25 @@ describe("AuditLogsTable", () => {
expect(screen.getByText("sk-hash-abc")).toBeInTheDocument();
});
it("opens the detail drawer from the Object ID identity cell with the full row", async () => {
it("opens the detail drawer from a plain cell and exposes the accessible row name", async () => {
const user = userEvent.setup();
const props = renderTable();
const row = screen.getByRole("row", { name: "Open audit log Created team-obj-123" });
await user.click(screen.getByText("team-obj-123"));
await user.click(within(row).getAllByRole("cell")[0]);
expect(props.onViewLog).toHaveBeenCalledTimes(1);
expect(props.onViewLog).toHaveBeenCalledWith(ROWS[0]);
});
it("opens the audit detail drawer when Enter is pressed on the focused row", async () => {
const user = userEvent.setup();
const props = renderTable();
const row = screen.getByRole("row", { name: "Open audit log Created team-obj-123" });
row.focus();
expect(row).toHaveFocus();
await user.keyboard("{Enter}");
expect(props.onViewLog).toHaveBeenCalledTimes(1);
expect(props.onViewLog).toHaveBeenCalledWith(ROWS[0]);

View file

@ -13,7 +13,12 @@ import {
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AUDIT_TABLE_NAME_DISPLAY, AuditLogEntry, getAuditLogsTableColumns } from "./AuditLogsTableColumns";
import {
AUDIT_TABLE_NAME_DISPLAY,
AuditLogEntry,
auditActionLabel,
getAuditLogsTableColumns,
} from "./AuditLogsTableColumns";
interface AuditLogsTableProps {
data: AuditLogEntry[];
@ -112,7 +117,7 @@ export function AuditLogsTable({
onViewLog,
}: AuditLogsTableProps) {
const [filtersOpen, setFiltersOpen] = useState(false);
const columns = useMemo(() => getAuditLogsTableColumns({ onViewLog }), [onViewLog]);
const columns = useMemo(() => getAuditLogsTableColumns(), []);
const hasActiveSearch = Boolean(searchValue?.trim());
return (
@ -121,6 +126,8 @@ export function AuditLogsTable({
data={data}
columns={columns}
getRowId={(row) => row.id}
onRowClick={onViewLog}
getRowLabel={(log) => `Open audit log ${auditActionLabel(log.action)} ${log.object_id}`}
paginationMode="server"
pagination={pagination}
onPaginationChange={onPaginationChange}

View file

@ -40,11 +40,7 @@ export const auditActionLabel = (action: string): string => {
return words ? words.charAt(0).toUpperCase() + words.slice(1) : words;
};
interface AuditLogsTableColumnsDeps {
onViewLog: (log: AuditLogEntry) => void;
}
export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDeps): ColumnDef<AuditLogEntry>[] => [
export const getAuditLogsTableColumns = (): ColumnDef<AuditLogEntry>[] => [
{
id: "updated_at",
accessorKey: "updated_at",
@ -82,9 +78,8 @@ export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDep
cell: ({ row }) => (
<IdentityCell
title={row.original.object_id}
titleClassName="font-mono text-xs font-normal text-primary"
titleClassName="font-mono text-xs font-normal"
className="max-w-72"
onClick={() => onViewLog(row.original)}
/>
),
},

View file

@ -534,7 +534,7 @@ describe("RequestLogsPanel", () => {
renderPanel();
await waitFor(() => expect(row("req-1")).not.toBeNull());
await user.click(row("req-1") as HTMLElement);
await user.click(within(row("req-1") as HTMLElement).getByText("gpt-4o"));
await waitFor(() => expect(urlParams().get("log_id")).toBe("req-1"));
expect(historyModes()).toEqual(["push"]);
@ -544,6 +544,20 @@ describe("RequestLogsPanel", () => {
expect(drawer()).toHaveAttribute("data-log-id", "req-1");
});
it("opens the drawer from a focused row and exposes its accessible name", async () => {
const user = userEvent.setup();
respondWith([logEntry({ request_id: "req-keyboard" })]);
renderPanel();
const requestRow = await screen.findByRole("row", { name: "Open request req-keyboard" });
requestRow.focus();
expect(requestRow).toHaveFocus();
await user.keyboard("{Enter}");
await waitFor(() => expect(drawer()).toHaveAttribute("data-log-id", "req-keyboard"));
expect(urlParams().get("log_id")).toBe("req-keyboard");
});
it("opens the drawer on load when ?log_id= matches a log in the loaded page", async () => {
respondWith([logEntry({ request_id: "req-1" }), logEntry({ request_id: "req-2" })]);
renderPanel("?log_id=req-2");

View file

@ -116,6 +116,7 @@ export function RequestLogsTable({
noDataMessage={<RequestLogsEmptyState filtered={isFiltered} />}
size="compact"
onRowClick={onRowClick}
getRowLabel={(row) => `Open request ${row.request_id}`}
toolbar={(table) => (
<>
<DataTableToolbar

View file

@ -303,6 +303,7 @@ describe("Request ID column", () => {
getRowId={(row) => row.request_id}
size="compact"
onRowClick={onRowClick}
getRowLabel={(row) => `Open request ${row.request_id}`}
/>,
);

View file

@ -4,7 +4,8 @@ import { render, screen, waitFor, within, fireEvent } from "@testing-library/rea
import userEvent from "@testing-library/user-event";
import { flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import PublicModelHub from "./public_model_hub";
import { getPublicMCPHubColumns, MCPServerData, ModelGroupInfo } from "./PublicModelHubTableColumns";
import { getPublicMCPHubColumns } from "./PublicModelHubTableColumns";
import type { AgentCard, MCPServerData, ModelGroupInfo } from "./PublicModelHubTableColumns";
const { apiGetMock } = vi.hoisted(() => ({ apiGetMock: vi.fn() }));
@ -61,6 +62,16 @@ const model = (overrides: Partial<ModelGroupInfo> & { model_group: string }): Mo
});
const DEFAULT_MODELS = [model({ model_group: "gpt-4" }), model({ model_group: "claude-3", providers: ["anthropic"] })];
const mockAgent: AgentCard = {
protocolVersion: "1",
name: "row-activation-agent",
description: "Agent used for row activation",
url: "https://example.com/agent",
version: "1.0",
defaultInputModes: [],
defaultOutputModes: [],
skills: [],
};
const respondWith = (rows: ModelGroupInfo[], totalCount: number = rows.length, pageSize: number = 50) =>
apiGetMock.mockImplementation((path: string) => {
@ -145,6 +156,58 @@ describe("PublicModelHub", () => {
expect(modelQueries()[0]).toEqual({ page: 1, page_size: 50, sort: "model_group" });
});
it("opens model details by clicking a plain cell and exposes the accessible row name", async () => {
const user = userEvent.setup();
respondWith([model({ model_group: "gpt-4" })]);
renderHub();
const row = await screen.findByRole("row", { name: "Open model gpt-4" });
await user.click(within(row).getByText("gpt-4"));
expect(await screen.findByRole("heading", { name: "gpt-4" })).toBeInTheDocument();
});
it("opens model details when Enter is pressed on the focused row", async () => {
const user = userEvent.setup();
respondWith([model({ model_group: "gpt-4" })]);
renderHub();
const row = await screen.findByRole("row", { name: "Open model gpt-4" });
row.focus();
expect(row).toHaveFocus();
await user.keyboard("{Enter}");
expect(await screen.findByRole("heading", { name: "gpt-4" })).toBeInTheDocument();
});
it("opens agent details from a plain cell and exposes the accessible row name", async () => {
const user = userEvent.setup();
const networkingModule = await import("./networking");
vi.mocked(networkingModule.agentHubPublicModelsCall).mockResolvedValueOnce([mockAgent]);
renderHub();
await user.click(await screen.findByRole("tab", { name: "Agent Hub" }));
const row = await screen.findByRole("row", { name: "Open agent row-activation-agent" });
await user.click(within(row).getByText("row-activation-agent"));
expect(await screen.findByRole("heading", { name: "row-activation-agent" })).toBeInTheDocument();
});
it("opens agent details when Enter is pressed on the focused row", async () => {
const user = userEvent.setup();
const networkingModule = await import("./networking");
vi.mocked(networkingModule.agentHubPublicModelsCall).mockResolvedValueOnce([mockAgent]);
renderHub();
await user.click(await screen.findByRole("tab", { name: "Agent Hub" }));
const row = await screen.findByRole("row", { name: "Open agent row-activation-agent" });
row.focus();
expect(row).toHaveFocus();
await user.keyboard("{Enter}");
expect(await screen.findByRole("heading", { name: "row-activation-agent" })).toBeInTheDocument();
});
it("waits for the resolved proxy base url before asking for a page", async () => {
const networkingModule = await import("./networking");
let publishConfig: () => void = () => {};
@ -409,7 +472,7 @@ const mockMcpServer: MCPServerData = {
};
function PublicMcpTestTable({ data }: { data: MCPServerData[] }) {
const columns = getPublicMCPHubColumns({ onServerClick: vi.fn() });
const columns = getPublicMCPHubColumns();
const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() });
return (
@ -447,7 +510,7 @@ describe("publicMCPHubColumns", () => {
it("does not expose a URL column header", () => {
render(<PublicMcpTestTable data={[mockMcpServer]} />);
expect(screen.queryByText("URL")).not.toBeInTheDocument();
const columns = getPublicMCPHubColumns({ onServerClick: vi.fn() });
const columns = getPublicMCPHubColumns();
expect(columns.some((c) => c.header === "URL" || c.meta?.title === "URL")).toBe(false);
});
@ -459,13 +522,15 @@ describe("publicMCPHubColumns", () => {
describe("public hub MCP details modal", () => {
it("does not show the upstream url when a server is opened", async () => {
const user = userEvent.setup();
const networkingModule = await import("./networking");
vi.mocked(networkingModule.mcpHubPublicServersCall).mockResolvedValue([mockMcpServer]);
renderHub();
fireEvent.click(await screen.findByRole("tab", { name: /MCP Hub/i }));
fireEvent.click(await screen.findByRole("button", { name: "exa_test" }));
await user.click(await screen.findByRole("tab", { name: /MCP Hub/i }));
const row = await screen.findByRole("row", { name: "Open MCP server exa_test" });
await user.click(within(row).getByText("exa_test"));
// "Server Overview" only exists inside the opened MCP details modal,
// so finding it proves the modal rendered and the url assertion is not vacuous.
@ -473,14 +538,32 @@ describe("public hub MCP details modal", () => {
expect(screen.queryByText(PUBLIC_SERVER_URL)).not.toBeInTheDocument();
});
it("opens MCP server details when Enter is pressed on the focused row", async () => {
const user = userEvent.setup();
const networkingModule = await import("./networking");
vi.mocked(networkingModule.mcpHubPublicServersCall).mockResolvedValueOnce([mockMcpServer]);
renderHub();
await user.click(await screen.findByRole("tab", { name: /MCP Hub/i }));
const row = await screen.findByRole("row", { name: "Open MCP server exa_test" });
row.focus();
expect(row).toHaveFocus();
await user.keyboard("{Enter}");
expect(await screen.findByText("Server Overview")).toBeInTheDocument();
});
it("closes the server details modal from its close control", async () => {
const user = userEvent.setup();
const networkingModule = await import("./networking");
vi.mocked(networkingModule.mcpHubPublicServersCall).mockResolvedValue([mockMcpServer]);
renderHub();
fireEvent.click(await screen.findByRole("tab", { name: /MCP Hub/i }));
fireEvent.click(await screen.findByRole("button", { name: "exa_test" }));
await user.click(await screen.findByRole("tab", { name: /MCP Hub/i }));
const row = await screen.findByRole("row", { name: "Open MCP server exa_test" });
await user.click(within(row).getByText("exa_test"));
await screen.findByText("Server Overview");
fireEvent.click(screen.getByRole("button", { name: /close/i }));

View file

@ -276,9 +276,9 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
const [agentSorting, setAgentSorting] = useState<SortingState>([{ id: "name", desc: false }]);
const [mcpSorting, setMcpSorting] = useState<SortingState>([{ id: "server_name", desc: false }]);
const modelColumns = useMemo(() => getPublicModelHubColumns({ onModelClick: showModal }), [showModal]);
const agentColumns = useMemo(() => getPublicAgentHubColumns({ onAgentClick: showAgentModal }), [showAgentModal]);
const mcpColumns = useMemo(() => getPublicMCPHubColumns({ onServerClick: showMcpModal }), [showMcpModal]);
const modelColumns = useMemo(() => getPublicModelHubColumns(), []);
const agentColumns = useMemo(() => getPublicAgentHubColumns(), []);
const mcpColumns = useMemo(() => getPublicMCPHubColumns(), []);
const hasAgents = Array.isArray(agentHubData) && agentHubData.length > 0;
const hasMcpServers = Array.isArray(mcpHubData) && mcpHubData.length > 0;
@ -489,6 +489,8 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
data={models.rows}
columns={modelColumns}
getRowId={(model, index) => model.model_group || String(index)}
onRowClick={showModal}
getRowLabel={(model) => `Open model ${model.model_group}`}
sortingMode="server"
sorting={models.sorting}
onSortingChange={models.onSortingChange}
@ -557,6 +559,8 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
paginationMode="client"
columns={agentColumns}
getRowId={(agent, index) => agent.name || String(index)}
onRowClick={showAgentModal}
getRowLabel={(agent) => `Open agent ${agent.name}`}
sortingMode="client"
sorting={agentSorting}
onSortingChange={setAgentSorting}
@ -624,6 +628,8 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
paginationMode="client"
columns={mcpColumns}
getRowId={(server, index) => server.server_id || String(index)}
onRowClick={showMcpModal}
getRowLabel={(server) => `Open MCP server ${server.server_name}`}
sortingMode="client"
sorting={mcpSorting}
onSortingChange={setMcpSorting}

View file

@ -23,6 +23,13 @@ const noop = () => {};
export const uncontrolled = <DataTable data={data} columns={columns} defaultSorting={sorting} />;
// @ts-expect-error clickable rows require a row label
export const rowActivationRequiresAccessibleLabel = <DataTable data={data} columns={columns} onRowClick={noop} />;
export const rowActivationAcceptsAccessibleLabel = (
<DataTable data={data} columns={columns} onRowClick={noop} getRowLabel={(row) => row.name} />
);
export const controlled = (
<DataTable
data={data}

View file

@ -1,5 +1,5 @@
import type { ColumnDef, ExpandedState, OnChangeFn, PaginationState, VisibilityState } from "@tanstack/react-table";
import { render, screen, waitFor, within } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
@ -118,7 +118,13 @@ const rowClickColumns: ColumnDef<Person, unknown>[] = [
cell: () => (
<div>
<button data-testid="row-button">Act</button>
<input data-testid="row-input" aria-label="row input" />
<a href="#" data-testid="row-link" onClick={(event) => event.preventDefault()}>
Link
</a>
<input type="checkbox" data-testid="row-checkbox" aria-label="row checkbox" />
<div data-row-click-exempt data-testid="row-click-exempt">
Exempt
</div>
</div>
),
},
@ -758,11 +764,18 @@ describe("DataTable pinned columns", () => {
});
});
describe("DataTable row click guard", () => {
it("fires onRowClick from a plain cell but not from interactive elements", async () => {
describe("DataTable row activation", () => {
it("fires onRowClick exactly once from a plain cell but not from interactive elements", async () => {
const user = userEvent.setup();
const onRowClick = vi.fn();
render(<DataTable data={[person("a", "Alice")]} columns={rowClickColumns} onRowClick={onRowClick} />);
render(
<DataTable
data={[person("a", "Alice")]}
columns={rowClickColumns}
onRowClick={onRowClick}
getRowLabel={(row) => `Open person ${row.name}`}
/>,
);
await user.click(screen.getByTestId("name-cell"));
expect(onRowClick).toHaveBeenCalledTimes(1);
@ -771,8 +784,68 @@ describe("DataTable row click guard", () => {
await user.click(screen.getByTestId("row-button"));
expect(onRowClick).toHaveBeenCalledTimes(1);
await user.click(screen.getByTestId("row-input"));
await user.click(screen.getByTestId("row-link"));
expect(onRowClick).toHaveBeenCalledTimes(1);
await user.click(screen.getByTestId("row-checkbox"));
expect(onRowClick).toHaveBeenCalledTimes(1);
await user.click(screen.getByTestId("row-click-exempt"));
expect(onRowClick).toHaveBeenCalledTimes(1);
});
it("activates a focused row with Enter and Space and exposes its accessible label", async () => {
const user = userEvent.setup();
const onRowClick = vi.fn();
render(
<DataTable
data={[person("a", "Alice")]}
columns={rowClickColumns}
onRowClick={onRowClick}
getRowLabel={(row) => `Open person ${row.name}`}
/>,
);
const row = screen.getByRole("row", { name: "Open person Alice" });
expect(row).toHaveAttribute("tabindex", "0");
row.focus();
expect(row).toHaveFocus();
await user.keyboard("{Enter}");
expect(onRowClick).toHaveBeenCalledTimes(1);
expect(onRowClick).toHaveBeenCalledWith(expect.objectContaining({ id: "a" }));
await user.keyboard(" ");
expect(onRowClick).toHaveBeenCalledTimes(2);
});
it("does not activate the row when Enter starts on an inner button", () => {
const onRowClick = vi.fn();
render(
<DataTable
data={[person("a", "Alice")]}
columns={rowClickColumns}
onRowClick={onRowClick}
getRowLabel={(row) => `Open person ${row.name}`}
/>,
);
fireEvent.keyDown(screen.getByTestId("row-button"), { key: "Enter", code: "Enter" });
expect(onRowClick).not.toHaveBeenCalled();
});
it("keeps rows without row activation unfocusable, unlabeled, and inactive", async () => {
const user = userEvent.setup();
render(<DataTable data={[person("a", "Alice")]} columns={rowClickColumns} />);
const row = screen.getByText("Alice").closest("tr");
expect(row).not.toHaveAttribute("tabindex");
expect(row).not.toHaveAttribute("aria-label");
await user.click(screen.getByText("Alice"));
fireEvent.keyDown(row as HTMLTableRowElement, { key: "Enter", code: "Enter" });
expect(row).not.toHaveFocus();
});
});

View file

@ -271,6 +271,7 @@ interface BodyRowProps<TData> {
stickyHeader: boolean;
enableColumnResizing: boolean;
onRowClick?: (row: TData) => void;
getRowLabel?: (row: TData) => string;
rowClassName?: (row: Row<TData>) => string;
renderSubComponent?: (props: { row: Row<TData> }) => React.ReactElement;
}
@ -281,6 +282,7 @@ function DataTableBodyRow<TData>({
stickyHeader,
enableColumnResizing,
onRowClick,
getRowLabel,
rowClassName,
renderSubComponent,
}: BodyRowProps<TData>) {
@ -301,12 +303,32 @@ function DataTableBodyRow<TData>({
onRowClick(row.original);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTableRowElement>) => {
if (onRowClick === undefined || event.target !== event.currentTarget) {
return;
}
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
onRowClick(row.original);
};
return (
<Fragment>
<TableRow
data-row-id={row.id}
className={cn(clickable ? "cursor-pointer" : "", size === "compact" ? "h-8" : "", rowClassName?.(row))}
tabIndex={clickable ? 0 : undefined}
aria-label={clickable ? getRowLabel?.(row.original) : undefined}
className={cn(
clickable
? "cursor-pointer focus-visible:outline-2 focus-visible:outline-primary focus-visible:-outline-offset-2"
: "",
size === "compact" ? "h-8" : "",
rowClassName?.(row),
)}
onClick={clickable ? handleClick : undefined}
onKeyDown={clickable ? handleKeyDown : undefined}
>
{cells.map((cell) => (
<DataTableBodyCell
@ -607,6 +629,7 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
enableColumnResizing = false,
onRowClick,
getRowLabel,
rowClassName,
renderSubComponent,
maxBodyHeight,
@ -673,6 +696,7 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
stickyHeader={stickyHeader}
enableColumnResizing={enableColumnResizing}
onRowClick={onRowClick}
getRowLabel={getRowLabel}
rowClassName={rowClassName}
renderSubComponent={renderSubComponent}
/>

View file

@ -68,6 +68,7 @@ export interface DataTableResolvedProps<TData extends RowData, TValue> {
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
onRowClick?: (row: TData) => void;
getRowLabel?: (row: TData) => string;
rowClassName?: (row: Row<TData>) => string;
@ -104,8 +105,20 @@ type DataTableBaseProps<TData extends RowData, TValue> = Omit<
| "defaultColumnVisibility"
| "rowSelection"
| "onRowSelectionChange"
| "onRowClick"
| "getRowLabel"
>;
type RowActivationProps<TData> =
| {
onRowClick: (row: TData) => void;
getRowLabel: (row: TData) => string;
}
| {
onRowClick?: undefined;
getRowLabel?: undefined;
};
type SortingProps =
| {
sorting: SortingState;
@ -165,6 +178,7 @@ type RowSelectionProps =
| { rowSelection?: never; onRowSelectionChange?: OnChangeFn<RowSelectionState> };
export type DataTableProps<TData extends RowData, TValue> = DataTableBaseProps<TData, TValue> &
RowActivationProps<TData> &
SortingProps &
PaginationProps &
FilterProps &