mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
fix(ui): scroll admin table rows inside the table instead of the page
Virtual Keys, Teams, Request Logs and Tags now hand DataTable a bounded flex chain and use fillHeight, so the app shell main stays the only page scroller, the rows scroll under a pinned header and the pagination footer sits at the bottom of the page. DataTable keeps the sticky header inside its own scroller in maxBodyHeight mode too, which is what let the header scroll away with the rows on Keys, Teams and Models. Model Hub, Vector Stores and the team detail keys tab drop their 75vh boxes and flow with the page scroller. Adds an e2e spec that fails on the merge base for every one of those pages and passes at this tip. Refs LIT-4738 Claude-Session: https://claude.ai/code/session_018yW93iDaEMhoQUXcYjus7D
This commit is contained in:
parent
a53c550951
commit
dc9f40c11f
14 changed files with 315 additions and 58 deletions
239
tests/e2e/ui/tests/tables/tableScrolling.spec.ts
Normal file
239
tests/e2e/ui/tests/tables/tableScrolling.spec.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
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 { CHAT_MODEL_A, masterKey, sendChatCompletion, waitForSpendLog } from "../../helpers/traffic";
|
||||
|
||||
/**
|
||||
* LIT-4738 table scrolling. On the paginated pages the app shell <main> is the only page scroller
|
||||
* and must never overflow: rows scroll inside the table body under a header that stays put, and the
|
||||
* pagination footer sits at the bottom of the page instead of below the fold or inside a clipped
|
||||
* box. Pages that keep plain page scrolling must never paint rows past a fixed-height ancestor.
|
||||
* The viewport is pinned so "more rows than fit" means the same thing on every machine.
|
||||
*/
|
||||
|
||||
const VIEWPORT = { width: 1280, height: 720 };
|
||||
const SEED_ROWS = 40;
|
||||
const LOG_ROWS = 20;
|
||||
const BODY_SCROLL_PX = 500;
|
||||
/** p-8 on Keys and Teams, p-6 on Logs: the footer may sit at most one page padding above the edge. */
|
||||
const MAX_FOOTER_GAP_PX = 40;
|
||||
|
||||
const uniqueSuffix = (): string => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const adminHeaders = (): Record<string, string> => ({
|
||||
Authorization: `Bearer ${masterKey()}`,
|
||||
});
|
||||
|
||||
/** Keys and Teams render a <main> of their own inside the app shell's, which comes first in document order. */
|
||||
const pageScroller = (page: PlaywrightPage): Locator => page.locator("main").first();
|
||||
|
||||
/** Tabs keep every panel mounted, so a bare test id can match a hidden table; scope to the visible one. */
|
||||
const visibleTestId = (page: PlaywrightPage, id: string): Locator => page.getByTestId(id).filter({ visible: true });
|
||||
|
||||
/** The page's data table; Model Hub also renders a plain links table above it, which this skips. */
|
||||
const visibleDataTable = (page: PlaywrightPage): Locator => visibleTestId(page, "data-table-root").first();
|
||||
|
||||
const visibleRows = (page: PlaywrightPage): Locator => visibleDataTable(page).locator("tbody tr");
|
||||
|
||||
interface BoxMetrics {
|
||||
top: number;
|
||||
bottom: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
scrollWidth: number;
|
||||
clientWidth: number;
|
||||
}
|
||||
|
||||
const metrics = (locator: Locator): Promise<BoxMetrics> =>
|
||||
locator.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
scrollWidth: el.scrollWidth,
|
||||
clientWidth: el.clientWidth,
|
||||
};
|
||||
});
|
||||
|
||||
async function postOk(
|
||||
request: APIRequestContext,
|
||||
path: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<Record<string, any>> {
|
||||
const res = await request.post(path, { headers: adminHeaders(), data });
|
||||
expect(res.ok(), `POST ${path} failed (${res.status()}): ${await res.text()}`).toBe(true);
|
||||
return (await res.json()) as Record<string, any>;
|
||||
}
|
||||
|
||||
/** One request at a time: a burst of forty management calls starves the proxy's transaction pool. */
|
||||
async function oneAtATime<T>(count: number, call: (index: number) => Promise<T>): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
results.push(await call(i));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function expectRowsAtLeast(page: PlaywrightPage, count: number): Promise<void> {
|
||||
await expect.poll(() => visibleRows(page).count(), { timeout: 30_000 }).toBeGreaterThanOrEqual(count);
|
||||
}
|
||||
|
||||
async function setRowsPerPage(page: PlaywrightPage, size: "25" | "50" | "100"): Promise<void> {
|
||||
await visibleTestId(page, "pagination-page-size").click();
|
||||
await page.getByRole("option", { name: size, exact: true }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* The page scroller stays put, the table body is what scrolls, the header does not move while the
|
||||
* body scrolls, and the pagination footer sits at the bottom of the page.
|
||||
*/
|
||||
async function expectBodyIsTheOnlyScroller(page: PlaywrightPage): Promise<void> {
|
||||
const scroller = await metrics(pageScroller(page));
|
||||
const body = visibleTestId(page, "data-table-scroller");
|
||||
const bodyBefore = await metrics(body);
|
||||
const headBefore = await metrics(visibleTestId(page, "data-table-head"));
|
||||
const footer = await metrics(visibleDataTable(page));
|
||||
|
||||
expect(scroller.scrollHeight, "page scroller must not overflow vertically").toBe(scroller.clientHeight);
|
||||
expect(scroller.scrollWidth, "page scroller must not overflow horizontally").toBe(scroller.clientWidth);
|
||||
expect(bodyBefore.scrollHeight, "table body must be the element that scrolls").toBeGreaterThan(
|
||||
bodyBefore.clientHeight,
|
||||
);
|
||||
expect(footer.bottom, "pagination footer must be inside the page").toBeLessThanOrEqual(scroller.bottom);
|
||||
expect(scroller.bottom - footer.bottom, "pagination footer must sit at the bottom of the page").toBeLessThanOrEqual(
|
||||
MAX_FOOTER_GAP_PX,
|
||||
);
|
||||
|
||||
await body.evaluate((el, px) => {
|
||||
el.scrollTop = px;
|
||||
}, BODY_SCROLL_PX);
|
||||
await expect.poll(() => body.evaluate((el) => el.scrollTop)).toBeGreaterThan(0);
|
||||
const headAfter = await metrics(visibleTestId(page, "data-table-head"));
|
||||
expect(Math.round(headAfter.top), "header must stay put while the body scrolls").toBe(Math.round(headBefore.top));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every row must sit inside each ancestor up to the nearest one that really scrolls vertically; a
|
||||
* fixed-height box that neither grows nor scrolls lets rows paint past its bottom edge.
|
||||
*/
|
||||
const rowsPaintingPastAnAncestor = (page: PlaywrightPage): Promise<string[]> =>
|
||||
visibleDataTable(page)
|
||||
.locator("table")
|
||||
.evaluate((table) => {
|
||||
const scrollsVertically = (el: Element): boolean =>
|
||||
/auto|scroll/.test(getComputedStyle(el).overflowY) && el.scrollHeight > el.clientHeight + 1;
|
||||
const describe = (el: Element): string =>
|
||||
`<${el.tagName.toLowerCase()} class="${el.getAttribute("class") ?? ""}">`;
|
||||
return Array.from(table.querySelectorAll("tbody tr")).flatMap((row, index) => {
|
||||
const rowBottom = row.getBoundingClientRect().bottom;
|
||||
const spills: string[] = [];
|
||||
for (let el = row.parentElement; el && el !== document.body && !scrollsVertically(el); el = el.parentElement) {
|
||||
const bottom = el.getBoundingClientRect().bottom;
|
||||
if (rowBottom > bottom + 1) {
|
||||
spills.push(
|
||||
`row ${index} bottom ${Math.round(rowBottom)} past ${describe(el)} bottom ${Math.round(bottom)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return spills;
|
||||
});
|
||||
});
|
||||
|
||||
type Cleanup = (request: APIRequestContext) => Promise<unknown>;
|
||||
const cleanups: Cleanup[] = [];
|
||||
|
||||
test.describe("Admin tables scroll inside the page", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH, viewport: VIEWPORT });
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
for (const cleanup of cleanups.splice(0)) {
|
||||
// Teardown must never turn a passing test red or mask a real failure.
|
||||
await cleanup(request).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test("Virtual Keys: rows scroll under a sticky header and the page itself never scrolls", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const suffix = uniqueSuffix();
|
||||
const created = await oneAtATime(SEED_ROWS, (i) =>
|
||||
postOk(request, "/key/generate", { key_alias: `e2e-scroll-key-${suffix}-${i}` }),
|
||||
);
|
||||
cleanups.push((r) => r.post("/key/delete", { headers: adminHeaders(), data: { keys: created.map((k) => k.key) } }));
|
||||
|
||||
await navigateToPage(page, Page.ApiKeys);
|
||||
await expectRowsAtLeast(page, SEED_ROWS);
|
||||
await expectBodyIsTheOnlyScroller(page);
|
||||
});
|
||||
|
||||
test("Teams: rows scroll under a sticky header and the page itself never scrolls", async ({ page, request }) => {
|
||||
const suffix = uniqueSuffix();
|
||||
const created = await oneAtATime(SEED_ROWS, (i) =>
|
||||
postOk(request, "/team/new", { team_alias: `e2e-scroll-team-${suffix}-${i}` }),
|
||||
);
|
||||
cleanups.push((r) =>
|
||||
r.post("/team/delete", { headers: adminHeaders(), data: { team_ids: created.map((t) => t.team_id) } }),
|
||||
);
|
||||
|
||||
await navigateToPage(page, Page.Teams);
|
||||
await expectRowsAtLeast(page, SEED_ROWS);
|
||||
await expectBodyIsTheOnlyScroller(page);
|
||||
});
|
||||
|
||||
test("Request Logs: rows scroll under a sticky header and the page itself never scrolls", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const suffix = uniqueSuffix();
|
||||
const ids = await oneAtATime(LOG_ROWS, (i) =>
|
||||
sendChatCompletion(request, { model: CHAT_MODEL_A, prompt: `scroll ${suffix} ${i}` }),
|
||||
);
|
||||
await waitForSpendLog(request, ids[ids.length - 1]);
|
||||
|
||||
await navigateToPage(page, Page.Logs);
|
||||
await expect(visibleTestId(page, "datatable-search")).toBeVisible({ timeout: 20_000 });
|
||||
await setRowsPerPage(page, "25");
|
||||
await expectRowsAtLeast(page, LOG_ROWS);
|
||||
await expectBodyIsTheOnlyScroller(page);
|
||||
});
|
||||
|
||||
test("Tags: no row paints past the box it lives in", async ({ page, request }) => {
|
||||
const suffix = uniqueSuffix();
|
||||
const names = Array.from({ length: SEED_ROWS }, (_, i) => `e2e-scroll-tag-${suffix}-${i}`);
|
||||
await oneAtATime(SEED_ROWS, (i) => postOk(request, "/tag/new", { name: names[i], description: "LIT-4738 scroll" }));
|
||||
cleanups.push((r) =>
|
||||
oneAtATime(SEED_ROWS, (i) => r.post("/tag/delete", { headers: adminHeaders(), data: { name: names[i] } })),
|
||||
);
|
||||
|
||||
await navigateToPage(page, Page.TagManagement);
|
||||
await expectRowsAtLeast(page, SEED_ROWS);
|
||||
expect(await rowsPaintingPastAnAncestor(page)).toEqual([]);
|
||||
});
|
||||
|
||||
test("Model Hub: no row paints past the box it lives in", async ({ page, request }) => {
|
||||
const suffix = uniqueSuffix();
|
||||
const created = await oneAtATime(SEED_ROWS, (i) =>
|
||||
postOk(request, "/model/new", {
|
||||
model_name: `e2e-scroll-model-${suffix}-${i}`,
|
||||
litellm_params: {
|
||||
model: "openai/fake-gpt-4",
|
||||
api_base: `http://127.0.0.1:${process.env.MOCK_LLM_PORT ?? "8090"}/v1`,
|
||||
api_key: "fake-key",
|
||||
},
|
||||
}),
|
||||
);
|
||||
cleanups.push((r) =>
|
||||
oneAtATime(SEED_ROWS, (i) =>
|
||||
r.post("/model/delete", { headers: adminHeaders(), data: { id: created[i].model_info.id } }),
|
||||
),
|
||||
);
|
||||
|
||||
await navigateToPage(page, Page.ModelHubTable);
|
||||
await expectRowsAtLeast(page, SEED_ROWS);
|
||||
expect(await rowsPaintingPastAnAncestor(page)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -41,6 +41,7 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
|
|||
data={data}
|
||||
columns={columns}
|
||||
getRowId={(tag, index) => tag.name || String(index)}
|
||||
fillHeight
|
||||
sortingMode="client"
|
||||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
|
|||
}, [accessToken]);
|
||||
|
||||
return (
|
||||
<div className="mx-4 h-[75vh]">
|
||||
<div className="mx-4 h-full">
|
||||
{selectedTagId ? (
|
||||
<TagInfoView
|
||||
tagId={selectedTagId}
|
||||
|
|
@ -139,7 +139,7 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
|
|||
editTag={editTag}
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-2 h-[75vh] w-full gap-2 p-8">
|
||||
<div className="flex h-full w-full flex-col p-8 pt-10">
|
||||
<div className="mt-2 mb-4 flex w-full items-center justify-between">
|
||||
<h1>Tag Management</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
|
|
@ -162,23 +162,21 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<Button className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
|
||||
<Button className="mb-4 self-start" onClick={() => setIsCreateModalVisible(true)}>
|
||||
+ Create New Tag
|
||||
</Button>
|
||||
|
||||
<div className="mt-2 grid h-[75vh] w-full grid-cols-1 gap-2 pt-2 pb-2">
|
||||
<div>
|
||||
<TagTable
|
||||
data={tags}
|
||||
isLoading={isLoadingTags}
|
||||
onEdit={(tag) => {
|
||||
setSelectedTagId(tag.name);
|
||||
setEditTag(true);
|
||||
}}
|
||||
onDelete={handleDelete}
|
||||
onSelectTag={setSelectedTagId}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex min-h-0 flex-1 flex-col">
|
||||
<TagTable
|
||||
data={tags}
|
||||
isLoading={isLoadingTags}
|
||||
onEdit={(tag) => {
|
||||
setSelectedTagId(tag.name);
|
||||
setEditTag(true);
|
||||
}}
|
||||
onDelete={handleDelete}
|
||||
onSelectTag={setSelectedTagId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create Tag Modal */}
|
||||
|
|
|
|||
|
|
@ -137,8 +137,8 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
|
|||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-4 h-[75vh]">
|
||||
<div className="gap-2 p-8 h-[75vh] w-full mt-2">
|
||||
<div className="mx-4">
|
||||
<div className="gap-2 p-8 w-full mt-2">
|
||||
<div className="flex justify-between mt-2 w-full items-center mb-4">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">Vector Store Management</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
|
|
|
|||
|
|
@ -400,7 +400,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mx-4 h-[75vh]">
|
||||
<div className="mx-4">
|
||||
{publicPage == false ? (
|
||||
<div className="w-full m-2 mt-2 p-8">
|
||||
{/* Header with Title, Description and URL */}
|
||||
|
|
|
|||
|
|
@ -559,6 +559,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
{
|
||||
key: "your-teams",
|
||||
label: "Your Teams",
|
||||
className: "flex min-h-0 flex-1 flex-col",
|
||||
children: (
|
||||
<>
|
||||
<TeamsTable
|
||||
|
|
@ -608,6 +609,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
{
|
||||
key: "available-teams",
|
||||
label: "Available Teams",
|
||||
className: "min-h-0 flex-1 overflow-y-auto",
|
||||
children: <AvailableTeamsPanel accessToken={accessToken} userID={userID} />,
|
||||
},
|
||||
...(isProxyAdminRole(userRole || "")
|
||||
|
|
@ -615,6 +617,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
{
|
||||
key: "default-settings",
|
||||
label: "Default Team Settings",
|
||||
className: "min-h-0 flex-1 overflow-y-auto",
|
||||
children: <TeamSSOSettings accessToken={accessToken} userID={userID || ""} userRole={userRole || ""} />,
|
||||
},
|
||||
]
|
||||
|
|
@ -622,7 +625,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
];
|
||||
|
||||
return (
|
||||
<main className={selectedTeamId ? "px-12 py-6" : "p-8"}>
|
||||
<main className={selectedTeamId ? "px-12 py-6" : "flex h-full flex-col p-8"}>
|
||||
{selectedTeamId ? (
|
||||
<TeamInfoView
|
||||
teamId={selectedTeamId}
|
||||
|
|
@ -642,7 +645,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
premiumUser={premiumUser}
|
||||
/>
|
||||
) : (
|
||||
<Tabs defaultValue={tabItems[0].key} className="gap-6">
|
||||
<Tabs defaultValue={tabItems[0].key} className="min-h-0 flex-1 gap-6">
|
||||
<PageHeader
|
||||
icon={<Users />}
|
||||
title="Teams"
|
||||
|
|
@ -674,7 +677,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
)}
|
||||
/>
|
||||
{tabItems.map((item) => (
|
||||
<TabsContent key={item.key} value={item.key}>
|
||||
<TabsContent key={item.key} value={item.key} className={item.className}>
|
||||
{item.children}
|
||||
</TabsContent>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
|
|||
isLoading={isLoading}
|
||||
loadingMessage="Loading teams..."
|
||||
noDataMessage="No teams found"
|
||||
maxBodyHeight="calc(75vh - 210px)"
|
||||
fillHeight
|
||||
size="compact"
|
||||
toolbar={(table) => (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-6 overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6">
|
||||
<PageHeader
|
||||
icon={<KeyRound />}
|
||||
title="Virtual Keys"
|
||||
|
|
@ -283,7 +283,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
|
|||
isLoading={isLoading}
|
||||
loadingMessage="Loading keys..."
|
||||
noDataMessage="No keys found"
|
||||
maxBodyHeight="calc(75vh - 210px)"
|
||||
fillHeight
|
||||
size="compact"
|
||||
toolbar={(table) => (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -613,8 +613,12 @@ describe("DataTable layout", () => {
|
|||
|
||||
it("makes the header sticky and constrains body height when maxBodyHeight is set", () => {
|
||||
render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} maxBodyHeight={240} />);
|
||||
expect(screen.getByTestId("data-table-head")).toHaveClass("sticky");
|
||||
expect(screen.getByTestId("data-table-scroller")).toHaveStyle({ maxHeight: "240px" });
|
||||
const scroller = screen.getByTestId("data-table-scroller");
|
||||
expect(scroller).toHaveStyle({ maxHeight: "240px" });
|
||||
expect(scroller).toHaveClass("overflow-auto");
|
||||
// As in fill mode: the Table primitive's own overflow container would otherwise capture the sticky header.
|
||||
expect(scroller).toHaveClass("[&_[data-slot=table-container]]:overflow-visible");
|
||||
expect(screen.getByTestId("data-table-head")).toHaveClass("sticky", "bg-background");
|
||||
});
|
||||
|
||||
it("caps fillHeight at the parent's height instead of stretching to it, so a short table stays short", () => {
|
||||
|
|
|
|||
|
|
@ -59,18 +59,28 @@ const noop = () => {};
|
|||
/**
|
||||
* Height-filling mode. The table still sizes to its rows; the parent's height is only a ceiling, so
|
||||
* a short table keeps its footer under the last row and a long one scrolls its rows instead of the
|
||||
* page. `table-container` is the Table primitive's own overflow-x wrapper; left as a scroll box it
|
||||
* captures the sticky header and the header scrolls away with the rows. And rows pass under that
|
||||
* header, which the semi-transparent header row tint alone would not hide.
|
||||
* page.
|
||||
*/
|
||||
const FILL_CLASSES = {
|
||||
outer: "flex max-h-full min-h-0 flex-col",
|
||||
frame: "flex min-h-0 flex-col",
|
||||
body: "min-h-0 [&_[data-slot=table-container]]:overflow-visible",
|
||||
body: "min-h-0",
|
||||
} as const;
|
||||
|
||||
const NO_FILL_CLASSES = { outer: "", frame: "", body: "" } as const;
|
||||
|
||||
/**
|
||||
* Sticky header, in both fill and maxBodyHeight mode. `table-container` is the Table primitive's own
|
||||
* overflow-x wrapper; left as a scroll box it captures the sticky header and the header scrolls away
|
||||
* with the rows. And rows pass under that header, which the semi-transparent header row tint alone
|
||||
* would not hide.
|
||||
*/
|
||||
const STICKY_CLASSES = {
|
||||
body: "[&_[data-slot=table-container]]:overflow-visible",
|
||||
header: "bg-background",
|
||||
} as const;
|
||||
|
||||
const NO_FILL_CLASSES = { outer: "", frame: "", body: "", header: "" } as const;
|
||||
const NO_STICKY_CLASSES = { body: "", header: "" } as const;
|
||||
|
||||
function columnDefId<TData, TValue>(column: ColumnDef<TData, TValue>): string | undefined {
|
||||
if ("id" in column && typeof column.id === "string") {
|
||||
|
|
@ -533,6 +543,7 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
|
|||
const visibleColumnCount = table.getVisibleLeafColumns().length;
|
||||
const stickyHeader = maxBodyHeight !== undefined || fillHeight;
|
||||
const fill = fillHeight ? FILL_CLASSES : NO_FILL_CLASSES;
|
||||
const sticky = stickyHeader ? STICKY_CLASSES : NO_STICKY_CLASSES;
|
||||
const tableStyle = enableColumnResizing ? { width: table.getTotalSize(), minWidth: "100%" } : undefined;
|
||||
|
||||
const renderPagination = (): React.ReactNode => {
|
||||
|
|
@ -593,13 +604,13 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
|
|||
{toolbar !== undefined && <div className="shrink-0 border-b border-border px-4 py-3">{toolbar(table)}</div>}
|
||||
<div
|
||||
data-testid="data-table-scroller"
|
||||
className={cn(stickyHeader ? "overflow-auto" : "overflow-x-auto", fill.body)}
|
||||
className={cn(stickyHeader ? "overflow-auto" : "overflow-x-auto", sticky.body, fill.body)}
|
||||
style={maxBodyHeight !== undefined ? { maxHeight: maxBodyHeight } : undefined}
|
||||
>
|
||||
<TableRoot className={enableColumnResizing ? "table-fixed" : ""} style={tableStyle}>
|
||||
<TableHeader
|
||||
data-testid="data-table-head"
|
||||
className={cn(stickyHeader ? "sticky top-0 z-sticky" : "", fill.header)}
|
||||
className={cn(stickyHeader ? "sticky top-0 z-sticky" : "", sticky.header)}
|
||||
>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="bg-muted/50">
|
||||
|
|
|
|||
|
|
@ -443,7 +443,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-hidden">
|
||||
<div className="w-full">
|
||||
{selectedKey ? (
|
||||
<KeyInfoView
|
||||
keyId={selectedKey.token}
|
||||
|
|
@ -453,7 +453,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
onDelete={refetch}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-4 flex-1 overflow-hidden">
|
||||
<div className="py-4">
|
||||
<DataTable
|
||||
data={displayKeys}
|
||||
columns={columns}
|
||||
|
|
@ -471,7 +471,6 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
|
|||
columnResizeMode="onChange"
|
||||
isLoading={isLoading || isFetching}
|
||||
loadingMessage="Loading keys..."
|
||||
maxBodyHeight="75vh"
|
||||
size="compact"
|
||||
toolbar={(table) => (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -216,24 +216,22 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
|
|||
const canCreateKey = userRole !== "Admin Viewer" && userRole !== "proxy_admin_viewer";
|
||||
|
||||
return (
|
||||
<main className="h-[75vh] p-8">
|
||||
<div className="flex h-full flex-col">
|
||||
<VirtualKeysTable
|
||||
headerActions={
|
||||
canCreateKey ? (
|
||||
<CreateKey
|
||||
key={selectedTeam ? selectedTeam.team_id : null}
|
||||
team={selectedTeam as Team | null}
|
||||
teams={teams as Team[]}
|
||||
data={keys}
|
||||
addKey={addKey}
|
||||
autoOpenCreate={autoOpenCreate}
|
||||
prefillData={prefillData}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<main className="flex h-full flex-col p-8">
|
||||
<VirtualKeysTable
|
||||
headerActions={
|
||||
canCreateKey ? (
|
||||
<CreateKey
|
||||
key={selectedTeam ? selectedTeam.team_id : null}
|
||||
team={selectedTeam as Team | null}
|
||||
teams={teams as Team[]}
|
||||
data={keys}
|
||||
addKey={addKey}
|
||||
autoOpenCreate={autoOpenCreate}
|
||||
prefillData={prefillData}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ export function RequestLogsTable({
|
|||
data={data}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.request_id}
|
||||
fillHeight
|
||||
sortingMode="server"
|
||||
sorting={sorting}
|
||||
onSortingChange={onSortingChange}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ const AUDIT_LOGS_TAB: LogsTab = { id: "audit logs", label: "Audit Logs" };
|
|||
const DELETED_KEYS_TAB: LogsTab = { id: "deleted keys", label: "Deleted Keys" };
|
||||
const DELETED_TEAMS_TAB: LogsTab = { id: "deleted teams", label: "Deleted Teams" };
|
||||
|
||||
const tabContentClassName = (tabId: LogsTabId): string =>
|
||||
tabId === REQUEST_LOGS_TAB.id ? "flex min-h-0 flex-1 flex-col" : "min-h-0 flex-1 overflow-y-auto";
|
||||
|
||||
export default function SpendLogsTable({ accessToken, token, userRole, userID, premiumUser }: SpendLogsTableProps) {
|
||||
const [activeTab, setActiveTab] = useState<LogsTabId>(REQUEST_LOGS_TAB.id);
|
||||
const canViewAuditLogs = useCan("viewAuditLogs");
|
||||
|
|
@ -78,8 +81,8 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="box-border w-full overflow-x-hidden p-6">
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as LogsTabId)}>
|
||||
<div className="flex h-full w-full flex-col p-6">
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as LogsTabId)} className="min-h-0 flex-1">
|
||||
<TabsList variant="line">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id} className="flex-none">
|
||||
|
|
@ -88,7 +91,7 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
|
|||
))}
|
||||
</TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.id} value={tab.id} keepMounted>
|
||||
<TabsContent key={tab.id} value={tab.id} keepMounted className={tabContentClassName(tab.id)}>
|
||||
{renderPanel(tab.id)}
|
||||
</TabsContent>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue