Skip to content

Commit 1fbd26b

Browse files
sorenbsclaude
andauthored
feat: show table row count in the data grid footer (#1554)
* feat: show table row count in the data grid footer Display the total row count of the current result set as a muted, thousands-separated label (e.g. "4,725 rows") on the right side of the data grid pagination footer. The label uses the same filtered count that already drives pagination, so it respects active filters and row search, stays exact for counts beyond Number.MAX_SAFE_INTEGER, and hides when the adapter cannot count rows (Infinity). Closes #1359 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: hide row count for unsafe-integer number inputs A `number` totalRowCount above Number.MAX_SAFE_INTEGER has already lost precision before BigInt() can see it, so the footer would display a rounded total. Hide the label for non-safe-integer numbers instead; exact large counts arrive as bigint or string, which stay supported. Addresses CodeRabbit review feedback on #1554. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 9eeb555 commit 1fbd26b

8 files changed

Lines changed: 238 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@prisma/studio-core": minor
3+
---
4+
5+
Show the total row count of the current result set in the data grid footer. The count uses the same filtered total that drives pagination, so it respects active filters and row search, formats with thousands separators, and hides when the adapter cannot count rows.

Architecture/table-query-controls.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ Column-header pin/sort control rendering and interaction rules are defined in:
160160
- Infinite scroll MUST query from `pageIndex = 0` and grow the effective `pageSize` window in fixed `25`-row batches as the grid scroll nears the bottom, independent of the paginated rows-per-page preference.
161161
- Infinite-scroll window growth MUST reset to the first chunk whenever the visible row set changes, including table scope, applied filter, row-search term, sort order, or shared page size.
162162
- Filtered row-count metadata MUST be cached independently of `pageIndex`, `pageSize`, and sort order, so pagination controls stay mounted while a different page of the same filtered result set is loading.
163+
- The footer MUST display the filtered row count that drives pagination as a read-only, thousands-separated label (singular `row` for exactly one). The label MUST format via `BigInt` so counts beyond `Number.MAX_SAFE_INTEGER` stay exact, and MUST be hidden when the adapter cannot count rows (`filteredRowCount === Infinity`).
163164
- When staged rows or staged updates exist, pagination controls MUST refuse page changes until the staged edits are resolved.
164165

165166
## Row Selection Contract

FEATURES.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,11 @@ The footer keeps page navigation, a page jump field, a fixed rows-per-page dropd
179179
Rows-per-page and infinite-scroll preferences persist across tables through local storage, while the known filtered-row count keeps the footer stable during page transitions for the same filtered result set. Infinite scroll preloads before the hard bottom edge, always appends in fixed 25-row chunks regardless of the paginated page-size setting, keeps filling tall viewports until the grid is actually scrollable, and appends new rows in place without snapping the grid back to the top.
180180
Rapid sort and filter changes keep the latest request authoritative, and superseded table reads are aborted so a slower older result cannot overwrite the visible grid.
181181

182+
## Table Row Count Display
183+
184+
The grid footer shows the total number of rows in the current result set as a muted, thousands-separated label (for example `4,725 rows`), so users can gauge table size at a glance without paging to the end.
185+
The label uses the same filtered count that drives pagination, so it reflects active filters and row search, and it stays exact for counts beyond JavaScript's safe integer range. When the adapter cannot count rows, the label is hidden instead of showing a misleading number.
186+
182187
## PostgreSQL Stored Temporal Values
183188

184189
When Studio reads PostgreSQL data through the `postgres.js` executor, `date` and `timestamp without time zone` values are normalized back to their stored wall-clock values before they reach the grid.

ui/studio/grid/DataGrid.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
} from "react";
4848

