Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/table-row-count-display.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions Architecture/table-query-controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions ui/studio/grid/DataGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
} from "react";

import type { SortOrderItem } from "../../../data/adapter";
import type { BigIntString, NumericString } from "../../../data/type-utils";
import {
ContextMenu,
ContextMenuContent,
Expand Down Expand Up @@ -230,6 +231,7 @@ export interface DataGridProps {
rows: Record<string, unknown>[];
rowSelectionState: RowSelectionState;
sortingState?: SortOrderItem[];
totalRowCount?: number | bigint | NumericString | BigIntString;
canWriteToCell?: (params: {
columnId: string;
row: Record<string, unknown>;
Expand Down Expand Up @@ -638,6 +640,7 @@ export function DataGrid(props: DataGridProps) {
rows,
rowSelectionState,
sortingState,
totalRowCount,
canWriteToCell,
} = props;

Expand Down Expand Up @@ -2750,6 +2753,7 @@ export function DataGrid(props: DataGridProps) {
onBlockedInteraction={onBlockedRowsInViewAction}
onInfiniteScrollEnabledChange={onInfiniteScrollEnabledChange}
table={table}
totalRowCount={totalRowCount}
variant="numeric"
/>
)}
Expand Down
144 changes: 144 additions & 0 deletions ui/studio/grid/DataGridPagination.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<DataGridPagination
table={createMockTable()}
totalRowCount={4725}
variant="numeric"
/>,
);
});

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(
<DataGridPagination
table={createMockTable()}
totalRowCount={1}
variant="numeric"
/>,
);
});

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(
<DataGridPagination
table={createMockTable()}
totalRowCount={"9007199254740993"}
variant="numeric"
/>,
);
});

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(
<DataGridPagination
table={createMockTable()}
totalRowCount={Number.MAX_SAFE_INTEGER + 1}
variant="numeric"
/>,
);
});

// 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(
<DataGridPagination table={createMockTable()} variant="numeric" />,
);
});

expect(
container.querySelector('[data-testid="data-grid-row-count"]'),
).toBeNull();

act(() => {
root.render(
<DataGridPagination
table={createMockTable()}
totalRowCount={Infinity}
variant="numeric"
/>,
);
});

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);
Expand Down
47 changes: 47 additions & 0 deletions ui/studio/grid/DataGridPagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -33,6 +34,7 @@ export interface DataGridPaginationProps {
onBlockedInteraction?: () => void;
onInfiniteScrollEnabledChange?: (enabled: boolean) => void;
table: Table<Record<string, unknown>>;
totalRowCount?: number | bigint | NumericString | BigIntString;
variant?: "basic" | "numeric";
}

Expand All @@ -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();
Expand Down Expand Up @@ -465,11 +469,54 @@ export function DataGridPagination(props: DataGridPaginationProps) {
</div>
</div>
)}
{rowCountLabel == null ? null : (
<span
className="shrink-0 px-1 font-sans text-xs font-medium text-muted-foreground tabular-nums"
data-testid="data-grid-row-count"
>
{rowCountLabel}
</span>
)}
</div>
</div>
);
}

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"}`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function parsePositiveInteger(value: string): number | null {
const trimmedValue = value.trim();

Expand Down
31 changes: 31 additions & 0 deletions ui/studio/views/table/ActiveTableView.filtering.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,7 @@ vi.mock("../../grid/DataGrid", async () => {
) => void;
paginationState?: { pageIndex: number; pageSize: number };
rows?: Record<string, unknown>[];
totalRowCount?: number | bigint | string;
}) => {
const table = useReactTable({
columns: props.columnDefs as never,
Expand Down Expand Up @@ -594,6 +595,11 @@ vi.mock("../../grid/DataGrid", async () => {
>
Load more rows
</button>
{props.totalRowCount == null ? null : (
<span data-testid="mock-grid-total-row-count">
{String(props.totalRowCount)}
</span>
)}
</>
);
},
Expand Down Expand Up @@ -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 };
Expand Down
1 change: 1 addition & 0 deletions ui/studio/views/table/ActiveTableView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1765,6 +1765,7 @@ export function ActiveTableView(_props: ViewProps) {
rowSelectionState={rowSelectionState}
selectionScopeKey={selectionScopeKey}
sortingState={sortingState}
totalRowCount={visibleData?.filteredRowCount}
/>
<BinaryAlertDialog
onOpenChange={setDeleteDialogOpen}
Expand Down