mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-16 04:29:31 +00:00
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:
parent
3981228d70
commit
e50d78760b
26 changed files with 650 additions and 96 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
|
||||
|
|
|
|||
254
tests/e2e/ui/tests/tables/rowActivation.spec.ts
Normal file
254
tests/e2e/ui/tests/tables/rowActivation.spec.ts
Normal 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] } });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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) => (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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,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");
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -303,6 +303,7 @@ describe("Request ID column", () => {
|
|||
getRowId={(row) => row.request_id}
|
||||
size="compact"
|
||||
onRowClick={onRowClick}
|
||||
getRowLabel={(row) => `Open request ${row.request_id}`}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
|
@ -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 }));
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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