4949
import type { SortOrderItem } from "../../../data/adapter";
50+
import type { BigIntString, NumericString } from "../../../data/type-utils";
5051
import {
5152
ContextMenu,
5253
ContextMenuContent,
@@ -230,6 +231,7 @@ export interface DataGridProps {
230231
rows: Record<string, unknown>[];
231232
rowSelectionState: RowSelectionState;
232233
sortingState?: SortOrderItem[];
234+
totalRowCount?: number | bigint | NumericString | BigIntString;
233235
canWriteToCell?: (params: {
234236
columnId: string;
235237
row: Record<string, unknown>;
@@ -638,6 +640,7 @@ export function DataGrid(props: DataGridProps) {
638640
rows,
639641
rowSelectionState,
640642
sortingState,
643+
totalRowCount,
641644
canWriteToCell,
642645
} = props;
643646

@@ -2750,6 +2753,7 @@ export function DataGrid(props: DataGridProps) {
27502753
onBlockedInteraction={onBlockedRowsInViewAction}
27512754
onInfiniteScrollEnabledChange={onInfiniteScrollEnabledChange}
27522755
table={table}
2756+
totalRowCount={totalRowCount}
27532757
variant="numeric"
27542758
/>
27552759
)}

ui/studio/grid/DataGridPagination.test.tsx

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,150 @@ describe("DataGridPagination", () => {
156156
container.remove();
157157
});
158158

159+
it("shows the formatted total row count in the pagination footer", () => {
160+
const container = document.createElement("div");
161+
document.body.appendChild(container);
162+
const root = createRoot(container);
163+
164+
act(() => {
165+
root.render(
166+
<DataGridPagination
167+
table={createMockTable()}
168+
totalRowCount={4725}
169+
variant="numeric"
170+
/>,
171+
);
172+
});
173+
174+
const rowCountLabel = container.querySelector(
175+
'[data-testid="data-grid-row-count"]',
176+
);
177+
178+
expect(rowCountLabel?.textContent).toBe("4,725 rows");
179+
180+
act(() => {
181+
root.unmount();
182+
});
183+
container.remove();
184+
});
185+
186+
it("uses the singular row label when there is exactly one row", () => {
187+
const container = document.createElement("div");
188+
document.body.appendChild(container);
189+
const root = createRoot(container);
190+
191+
act(() => {
192+
root.render(
193+
<DataGridPagination
194+
table={createMockTable()}
195+
totalRowCount={1}
196+
variant="numeric"
197+
/>,
198+
);
199+
});
200+
201+
const rowCountLabel = container.querySelector(
202+
'[data-testid="data-grid-row-count"]',
203+
);
204+
205+
expect(rowCountLabel?.textContent).toBe("1 row");
206+
207+
act(() => {
208+
root.unmount();
209+
});
210+
container.remove();
211+
});
212+
213+
it("renders row counts beyond the safe integer range exactly", () => {
214+
const container = document.createElement("div");
215+
document.body.appendChild(container);
216+
const root = createRoot(container);
217+
218+
act(() => {
219+
root.render(
220+
<DataGridPagination
221+
table={createMockTable()}
222+
totalRowCount={"9007199254740993"}
223+
variant="numeric"
224+
/>,
225+
);
226+
});
227+
228+
const rowCountLabel = container.querySelector(
229+
'[data-testid="data-grid-row-count"]',
230+
);
231+
232+
expect(rowCountLabel?.textContent).toBe("9,007,199,254,740,993 rows");
233+
234+
act(() => {
235+
root.unmount();
236+
});
237+
container.remove();
238+
});
239+
240+
it("hides the row count for numbers that exceed the safe integer range", () => {
241+
const container = document.createElement("div");
242+
document.body.appendChild(container);
243+
const root = createRoot(container);
244+
245+
act(() => {
246+
root.render(
247+
<DataGridPagination
248+
table={createMockTable()}
249+
totalRowCount={Number.MAX_SAFE_INTEGER + 1}
250+
variant="numeric"
251+
/>,
252+
);
253+
});
254+
255+
// A `number` above Number.MAX_SAFE_INTEGER has already lost precision
256+
// before rendering, so no label is shown instead of a wrong total.
257+
// Exact large counts must arrive as bigint or string.
258+
expect(
259+
container.querySelector('[data-testid="data-grid-row-count"]'),
260+
).toBeNull();
261+
262+
act(() => {
263+
root.unmount();
264+
});
265+
container.remove();
266+
});
267+
268+
it("hides the row count when no count is available", () => {
269+
const container = document.createElement("div");
270+
document.body.appendChild(container);
271+
const root = createRoot(container);
272+
273+
act(() => {
274+
root.render(
275+
<DataGridPagination table={createMockTable()} variant="numeric" />,
276+
);
277+
});
278+
279+
expect(
280+
container.querySelector('[data-testid="data-grid-row-count"]'),
281+
).toBeNull();
282+
283+
act(() => {
284+
root.render(
285+
<DataGridPagination
286+
table={createMockTable()}
287+
totalRowCount={Infinity}
288+
variant="numeric"
289+
/>,
290+
);
291+
});
292+
293+
expect(
294+
container.querySelector('[data-testid="data-grid-row-count"]'),
295+
).toBeNull();
296+
297+
act(() => {
298+
root.unmount();
299+
});
300+
container.remove();
301+
});
302+
159303
it("renders the page number as a tight right-aligned phrase", () => {
160304
const container = document.createElement("div");
161305
document.body.appendChild(container);

ui/studio/grid/DataGridPagination.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
} from "react";
1414
import { useEffect, useId, useState } from "react";
1515

16+
import type { BigIntString, NumericString } from "@/data/type-utils";
1617
import { Button, buttonVariants } from "@/ui/components/ui/button";
1718
import {
1819
DropdownMenu,
@@ -33,6 +34,7 @@ export interface DataGridPaginationProps {
3334
onBlockedInteraction?: () => void;
3435
onInfiniteScrollEnabledChange?: (enabled: boolean) => void;
3536
table: Table<Record<string, unknown>>;
37+
totalRowCount?: number | bigint | NumericString | BigIntString;
3638
variant?: "basic" | "numeric";
3739
}
3840

@@ -45,11 +47,13 @@ export function DataGridPagination(props: DataGridPaginationProps) {
4547
onBlockedInteraction,
4648
onInfiniteScrollEnabledChange,
4749
table,
50+
totalRowCount,
4851
variant = "basic",
4952
} = props;
5053

5154
const { pageIndex, pageSize } = table.getState().pagination;
5255
const pageCount = table.getPageCount();
56+
const rowCountLabel = getRowCountLabel(totalRowCount);
5357
const [pageDraft, setPageDraft] = useState(String(pageIndex + 1));
5458
const [isPageSizeMenuOpen, setIsPageSizeMenuOpen] = useState(false);
5559
const infiniteScrollControlId = useId();
@@ -465,11 +469,54 @@ export function DataGridPagination(props: DataGridPaginationProps) {
465469
</div>
466470
</div>
467471
)}
472+
{rowCountLabel == null ? null : (
473+
<span
474+
className="shrink-0 px-1 font-sans text-xs font-medium text-muted-foreground tabular-nums"
475+
data-testid="data-grid-row-count"
476+
>
477+
{rowCountLabel}
478+
</span>
479+
)}
468480
</div>
469481
</div>
470482
);
471483
}
472484

485+
function getRowCountLabel(
486+
totalRowCount: DataGridPaginationProps["totalRowCount"],
487+
): string | null {
488+
if (totalRowCount == null) {
489+
return null;
490+
}
491+
492+
// A `number` above Number.MAX_SAFE_INTEGER has already lost precision
493+
// before BigInt() could see it, so hide the label instead of showing a
494+
// rounded total; exact large counts must arrive as bigint or string. This
495+
// also covers `Infinity`, which adapters use when rows cannot be counted.
496+
if (
497+
typeof totalRowCount === "number" &&
498+
!Number.isSafeInteger(totalRowCount)
499+
) {
500+
return null;
501+
}
502+
503+
let rowCount: bigint;
504+
505+
try {
506+
rowCount = BigInt(totalRowCount);
507+
} catch {
508+
return null;
509+
}
510+
511+
if (rowCount < BigInt(0)) {
512+
return null;
513+
}
514+
515+
const formattedRowCount = rowCount.toLocaleString("en-US");
516+
517+
return `${formattedRowCount} ${rowCount === BigInt(1) ? "row" : "rows"}`;
518+
}
519+
473520
function parsePositiveInteger(value: string): number | null {
474521
const trimmedValue = value.trim();
475522

ui/studio/views/table/ActiveTableView.filtering.test.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,7 @@ vi.mock("../../grid/DataGrid", async () => {
477477
) => void;
478478
paginationState?: { pageIndex: number; pageSize: number };
479479
rows?: Record<string, unknown>[];
480+
totalRowCount?: number | bigint | string;
480481
}) => {
481482
const table = useReactTable({
482483
columns: props.columnDefs as never,
@@ -594,6 +595,11 @@ vi.mock("../../grid/DataGrid", async () => {
594595
>
595596
Load more rows
596597
</button>
598+
{props.totalRowCount == null ? null : (
599+
<span data-testid="mock-grid-total-row-count">
600+
{String(props.totalRowCount)}
601+
</span>
602+
)}
597603
</>
598604
);
599605
},
@@ -2699,6 +2705,31 @@ describe("ActiveTableView filtering", () => {
26992705
}
27002706
});
27012707

2708+
it("passes the filtered row count that drives pagination to the grid footer", async () => {
2709+
useActiveTableQueryMock.mockReturnValue({
2710+
data: {
2711+
filteredRowCount: 4725,
2712+
rows: [],
2713+
},
2714+
isFetching: false,
2715+
refetch: vi.fn(),
2716+
});
2717+
2718+
const view = renderView();
2719+
2720+
try {
2721+
await flush();
2722+
2723+
const rowCountLabel = view.container.querySelector(
2724+
'[data-testid="mock-grid-total-row-count"]',
2725+
);
2726+
2727+
expect(rowCountLabel?.textContent).toBe("4725");
2728+
} finally {
2729+
view.cleanup();
2730+
}
2731+
});
2732+
27022733
it("grows the query window from page zero when infinite scroll loads more rows", async () => {
27032734
isInfiniteScrollEnabled = true;
27042735
paginationStateValue = { pageIndex: 0, pageSize: 10 };

ui/studio/views/table/ActiveTableView.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1766,6 +1766,7 @@ export function ActiveTableView(_props: ViewProps) {
17661766
rowSelectionState={rowSelectionState}
17671767
selectionScopeKey={selectionScopeKey}
17681768
sortingState={sortingState}
1769+
totalRowCount={visibleData?.filteredRowCount}
17691770
/>
17701771
<BinaryAlertDialog
17711772
onOpenChange={setDeleteDialogOpen}

0 commit comments

Comments
 (0)