mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-23 05:34:41 +00:00
Merge 9fb3e036d4 into 82ca10cc2d
This commit is contained in:
commit
02bdc19ba4
33 changed files with 828 additions and 112 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export async function openKeyDetail(page: PlaywrightPage, alias: string): Promis
|
|||
await page.getByPlaceholder("Search by key alias or ID").fill(alias);
|
||||
const row = page.getByRole("row").filter({ hasText: alias });
|
||||
await expect(row, `key row "${alias}" never appeared on the Virtual Keys page`).toBeVisible({ timeout: 15_000 });
|
||||
await row.getByRole("button", { name: alias }).click();
|
||||
await row.getByText(alias, { exact: true }).click();
|
||||
await expect(page.getByText("Back to Keys"), `key detail for "${alias}" never opened`).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ test.describe("Internal Viewer", () => {
|
|||
// Open the viewer's own key info page
|
||||
const keyRow = page.getByRole("row").filter({ hasText: E2E_VIEWER_KEY_ALIAS });
|
||||
await expect(keyRow).toBeVisible({ timeout: 10_000 });
|
||||
await keyRow.getByRole("button", { name: E2E_VIEWER_KEY_ALIAS }).click();
|
||||
await keyRow.getByText(E2E_VIEWER_KEY_ALIAS, { exact: true }).click();
|
||||
await expect(page.getByText("Back to Keys")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// None of the destructive / mutating actions should render
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ test.describe("Proxy Admin - Keys", () => {
|
|||
|
||||
const keyRow = page.getByRole("row").filter({ hasText: E2E_REGENERATE_KEY_ALIAS });
|
||||
await expect(keyRow).toBeVisible({ timeout: 10_000 });
|
||||
await keyRow.getByRole("button", { name: E2E_REGENERATE_KEY_ALIAS }).click();
|
||||
await keyRow.getByText(E2E_REGENERATE_KEY_ALIAS, { exact: true }).click();
|
||||
|
||||
await expect(page.getByText("Back to Keys")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ test.describe("Proxy Admin - Keys", () => {
|
|||
|
||||
const keyRow = page.getByRole("row").filter({ hasText: E2E_UPDATE_LIMITS_KEY_ALIAS });
|
||||
await expect(keyRow).toBeVisible({ timeout: 10_000 });
|
||||
await keyRow.getByRole("button", { name: E2E_UPDATE_LIMITS_KEY_ALIAS }).click();
|
||||
await keyRow.getByText(E2E_UPDATE_LIMITS_KEY_ALIAS, { exact: true }).click();
|
||||
|
||||
await expect(page.getByText("Back to Keys")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
|
|
@ -168,7 +168,7 @@ test.describe("Proxy Admin - Keys", () => {
|
|||
|
||||
const keyRow = page.getByRole("row").filter({ hasText: alias });
|
||||
await expect(keyRow).toBeVisible({ timeout: 10_000 });
|
||||
await keyRow.getByRole("button", { name: alias }).click();
|
||||
await keyRow.getByText(alias, { exact: true }).click();
|
||||
|
||||
await expect(page.getByText("Back to Keys")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
|
|
|
|||
277
tests/e2e/ui/tests/tables/rowActivation.spec.ts
Normal file
277
tests/e2e/ui/tests/tables/rowActivation.spec.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
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 AuditLogListResponse {
|
||||
audit_logs: Array<{ object_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 waitForAuditLog(request: APIRequestContext, teamId: string): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const response = await request.get(
|
||||
`/audit?page=1&page_size=50&object_id=${encodeURIComponent(teamId)}`,
|
||||
{ headers: adminHeaders() },
|
||||
);
|
||||
expect(response.ok(), `GET /audit for ${teamId} failed (${response.status()})`).toBe(true);
|
||||
const body = (await response.json()) as AuditLogListResponse;
|
||||
return body.audit_logs.some((entry) => entry.object_id === teamId);
|
||||
},
|
||||
{ timeout: 20_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
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 {
|
||||
await waitForAuditLog(request, team.team_id);
|
||||
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 {
|
||||
await waitForAuditLog(request, team.team_id);
|
||||
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] } });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
|
|||
import { fireEvent, renderWithProviders, screen, waitFor } from "../../../../../tests/test-utils";
|
||||
import { ProjectKeysSection } from "./ProjectKeysSection";
|
||||
|
||||
vi.mock("next/navigation", async () => ({
|
||||
...(await vi.importActual("next/navigation")),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
const mockUseKeys = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
|
||||
useKeys: (...args: unknown[]) => mockUseKeys(...args),
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -24,7 +24,6 @@ const defaultProps = {
|
|||
function makeKey(overrides: Partial<KeyResponse> = {}): KeyResponse {
|
||||
return {
|
||||
token: "tok-abc123",
|
||||
token_id: "tid-abc123",
|
||||
key_name: "sk-...abc",
|
||||
key_alias: "Test Key",
|
||||
spend: 0,
|
||||
|
|
@ -106,38 +105,75 @@ 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("falls back to the key name in the accessible row name when the alias is empty", () => {
|
||||
renderWithProviders(
|
||||
<ProjectKeysTable
|
||||
{...defaultProps}
|
||||
keys={[makeKey({ token: "tok-empty-alias", key_name: "name-empty-alias", key_alias: "" })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("row", { name: "Open key name-empty-alias" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the token when the alias and key name are empty", () => {
|
||||
renderWithProviders(
|
||||
<ProjectKeysTable {...defaultProps} keys={[makeKey({ token: "tok-empty-name", key_name: "", key_alias: "" })]} />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("row", { name: "Open key tok-empty-name" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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", () => {
|
||||
renderWithProviders(
|
||||
<ProjectKeysTable
|
||||
{...defaultProps}
|
||||
keys={[makeKey({ token: "tok-no-alias", key_alias: "", user_id: "owner-1" })]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "—" })).toHaveAttribute("href", "/ui/api-keys?key=tok-no-alias");
|
||||
it("opens a focused row with Enter and labels an alias-less key with its key name", async () => {
|
||||
const user = userEvent.setup();
|
||||
push.mockClear();
|
||||
const aliasLessKeyInput = {
|
||||
token: "tok-no-alias",
|
||||
key_name: "name-no-alias",
|
||||
key_alias: null as any,
|
||||
user_id: "owner-1",
|
||||
};
|
||||
const aliasLessKey = makeKey(aliasLessKeyInput);
|
||||
renderWithProviders(<ProjectKeysTable {...defaultProps} keys={[aliasLessKey]} />);
|
||||
const row = screen.getByRole("row", { name: "Open key name-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", () => {
|
||||
|
|
@ -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.key_name || key.token}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -167,3 +167,47 @@ 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, project = makeProject(1)) =>
|
||||
renderWithProviders(
|
||||
<ProjectsTable
|
||||
projects={[project]}
|
||||
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("falls back to the project ID in the accessible row name when the alias is empty", () => {
|
||||
renderProject(vi.fn(), { ...makeProject(1), project_alias: "" });
|
||||
|
||||
expect(screen.getByRole("row", { name: "Open project proj-01" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -162,11 +162,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",
|
||||
|
|
@ -180,7 +176,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)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
@ -311,11 +306,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",
|
||||
|
|
@ -325,12 +316,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" />
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -403,11 +389,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",
|
||||
|
|
@ -421,7 +403,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)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -427,20 +427,41 @@ 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("falls back to the token in the accessible row name when the alias is empty", () => {
|
||||
mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, key_alias: "", key_name: "" }]));
|
||||
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
|
||||
expect(screen.getByRole("row", { name: `Open key ${mockKey.token}` })).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 +577,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();
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,36 @@ 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 a multi-request session from its focused row and exposes its accessible name", async () => {
|
||||
const user = userEvent.setup();
|
||||
respondWith([
|
||||
logEntry({ request_id: "req-session-keyboard", session_id: "sess-keyboard", session_total_count: 3 }),
|
||||
]);
|
||||
renderPanel();
|
||||
|
||||
const sessionRow = await screen.findByRole("row", { name: "Open session sess-keyboard" });
|
||||
sessionRow.focus();
|
||||
expect(sessionRow).toHaveFocus();
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
await waitFor(() => expect(drawer()).toHaveAttribute("data-session-id", "sess-keyboard"));
|
||||
expect(urlParams().get("log_id")).toBe("req-session-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");
|
||||
|
|
@ -22,7 +22,7 @@ import {
|
|||
type PaginatedResponse,
|
||||
useLogFilterLogic,
|
||||
} from "./useLogFilterLogic";
|
||||
import { useLogDetailRouting } from "./logDetailRouting";
|
||||
import { isMultiRequestSession, useLogDetailRouting } from "./logDetailRouting";
|
||||
import { LogDetailsDrawer } from "../detail";
|
||||
import {
|
||||
defaultLogsTimeRange,
|
||||
|
|
@ -253,7 +253,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
|
|||
const handleRowClick = useCallback(
|
||||
(log: LogEntry) => {
|
||||
setSelectedLog(log);
|
||||
if (log.session_id && (log.session_total_count || 1) > 1) {
|
||||
if (isMultiRequestSession(log) && log.session_id) {
|
||||
openSession(log.session_id, log.request_id);
|
||||
} else {
|
||||
openLog(log.request_id);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { CREDENTIAL_LABELS, SPAN_TYPE_LABELS } from "../constants";
|
|||
import { LOG_FILTER_IDS, LOG_FILTER_LABELS, type LogsWindow } from "./useLogFilterLogic";
|
||||
import { RequestLogsFilters } from "./RequestLogsFilters";
|
||||
import { getRequestLogsTableColumns } from "./RequestLogsTableColumns";
|
||||
import { requestLogRowLabel } from "./logDetailRouting";
|
||||
|
||||
interface RequestLogsTableProps {
|
||||
data: LogEntry[];
|
||||
|
|
@ -116,6 +117,7 @@ export function RequestLogsTable({
|
|||
noDataMessage={<RequestLogsEmptyState filtered={isFiltered} />}
|
||||
size="compact"
|
||||
onRowClick={onRowClick}
|
||||
getRowLabel={requestLogRowLabel}
|
||||
toolbar={(table) => (
|
||||
<>
|
||||
<DataTableToolbar
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { DataTable } from "@/components/shared/DataTable";
|
|||
|
||||
import type { LogEntry } from "../types";
|
||||
import { getRequestLogsTableColumns } from "./RequestLogsTableColumns";
|
||||
import { requestLogRowLabel } from "./logDetailRouting";
|
||||
|
||||
const { copyToClipboardMock } = vi.hoisted(() => ({ copyToClipboardMock: vi.fn() }));
|
||||
|
||||
|
|
@ -303,6 +304,7 @@ describe("Request ID column", () => {
|
|||
getRowId={(row) => row.request_id}
|
||||
size="compact"
|
||||
onRowClick={onRowClick}
|
||||
getRowLabel={requestLogRowLabel}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { LogEntry } from "../types";
|
||||
import { isMultiRequestSession, requestLogRowLabel } from "./logDetailRouting";
|
||||
|
||||
const logEntry = (overrides: Partial<LogEntry> = {}): LogEntry => ({
|
||||
request_id: "r1",
|
||||
api_key: "key-1",
|
||||
team_id: "team-1",
|
||||
model: "gpt-4o",
|
||||
model_id: "model-1",
|
||||
call_type: "acompletion",
|
||||
spend: 0,
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
startTime: "2026-07-07T09:50:13Z",
|
||||
endTime: "2026-07-07T09:50:14Z",
|
||||
cache_hit: "false",
|
||||
messages: [],
|
||||
response: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("Request Logs row labels", () => {
|
||||
it("labels a multi-request session by its session id", () => {
|
||||
const log = logEntry({ session_id: "s1", session_total_count: 3 });
|
||||
|
||||
expect(isMultiRequestSession(log)).toBe(true);
|
||||
expect(requestLogRowLabel(log)).toBe("Open session s1");
|
||||
});
|
||||
|
||||
it("labels a single-request session by its request id", () => {
|
||||
const log = logEntry({ session_id: "s1", session_total_count: 1 });
|
||||
|
||||
expect(isMultiRequestSession(log)).toBe(false);
|
||||
expect(requestLogRowLabel(log)).toBe("Open request r1");
|
||||
});
|
||||
|
||||
it("labels a request without a session by its request id", () => {
|
||||
const log = logEntry({ session_total_count: 3 });
|
||||
|
||||
expect(isMultiRequestSession(log)).toBe(false);
|
||||
expect(requestLogRowLabel(log)).toBe("Open request r1");
|
||||
});
|
||||
|
||||
it("defaults an undefined session count to a request label", () => {
|
||||
const log = logEntry({ request_id: "", session_id: "s1", session_total_count: undefined });
|
||||
|
||||
expect(isMultiRequestSession(log)).toBe(false);
|
||||
expect(requestLogRowLabel(log)).toBe("Open request");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,22 @@
|
|||
import { parseAsString, useQueryStates } from "nuqs";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { LogEntry } from "../types";
|
||||
|
||||
export const LOG_ID_QUERY_PARAM = "log_id";
|
||||
export const SESSION_ID_QUERY_PARAM = "session_id";
|
||||
|
||||
export function isMultiRequestSession(log: LogEntry): boolean {
|
||||
return Boolean(log.session_id) && (log.session_total_count || 1) > 1;
|
||||
}
|
||||
|
||||
export function requestLogRowLabel(log: LogEntry): string {
|
||||
if (isMultiRequestSession(log)) {
|
||||
return `Open session ${log.session_id}`;
|
||||
}
|
||||
return log.request_id ? `Open request ${log.request_id}` : "Open request";
|
||||
}
|
||||
|
||||
export interface LogDetailRouting {
|
||||
logId: string | null;
|
||||
sessionId: string | null;
|
||||
|
|
|
|||
|
|
@ -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() }));
|
||||
|
||||
|
|
@ -63,6 +64,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[],
|
||||
|
|
@ -152,6 +163,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 = () => {};
|
||||
|
|
@ -307,7 +370,7 @@ describe("PublicModelHub", () => {
|
|||
respondWith([model({ model_group: "jev-latest", providers: ["typesafe"], mode: "evaluation" })]);
|
||||
renderHub();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "jev-latest" }));
|
||||
fireEvent.click(await screen.findByRole("row", { name: "Open model jev-latest" }));
|
||||
|
||||
const example = (await screen.findByText(/\/v1\/systemone/)).closest("pre");
|
||||
expect(example).toHaveTextContent('"model": "jev-latest"');
|
||||
|
|
@ -317,7 +380,7 @@ describe("PublicModelHub", () => {
|
|||
it("keeps the chat completions usage example for a chat model", async () => {
|
||||
renderHub();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "gpt-4" }));
|
||||
fireEvent.click(await screen.findByRole("row", { name: "Open model gpt-4" }));
|
||||
|
||||
const example = (await screen.findByText(/chat\.completions\.create/)).closest("pre");
|
||||
expect(example).toHaveTextContent('model="gpt-4"');
|
||||
|
|
@ -455,7 +518,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 (
|
||||
|
|
@ -493,7 +556,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);
|
||||
});
|
||||
|
||||
|
|
@ -504,14 +567,32 @@ describe("publicMCPHubColumns", () => {
|
|||
});
|
||||
|
||||
describe("public hub MCP details modal", () => {
|
||||
it("uses the alias in the accessible label when the server name is empty", async () => {
|
||||
const networkingModule = await import("./networking");
|
||||
const serverWithoutName = {
|
||||
...mockMcpServer,
|
||||
server_id: "server-fallback-id",
|
||||
server_name: "",
|
||||
alias: "server-fallback-alias",
|
||||
};
|
||||
vi.mocked(networkingModule.mcpHubPublicServersCall).mockResolvedValueOnce([serverWithoutName]);
|
||||
|
||||
renderHub();
|
||||
|
||||
await userEvent.click(await screen.findByRole("tab", { name: /MCP Hub/i }));
|
||||
expect(await screen.findByRole("row", { name: "Open MCP server server-fallback-alias" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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.
|
||||
|
|
@ -519,6 +600,22 @@ 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("uses the backend URL in displayed and copied MCP examples", async () => {
|
||||
const networkingModule = await import("./networking");
|
||||
vi.mocked(networkingModule.mcpHubPublicServersCall).mockResolvedValue([mockMcpServer]);
|
||||
|
|
@ -528,7 +625,7 @@ describe("public hub MCP details modal", () => {
|
|||
renderHub();
|
||||
|
||||
await user.click(await screen.findByRole("tab", { name: /MCP Hub/i }));
|
||||
await user.click(await screen.findByRole("button", { name: "exa_test" }));
|
||||
await user.click(await screen.findByRole("row", { name: "Open MCP server exa_test" }));
|
||||
await screen.findByText("Server Overview");
|
||||
|
||||
expect(screen.getByText(/https:\/\/backend\.example\.com\/exa_test\/mcp/)).toBeInTheDocument();
|
||||
|
|
@ -543,13 +640,15 @@ describe("public hub MCP details modal", () => {
|
|||
});
|
||||
|
||||
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 }));
|
||||
|
|
@ -305,9 +305,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;
|
||||
|
|
@ -518,6 +518,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}
|
||||
|
|
@ -586,6 +588,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}
|
||||
|
|
@ -653,6 +657,10 @@ 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 || server.alias || server.server_id}`
|
||||
}
|
||||
sortingMode="client"
|
||||
sorting={mcpSorting}
|
||||
onSortingChange={setMcpSorting}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 &
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue