From dc9f40c11fce86cb6c473d8235366a049894d583 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 3 Sep 2026 17:13:18 -0700 Subject: [PATCH] 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 --- .../ui/tests/tables/tableScrolling.spec.ts | 239 ++++++++++++++++++ .../tag-management/_components/TagTable.tsx | 1 + .../tag-management/_components/index.tsx | 30 +-- .../vector-stores/_components/index.tsx | 4 +- .../src/components/AIHub/ModelHubTable.tsx | 2 +- ui/litellm-dashboard/src/components/Teams.tsx | 9 +- .../src/components/TeamsPage/TeamsTable.tsx | 2 +- .../VirtualKeysPage/VirtualKeysTable.tsx | 4 +- .../shared/DataTable/DataTable.test.tsx | 8 +- .../components/shared/DataTable/DataTable.tsx | 25 +- .../components/team/TeamVirtualKeysTable.tsx | 5 +- .../src/components/user_dashboard.tsx | 34 ++- .../components/view_logs/RequestLogsTable.tsx | 1 + .../src/components/view_logs/index.tsx | 9 +- 14 files changed, 315 insertions(+), 58 deletions(-) create mode 100644 tests/e2e/ui/tests/tables/tableScrolling.spec.ts diff --git a/tests/e2e/ui/tests/tables/tableScrolling.spec.ts b/tests/e2e/ui/tests/tables/tableScrolling.spec.ts new file mode 100644 index 00000000000..f51c772185c --- /dev/null +++ b/tests/e2e/ui/tests/tables/tableScrolling.spec.ts @@ -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
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 => ({ + Authorization: `Bearer ${masterKey()}`, +}); + +/** Keys and Teams render a
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 => + 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, +): Promise> { + 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; +} + +/** One request at a time: a burst of forty management calls starves the proxy's transaction pool. */ +async function oneAtATime(count: number, call: (index: number) => Promise): Promise { + 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 { + await expect.poll(() => visibleRows(page).count(), { timeout: 30_000 }).toBeGreaterThanOrEqual(count); +} + +async function setRowsPerPage(page: PlaywrightPage, size: "25" | "50" | "100"): Promise { + 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 { + 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 => + 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; +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([]); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx index 488190a0fdf..feea01f19ca 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx @@ -41,6 +41,7 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag data={data} columns={columns} getRowId={(tag, index) => tag.name || String(index)} + fillHeight sortingMode="client" sorting={sorting} onSortingChange={setSorting} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/index.tsx index a04afcd6d45..583492bf837 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/index.tsx @@ -126,7 +126,7 @@ const TagManagement: React.FC = ({ accessToken, userID, userRole }) => }, [accessToken]); return ( -
+
{selectedTagId ? ( = ({ accessToken, userID, userRole }) => editTag={editTag} /> ) : ( -
+

Tag Management

@@ -162,23 +162,21 @@ const TagManagement: React.FC = ({ accessToken, userID, userRole }) =>

- -
-
- { - setSelectedTagId(tag.name); - setEditTag(true); - }} - onDelete={handleDelete} - onSelectTag={setSelectedTagId} - /> -
+
+ { + setSelectedTagId(tag.name); + setEditTag(true); + }} + onDelete={handleDelete} + onSelectTag={setSelectedTagId} + />
{/* Create Tag Modal */} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx index f74f444ce26..1745c710c51 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx @@ -137,8 +137,8 @@ const VectorStoreManagement: React.FC = ({ accessToken, userID />
) : ( -
-
+
+

Vector Store Management

diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index 475e3dcd70b..74f44db5cb9 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -400,7 +400,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, } return ( -
+
{publicPage == false ? (
{/* Header with Title, Description and URL */} diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index ef58237a6aa..4be91f22339 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -559,6 +559,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser { key: "your-teams", label: "Your Teams", + className: "flex min-h-0 flex-1 flex-col", children: ( <> = ({ accessToken, userID, userRole, premiumUser { key: "available-teams", label: "Available Teams", + className: "min-h-0 flex-1 overflow-y-auto", children: , }, ...(isProxyAdminRole(userRole || "") @@ -615,6 +617,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser { key: "default-settings", label: "Default Team Settings", + className: "min-h-0 flex-1 overflow-y-auto", children: , }, ] @@ -622,7 +625,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser ]; return ( -
+
{selectedTeamId ? ( = ({ accessToken, userID, userRole, premiumUser premiumUser={premiumUser} /> ) : ( - + } title="Teams" @@ -674,7 +677,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser )} /> {tabItems.map((item) => ( - + {item.children} ))} diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx index 0b38d51e36a..3fb19e522f3 100644 --- a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx +++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx @@ -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) => ( <> diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index ebedf57af45..f28ae27b6bc 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -256,7 +256,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { } return ( -
+
} 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) => ( <> diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx index f502ea77127..daf20d927e7 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx @@ -613,8 +613,12 @@ describe("DataTable layout", () => { it("makes the header sticky and constrains body height when maxBodyHeight is set", () => { render(); - 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", () => { diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx index 60267606951..f04430c1698 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx @@ -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(column: ColumnDef): string | undefined { if ("id" in column && typeof column.id === "string") { @@ -533,6 +543,7 @@ export function DataTable(props: DataTableProps { @@ -593,13 +604,13 @@ export function DataTable(props: DataTableProps{toolbar(table)}
}
{table.getHeaderGroups().map((headerGroup) => ( diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index aa9df4a0319..b0380255b95 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -443,7 +443,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi }, []); return ( -
+
{selectedKey ? ( ) : ( -
+
( <> diff --git a/ui/litellm-dashboard/src/components/user_dashboard.tsx b/ui/litellm-dashboard/src/components/user_dashboard.tsx index 614b3b7af62..dcadc141103 100644 --- a/ui/litellm-dashboard/src/components/user_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/user_dashboard.tsx @@ -216,24 +216,22 @@ const UserDashboard: React.FC = ({ const canCreateKey = userRole !== "Admin Viewer" && userRole !== "proxy_admin_viewer"; return ( -
-
- - ) : undefined - } - /> -
+
+ + ) : undefined + } + />
); }; diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTable.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTable.tsx index 17caa4466fa..db108ee681c 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTable.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTable.tsx @@ -86,6 +86,7 @@ export function RequestLogsTable({ data={data} columns={columns} getRowId={(row) => row.request_id} + fillHeight sortingMode="server" sorting={sorting} onSortingChange={onSortingChange} diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index f20285dffa0..aadf90fad6c 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -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(REQUEST_LOGS_TAB.id); const canViewAuditLogs = useCan("viewAuditLogs"); @@ -78,8 +81,8 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p }; return ( -
- setActiveTab(value as LogsTabId)}> +
+ setActiveTab(value as LogsTabId)} className="min-h-0 flex-1"> {tabs.map((tab) => ( @@ -88,7 +91,7 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p ))} {tabs.map((tab) => ( - + {renderPanel(tab.id)} ))}