diff --git a/.changeset/table-row-count-display.md b/.changeset/table-row-count-display.md new file mode 100644 index 00000000..7db7a1e6 --- /dev/null +++ b/.changeset/table-row-count-display.md @@ -0,0 +1,5 @@ +--- +"@prisma/studio-core": minor +--- + +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. diff --git a/Architecture/table-query-controls.md b/Architecture/table-query-controls.md index c720bea0..9eff1bc4 100644 --- a/Architecture/table-query-controls.md +++ b/Architecture/table-query-controls.md @@ -160,6 +160,7 @@ Column-header pin/sort control rendering and interaction rules are defined in: - 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. - 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. - 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. +- 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`). - When staged rows or staged updates exist, pagination controls MUST refuse page changes until the staged edits are resolved. ## Row Selection Contract diff --git a/FEATURES.md b/FEATURES.md index 2acaa70e..b133b408 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -173,6 +173,11 @@ The footer keeps page navigation, a page jump field, a fixed rows-per-page dropd 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. 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. +## Table Row Count Display + +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. +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. + ## PostgreSQL Stored Temporal Values 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. diff --git a/ui/studio/grid/DataGrid.tsx b/ui/studio/grid/DataGrid.tsx index 6a611298..ccb97ba0 100644 --- a/ui/studio/grid/DataGrid.tsx +++ b/ui/studio/grid/DataGrid.tsx @@ -47,6 +47,7 @@ import { } from "react"; import type { SortOrderItem } from "../../../data/adapter"; +import type { BigIntString, NumericString } from "../../../data/type-utils"; import { ContextMenu, ContextMenuContent, @@ -230,6 +231,7 @@ export interface DataGridProps { rows: Record[]; rowSelectionState: RowSelectionState; sortingState?: SortOrderItem[]; + totalRowCount?: number | bigint | NumericString | BigIntString; canWriteToCell?: (params: { columnId: string; row: Record; @@ -638,6 +640,7 @@ export function DataGrid(props: DataGridProps) { rows, rowSelectionState, sortingState, + totalRowCount, canWriteToCell, } = props; @@ -2750,6 +2753,7 @@ export function DataGrid(props: DataGridProps) { onBlockedInteraction={onBlockedRowsInViewAction} onInfiniteScrollEnabledChange={onInfiniteScrollEnabledChange} table={table} + totalRowCount={totalRowCount} variant="numeric" /> )} diff --git a/ui/studio/grid/DataGridPagination.test.tsx b/ui/studio/grid/DataGridPagination.test.tsx index 12a5eeb1..9a6e3c93 100644 --- a/ui/studio/grid/DataGridPagination.test.tsx +++ b/ui/studio/grid/DataGridPagination.test.tsx @@ -156,6 +156,150 @@ describe("DataGridPagination", () => { container.remove(); }); + it("shows the formatted total row count in the pagination footer", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const rowCountLabel = container.querySelector( + '[data-testid="data-grid-row-count"]', + ); + + expect(rowCountLabel?.textContent).toBe("4,725 rows"); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("uses the singular row label when there is exactly one row", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const rowCountLabel = container.querySelector( + '[data-testid="data-grid-row-count"]', + ); + + expect(rowCountLabel?.textContent).toBe("1 row"); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("renders row counts beyond the safe integer range exactly", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const rowCountLabel = container.querySelector( + '[data-testid="data-grid-row-count"]', + ); + + expect(rowCountLabel?.textContent).toBe("9,007,199,254,740,993 rows"); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("hides the row count for numbers that exceed the safe integer range", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + // A `number` above Number.MAX_SAFE_INTEGER has already lost precision + // before rendering, so no label is shown instead of a wrong total. + // Exact large counts must arrive as bigint or string. + expect( + container.querySelector('[data-testid="data-grid-row-count"]'), + ).toBeNull(); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("hides the row count when no count is available", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + expect( + container.querySelector('[data-testid="data-grid-row-count"]'), + ).toBeNull(); + + act(() => { + root.render( + , + ); + }); + + expect( + container.querySelector('[data-testid="data-grid-row-count"]'), + ).toBeNull(); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + it("renders the page number as a tight right-aligned phrase", () => { const container = document.createElement("div"); document.body.appendChild(container); diff --git a/ui/studio/grid/DataGridPagination.tsx b/ui/studio/grid/DataGridPagination.tsx index d302b1ba..11e1272e 100644 --- a/ui/studio/grid/DataGridPagination.tsx +++ b/ui/studio/grid/DataGridPagination.tsx @@ -13,6 +13,7 @@ import type { } from "react"; import { useEffect, useId, useState } from "react"; +import type { BigIntString, NumericString } from "@/data/type-utils"; import { Button, buttonVariants } from "@/ui/components/ui/button"; import { DropdownMenu, @@ -33,6 +34,7 @@ export interface DataGridPaginationProps { onBlockedInteraction?: () => void; onInfiniteScrollEnabledChange?: (enabled: boolean) => void; table: Table>; + totalRowCount?: number | bigint | NumericString | BigIntString; variant?: "basic" | "numeric"; } @@ -45,11 +47,13 @@ export function DataGridPagination(props: DataGridPaginationProps) { onBlockedInteraction, onInfiniteScrollEnabledChange, table, + totalRowCount, variant = "basic", } = props; const { pageIndex, pageSize } = table.getState().pagination; const pageCount = table.getPageCount(); + const rowCountLabel = getRowCountLabel(totalRowCount); const [pageDraft, setPageDraft] = useState(String(pageIndex + 1)); const [isPageSizeMenuOpen, setIsPageSizeMenuOpen] = useState(false); const infiniteScrollControlId = useId(); @@ -465,11 +469,54 @@ export function DataGridPagination(props: DataGridPaginationProps) { )} + {rowCountLabel == null ? null : ( + + {rowCountLabel} + + )} ); } +function getRowCountLabel( + totalRowCount: DataGridPaginationProps["totalRowCount"], +): string | null { + if (totalRowCount == null) { + return null; + } + + // A `number` above Number.MAX_SAFE_INTEGER has already lost precision + // before BigInt() could see it, so hide the label instead of showing a + // rounded total; exact large counts must arrive as bigint or string. This + // also covers `Infinity`, which adapters use when rows cannot be counted. + if ( + typeof totalRowCount === "number" && + !Number.isSafeInteger(totalRowCount) + ) { + return null; + } + + let rowCount: bigint; + + try { + rowCount = BigInt(totalRowCount); + } catch { + return null; + } + + if (rowCount < BigInt(0)) { + return null; + } + + const formattedRowCount = rowCount.toLocaleString("en-US"); + + return `${formattedRowCount} ${rowCount === BigInt(1) ? "row" : "rows"}`; +} + function parsePositiveInteger(value: string): number | null { const trimmedValue = value.trim(); diff --git a/ui/studio/views/table/ActiveTableView.filtering.test.tsx b/ui/studio/views/table/ActiveTableView.filtering.test.tsx index e0fe996f..fae1a881 100644 --- a/ui/studio/views/table/ActiveTableView.filtering.test.tsx +++ b/ui/studio/views/table/ActiveTableView.filtering.test.tsx @@ -477,6 +477,7 @@ vi.mock("../../grid/DataGrid", async () => { ) => void; paginationState?: { pageIndex: number; pageSize: number }; rows?: Record[]; + totalRowCount?: number | bigint | string; }) => { const table = useReactTable({ columns: props.columnDefs as never, @@ -594,6 +595,11 @@ vi.mock("../../grid/DataGrid", async () => { > Load more rows + {props.totalRowCount == null ? null : ( + + {String(props.totalRowCount)} + + )} ); }, @@ -2699,6 +2705,31 @@ describe("ActiveTableView filtering", () => { } }); + it("passes the filtered row count that drives pagination to the grid footer", async () => { + useActiveTableQueryMock.mockReturnValue({ + data: { + filteredRowCount: 4725, + rows: [], + }, + isFetching: false, + refetch: vi.fn(), + }); + + const view = renderView(); + + try { + await flush(); + + const rowCountLabel = view.container.querySelector( + '[data-testid="mock-grid-total-row-count"]', + ); + + expect(rowCountLabel?.textContent).toBe("4725"); + } finally { + view.cleanup(); + } + }); + it("grows the query window from page zero when infinite scroll loads more rows", async () => { isInfiniteScrollEnabled = true; paginationStateValue = { pageIndex: 0, pageSize: 10 }; diff --git a/ui/studio/views/table/ActiveTableView.tsx b/ui/studio/views/table/ActiveTableView.tsx index cdf7f11b..dcbc2ba6 100644 --- a/ui/studio/views/table/ActiveTableView.tsx +++ b/ui/studio/views/table/ActiveTableView.tsx @@ -1765,6 +1765,7 @@ export function ActiveTableView(_props: ViewProps) { rowSelectionState={rowSelectionState} selectionScopeKey={selectionScopeKey} sortingState={sortingState} + totalRowCount={visibleData?.filteredRowCount} />