Skip to content

Commit 92c3242

Browse files
authored
fix(grid): rows-per-page selector honors pagination.pageSizeOptions; drop duplicate ListView <select> (#1919)
* fix(grid): rows-per-page selector honors pagination.pageSizeOptions; drop duplicate ListView <select> The DataTable pager hardcoded its rows-per-page choices to 5/10/20/50/100, ignoring view metadata, while ListView separately rendered its own native <select> from pagination.pageSizeOptions. For a grid that produced TWO page-size controls with different option sets. - data-table: add pageSizeOptions prop; the selector renders the configured options (current pageSize merged in, de-duped, sorted) instead of the hardcoded list. - ObjectGrid: forward schema.pagination.pageSizeOptions to the DataTable so the single server-driven pager exposes the configured choices. - ListView: suppress the redundant native <select> for grid view (the DataTable pager owns it); keep it only for pager-less views (gallery/ kanban/calendar). - types/zod: declare DataTableSchema.pageSizeOptions. Follow-up to #1916 (#2212). Verified in browser against a 3125-row plan grid: single selector with 50/100/200/500, switching 500 -> 7 pages, last page loads the 125-row remainder. * fix(grid): align selection checkbox column header with body; pad pager - selection checkbox: header (px-4) and body (px-3 from cellClassName) cells had mismatched left padding, so the header checkbox sat ~4px right of the body checkboxes. Force both selection cells to px-0 + text-center so the checkbox centers in the column identically (verified: both at x=264). - pagination footer: the rows-per-page / page-info row had no horizontal padding and sat flush against the table edge. Add px-3 sm:px-4 py-2 to match the table cells and the grouped pager. * fix(data-table): restore left gutter on selection column The previous px-0 fix aligned the header/body checkboxes but removed the column's left padding entirely, leaving the checkboxes flush against the table's left border. Use px-3 (matching the data cells) on both the header and body selection cells: identical padding keeps the checkboxes aligned while restoring a 12px left gutter. * fix(grid): align column headers with body cell padding Root cause of the header/content misalignment: ObjectGrid's applyDensity gave every body cell px-3 (12px) via rowHeightCellClass but left the header at the primitive <th> default px-4 (16px), so every left-aligned header label sat 4px to the right of its data. Add px-3 to the header className in applyDensity (and the right-pinned branch) so headers share the body's horizontal padding. Also align the row-number (#) header to px-3 in data-table. Verified: all 16 columns now match (0 mismatches).
1 parent 3e1fcf5 commit 92c3242

7 files changed

Lines changed: 81 additions & 16 deletions

File tree

packages/components/src/__tests__/data-table-manual-pagination.test.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,29 @@ describe('data-table — manual (server-side) pagination', () => {
5858
expect(bodyRows.length).toBe(5);
5959
});
6060

61+
it('renders the configured pageSizeOptions in the rows-per-page selector (no built-in 5/10/20)', () => {
62+
// With pageSizeOptions provided, the selector must offer exactly those
63+
// choices (plus the active pageSize merged in) — NOT the hardcoded
64+
// 5/10/20/50/100 list. This is what lets a single server-driven pager show
65+
// the view's 50/100/200/500 without a second native <select> being rendered.
66+
const { getAllByRole } = renderComponent({
67+
...baseSchema,
68+
pageSize: 100,
69+
pageSizeOptions: [50, 100, 200, 500],
70+
});
71+
// Radix Select renders <SelectItem> options; the trigger combobox shows the
72+
// active value. Open the listbox by clicking the combobox.
73+
const combos = getAllByRole('combobox');
74+
fireEvent.click(combos[0]);
75+
const options = Array.from(document.querySelectorAll('[role="option"]')).map(
76+
(o) => o.textContent?.trim(),
77+
);
78+
expect(options).toEqual(['50', '100', '200', '500']);
79+
expect(options).not.toContain('5');
80+
expect(options).not.toContain('10');
81+
expect(options).not.toContain('20');
82+
});
83+
6184
it('reports navigation via onPageChange instead of mutating internal state', () => {
6285
const onPageChange = vi.fn();
6386
const { container } = renderComponent({ ...baseSchema, onPageChange });

packages/components/src/renderers/complex/data-table.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
165165
data: rawData = [],
166166
pagination = true,
167167
pageSize: initialPageSize = 10,
168+
pageSizeOptions,
168169
manualPagination = false,
169170
rowCount,
170171
page: controlledPage,
@@ -343,6 +344,19 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
343344
}
344345
};
345346

347+
// Rows-per-page choices: caller-supplied options (e.g. view metadata's
348+
// pagination.pageSizeOptions) or the built-in fallback. The active pageSize
349+
// is always merged in and the list de-duplicated + sorted so the selector can
350+
// display the current value even when it is not one of the configured steps.
351+
const pageSizeChoices = React.useMemo(() => {
352+
const base = pageSizeOptions && pageSizeOptions.length > 0
353+
? pageSizeOptions
354+
: [5, 10, 20, 50, 100];
355+
return Array.from(new Set([...base, pageSize]))
356+
.filter((n) => Number.isFinite(n) && n > 0)
357+
.sort((a, b) => a - b);
358+
}, [pageSizeOptions, pageSize]);
359+
346360
/**
347361
* Generates a unique identifier for each row to maintain stable selection state
348362
* across pagination and sorting operations.
@@ -803,15 +817,15 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
803817
<TableHeader className="sticky top-0 bg-background z-10">
804818
<TableRow>
805819
{selectable && (
806-
<TableHead className={cn("w-10 bg-background", frozenColumns > 0 && "sticky left-0 z-20")}>
820+
<TableHead className={cn("w-10 bg-background px-3", frozenColumns > 0 && "sticky left-0 z-20")}>
807821
<Checkbox
808822
checked={allPageRowsSelected ? true : somePageRowsSelected ? 'indeterminate' : false}
809823
onCheckedChange={handleSelectAll}
810824
/>
811825
</TableHead>
812826
)}
813827
{showRowNumbers && (
814-
<TableHead className={cn("w-10 bg-background text-center", frozenColumns > 0 && "sticky z-20")} style={frozenColumns > 0 ? { left: selectable ? 40 : 0 } : undefined}>
828+
<TableHead className={cn("w-10 bg-background text-center px-3", frozenColumns > 0 && "sticky z-20")} style={frozenColumns > 0 ? { left: selectable ? 40 : 0 } : undefined}>
815829
<span className="text-xs text-muted-foreground">#</span>
816830
</TableHead>
817831
)}
@@ -973,7 +987,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
973987
}}
974988
>
975989
{selectable && (
976-
<TableCell className={cn(cellClassName, frozenColumns > 0 && "sticky left-0 z-10 bg-background", selectionStyle === 'hover' && "relative")}>
990+
<TableCell className={cn(cellClassName, "px-3", frozenColumns > 0 && "sticky left-0 z-10 bg-background", selectionStyle === 'hover' && "relative")}>
977991
{selectionStyle === 'hover' ? (
978992
<div className={cn("transition-opacity", isSelected ? "opacity-100" : "opacity-0 group-hover/row:opacity-100")}>
979993
<Checkbox
@@ -1166,7 +1180,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
11661180

11671181
{/* Pagination — hidden when only one page (no controls would be actionable) */}
11681182
{pagination && sortedData.length > 0 && totalPages > 1 && (
1169-
<div className="flex flex-col sm:flex-row items-center justify-between gap-2">
1183+
<div className="flex flex-col sm:flex-row items-center justify-between gap-2 px-3 sm:px-4 py-2">
11701184
<div className="flex items-center gap-2">
11711185
<span className="text-xs sm:text-sm text-muted-foreground">{t('table.rowsPerPage')}:</span>
11721186
<Select
@@ -1177,11 +1191,9 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
11771191
<SelectValue />
11781192
</SelectTrigger>
11791193
<SelectContent>
1180-
<SelectItem value="5">5</SelectItem>
1181-
<SelectItem value="10">10</SelectItem>
1182-
<SelectItem value="20">20</SelectItem>
1183-
<SelectItem value="50">50</SelectItem>
1184-
<SelectItem value="100">100</SelectItem>
1194+
{pageSizeChoices.map((n) => (
1195+
<SelectItem key={n} value={n.toString()}>{n}</SelectItem>
1196+
))}
11851197
</SelectContent>
11861198
</Select>
11871199
</div>

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1419,8 +1419,12 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
14191419
? 'px-3 py-3.5 h-16 text-sm leading-relaxed'
14201420
: 'px-3 py-1.5 h-11 text-[13px] leading-normal';
14211421

1422+
// Body cells get `px-3` from rowHeightCellClass; give the header the same
1423+
// horizontal padding so header labels line up exactly with the cell content
1424+
// below them (the primitive <th> default is px-4, which is 4px wider).
14221425
const applyDensity = (col: any) => ({
14231426
...col,
1427+
className: ['px-3', col.className].filter(Boolean).join(' '),
14241428
cellClassName: [rowHeightCellClass, col.cellClassName].filter(Boolean).join(' '),
14251429
});
14261430

@@ -1430,7 +1434,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
14301434
...unpinnedCols.map(applyDensity),
14311435
...pinnedRightCols.map((col: any) => ({
14321436
...applyDensity(col),
1433-
className: [col.className, rightPinnedClasses].filter(Boolean).join(' '),
1437+
className: ['px-3', col.className, rightPinnedClasses].filter(Boolean).join(' '),
14341438
cellClassName: [rowHeightCellClass, col.cellClassName, rightPinnedClasses].filter(Boolean).join(' '),
14351439
})),
14361440
]
@@ -1578,6 +1582,11 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
15781582
data,
15791583
pagination: paginationEnabled,
15801584
pageSize: manualPaginationOn ? manualPageSize : pageSize,
1585+
// Rows-per-page selector options sourced from view metadata
1586+
// (schema.pagination.pageSizeOptions). When absent the DataTable falls back
1587+
// to its built-in list. This is what makes the single, server-driven pager
1588+
// expose the configured 50/100/200/500 choices instead of a second control.
1589+
pageSizeOptions: schema.pagination?.pageSizeOptions,
15811590
// In server mode `data` IS the current page; tell DataTable to render it
15821591
// as-is and drive paging via the callbacks below using the real match total.
15831592
manualPagination: manualPaginationOn,

packages/plugin-list/src/ListView.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2314,7 +2314,12 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
23142314
{t('list.dataLimitReached', { limit: effectivePageSize })}
23152315
</span>
23162316
)}
2317-
{schema.pagination?.pageSizeOptions && schema.pagination.pageSizeOptions.length > 0 && (
2317+
{/* Grid view delegates the rows-per-page selector to the DataTable's
2318+
own server-driven pager (ObjectGrid passes pagination.pageSizeOptions
2319+
straight through). Rendering a second native <select> here produced a
2320+
duplicate control, so for grid we suppress it and only keep this
2321+
fallback selector for pager-less views (gallery/kanban/calendar). */}
2322+
{currentView !== 'grid' && schema.pagination?.pageSizeOptions && schema.pagination.pageSizeOptions.length > 0 && (
23182323
<div className="ml-auto flex items-center gap-2">
23192324
<span>{t('table.rowsPerPage', { defaultValue: 'Rows per page' })}</span>
23202325
<select

packages/plugin-list/src/__tests__/ListView.test.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1574,8 +1574,16 @@ describe('ListView', () => {
15741574
// ============================
15751575
// pageSizeOptions UI
15761576
// ============================
1577+
// NOTE: For the GRID view the rows-per-page selector now lives in the
1578+
// DataTable's own server-driven pager (ObjectGrid forwards
1579+
// pagination.pageSizeOptions straight through), so ListView no longer renders
1580+
// its native <select data-testid="page-size-selector"> for grids — that fixed
1581+
// a duplicate-control bug. The native fallback selector below is therefore
1582+
// exercised through a NON-grid view (gallery), which has no DataTable pager.
1583+
// The grid combobox option list is covered in
1584+
// components/data-table-manual-pagination.test.tsx.
15771585
describe('pageSizeOptions', () => {
1578-
it('should render page size selector when pageSizeOptions is provided', async () => {
1586+
it('should render page size selector when pageSizeOptions is provided (non-grid view)', async () => {
15791587
const mockItems = [
15801588
{ id: '1', name: 'Alice', email: 'alice@test.com' },
15811589
];
@@ -1584,7 +1592,7 @@ describe('ListView', () => {
15841592
const schema: ListViewSchema = {
15851593
type: 'list-view',
15861594
objectName: 'contacts',
1587-
viewType: 'grid',
1595+
viewType: 'gallery',
15881596
fields: ['name', 'email'],
15891597
pagination: { pageSize: 25, pageSizeOptions: [10, 25, 50, 100] },
15901598
};
@@ -1973,7 +1981,7 @@ describe('ListView', () => {
19731981
const schema: ListViewSchema = {
19741982
type: 'list-view',
19751983
objectName: 'contacts',
1976-
viewType: 'grid',
1984+
viewType: 'gallery',
19771985
fields: ['name', 'email'],
19781986
pagination: { pageSize: 25, pageSizeOptions: [10, 25, 50] },
19791987
};
@@ -1999,7 +2007,7 @@ describe('ListView', () => {
19992007
const schema: ListViewSchema = {
20002008
type: 'list-view',
20012009
objectName: 'contacts',
2002-
viewType: 'grid',
2010+
viewType: 'gallery',
20032011
fields: ['name', 'email'],
20042012
pagination: { pageSize: 25, pageSizeOptions: [10, 25, 50, 100] },
20052013
};
@@ -2033,7 +2041,7 @@ describe('ListView', () => {
20332041
const schema: ListViewSchema = {
20342042
type: 'list-view',
20352043
objectName: 'contacts',
2036-
viewType: 'grid',
2044+
viewType: 'gallery',
20372045
fields: ['name', 'email'],
20382046
pagination: { pageSize: 10, pageSizeOptions: [10, 25, 50, 100] },
20392047
};

packages/types/src/data-display.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,13 @@ export interface DataTableSchema extends BaseSchema {
344344
* @default 10
345345
*/
346346
pageSize?: number;
347+
/**
348+
* Options offered in the "rows per page" selector. When omitted the table
349+
* falls back to its built-in list (5/10/20/50/100). The current `pageSize`
350+
* is always merged in so the selector can show the active value even if it
351+
* is not one of the configured options.
352+
*/
353+
pageSizeOptions?: number[];
347354
/**
348355
* Server-side ("manual") pagination. When true, `data` is treated as the
349356
* already-fetched current page (not sliced locally), `rowCount` provides the

packages/types/src/zod/data-display.zod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export const DataTableSchema = BaseSchema.extend({
137137
data: z.array(z.any()).describe('Table data'),
138138
pagination: z.boolean().optional().describe('Enable pagination'),
139139
pageSize: z.number().optional().describe('Default page size'),
140+
pageSizeOptions: z.array(z.number()).optional().describe('Options for the rows-per-page selector (defaults to 5/10/20/50/100).'),
140141
searchable: z.boolean().optional().describe('Enable search'),
141142
selectable: z.boolean().optional().describe('Enable row selection'),
142143
sortable: z.boolean().optional().describe('Enable sorting'),

0 commit comments

Comments
 (0)