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
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
85 changes: 57 additions & 28 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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.
Expand DownExpand Up@@ -366,7 +396,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
const newSelected = new Set<any>();
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);
});
Expand DownExpand Up@@ -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);
Expand All@@ -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;
Expand DownExpand Up@@ -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 };
});

Expand All@@ -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) : '';
Expand DownExpand Up@@ -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;
Expand DownExpand Up@@ -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);
Expand DownExpand Up@@ -1139,10 +1171,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
<span className="text-xs sm:text-sm text-muted-foreground">{t('table.rowsPerPage')}:</span>
<Select
value={pageSize.toString()}
onValueChange={(value) => {
setPageSize(Number(value));
setCurrentPage(1);
}}
onValueChange={(value) => changePageSize(Number(value))}
>
<SelectTrigger className="w-20">
<SelectValue />
Expand All@@ -1159,38 +1188,38 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {

<div className="flex items-center gap-2">
<span className="text-xs sm:text-sm text-muted-foreground">
{t('table.pageInfo', { current: currentPage, total: totalPages })}
{t('table.pageInfo', { current: effectivePage, total: totalPages })}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
onClick={() => goToPage(1)}
disabled={effectivePage === 1}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
onClick={() => goToPage(effectivePage - 1)}
disabled={effectivePage === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
onClick={() => goToPage(effectivePage + 1)}
disabled={effectivePage === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
onClick={() => goToPage(totalPages)}
disabled={effectivePage === totalPages}
>
<ChevronsRight className="h-4 w-4" />
</Button>
Expand Down
10 changes: 8 additions & 2 deletions packages/data-objectstack/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1062,16 +1062,22 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
};
}

const resultObj = result as { records?: T[]; total?: number; value?: T[]; count?: number };
const resultObj = result as { records?: T[]; total?: number; value?: T[]; count?: number; hasMore?: boolean };
const records = resultObj.records || resultObj.value || [];
const total = resultObj.total ?? resultObj.count ?? records.length;
// Prefer the server's `hasMore` (real server-side pagination, framework
// issue #2212). Fall back to the page-local estimate (a full page implies
// there may be more) only when the server doesn't report it.
const hasMore = typeof resultObj.hasMore === 'boolean'
? resultObj.hasMore
: (params?.$top ? records.length === params.$top : false);
return {
data: records,
total,
// Calculate page number safely
page: params?.$skip && params.$top ? Math.floor(params.$skip / params.$top) + 1 : 1,
pageSize: params?.$top,
hasMore: params?.$top ? records.length === params.$top : false,
hasMore,
};
}

Expand Down
40 changes: 37 additions & 3 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -374,6 +374,16 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
const schemaPagination = schema.pagination;
const schemaPageSize = schema.pageSize;

// Server-side ("manual") pagination for the flat list view. The fetch window
// ($top/$skip) and the DataTable's display page size are the SAME number here
// — the records we hold ARE one page, so paging means refetching the next
// slice from the server instead of slicing an in-memory batch. This is what
// makes records beyond the first batch reachable at all (framework #2212).
const [serverPage, setServerPage] = useState(1);
const [serverPageSize, setServerPageSize] = useState<number>(
(schema.pagination as any)?.pageSize ?? schema.pageSize ?? 50,
);

// --- Inline data effect (synchronous, no fetch needed) ---
useEffect(() => {
if (hasInlineData && dataConfig?.provider === 'value') {
Expand DownExpand Up@@ -478,7 +488,8 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({

const params: any = {
$select: getSelectFields(),
$top: (schemaPagination as any)?.pageSize || schemaPageSize || 50,
$top: serverPageSize,
$skip: (serverPage - 1) * serverPageSize,
};

// Support new filter format
Expand DownExpand Up@@ -537,7 +548,15 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
return () => {
cancelled = true;
};
}, [objectName, schemaFields, schemaColumns, schemaFilter, schemaSort, schemaPagination, schemaPageSize, dataSource, hasInlineData, dataConfig, refreshKey]);
}, [objectName, schemaFields, schemaColumns, schemaFilter, schemaSort, schemaPagination, schemaPageSize, serverPage, serverPageSize, dataSource, hasInlineData, dataConfig, refreshKey]);

// Reset to page 1 whenever the query itself changes (object / filter / sort),
// so we never request a page index that no longer exists for the new result
// set (e.g. applying a filter while sitting on page 5 of the old query).
React.useEffect(() => {
setServerPage(1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [objectName, schemaFilter, schemaSort]);

// --- NavigationConfig support ---
// Must be called before any early returns to satisfy React hooks rules
Expand DownExpand Up@@ -1518,13 +1537,28 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
? schema.searchableFields.length > 0
: (schema.showSearch !== undefined ? schema.showSearch : true);

// Server-side pagination applies to the flat, server-fetched list only.
// Inline/static data and the grouped view paginate in-memory (grouped mode
// keeps whole groups together via its own groupedPage state), so they stay
// on DataTable's default client-side slicing.
const useServerPagination = !hasInlineData && !isGrouped;

const dataTableSchema: any = {
type: 'data-table',
caption: schema.label || schema.title,
columns: orderedColumns,
data,
pagination: paginationEnabled,
pageSize: pageSize,
pageSize: useServerPagination ? serverPageSize : pageSize,
// In server mode `data` IS the current page; tell DataTable to render it
// as-is and drive paging via the callbacks below using the real match total.
manualPagination: useServerPagination,
rowCount: useServerPagination ? totalMatching : undefined,
page: useServerPagination ? serverPage : undefined,
onPageChange: useServerPagination ? setServerPage : undefined,
onPageSizeChange: useServerPagination
? (size: number) => { setServerPageSize(size); setServerPage(1); }
: undefined,
searchable: searchEnabled,
selectable: selectionMode,
sortable: true,
Expand Down
Loading
Loading