mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
feat(ui): put the budgets CTA in the tab bar and scroll the rows, not the page
The create button now sits in the tab bar beside the tabs, the way Teams lays it out, with one divider between them and the rule running the full width underneath. Adds a fillHeight mode to DataTable that treats the parent's height as a ceiling rather than a target, so the table still sizes to its rows and a short one keeps its footer under the last row, while a long one scrolls its rows under a sticky header instead of scrolling the page. This replaces the hardcoded viewport-height caps those tables would otherwise need. Two details the mode has to fix: the Table primitive's own overflow container would capture the sticky header, and rows would show through the semi-transparent header tint.
This commit is contained in:
parent
2769fbe37b
commit
79b2a5e56e
5 changed files with 96 additions and 29 deletions
|
|
@ -226,6 +226,7 @@ const BudgetTable: React.FC<BudgetTableProps> = ({ list, canModify, onEditClick,
|
|||
columns={columns}
|
||||
getRowId={(budget, index) => budget.budget_id || String(index)}
|
||||
defaultColumnVisibility={BUDGET_TABLE_HIDDEN_COLUMNS}
|
||||
fillHeight
|
||||
sortingMode="server"
|
||||
sorting={list.sorting}
|
||||
onSortingChange={list.onSortingChange}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Plus, Wallet } from "lucide-react";
|
|||
import React, { useCallback, useState } from "react";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { PageHeader } from "@/components/shared/PageHeader";
|
||||
import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
|
|
@ -78,31 +79,34 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-6 px-12">
|
||||
<div className="flex h-full flex-col gap-4 p-6 px-12">
|
||||
<PageHeader
|
||||
icon={<Wallet className="size-5" />}
|
||||
title="Budgets"
|
||||
subtitle="Spend, TPM and RPM limits you can assign to customers."
|
||||
/>
|
||||
{canModify && (
|
||||
<div>
|
||||
<Button onClick={() => setIsCreateModelVisible(true)}>
|
||||
<Plus className="size-4" />
|
||||
Create Budget
|
||||
</Button>
|
||||
<Tabs defaultValue="budgets" className="min-h-0 flex-1 gap-0">
|
||||
<div className="flex items-center gap-4 border-b border-border">
|
||||
{canModify && (
|
||||
<>
|
||||
<Button onClick={() => setIsCreateModelVisible(true)}>
|
||||
<Plus className="size-4" />
|
||||
Create Budget
|
||||
</Button>
|
||||
<ToolbarSeparator className="h-6" />
|
||||
</>
|
||||
)}
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="budgets" className="flex-none px-4">
|
||||
Budgets
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="examples" className="flex-none px-4">
|
||||
Examples
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
)}
|
||||
<Tabs defaultValue="budgets">
|
||||
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
|
||||
<TabsTrigger value="budgets" className="flex-none rounded-none px-4 py-2">
|
||||
Budgets
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="examples" className="flex-none rounded-none px-4 py-2">
|
||||
Examples
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="budgets">
|
||||
<div className="mt-6">
|
||||
<TabsContent value="budgets" className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col pt-6">
|
||||
<BudgetModal isModalVisible={isCreateModelVisible} setIsModalVisible={setIsCreateModelVisible} />
|
||||
{selectedBudget && (
|
||||
<EditBudgetModal
|
||||
|
|
@ -134,8 +138,8 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
|
|||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="examples">
|
||||
<div className="mt-6">
|
||||
<TabsContent value="examples" className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="pt-6">
|
||||
<p className="text-base text-muted-foreground">How to use budget id</p>
|
||||
<Tabs defaultValue="assign-budget">
|
||||
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
|
||||
|
|
|
|||
|
|
@ -625,6 +625,44 @@ describe("DataTable layout", () => {
|
|||
const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement;
|
||||
expect(scroller.style.maxHeight).toBe("240px");
|
||||
});
|
||||
|
||||
it("caps fillHeight at the parent's height instead of stretching to it, so a short table stays short", () => {
|
||||
const { container } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} fillHeight />);
|
||||
const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement;
|
||||
const frame = scroller.parentElement as HTMLElement;
|
||||
const outer = frame.parentElement as HTMLElement;
|
||||
|
||||
// A ceiling, not a stretch: flex-1 here would hold the footer at the bottom on a two-row table.
|
||||
expect(outer.className).toContain("max-h-full");
|
||||
expect(outer.className).not.toContain("flex-1");
|
||||
expect(frame.className).not.toContain("flex-1");
|
||||
expect(scroller.className).not.toContain("flex-1");
|
||||
|
||||
expect(outer.className).toContain("flex-col");
|
||||
expect(frame.className).toContain("flex-col");
|
||||
expect(scroller.className).toContain("min-h-0");
|
||||
expect(scroller.className).toContain("overflow-auto");
|
||||
expect(scroller.style.maxHeight).toBe("");
|
||||
// Without this the Table primitive's own overflow container captures the sticky header.
|
||||
expect(scroller.className).toContain("[&_[data-slot=table-container]]:overflow-visible");
|
||||
|
||||
const thead = container.querySelector("thead") as HTMLElement;
|
||||
expect(thead.className).toContain("sticky");
|
||||
// Rows pass under the header, so the semi-transparent row tint alone would let them show through.
|
||||
expect(thead.className).toContain("bg-background");
|
||||
});
|
||||
|
||||
it("leaves the default layout untouched when neither height mode is set", () => {
|
||||
const { container } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} />);
|
||||
const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement;
|
||||
|
||||
expect(scroller.className).toContain("overflow-x-auto");
|
||||
expect(scroller.className).not.toContain("min-h-0");
|
||||
expect(scroller.style.maxHeight).toBe("");
|
||||
expect((scroller.parentElement as HTMLElement).className).not.toContain("flex-col");
|
||||
expect(container.querySelector("thead")?.className).not.toContain("sticky");
|
||||
expect(container.querySelector("thead")?.className).not.toContain("bg-background");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataTable misconfiguration guards", () => {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,22 @@ const INTERACTIVE_SELECTOR = "button, a, input, select, textarea, [role=checkbox
|
|||
|
||||
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.
|
||||
*/
|
||||
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",
|
||||
header: "bg-background",
|
||||
} as const;
|
||||
|
||||
const NO_FILL_CLASSES = { outer: "", frame: "", body: "", header: "" } as const;
|
||||
|
||||
export class DataTableConfigError extends Error {
|
||||
constructor(messages: readonly string[]) {
|
||||
super(`DataTable misconfiguration:\n- ${messages.join("\n- ")}`);
|
||||
|
|
@ -538,6 +554,7 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
|
|||
rowClassName,
|
||||
renderSubComponent,
|
||||
maxBodyHeight,
|
||||
fillHeight = false,
|
||||
size = "default",
|
||||
toolbar,
|
||||
paginationSlot,
|
||||
|
|
@ -548,7 +565,8 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
|
|||
|
||||
const rows = table.getRowModel().rows;
|
||||
const visibleColumnCount = table.getVisibleLeafColumns().length;
|
||||
const stickyHeader = maxBodyHeight !== undefined;
|
||||
const stickyHeader = maxBodyHeight !== undefined || fillHeight;
|
||||
const fill = fillHeight ? FILL_CLASSES : NO_FILL_CLASSES;
|
||||
const tableStyle = enableColumnResizing ? { width: table.getTotalSize(), minWidth: "100%" } : undefined;
|
||||
|
||||
const renderPagination = (): React.ReactNode => {
|
||||
|
|
@ -604,15 +622,15 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
|
|||
const paginationNode = renderPagination();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
{toolbar !== undefined && <div className="border-b border-border px-4 py-3">{toolbar(table)}</div>}
|
||||
<div className={cn("w-full", fill.outer)}>
|
||||
<div className={cn("overflow-hidden rounded-lg border border-border", fill.frame)}>
|
||||
{toolbar !== undefined && <div className="shrink-0 border-b border-border px-4 py-3">{toolbar(table)}</div>}
|
||||
<div
|
||||
className={stickyHeader ? "overflow-auto" : "overflow-x-auto"}
|
||||
style={stickyHeader ? { maxHeight: maxBodyHeight } : undefined}
|
||||
className={cn(stickyHeader ? "overflow-auto" : "overflow-x-auto", fill.body)}
|
||||
style={maxBodyHeight !== undefined ? { maxHeight: maxBodyHeight } : undefined}
|
||||
>
|
||||
<TableRoot className={enableColumnResizing ? "table-fixed" : ""} style={tableStyle}>
|
||||
<TableHeader className={stickyHeader ? "sticky top-0 z-20" : ""}>
|
||||
<TableHeader className={cn(stickyHeader ? "sticky top-0 z-20" : "", fill.header)}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="bg-muted/50 hover:bg-muted/50">
|
||||
{headerGroup.headers.map((header) => (
|
||||
|
|
@ -631,7 +649,7 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
|
|||
{footer !== undefined && <TableFooter>{footer(table)}</TableFooter>}
|
||||
</TableRoot>
|
||||
</div>
|
||||
{paginationNode !== null && <div className="border-t border-border">{paginationNode}</div>}
|
||||
{paginationNode !== null && <div className="shrink-0 border-t border-border">{paginationNode}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -69,6 +69,12 @@ export interface DataTableProps<TData extends RowData, TValue> {
|
|||
rowClassName?: (row: Row<TData>) => string;
|
||||
|
||||
maxBodyHeight?: number | string;
|
||||
/**
|
||||
* Scroll the rows inside whatever height the parent gives the table, rather than growing the page.
|
||||
* The table becomes a flex column, so the parent must be a height-constrained flex container; without
|
||||
* one it degrades to the normal auto-height layout. Use instead of `maxBodyHeight` to avoid a magic number.
|
||||
*/
|
||||
fillHeight?: boolean;
|
||||
size?: DataTableSize;
|
||||
|
||||
toolbar?: (table: Table<TData>) => React.ReactNode;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue