From e4d3139e21ab10b8d6c1fac3386c750d69f37082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Mon, 22 Jun 2026 23:37:34 +0800 Subject: [PATCH] fix(grid): true server-side pagination for ObjectGrid (#2212) Pairs with the framework findData fix (objectstack-ai/framework#2212). Once the backend reports the real match `total`/`hasMore`, the grid must actually USE it: fetch one page at a time with $skip and refetch on navigation, rather than fetching a single batch and slicing it in memory (which left records beyond the first batch unreachable). - ObjectGrid: add serverPage/serverPageSize state; fetch with $top + $skip = (page-1)*size; reset to page 1 when object/filter/sort changes. For the flat server-fetched list (not inline data, not grouped), drive DataTable in manual mode with rowCount = real total and onPageChange/onPageSizeChange refetching from the server. - DataTable: add manualPagination/rowCount/page/onPageChange/onPageSizeChange. In manual mode it renders `data` as the current page WITHOUT client slicing and derives total pages from rowCount. - data-objectstack adapter: prefer the server's `hasMore`, falling back to the page-local estimate only when absent. - types: declare the new DataTable manual-pagination props. Tests: new data-table-manual-pagination.test.tsx (3) and ObjectGrid serverPagination.test.tsx (4, renders the real ObjectGrid against a mock server and asserts $skip refetch + true page count). Full suite green (4227 passed | 24 skipped). --- .../data-table-manual-pagination.test.tsx | 78 +++++++++ .../src/renderers/complex/data-table.tsx | 85 ++++++---- packages/data-objectstack/src/index.ts | 10 +- packages/plugin-grid/src/ObjectGrid.tsx | 40 ++++- .../src/__tests__/serverPagination.test.tsx | 156 ++++++++++++++++++ packages/types/src/data-display.ts | 27 +++ 6 files changed, 363 insertions(+), 33 deletions(-) create mode 100644 packages/components/src/__tests__/data-table-manual-pagination.test.tsx create mode 100644 packages/plugin-grid/src/__tests__/serverPagination.test.tsx diff --git a/packages/components/src/__tests__/data-table-manual-pagination.test.tsx b/packages/components/src/__tests__/data-table-manual-pagination.test.tsx new file mode 100644 index 0000000000..047d9cb43c --- /dev/null +++ b/packages/components/src/__tests__/data-table-manual-pagination.test.tsx @@ -0,0 +1,78 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Server-side ("manual") pagination for DataTable (framework issue #2212). + * + * In manual mode the `data` prop is ONE page already fetched from the server, + * so DataTable must NOT slice it client-side. Total page count comes from + * `rowCount` (the real match total), the visible page index from `page`, and + * navigation is reported via `onPageChange` instead of mutating internal state. + * This is what lets a grid reach records beyond the first batch. + */ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { renderComponent } from './test-utils'; + +beforeAll(async () => { + await import('../renderers'); +}, 30000); + +describe('data-table — manual (server-side) pagination', () => { + // 5 rows that represent page 2 of a 3125-row result set, page size 50. + const pageData = Array.from({ length: 5 }, (_, i) => ({ + id: `r${i}`, + name: `Row ${i}`, + })); + + const baseSchema = { + type: 'data-table' as const, + columns: [{ header: 'Name', accessorKey: 'name' }], + data: pageData, + pagination: true, + pageSize: 50, + manualPagination: true, + rowCount: 3125, + page: 2, + } as any; + + it('derives total pages from rowCount, not from the page-local data length', () => { + const { container } = renderComponent(baseSchema); + // ceil(3125 / 50) = 63 pages. The footer shows "2 / 63" (or localized + // equivalent). Assert the real total page count is rendered somewhere. + expect(container.textContent).toContain('63'); + expect(container.textContent).toContain('2'); + }); + + it('renders the page data as-is without client-side slicing', () => { + const { container } = renderComponent(baseSchema); + const bodyRows = container.querySelectorAll('tbody tr'); + // All 5 server-provided rows must be visible — none sliced away by the + // 50-per-page setting (the data IS the page). + expect(bodyRows.length).toBe(5); + }); + + it('reports navigation via onPageChange instead of mutating internal state', () => { + const onPageChange = vi.fn(); + const { container } = renderComponent({ ...baseSchema, onPageChange }); + + // Find the "next page" control. Nav buttons are the footer's icon buttons; + // click the one that advances from page 2 -> 3. + const buttons = Array.from(container.querySelectorAll('button')).filter( + (b) => !(b as HTMLButtonElement).disabled, + ); + // The last-page and next-page buttons live at the tail of the footer. + // Click each enabled button and assert at least one requests a forward page. + buttons.forEach((b) => fireEvent.click(b)); + expect(onPageChange).toHaveBeenCalled(); + const requested = onPageChange.mock.calls.map((c) => c[0]); + // Forward navigation from page 2 should request page 3 and/or the last (63). + expect(requested.some((p) => p === 3 || p === 63)).toBe(true); + }); +}); diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index fd8d35ecb7..f92df6e0ee 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -165,6 +165,11 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { data: rawData = [], pagination = true, pageSize: initialPageSize = 10, + manualPagination = false, + rowCount, + page: controlledPage, + onPageChange, + onPageSizeChange, searchable = true, selectable = false, sortable = true, @@ -307,12 +312,37 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { }); }, [filteredData, sortColumn, sortDirection]); - // Pagination - const totalPages = Math.ceil(sortedData.length / pageSize); - const paginatedData = pagination + // Pagination. Under manual (server-side) pagination the parent controls the + // page and supplies the grand total via `rowCount`; `data` already IS the + // current page, so we never slice it locally. Otherwise we paginate the + // in-memory rows client-side (legacy behavior). + const effectivePage = manualPagination + ? Math.max(1, controlledPage ?? 1) + : currentPage; + const totalPages = manualPagination + ? Math.max(1, Math.ceil((rowCount ?? sortedData.length) / pageSize)) + : Math.ceil(sortedData.length / pageSize); + const paginatedData = (pagination && !manualPagination) ? sortedData.slice((currentPage - 1) * pageSize, currentPage * pageSize) : sortedData; + // Route page / page-size changes to the parent under manual pagination, + // otherwise drive the internal state. + const goToPage = (p: number) => { + const clamped = Math.min(totalPages, Math.max(1, p)); + if (manualPagination) onPageChange?.(clamped); + else setCurrentPage(clamped); + }; + const changePageSize = (size: number) => { + setPageSize(size); + if (manualPagination) { + onPageSizeChange?.(size); + onPageChange?.(1); + } else { + setCurrentPage(1); + } + }; + /** * Generates a unique identifier for each row to maintain stable selection state * across pagination and sorting operations. @@ -366,7 +396,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { const newSelected = new Set(); if (checked) { paginatedData.forEach((row, idx) => { - const globalIndex = (currentPage - 1) * pageSize + idx; + const globalIndex = (effectivePage - 1) * pageSize + idx; const rowId = getRowId(row, globalIndex); newSelected.add(rowId); }); @@ -531,8 +561,10 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { if (!force && editingCell === null) return; const { rowIndex, columnKey } = editingCell; - const globalIndex = (currentPage - 1) * pageSize + rowIndex; - const row = sortedData[globalIndex]; + const globalIndex = (effectivePage - 1) * pageSize + rowIndex; + // Under manual pagination `sortedData` IS the current page, so address it + // page-locally; otherwise it's the full in-memory set indexed absolutely. + const row = sortedData[manualPagination ? rowIndex : globalIndex]; // Update pending changes const newPendingChanges = new Map(pendingChanges); @@ -556,8 +588,8 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { }; const saveRow = async (rowIndex: number) => { - const globalIndex = (currentPage - 1) * pageSize + rowIndex; - const row = sortedData[globalIndex]; + const globalIndex = (effectivePage - 1) * pageSize + rowIndex; + const row = sortedData[manualPagination ? rowIndex : globalIndex]; const rowChanges = pendingChanges.get(rowIndex); if (!rowChanges || Object.keys(rowChanges).length === 0) return; @@ -591,8 +623,8 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { setIsSaving(true); try { const changesToSave = Array.from(pendingChanges.entries()).map(([rowIndex, changes]) => { - const globalIndex = (currentPage - 1) * pageSize + rowIndex; - const row = sortedData[globalIndex]; + const globalIndex = (effectivePage - 1) * pageSize + rowIndex; + const row = sortedData[manualPagination ? rowIndex : globalIndex]; return { rowIndex: globalIndex, changes, row }; }); @@ -617,8 +649,8 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { // Copy cell value with Ctrl+C / Cmd+C if ((e.ctrlKey || e.metaKey) && e.key === 'c' && !editingCell) { e.preventDefault(); - const globalIdx = (currentPage - 1) * pageSize + rowIndex; - const row = sortedData[globalIdx]; + const globalIdx = (effectivePage - 1) * pageSize + rowIndex; + const row = sortedData[manualPagination ? rowIndex : globalIdx]; if (row) { const value = row[columnKey]; const text = value != null ? String(value) : ''; @@ -668,13 +700,13 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { // Check if all rows on current page are selected const allPageRowsSelected = paginatedData.length > 0 && paginatedData.every((row, idx) => { - const globalIndex = (currentPage - 1) * pageSize + idx; + const globalIndex = (effectivePage - 1) * pageSize + idx; const rowId = getRowId(row, globalIndex); return selectedRowIds.has(rowId); }); const somePageRowsSelected = paginatedData.some((row, idx) => { - const globalIndex = (currentPage - 1) * pageSize + idx; + const globalIndex = (effectivePage - 1) * pageSize + idx; const rowId = getRowId(row, globalIndex); return selectedRowIds.has(rowId); }) && !allPageRowsSelected; @@ -885,7 +917,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { ) : ( <> {paginatedData.map((row, rowIndex) => { - const globalIndex = (currentPage - 1) * pageSize + rowIndex; + const globalIndex = (effectivePage - 1) * pageSize + rowIndex; const rowId = getRowId(row, globalIndex); const isSelected = selectedRowIds.has(rowId); const rowHasChanges = pendingChanges.has(rowIndex); @@ -1139,10 +1171,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { {t('table.rowsPerPage')}: