From 43d21e6b194f837b641da8499086d9c50bed9113 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:16:49 +0000 Subject: [PATCH 1/8] feat(plugin-grid): one display page size read from the spec, and a distinct fetch batch The grid fell back to three page-size literals: 10 rows (client-paged table), 10 groups (grouped view) and a 50-row "server window" that sized both the fetch and the visible page of the server-paged table. Per ruling C-prime: - the undeclared display page size on all three display paths is the default @objectstack/spec declares for pagination.pageSize, read via PaginationConfigSchema.parse({}) with no local fallback number; - the server-window constant becomes DEFAULT_FETCH_BATCH_SIZE (value kept), read only by the grouped view's fetch, never as a page size; - the grouped pager's size selector merges in the active size, as the DataTable pager already does, so it shows the size in force. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LkCKMa5bvrw3L4ezcNXEXW --- packages/plugin-grid/src/ObjectGrid.tsx | 92 +++++--- ...tGrid.displayPageSizeDefault-9853.test.tsx | 210 ++++++++++++++++++ ...jectGrid.pageSizeNonPositive-9853.test.tsx | 31 ++- 3 files changed, 298 insertions(+), 35 deletions(-) create mode 100644 packages/plugin-grid/src/__tests__/ObjectGrid.displayPageSizeDefault-9853.test.tsx diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 386ed69a42..b39b14958e 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -45,7 +45,7 @@ import { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object // `string | I18nLabel` form `BaseSchema` has carried since objectui#4580: the // two reads below put the label in STRING positions, so a map-valued label used // to reach them as an object and the compiler could not say so. -import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; +import { resolveI18nLabel as resolveInlineI18nLabel, PaginationConfigSchema } from '@objectstack/spec/ui'; import { stateMachineNextValues, isFieldInlineEditable } from './inline-edit-options'; import { Badge, Button, NavigationOverlay, EmptyValue, @@ -1146,24 +1146,49 @@ function resolveRowHeightMode(rowHeight: unknown): RowHeightMode { } /** - * The three page-size defaults this component falls back to, named so the - * divergence between them is DECLARED rather than a by-product of three - * hand-spelled fallback chains that happened to end in different literals. + * The page size every display surface of this component falls back to when the + * author declared none (objectui#9853, ruling C-prime): the flat table, the + * server-paged table and the grouped view's page of groups. * - * They are three different quantities and that is why they are three - * constants: one sizes a page of ROWS in the client-paged table, one sizes a - * page of GROUPS in the grouped view, and one sizes the `$top` WINDOW the - * server-paged fetch asks for. ⚠️ What is NOT settled here is whether the - * first and the third should be the same number — the same grid with no - * authored `pagination` shows the server-window default per page while it - * fetches its own rows and the flat default when it does not, which is a - * visible inconsistency an author never declared. Changing either literal - * changes what every undeclared grid renders, so it is handed back as a - * question (objectui#9853) rather than decided here. + * READ from `@objectstack/spec`, not restated. The protocol's pagination config + * declares `pageSize` with a default, and that declaration is the one answer an + * author can look up; a literal here would be a second answer free to drift + * from it. Parsing an empty object is the spec's own way of saying what an + * undeclared member means, so this is the value the schema would fill in. + * + * ⚠️ There is deliberately no local fallback number behind this read. If a + * future `@objectstack/spec` stops declaring the default (the same principle + * `packages/types` applies at its own import boundary is that a validator does + * not write values), this throws at module load instead of quietly + * substituting a number nobody declared. The spec version is locked, so that + * can only surface on a spec upgrade, where the pin + * `ObjectGrid.displayPageSizeDefault-9853.test.tsx` reddens first. + */ +function readSpecDisplayPageSize(): number { + const declared: unknown = PaginationConfigSchema.parse({}).pageSize; + if (typeof declared !== 'number' || !Number.isInteger(declared) || declared <= 0) { + throw new Error( + '[ObjectUI] ObjectGrid: @objectstack/spec no longer declares a positive default ' + + `for pagination.pageSize (read ${String(declared)}); the grid has no display ` + + 'page size to fall back to.', + ); + } + return declared; +} +const DEFAULT_DISPLAY_PAGE_SIZE = readSpecDisplayPageSize(); + +/** + * How many rows the grouped view FETCHES to bucket client-side when the author + * declared no page size. A fetch batch, ⛔ not a page size: no display path + * reads it (objectui#9853, ruling C-prime, clause 1). Before that ruling this + * number was named as a "server window" and also sized the server-paged table's + * visible page, so one constant meant two quantities. + * + * It stays a constant of this component rather than the protocol's value + * because the protocol declares no fetch batch: it is how much of the result + * set the grouped view holds, which is an implementation choice. */ -const DEFAULT_FLAT_PAGE_SIZE = 10; -const DEFAULT_GROUPS_PER_PAGE = 10; -const DEFAULT_SERVER_WINDOW_SIZE = 50; +const DEFAULT_FETCH_BATCH_SIZE = 50; /** * The mode a `selection` object with no `type` member asks for (objectui#9837, @@ -1334,10 +1359,11 @@ export const ObjectGrid: React.FC = ({ const [activeBulkSkipped, setActiveBulkSkipped] = useState(0); const lastFindParamsRef = React.useRef | null>(null); // Grouped view paginates whole groups (groups stay intact, never split across - // pages). Defaults to the schema page size, falling back to 10 groups/page. + // pages). Defaults to the schema page size, falling back to the display + // default the spec declares (objectui#9853) — here counted in groups. const [groupedPage, setGroupedPage] = useState(1); const [groupedPageSize, setGroupedPageSize] = useState( - resolvePageSize(schema, DEFAULT_GROUPS_PER_PAGE), + resolvePageSize(schema, DEFAULT_DISPLAY_PAGE_SIZE), ); // Sync internal rowHeightMode when schema.rowHeight prop changes (e.g., parent ListView density toggle). @@ -1771,10 +1797,21 @@ export const ObjectGrid: React.FC = ({ // — 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). + // + // That makes this a DISPLAY page size, so undeclared it is the spec's display + // default, ⛔ not the fetch batch (objectui#9853, ruling C-prime). const [serverPage, setServerPage] = useState(1); const [serverPageSize, setServerPageSize] = useState( - resolvePageSize(schema, DEFAULT_SERVER_WINDOW_SIZE), + resolvePageSize(schema, DEFAULT_DISPLAY_PAGE_SIZE), ); + // What one fetch asks for. A grouped view buckets the rows it holds and has + // no row pager, so it asks for a fetch BATCH; everywhere else the rows held + // ARE the page on screen. `usableGroupingFields` is the predicate + // `useGroupedData` answers `isGrouped` with, so the two cannot disagree. + const groupedFetch = usableGroupingFields(schema.grouping?.fields).length > 0; + const fetchWindow = groupedFetch + ? resolvePageSize(schema, DEFAULT_FETCH_BATCH_SIZE) + : serverPageSize; // Column-header sort, when this grid fetches its own rows (objectui#3106). // `null` means "nobody has clicked a header" and the view's declared @@ -2103,8 +2140,8 @@ export const ObjectGrid: React.FC = ({ const params: any = { $select: getSelectFields(), - $top: serverPageSize, - $skip: (serverPage - 1) * serverPageSize, + $top: fetchWindow, + $skip: (serverPage - 1) * fetchWindow, }; // The block's declared `filter` input, already lowered to an AST at @@ -2349,7 +2386,7 @@ export const ObjectGrid: React.FC = ({ // the query asking for the OLD one and the new grouping would read // `undefined` on every row — the very `(empty)` bucket this card fixes, // reachable a second way. - }, [objectName, schemaFields, schemaColumns, schemaFilter, schemaFilterRefusal, schemaSort, headerSort, searchTerm, schemaPagination, schemaPageSize, serverPage, serverPageSize, dataSource, hasInlineData, dataConfig, refreshKey, perms.isLoaded, groupingProjectionKey]); + }, [objectName, schemaFields, schemaColumns, schemaFilter, schemaFilterRefusal, schemaSort, headerSort, searchTerm, schemaPagination, schemaPageSize, serverPage, fetchWindow, dataSource, hasInlineData, dataConfig, refreshKey, perms.isLoaded, groupingProjectionKey]); // The same reset, for the path the loader above never runs on (objectui#4501 // clause 2). "All N matching are selected" is a claim about ONE query, so it @@ -4419,7 +4456,7 @@ export const ObjectGrid: React.FC = ({ // Through the same resolver as the two seeds above (objectui#9853). This // site used `||` and the seeds used `??`, so one authored `pageSize: 0` // reached three read points and got two different answers. - const pageSize = resolvePageSize(schema, DEFAULT_FLAT_PAGE_SIZE); + const pageSize = resolvePageSize(schema, DEFAULT_DISPLAY_PAGE_SIZE); // Determine search settings const searchEnabled = schema.searchableFields !== undefined @@ -4494,7 +4531,7 @@ export const ObjectGrid: React.FC = ({ const groupingPartialWithTotal = groupingTotalKnown && (resolvedTotalMatching as number) > groupingRowsLoaded; const groupingPartialWindowFull = - !groupingTotalKnown && !hasInlineData && groupingRowsLoaded >= serverPageSize; + !groupingTotalKnown && !hasInlineData && groupingRowsLoaded >= fetchWindow; const groupingIsPartial = isGrouped && (groupingPartialWithTotal || groupingPartialWindowFull); // ONE sentence, used in both places it belongs: the notice above the group @@ -5507,7 +5544,10 @@ export const ObjectGrid: React.FC = ({ value={groupedPageSize} onChange={(e) => { setGroupedPageSize(Number(e.target.value)); setGroupedPage(1); }} > - {[5, 10, 20, 50, 100].map((n) => ( + {/* The active size is merged in, as the DataTable pager does, so the + selector shows the size in force even when it is not one of the + steps: the spec's display default is not (objectui#9853). */} + {Array.from(new Set([5, 10, 20, 50, 100, groupedPageSize])).sort((a, b) => a - b).map((n) => ( ))} diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.displayPageSizeDefault-9853.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.displayPageSizeDefault-9853.test.tsx new file mode 100644 index 0000000000..5f3c50054c --- /dev/null +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.displayPageSizeDefault-9853.test.tsx @@ -0,0 +1,210 @@ +/** + * 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. + */ + +/** + * objectui#9853, ruling C-prime — ONE display page size, read from the + * protocol, and a fetch batch that is a different quantity. + * + * Before the ruling this component fell back to three page-size literals: a + * page of rows in the client-paged table, a page of groups in the grouped + * view, and a "server window" that sized both the `$top` of every fetch AND the + * visible page of the server-paged table. The ruling: + * + * 1. the fetch batch size is not a page size, and no display path reads it; + * 2. an undeclared display page size is the default `@objectstack/spec` + * declares for `pagination.pageSize`, read from the spec rather than + * restated; + * 3. a declared `pageSize` is honoured as before, and a refused one keeps the + * loud warning (pinned row by row in + * `ObjectGrid.pageSizeNonPositive-9853.test.tsx`). + * + * Every display surface is read OFF THE SCREEN (rows or groups rendered), and + * the fetch side off the WIRE (`$top` the data source received), so a pin that + * only compares constants cannot pass for this one. + * + * `../ObjectGrid` is imported relatively and the root vitest config aliases + * `@object-ui/*` to each package's `src`, so no build step stands between an + * edit and this run. + */ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { PaginationConfigSchema } from '@objectstack/spec/ui'; + +import { ObjectGrid } from '../ObjectGrid'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider } from '@object-ui/react'; + +registerAllFields(); + +/** The protocol's answer, read the way the renderer reads it. */ +const SPEC_DISPLAY_DEFAULT: number = PaginationConfigSchema.parse({}).pageSize; + +/** + * The grouped view's fetch batch. Pinned by value because the ruling keeps + * the value; what matters for the ruling is that it is NOT the display default. + */ +const FETCH_BATCH = 50; + +const OBJECT = 'duly_task'; +const OBJECT_FIELDS = { + id: { type: 'text', label: 'Id' }, + subject: { type: 'text', label: 'Subject' }, + unit: { type: 'text', label: 'Unit' }, +}; + +/** More rows than either number, every row its own group. */ +const ROW_COUNT = 80; +const ROWS = Array.from({ length: ROW_COUNT }, (_, i) => ({ + id: `r-${i}`, + subject: `Task ${String(i).padStart(3, '0')}`, + unit: `Unit ${String(i).padStart(3, '0')}`, +})); + +const makeDataSource = () => { + const calls: Array> = []; + return { + calls, + find: vi.fn(async (_object: string, params: Record) => { + calls.push(params); + const skip = (params.$skip as number | undefined) ?? 0; + const top = (params.$top as number | undefined) ?? ROWS.length; + return { data: ROWS.slice(skip, skip + top), total: ROWS.length }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: OBJECT, fields: OBJECT_FIELDS })), + }; +}; + +const renderGrid = (schema: Record, dataSource?: unknown) => + render( + + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + , + ); + +const fetchedSchema = (extra: Record = {}) => ({ + type: 'object-grid', + objectName: OBJECT, + columns: ['subject'], + ...extra, +}); +const groupedSchema = (extra: Record = {}) => + fetchedSchema({ grouping: { fields: [{ field: 'unit' }] }, ...extra }); +const inlineSchema = (extra: Record = {}) => ({ + type: 'object-grid', + objectName: OBJECT, + columns: [{ field: 'subject', label: 'Subject' }], + data: { provider: 'value', items: ROWS }, + ...extra, +}); + +const bodyRows = (container: HTMLElement) => Array.from(container.querySelectorAll('tbody tr')); +const groupRows = () => [...document.querySelectorAll('[data-testid^="group-row-"]')]; + +let warnings: string[] = []; +beforeEach(() => { + warnings = []; + vi.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => { + warnings.push(args.map(String).join(' ')); + }); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); +const paginationWarnings = () => warnings.filter((w) => w.includes('ObjectGrid pagination')); + +describe('ObjectGrid — one display page size from the spec, and a distinct fetch batch (objectui#9853)', () => { + it('the spec declares a usable display default, and it is not the fetch batch', () => { + // If a spec upgrade stops declaring the default, this is the row that says + // so first — the renderer refuses to invent one. + expect(Number.isInteger(SPEC_DISPLAY_DEFAULT) && SPEC_DISPLAY_DEFAULT > 0).toBe(true); + expect(FETCH_BATCH).not.toBe(SPEC_DISPLAY_DEFAULT); + }); + + it('every display surface shows the spec default when nothing is declared', async () => { + // Server-paged table: the page on screen, and the `$top` that fetched it. + const flatDs = makeDataSource(); + const flat = renderGrid(fetchedSchema(), flatDs); + await vi.waitFor(() => expect(bodyRows(flat.container).length).toBeGreaterThan(0)); + const serverPaged = bodyRows(flat.container).length; + const serverTop = flatDs.calls.at(-1)?.$top; + cleanup(); + + // Client-paged table over inline rows. + const inline = renderGrid(inlineSchema()); + await vi.waitFor(() => expect(bodyRows(inline.container).length).toBeGreaterThan(0)); + const clientPaged = bodyRows(inline.container).length; + cleanup(); + + // Grouped view: a page of groups. + const groupedDs = makeDataSource(); + renderGrid(groupedSchema(), groupedDs); + await vi.waitFor(() => expect(groupRows().length).toBeGreaterThan(0)); + const groupsShown = groupRows().length; + + // ONE assertion over the three surfaces, so a surface that keeps a + // default of its own shows up as the odd one out. + expect({ serverPaged, serverTop, clientPaged, groupsShown }).toEqual({ + serverPaged: SPEC_DISPLAY_DEFAULT, + serverTop: SPEC_DISPLAY_DEFAULT, + clientPaged: SPEC_DISPLAY_DEFAULT, + groupsShown: SPEC_DISPLAY_DEFAULT, + }); + expect(paginationWarnings()).toHaveLength(0); + }); + + it('the grouped view FETCHES the batch, which no display path reads', async () => { + const ds = makeDataSource(); + renderGrid(groupedSchema(), ds); + await vi.waitFor(() => expect(groupRows().length).toBeGreaterThan(0)); + // The batch left on the wire, and the groups on screen are still a page + // of the display default — two quantities, two numbers. + expect(ds.calls.map((p) => p.$top)).toContain(FETCH_BATCH); + expect(ds.calls.map((p) => p.$top)).not.toContain(SPEC_DISPLAY_DEFAULT); + expect(groupRows()).toHaveLength(SPEC_DISPLAY_DEFAULT); + }); + + it('CONTROL — a declared page size is honoured on every surface, and on the grouped fetch as before', async () => { + const declared = { pagination: { pageSize: 7 } }; + + const flatDs = makeDataSource(); + const flat = renderGrid(fetchedSchema(declared), flatDs); + await vi.waitFor(() => expect(bodyRows(flat.container).length).toBeGreaterThan(0)); + expect(bodyRows(flat.container)).toHaveLength(7); + expect(flatDs.calls.map((p) => p.$top)).toContain(7); + cleanup(); + + const inline = renderGrid(inlineSchema(declared)); + await vi.waitFor(() => expect(bodyRows(inline.container).length).toBeGreaterThan(0)); + expect(bodyRows(inline.container)).toHaveLength(7); + cleanup(); + + const groupedDs = makeDataSource(); + renderGrid(groupedSchema(declared), groupedDs); + await vi.waitFor(() => expect(groupRows().length).toBeGreaterThan(0)); + expect(groupRows()).toHaveLength(7); + expect(groupedDs.calls.map((p) => p.$top)).toContain(7); + expect(paginationWarnings()).toHaveLength(0); + }); + + it('CONTROL — a refused page size still warns, then falls back to the spec default', async () => { + const ds = makeDataSource(); + const flat = renderGrid(fetchedSchema({ pagination: { pageSize: 0 } }), ds); + await vi.waitFor(() => expect(bodyRows(flat.container).length).toBeGreaterThan(0)); + await vi.waitFor(() => expect(paginationWarnings().length).toBeGreaterThan(0)); + expect(bodyRows(flat.container)).toHaveLength(SPEC_DISPLAY_DEFAULT); + expect(ds.calls.map((p) => p.$top)).not.toContain(0); + }); +}); diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.pageSizeNonPositive-9853.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.pageSizeNonPositive-9853.test.tsx index 62eba5c20c..b24cef3fa5 100644 --- a/packages/plugin-grid/src/__tests__/ObjectGrid.pageSizeNonPositive-9853.test.tsx +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.pageSizeNonPositive-9853.test.tsx @@ -53,6 +53,7 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { render, cleanup } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; +import { PaginationConfigSchema } from '@objectstack/spec/ui'; import { ObjectGrid } from '../ObjectGrid'; import { registerAllFields } from '@object-ui/fields'; @@ -60,6 +61,13 @@ import { ActionProvider } from '@object-ui/react'; registerAllFields(); +/** + * The display page size an undeclared grid falls back to — the protocol's, + * read the way the renderer reads it (objectui#9853, ruling C-prime). Before + * that ruling the flat fallback was a local ten. + */ +const DISPLAY_DEFAULT: number = PaginationConfigSchema.parse({}).pageSize; + const OBJECT = 'duly_task'; const OBJECT_FIELDS = { @@ -143,8 +151,8 @@ describe('ObjectGrid — a non-positive authored page size is refused at every r }); await vi.waitFor(() => expect(groupRows().length).toBeGreaterThan(0)); - // Seven rows, seven distinct units, a default group page of ten: every - // group is on screen. Before this change `groups.slice(0, 0)` put NONE + // Seven rows, seven distinct units, a default group page larger than + // seven: every group is on screen. Before this change `groups.slice(0, 0)` put NONE // of them there. expect( groupRows().length, @@ -258,7 +266,10 @@ describe('ObjectGrid — a non-positive authored page size is refused at every r // // The channel matters: the flat size is the table's live page size only when // `manualPaginationOn` is false, which is what inline `data` gives. - const INLINE_ROWS = Array.from({ length: 12 }, (_, i) => ({ + // + // Thirty rows, so the display default leaves a second page and a refused + // value that slipped through would show a different count. + const INLINE_ROWS = Array.from({ length: 30 }, (_, i) => ({ id: String(i + 1), name: `Row ${String(i + 1).padStart(2, '0')}`, status: 'open', @@ -288,17 +299,19 @@ describe('ObjectGrid — a non-positive authored page size is refused at every r it('a negative page size does not reach the flat table', async () => { const { container } = renderInline({ pagination: { pageSize: -10 } }); await vi.waitFor(() => expect(bodyRows(container).length).toBeGreaterThan(0)); - // Twelve rows at the default ten: a full first page and a second one. - expect(bodyRows(container)).toHaveLength(10); + // Thirty rows at the display default: a full first page and a second one. + expect(bodyRows(container)).toHaveLength(DISPLAY_DEFAULT); expect(paginationWarnings().length).toBeGreaterThan(0); }); it('a non-integer page size does not reach the flat table', async () => { - const { container } = renderInline({ pagination: { pageSize: 25.5 } }); + // `12.5`, not the `25.5` this row used while the default was ten: a + // fractional size next to the new default would slice to the same count + // and stop telling the two spellings apart. + const { container } = renderInline({ pagination: { pageSize: 12.5 } }); await vi.waitFor(() => expect(bodyRows(container).length).toBeGreaterThan(0)); - // `25.5` is truthy, so the old spelling handed it to the table and every - // one of the twelve rows landed on a single page. - expect(bodyRows(container)).toHaveLength(10); + // `12.5` is truthy, so the old spelling handed it to the table. + expect(bodyRows(container)).toHaveLength(DISPLAY_DEFAULT); expect(paginationWarnings().length).toBeGreaterThan(0); }); From bd720eeb1438446e5828f378e090aed9fdee6a18 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:26:48 +0000 Subject: [PATCH 2/8] test(plugin-grid): flip the grouped-pagination pin to the spec display default groupedPagination.test.tsx pinned the undeclared page of groups as 10. The fixture is now sized off the spec's declared default (two full pages and a partial third), so the same seven rows keep their meaning. Adds the grouped size-selector row to the objectui#9853 pin. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01LkCKMa5bvrw3L4ezcNXEXW --- ...tGrid.displayPageSizeDefault-9853.test.tsx | 11 +++++ .../src/__tests__/groupedPagination.test.tsx | 48 +++++++++++-------- 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.displayPageSizeDefault-9853.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.displayPageSizeDefault-9853.test.tsx index 5f3c50054c..0398042e43 100644 --- a/packages/plugin-grid/src/__tests__/ObjectGrid.displayPageSizeDefault-9853.test.tsx +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.displayPageSizeDefault-9853.test.tsx @@ -176,6 +176,17 @@ describe('ObjectGrid — one display page size from the spec, and a distinct fet expect(groupRows()).toHaveLength(SPEC_DISPLAY_DEFAULT); }); + it('the grouped pager\'s size selector shows the size in force', async () => { + renderGrid(groupedSchema(), makeDataSource()); + await vi.waitFor(() => expect(groupRows().length).toBeGreaterThan(0)); + // The display default is not one of the selector's fixed steps; without + // the active size merged in, the control would display a size that is not + // the one on screen. + const select = document.querySelector('select') as HTMLSelectElement | null; + expect(select).toBeTruthy(); + expect(select!.value).toBe(String(SPEC_DISPLAY_DEFAULT)); + }); + it('CONTROL — a declared page size is honoured on every surface, and on the grouped fetch as before', async () => { const declared = { pagination: { pageSize: 7 } }; diff --git a/packages/plugin-grid/src/__tests__/groupedPagination.test.tsx b/packages/plugin-grid/src/__tests__/groupedPagination.test.tsx index 5e289c8558..ca1110f885 100644 --- a/packages/plugin-grid/src/__tests__/groupedPagination.test.tsx +++ b/packages/plugin-grid/src/__tests__/groupedPagination.test.tsx @@ -14,6 +14,7 @@ import { describe, it, expect } from 'vitest'; import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; +import { PaginationConfigSchema } from '@objectstack/spec/ui'; import { ObjectGrid } from '../ObjectGrid'; import { registerAllFields } from '@object-ui/fields'; @@ -21,11 +22,18 @@ import { ActionProvider } from '@object-ui/react'; registerAllFields(); -// 25 rows, each with a unique `category` → 25 distinct top-level groups. -const manyGroups = Array.from({ length: 25 }, (_, i) => ({ +// The undeclared page of groups is the display default the spec declares +// (objectui#9853, ruling C-prime; it was a local ten before). The fixture is +// sized off it: two full pages and a partial third of ten groups. +const DISPLAY_DEFAULT: number = PaginationConfigSchema.parse({}).pageSize; +const GROUP_COUNT = DISPLAY_DEFAULT * 2 + 10; +const catName = (n: number) => `Cat ${String(n).padStart(3, '0')}`; + +// One row per unique `category` → GROUP_COUNT distinct top-level groups. +const manyGroups = Array.from({ length: GROUP_COUNT }, (_, i) => ({ id: String(i + 1), name: `Row ${i + 1}`, - category: `Cat ${String(i + 1).padStart(2, '0')}`, + category: catName(i + 1), amount: (i + 1) * 10, })); @@ -54,16 +62,16 @@ const groupRows = () => describe('Grouped view pagination', () => { it('paginates top-level groups instead of rendering all at once', async () => { renderGroupedGrid(); - // Default page size falls back to 10 → first page shows 10 of 25 groups. - await waitFor(() => expect(groupRows().length).toBe(10)); - expect(screen.getByText('Cat 01')).toBeInTheDocument(); - expect(screen.queryByText('Cat 11')).not.toBeInTheDocument(); + // Undeclared, the first page shows DISPLAY_DEFAULT of GROUP_COUNT groups. + await waitFor(() => expect(groupRows().length).toBe(DISPLAY_DEFAULT)); + expect(screen.getByText(catName(1))).toBeInTheDocument(); + expect(screen.queryByText(catName(DISPLAY_DEFAULT + 1))).not.toBeInTheDocument(); }); it('renders a pager with page info when there is more than one page', async () => { renderGroupedGrid(); - await waitFor(() => expect(groupRows().length).toBe(10)); - // 25 groups / 10 per page = 3 pages. + await waitFor(() => expect(groupRows().length).toBe(DISPLAY_DEFAULT)); + // Two full pages and a partial third. expect(screen.getByText('Page 1 of 3')).toBeInTheDocument(); expect(screen.getByText('Rows per page:')).toBeInTheDocument(); }); @@ -80,9 +88,9 @@ describe('Grouped view pagination', () => { fireEvent.click(navButtons[2]); // next await waitFor(() => expect(screen.getByText('Page 2 of 3')).toBeInTheDocument()); - expect(screen.getByText('Cat 11')).toBeInTheDocument(); - expect(screen.queryByText('Cat 01')).not.toBeInTheDocument(); - expect(groupRows().length).toBe(10); + expect(screen.getByText(catName(DISPLAY_DEFAULT + 1))).toBeInTheDocument(); + expect(screen.queryByText(catName(1))).not.toBeInTheDocument(); + expect(groupRows().length).toBe(DISPLAY_DEFAULT); }); it('shows the last (partial) page of groups', async () => { @@ -95,9 +103,9 @@ describe('Grouped view pagination', () => { fireEvent.click(navButtons[3]); // last await waitFor(() => expect(screen.getByText('Page 3 of 3')).toBeInTheDocument()); - // 25 = 10 + 10 + 5 → last page has the remaining 5 groups. - expect(groupRows().length).toBe(5); - expect(screen.getByText('Cat 25')).toBeInTheDocument(); + // Two full pages leave the remaining ten groups for the last one. + expect(groupRows().length).toBe(10); + expect(screen.getByText(catName(GROUP_COUNT))).toBeInTheDocument(); }); it('changing page size repaginates and resets to page 1', async () => { @@ -106,22 +114,22 @@ describe('Grouped view pagination', () => { const select = document.querySelector('select') as HTMLSelectElement; expect(select).toBeTruthy(); - fireEvent.change(select, { target: { value: '20' } }); + fireEvent.change(select, { target: { value: '50' } }); - // 25 / 20 = 2 pages, page reset to 1, first page shows 20 groups. + // GROUP_COUNT / 50 = 2 pages, page reset to 1, first page shows 50 groups. await waitFor(() => expect(screen.getByText('Page 1 of 2')).toBeInTheDocument()); - expect(groupRows().length).toBe(20); + expect(groupRows().length).toBe(50); }); it('does not render a pager when groups fit on a single page', async () => { renderGroupedGrid({ pagination: { pageSize: 100 } }); - await waitFor(() => expect(groupRows().length).toBe(25)); + await waitFor(() => expect(groupRows().length).toBe(GROUP_COUNT)); expect(screen.queryByText(/^Page \d+ of \d+$/)).not.toBeInTheDocument(); }); it('wraps grouped tables in a single shared horizontal scroll container', async () => { const { container } = renderGroupedGrid(); - await waitFor(() => expect(groupRows().length).toBe(10)); + await waitFor(() => expect(groupRows().length).toBe(DISPLAY_DEFAULT)); // One shared overflow scroller whose inner track is min-w-max so every // sub-table overflows into the SAME x-axis scrollbar. const innerTrack = container.querySelector('.min-w-max'); From c1990d0ad6c64853c5196cc24ccfe51a24c89be5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:45:25 +0000 Subject: [PATCH 3/8] docs(plugin-grid): state where the undeclared page size comes from; add changeset Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01LkCKMa5bvrw3L4ezcNXEXW --- .../9853-grid-display-page-size-from-spec.md | 35 +++++++++++++++++++ content/docs/plugins/plugin-grid.mdx | 7 ++++ packages/plugin-grid/README.md | 7 ++++ 3 files changed, 49 insertions(+) create mode 100644 .changeset/9853-grid-display-page-size-from-spec.md diff --git a/.changeset/9853-grid-display-page-size-from-spec.md b/.changeset/9853-grid-display-page-size-from-spec.md new file mode 100644 index 0000000000..94b163fba1 --- /dev/null +++ b/.changeset/9853-grid-display-page-size-from-spec.md @@ -0,0 +1,35 @@ +--- +'@object-ui/plugin-grid': minor +--- + +`object-grid` with no declared `pageSize` now shows **25** rows (or groups) per +page — the default `@objectstack/spec` declares for `pagination.pageSize` — on +every page it renders (objectui#9853). + +⚠️ **Visible change on grids that declare no page size.** Declare +`pagination: { pageSize: N }` to keep the old count: + +| surface (no `pageSize` declared) | before | after | +|:--|--:|--:| +| grid that fetches its own rows (server-paged table), rows per page **and** the `$top` of each page fetch | 50 | 25 | +| grid over inline `data` (client-paged table), rows per page | 10 | 25 | +| grouped grid, groups per page | 10 | 25 | +| grouped grid, rows fetched to group (`$top`) | 50 | 50 (unchanged) | + +The grid used to fall back to three local numbers, and one of them — named a +"server window" — sized both a fetch and the page the user sees. The display +default is now read from `@objectstack/spec` (`PaginationConfigSchema`), with no +local number behind it; the fetch batch is renamed `DEFAULT_FETCH_BATCH_SIZE`, +keeps its value, and is read only by the grouped view's fetch, never as a page +size. A declared `pageSize` is honoured exactly as before, and a refused one (zero, +negative, non-integer) still warns and falls back — now to 25. + +Inside a `ListView`, the server-paged flat grid still takes its page size from the +list and does not change here. The list hands no page size to a grouped grid, +though, so a grouped list with no declared `pageSize` also moves from 10 to 25 +groups per page. The grouped view's rows-per-page selector now also lists the +size in force when it is not one of its fixed steps, as the table pager already +did. + +Released as `minor`, not `major`: this repository keeps its major aligned with +`@objectstack/spec`, so a behaviour change is spelled out here instead. diff --git a/content/docs/plugins/plugin-grid.mdx b/content/docs/plugins/plugin-grid.mdx index d4a24bf5cf..736a800da4 100644 --- a/content/docs/plugins/plugin-grid.mdx +++ b/content/docs/plugins/plugin-grid.mdx @@ -335,6 +335,13 @@ see [Row callbacks are component props](#row-callbacks-are-component-props). always carries a rows-per-page picker, and `pageSizeOptions` only replaces the choices it offers with your own. +With no `pageSize` declared, every page the grid shows — the server-paged table, +the table over inline `data`, and the grouped view's page of groups — uses the +default `@objectstack/spec` declares for `pagination.pageSize`; the grid reads it +from the spec rather than keeping a number of its own. A grouped view separately +fetches a larger batch of rows to group, which is a fetch size and never a page +size. Declare `pagination.pageSize` to choose the count yourself. + ### Grouping is page-scoped `grouping` groups **the records the browser has already fetched** — one page — diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 60cf444f3d..9e125a4faa 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -416,6 +416,13 @@ const schema: ObjectGridSchema = { always carries a rows-per-page picker, and `pageSizeOptions` only replaces the choices it offers with your own. +With no `pageSize` declared, every page the grid shows — the server-paged table, +the table over inline `data`, and the grouped view's page of groups — uses the +default `@objectstack/spec` declares for `pagination.pageSize`; the grid reads it +from the spec rather than keeping a number of its own. A grouped view separately +fetches a larger batch of rows to group, which is a fetch size and never a page +size. Declare `pagination.pageSize` to choose the count yourself. + ### Grouping is page-scoped `grouping` groups **the records the browser has already fetched** — one page — From 3445f3213b27102af41bc7c537b98a959a8e2606 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 06:22:58 +0000 Subject: [PATCH 4/8] fix(plugin-grid): the grid's fetch batch follows server grouping, and its pins hold on the merged tree (objectui#9853) After merging main, a grouped grid that fetches its own rows either groups on the server (no flat window, each group paged by the display page size) or refuses (objectui#10881). The flat fetch asks for DEFAULT_FETCH_BATCH_SIZE only where the grid still buckets a window in the browser: not server-grouped, not refused, and a grouping entry left once a known masked key is refused. Counted like the two server predicates, so the first fetch does not flip from page size to batch when the object's types land. Pins reworked for the merged tree: - displayPageSizeDefault-9853 stands in a spec default the spec never shipped, so it proves the grid reads the spec rather than a literal equal to it; the grouped rows use a data source that answers the group header query; the batch row runs the one path that still buckets a window. - pageSizeNonPositive-9853 and groupedPagination size their fixtures and their resize step off the spec default, so they discriminate at 50. Also qualifies the server pagination comment's citation as objectstack-ai/objectstack#2212 (carrier note on PR objectui#10278), and the docs state that a group's own page of rows uses the display default. Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm Co-authored-by: Claude --- content/docs/plugins/plugin-grid.mdx | 12 +- packages/plugin-grid/README.md | 12 +- packages/plugin-grid/src/ObjectGrid.tsx | 58 +++-- ...tGrid.displayPageSizeDefault-9853.test.tsx | 200 +++++++++++++++--- ...jectGrid.pageSizeNonPositive-9853.test.tsx | 8 +- .../src/__tests__/groupedPagination.test.tsx | 16 +- 6 files changed, 236 insertions(+), 70 deletions(-) diff --git a/content/docs/plugins/plugin-grid.mdx b/content/docs/plugins/plugin-grid.mdx index a92cdff8df..7ad6d06ce7 100644 --- a/content/docs/plugins/plugin-grid.mdx +++ b/content/docs/plugins/plugin-grid.mdx @@ -368,11 +368,13 @@ always carries a rows-per-page picker, and `pageSizeOptions` only replaces the choices it offers with your own. With no `pageSize` declared, every page the grid shows — the server-paged table, -the table over inline `data`, and the grouped view's page of groups — uses the -default `@objectstack/spec` declares for `pagination.pageSize`; the grid reads it -from the spec rather than keeping a number of its own. A grouped view separately -fetches a larger batch of rows to group, which is a fetch size and never a page -size. Declare `pagination.pageSize` to choose the count yourself. +the table over inline `data`, the grouped view's page of groups, and a group's +own page of rows — uses the default `@objectstack/spec` declares for +`pagination.pageSize`; the grid reads it from the spec rather than keeping a +number of its own. Declare `pagination.pageSize` to choose the count yourself. +A window of rows the grid groups in the browser, because the server does not +group it, is not a page: undeclared, it is a fixed fetch batch of the grid's +own, and it does not follow the display default. ### Grouping is server-side diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 56a7d3a87c..7ee224edf4 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -486,11 +486,13 @@ always carries a rows-per-page picker, and `pageSizeOptions` only replaces the choices it offers with your own. With no `pageSize` declared, every page the grid shows — the server-paged table, -the table over inline `data`, and the grouped view's page of groups — uses the -default `@objectstack/spec` declares for `pagination.pageSize`; the grid reads it -from the spec rather than keeping a number of its own. A grouped view separately -fetches a larger batch of rows to group, which is a fetch size and never a page -size. Declare `pagination.pageSize` to choose the count yourself. +the table over inline `data`, the grouped view's page of groups, and a group's +own page of rows — uses the default `@objectstack/spec` declares for +`pagination.pageSize`; the grid reads it from the spec rather than keeping a +number of its own. Declare `pagination.pageSize` to choose the count yourself. +A window of rows the grid groups in the browser, because the server does not +group it, is not a page: undeclared, it is a fixed fetch batch of the grid's +own, and it does not follow the display default. ### Grouping is server-side diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 512d60d23b..fbf1cb6ab6 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -1291,8 +1291,10 @@ function resolveRowHeightMode(rowHeight: unknown): RowHeightMode { /** * The page size every display surface of this component falls back to when the - * author declared none (objectui#9853, ruling C-prime): the flat table, the - * server-paged table and the grouped view's page of groups. + * author declared none (objectui#9853, rulings 5749197629 and 5824040487): the + * flat table, the server-paged table, the grouped view's page of groups, and a + * server-grouped leaf's page of rows. Wherever there is a pager, this is its + * page. * * READ from `@objectstack/spec`, not restated. The protocol's pagination config * declares `pageSize` with a default, and that declaration is the one answer an @@ -1322,15 +1324,17 @@ function readSpecDisplayPageSize(): number { const DEFAULT_DISPLAY_PAGE_SIZE = readSpecDisplayPageSize(); /** - * How many rows the grouped view FETCHES to bucket client-side when the author - * declared no page size. A fetch batch, ⛔ not a page size: no display path - * reads it (objectui#9853, ruling C-prime, clause 1). Before that ruling this - * number was named as a "server window" and also sized the server-paged table's - * visible page, so one constant meant two quantities. + * How many rows this grid FETCHES when it buckets a window into groups in the + * browser and the author declared no page size (`bucketsFetchedWindow`). A + * fetch batch, ⛔ not a page size: no display path reads it, and it does not + * follow the display default (objectui#9853, ruling 5824040487, structure B). + * Before ruling 5749197629 this number was named as a "server window" and also + * sized the server-paged table's visible page, so one constant meant two + * quantities. * * It stays a constant of this component rather than the protocol's value * because the protocol declares no fetch batch: it is how much of the result - * set the grouped view holds, which is an implementation choice. + * set a client-grouped view holds, which is an implementation choice. */ const DEFAULT_FETCH_BATCH_SIZE = 50; @@ -1983,22 +1987,16 @@ export const ObjectGrid: React.FC = ({ // ($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). + // makes records beyond the first batch reachable at all + // (objectstack-ai/objectstack#2212). // // That makes this a DISPLAY page size, so undeclared it is the spec's display - // default, ⛔ not the fetch batch (objectui#9853, ruling C-prime). + // default, ⛔ not the fetch batch (objectui#9853). A server-grouped leaf pages + // its rows by this same size (`useServerGroupRows` below). const [serverPage, setServerPage] = useState(1); const [serverPageSize, setServerPageSize] = useState( resolvePageSize(schema, DEFAULT_DISPLAY_PAGE_SIZE), ); - // What one fetch asks for. A grouped view buckets the rows it holds and has - // no row pager, so it asks for a fetch BATCH; everywhere else the rows held - // ARE the page on screen. `usableGroupingFields` is the predicate - // `useGroupedData` answers `isGrouped` with, so the two cannot disagree. - const groupedFetch = usableGroupingFields(schema.grouping?.fields).length > 0; - const fetchWindow = groupedFetch - ? resolvePageSize(schema, DEFAULT_FETCH_BATCH_SIZE) - : serverPageSize; // Column-header sort, when this grid fetches its own rows (objectui#3106). // `null` means "nobody has clicked a header" and the view's declared @@ -2193,6 +2191,30 @@ export const ObjectGrid: React.FC = ({ && Array.isArray(handedRows) && (hostRowCount as number) > handedRows.length && usableGroupingFields(groupingFieldsRaw).some((gf) => !groupingRefusedKnownMasked.includes(gf.field)); + // What the load effect's ONE flat fetch asks for (objectui#9853, ruling + // 5824040487, structure B). Where the rows it holds ARE the page on screen, + // that is the display page size. Where this grid buckets the rows it holds + // into groups in the browser, nothing pages them, so it asks for the fetch + // BATCH instead, which no display path reads. Since objectui#7189 a grouped + // grid that fetches its own rows buckets a window only when the server + // cannot group for it and the grid has not refused: not server-grouped + // (`serverGroupedFetch` strips `$top` and pages each group), not refused + // (`groupingNeedsHeaderQuery` asks for no rows), and at least one grouping + // entry left once a KNOWN masked key is refused. The case that reaches it + // today is a grouping key this principal may not read, over a data source + // that answers the header query (see `groupingKeyReadable`). + // + // Counted the way the two predicates above count, less the keys refused on a + // KNOWN masked type and NOT less the ones merely withheld while the object's + // types load: otherwise the first fetch would go out at the page size and be + // re-issued at the batch the moment the types land. + const bucketsFetchedWindow = + !serverGroupedFetch + && !groupingNeedsHeaderQuery + && usableGroupingFields(groupingFieldsRaw).some((gf) => !groupingRefusedKnownMasked.includes(gf.field)); + const fetchWindow = bucketsFetchedWindow + ? resolvePageSize(schema, DEFAULT_FETCH_BATCH_SIZE) + : serverPageSize; // The grid's own row query — projection, expansion, order and the view's // filter — resolved by the load effect below exactly as the flat fetch // resolves it, and handed to each group's row page. `null` until resolved. diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.displayPageSizeDefault-9853.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.displayPageSizeDefault-9853.test.tsx index 0398042e43..0446feccea 100644 --- a/packages/plugin-grid/src/__tests__/ObjectGrid.displayPageSizeDefault-9853.test.tsx +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.displayPageSizeDefault-9853.test.tsx @@ -7,36 +7,102 @@ */ /** - * objectui#9853, ruling C-prime — ONE display page size, read from the - * protocol, and a fetch batch that is a different quantity. + * objectui#9853 — ONE display page size, read from the protocol, and a fetch + * batch that is a different quantity (rulings 5749197629 and 5824040487, + * structure B: "page size" means one page only where there is a pager). * - * Before the ruling this component fell back to three page-size literals: a + * Before the rulings this component fell back to three page-size literals: a * page of rows in the client-paged table, a page of groups in the grouped * view, and a "server window" that sized both the `$top` of every fetch AND the - * visible page of the server-paged table. The ruling: + * visible page of the server-paged table. Now: * - * 1. the fetch batch size is not a page size, and no display path reads it; - * 2. an undeclared display page size is the default `@objectstack/spec` - * declares for `pagination.pageSize`, read from the spec rather than - * restated; + * 1. every page this grid shows — the server-paged table, the table over + * inline rows, the page of groups, and a server-grouped leaf's page of + * rows — falls back to the default `@objectstack/spec` declares for + * `pagination.pageSize`, read from the spec rather than restated; + * 2. the one fetch that is not a page — a window this grid groups in the + * browser — asks for the grid's own fetch batch, which does not follow + * that default; * 3. a declared `pageSize` is honoured as before, and a refused one keeps the * loud warning (pinned row by row in * `ObjectGrid.pageSizeNonPositive-9853.test.tsx`). * - * Every display surface is read OFF THE SCREEN (rows or groups rendered), and - * the fetch side off the WIRE (`$top` the data source received), so a pin that - * only compares constants cannot pass for this one. + * ## Why the spec's default is STOOD IN FOR here + * + * A pin that reads the real default cannot tell "the grid reads the spec" from + * "the grid hardcodes the number the spec happens to say today", and today + * that number is also the fetch batch's. So this file replaces the declared + * default with a number the spec has never shipped and that is none of the + * selector's fixed steps, and every expectation reads it back from the spec + * module the renderer imports. The stand-in records that its factory ran and + * what the REAL default is, so the first row proves the substitution is live + * rather than trusting it. The real value is pinned by the fixtures of + * `groupedPagination.test.tsx` and `ObjectGrid.pageSizeNonPositive-9853.test.tsx`, + * which read it unstubbed. * - * `../ObjectGrid` is imported relatively and the root vitest config aliases - * `@object-ui/*` to each package's `src`, so no build step stands between an - * edit and this run. + * Every display surface is read OFF THE SCREEN (rows or groups rendered), and + * the fetch side off the WIRE (`$top` the data source received). */ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { render, cleanup } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; -import { PaginationConfigSchema } from '@objectstack/spec/ui'; +const { STUB_DISPLAY_DEFAULT, probe } = vi.hoisted(() => ({ + /** + * Deliberately nothing the spec has shipped, and not one of the grouped + * size selector's fixed steps, so the selector row below discriminates. + */ + STUB_DISPLAY_DEFAULT: 13, + probe: { factoryRan: false, realDefault: undefined as unknown }, +})); + +vi.mock('@objectstack/spec/ui', async (importOriginal) => { + const actual = await importOriginal(); + probe.factoryRan = true; + probe.realDefault = actual.PaginationConfigSchema.parse({}).pageSize; + return { + ...actual, + PaginationConfigSchema: actual.PaginationConfigSchema.extend({ + pageSize: actual.PaginationConfigSchema.shape.pageSize.unwrap().default(STUB_DISPLAY_DEFAULT), + }), + }; +}); + +/** + * The permission policy, for the one row that needs a grouping key this + * principal may not read. Unloaded by default, which filters nothing (the + * grid defers until the policy answers), so every other row runs as if no + * policy were installed. + */ +const { permsStub, permsState } = vi.hoisted(() => { + const permsState: { isLoaded: boolean; readable: string[] } = { isLoaded: false, readable: [] }; + return { + permsState, + permsStub: { + get isLoaded() { return permsState.isLoaded; }, + checkField: (_object: string, field: string, action: string) => + action === 'read' ? permsState.readable.includes(field) : true, + check: () => ({ allowed: true }), + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + userId: null, + systemPermissions: undefined, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }, + }; +}); + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, usePermissions: () => permsStub as any }; +}); + +import { PaginationConfigSchema } from '@objectstack/spec/ui'; import { ObjectGrid } from '../ObjectGrid'; import { registerAllFields } from '@object-ui/fields'; import { ActionProvider } from '@object-ui/react'; @@ -47,8 +113,9 @@ registerAllFields(); const SPEC_DISPLAY_DEFAULT: number = PaginationConfigSchema.parse({}).pageSize; /** - * The grouped view's fetch batch. Pinned by value because the ruling keeps - * the value; what matters for the ruling is that it is NOT the display default. + * The grid's fetch batch for a window it groups in the browser. Pinned by + * value because the ruling keeps the value; what the ruling asks is that it is + * NOT the display default, which the stand-in above makes observable. */ const FETCH_BATCH = 50; @@ -59,23 +126,62 @@ const OBJECT_FIELDS = { unit: { type: 'text', label: 'Unit' }, }; -/** More rows than either number, every row its own group. */ +/** More rows than any number here, every row its own group. */ const ROW_COUNT = 80; -const ROWS = Array.from({ length: ROW_COUNT }, (_, i) => ({ +interface Row { id: string; subject: string; unit: string } +const ROWS: Row[] = Array.from({ length: ROW_COUNT }, (_, i) => ({ id: `r-${i}`, subject: `Task ${String(i).padStart(3, '0')}`, unit: `Unit ${String(i).padStart(3, '0')}`, })); +/** The `FilterCondition` subset the compiled group queries use. */ +function matches(row: Record, cond: unknown): boolean { + if (cond === undefined || cond === null) return true; + return Object.entries(cond as Record).every(([key, value]) => { + if (key === '$and') return (value as unknown[]).every((c) => matches(row, c)); + if (value && typeof value === 'object' && !Array.isArray(value)) { + const op = value as Record; + if ('$eq' in op) return row[key] === op.$eq; + if ('$null' in op) return op.$null ? row[key] == null : row[key] != null; + throw new Error(`double: unsupported operator ${JSON.stringify(op)}`); + } + return row[key] === value; + }); +} + +/** + * A data source that pages `find` by `$top` / `$skip` and answers the group + * header query over the whole store, the two faces a grouped grid reads. + */ const makeDataSource = () => { const calls: Array> = []; return { calls, + queryGroupHeaders: vi.fn(async (_object: string, query: { where?: unknown; groupBy?: string[]; aggregations?: Array<{ function: string; alias: string }> }) => { + const groupBy = query.groupBy ?? []; + const buckets = new Map(); + for (const row of ROWS.filter((r) => matches(r as any, query.where))) { + const key = JSON.stringify(groupBy.map((f) => (row as any)[f] ?? null)); + if (!buckets.has(key)) buckets.set(key, []); + buckets.get(key)!.push(row); + } + return [...buckets.entries()].map(([key, members]) => { + const values = JSON.parse(key) as unknown[]; + const out: Record = Object.fromEntries(groupBy.map((f, i) => [f, values[i]])); + for (const agg of query.aggregations ?? []) { + out[agg.alias] = agg.function === 'count' ? members.length : null; + } + return out as { count: number }; + }); + }), find: vi.fn(async (_object: string, params: Record) => { calls.push(params); + const where = Array.isArray(params.$filter) ? undefined : params.$filter; + const matching = ROWS.filter((r) => matches(r as any, where)); const skip = (params.$skip as number | undefined) ?? 0; - const top = (params.$top as number | undefined) ?? ROWS.length; - return { data: ROWS.slice(skip, skip + top), total: ROWS.length }; + const top = (params.$top as number | undefined) ?? matching.length; + return { data: matching.slice(skip, skip + top), total: matching.length }; }), findOne: vi.fn(), create: vi.fn(), @@ -111,10 +217,15 @@ const inlineSchema = (extra: Record = {}) => ({ const bodyRows = (container: HTMLElement) => Array.from(container.querySelectorAll('tbody tr')); const groupRows = () => [...document.querySelectorAll('[data-testid^="group-row-"]')]; +/** The `$top` of every per-group row query (a row query carries a group `$filter`). */ +const groupRowTops = (calls: Array>) => + calls.filter((p) => p.$filter && !Array.isArray(p.$filter)).map((p) => p.$top); let warnings: string[] = []; beforeEach(() => { warnings = []; + permsState.isLoaded = false; + permsState.readable = []; vi.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => { warnings.push(args.map(String).join(' ')); }); @@ -126,14 +237,16 @@ afterEach(() => { const paginationWarnings = () => warnings.filter((w) => w.includes('ObjectGrid pagination')); describe('ObjectGrid — one display page size from the spec, and a distinct fetch batch (objectui#9853)', () => { - it('the spec declares a usable display default, and it is not the fetch batch', () => { - // If a spec upgrade stops declaring the default, this is the row that says - // so first — the renderer refuses to invent one. - expect(Number.isInteger(SPEC_DISPLAY_DEFAULT) && SPEC_DISPLAY_DEFAULT > 0).toBe(true); + it('the spec stand-in is live: the renderer\'s spec module answers it, and it is not the real default', () => { + // Without these, every row below could pass against a grid that + // hardcodes whatever number the real spec says today. + expect(probe.factoryRan).toBe(true); + expect(SPEC_DISPLAY_DEFAULT).toBe(STUB_DISPLAY_DEFAULT); + expect(probe.realDefault).not.toBe(STUB_DISPLAY_DEFAULT); expect(FETCH_BATCH).not.toBe(SPEC_DISPLAY_DEFAULT); }); - it('every display surface shows the spec default when nothing is declared', async () => { + it('every page the grid shows uses the spec default when nothing is declared', async () => { // Server-paged table: the page on screen, and the `$top` that fetched it. const flatDs = makeDataSource(); const flat = renderGrid(fetchedSchema(), flatDs); @@ -148,29 +261,39 @@ describe('ObjectGrid — one display page size from the spec, and a distinct fet const clientPaged = bodyRows(inline.container).length; cleanup(); - // Grouped view: a page of groups. + // Server-grouped view: a page of groups, and each open group's own page + // of rows (the group's pager turns it). const groupedDs = makeDataSource(); renderGrid(groupedSchema(), groupedDs); await vi.waitFor(() => expect(groupRows().length).toBeGreaterThan(0)); + await vi.waitFor(() => expect(groupRowTops(groupedDs.calls).length).toBeGreaterThan(0)); const groupsShown = groupRows().length; + const groupRowTop = [...new Set(groupRowTops(groupedDs.calls))]; - // ONE assertion over the three surfaces, so a surface that keeps a - // default of its own shows up as the odd one out. - expect({ serverPaged, serverTop, clientPaged, groupsShown }).toEqual({ + // ONE assertion over every surface, so a surface that keeps a default of + // its own shows up as the odd one out. + expect({ serverPaged, serverTop, clientPaged, groupsShown, groupRowTop }).toEqual({ serverPaged: SPEC_DISPLAY_DEFAULT, serverTop: SPEC_DISPLAY_DEFAULT, clientPaged: SPEC_DISPLAY_DEFAULT, groupsShown: SPEC_DISPLAY_DEFAULT, + groupRowTop: [SPEC_DISPLAY_DEFAULT], }); expect(paginationWarnings()).toHaveLength(0); }); - it('the grouped view FETCHES the batch, which no display path reads', async () => { + it('a window the grid groups in the browser FETCHES the batch, which no display path reads', async () => { + // The one path left since objectui#7189 where this grid buckets a fetched + // window: a grouping key this principal may not read, over a data source + // that answers the header query, so the server cannot group for it. + permsState.isLoaded = true; + permsState.readable = ['id', 'subject']; const ds = makeDataSource(); renderGrid(groupedSchema(), ds); await vi.waitFor(() => expect(groupRows().length).toBeGreaterThan(0)); + expect(ds.queryGroupHeaders).not.toHaveBeenCalled(); // The batch left on the wire, and the groups on screen are still a page - // of the display default — two quantities, two numbers. + // of the display default: two quantities, two numbers. expect(ds.calls.map((p) => p.$top)).toContain(FETCH_BATCH); expect(ds.calls.map((p) => p.$top)).not.toContain(SPEC_DISPLAY_DEFAULT); expect(groupRows()).toHaveLength(SPEC_DISPLAY_DEFAULT); @@ -187,7 +310,7 @@ describe('ObjectGrid — one display page size from the spec, and a distinct fet expect(select!.value).toBe(String(SPEC_DISPLAY_DEFAULT)); }); - it('CONTROL — a declared page size is honoured on every surface, and on the grouped fetch as before', async () => { + it('CONTROL — a declared page size is honoured on every surface, and on the bucketed fetch as before', async () => { const declared = { pagination: { pageSize: 7 } }; const flatDs = makeDataSource(); @@ -205,8 +328,17 @@ describe('ObjectGrid — one display page size from the spec, and a distinct fet const groupedDs = makeDataSource(); renderGrid(groupedSchema(declared), groupedDs); await vi.waitFor(() => expect(groupRows().length).toBeGreaterThan(0)); + await vi.waitFor(() => expect(groupRowTops(groupedDs.calls).length).toBeGreaterThan(0)); expect(groupRows()).toHaveLength(7); - expect(groupedDs.calls.map((p) => p.$top)).toContain(7); + expect([...new Set(groupRowTops(groupedDs.calls))]).toEqual([7]); + cleanup(); + + permsState.isLoaded = true; + permsState.readable = ['id', 'subject']; + const bucketedDs = makeDataSource(); + renderGrid(groupedSchema(declared), bucketedDs); + await vi.waitFor(() => expect(groupRows().length).toBeGreaterThan(0)); + expect(bucketedDs.calls.map((p) => p.$top)).toContain(7); expect(paginationWarnings()).toHaveLength(0); }); diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.pageSizeNonPositive-9853.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.pageSizeNonPositive-9853.test.tsx index 894d3b8463..018849c8f0 100644 --- a/packages/plugin-grid/src/__tests__/ObjectGrid.pageSizeNonPositive-9853.test.tsx +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.pageSizeNonPositive-9853.test.tsx @@ -274,9 +274,9 @@ describe('ObjectGrid — a non-positive authored page size is refused at every r // The channel matters: the flat size is the table's live page size only when // `manualPaginationOn` is false, which is what inline `data` gives. // - // Thirty rows, so the display default leaves a second page and a refused - // value that slipped through would show a different count. - const INLINE_ROWS = Array.from({ length: 30 }, (_, i) => ({ + // Ten rows past the display default, so it leaves a second page and a + // refused value that slipped through would show a different count. + const INLINE_ROWS = Array.from({ length: DISPLAY_DEFAULT + 10 }, (_, i) => ({ id: String(i + 1), name: `Row ${String(i + 1).padStart(2, '0')}`, status: 'open', @@ -306,7 +306,7 @@ describe('ObjectGrid — a non-positive authored page size is refused at every r it('a negative page size does not reach the flat table', async () => { const { container } = renderInline({ pagination: { pageSize: -10 } }); await vi.waitFor(() => expect(bodyRows(container).length).toBeGreaterThan(0)); - // Thirty rows at the display default: a full first page and a second one. + // The display default plus ten rows: a full first page and a second one. expect(bodyRows(container)).toHaveLength(DISPLAY_DEFAULT); expect(paginationWarnings().length).toBeGreaterThan(0); }); diff --git a/packages/plugin-grid/src/__tests__/groupedPagination.test.tsx b/packages/plugin-grid/src/__tests__/groupedPagination.test.tsx index ca1110f885..b5aca2ac47 100644 --- a/packages/plugin-grid/src/__tests__/groupedPagination.test.tsx +++ b/packages/plugin-grid/src/__tests__/groupedPagination.test.tsx @@ -112,17 +112,25 @@ describe('Grouped view pagination', () => { renderGroupedGrid(); await waitFor(() => expect(screen.getByText('Page 1 of 3')).toBeInTheDocument()); + // One of the selector's fixed steps that is not the default and splits + // the fixture into exactly two pages, derived rather than spelled so the + // row keeps its meaning whatever the spec declares. + const resizeTo = [5, 10, 20, 50, 100].find( + (n) => n !== DISPLAY_DEFAULT && n > GROUP_COUNT / 2 && n < GROUP_COUNT, + ); + expect(resizeTo, 'no fixed selector step splits the fixture into two pages').toBeDefined(); + const select = document.querySelector('select') as HTMLSelectElement; expect(select).toBeTruthy(); - fireEvent.change(select, { target: { value: '50' } }); + fireEvent.change(select, { target: { value: String(resizeTo) } }); - // GROUP_COUNT / 50 = 2 pages, page reset to 1, first page shows 50 groups. + // Two pages, page reset to 1, the first page shows `resizeTo` groups. await waitFor(() => expect(screen.getByText('Page 1 of 2')).toBeInTheDocument()); - expect(groupRows().length).toBe(50); + expect(groupRows().length).toBe(resizeTo); }); it('does not render a pager when groups fit on a single page', async () => { - renderGroupedGrid({ pagination: { pageSize: 100 } }); + renderGroupedGrid({ pagination: { pageSize: GROUP_COUNT } }); await waitFor(() => expect(groupRows().length).toBe(GROUP_COUNT)); expect(screen.queryByText(/^Page \d+ of \d+$/)).not.toBeInTheDocument(); }); From a8de5aee15de6354ded42ca7001a8349d6b6fa33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 06:23:11 +0000 Subject: [PATCH 5/8] feat(plugin-list,plugin-kanban): a paged list reads the spec display default; unpaged views and the board keep a fetch batch (objectui#9853) Ruling 5824040487, structure B: "page size" means one page only where there is a pager. - ListView: the undeclared fallback splits by whether the view pages. The grid view, which it pages on the server, reads the display default the spec declares for pagination.pageSize (no local number behind the read); every other view keeps a fetch batch, DEFAULT_LIST_FETCH_BATCH_SIZE, value 100 unchanged. A declared pageSize sizes every view as before. The refused value warning names the fallback the view used. - ObjectKanban: DEFAULT_KANBAN_LIMIT is renamed DEFAULT_KANBAN_FETCH_BATCH_SIZE, value 100 unchanged, and its diagnostic calls it the board's fetch batch. - Every reader of the kanban constant is updated (types docblock and zod describe, app-shell block config and its pins, the types read-text pin). Pins: ListView.pageSizeAndFetchBatch-9853 (paged vs unpaged fallback, the declared control, the grid-to-kanban refetch cost, the named fallback), and app-shell displayPageSizeFromSpec-9853, which mounts the real grid, list and board under a stood-in spec default and asserts every paged surface moves with it while the fetch batches do not. Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm Co-authored-by: Claude --- content/docs/plugins/plugin-kanban.mdx | 6 +- .../displayPageSizeFromSpec-9853.test.tsx | 258 ++++++++++++++++++ ...eBlockInspector.retiredBlockProps.test.tsx | 2 +- .../__tests__/block-config-i18n.test.ts | 4 +- .../previews/__tests__/block-config.test.ts | 8 +- .../metadata-admin/previews/block-config.ts | 4 +- .../ObjectKanban.elementDataSource.test.tsx | 4 +- packages/plugin-kanban/src/ObjectKanban.tsx | 27 +- .../rowLimitNonPositive-9925.test.tsx | 14 +- packages/plugin-kanban/src/index.tsx | 4 +- packages/plugin-list/README.md | 20 ++ packages/plugin-list/src/ListView.tsx | 105 ++++--- ...stView.pageSizeAndFetchBatch-9853.test.tsx | 236 ++++++++++++++++ .../object-kanban-group-by-limit-7322.test.ts | 12 +- packages/types/src/objectql.ts | 4 +- packages/types/src/zod/objectql.zod.ts | 4 +- 16 files changed, 642 insertions(+), 70 deletions(-) create mode 100644 packages/app-shell/src/__tests__/displayPageSizeFromSpec-9853.test.tsx create mode 100644 packages/plugin-list/src/__tests__/ListView.pageSizeAndFetchBatch-9853.test.tsx diff --git a/content/docs/plugins/plugin-kanban.mdx b/content/docs/plugins/plugin-kanban.mdx index 3a2bbed699..b8f5b6e221 100644 --- a/content/docs/plugins/plugin-kanban.mdx +++ b/content/docs/plugins/plugin-kanban.mdx @@ -208,11 +208,13 @@ rendered on each card resolve in priority order: Defaulting to `highlightFields` keeps a card's contents consistent with the object's other views without every kanban view having to re-declare its fields. -### Row cap (object-driven boards) +### Fetch batch (object-driven boards) An object-driven board fetches at most `limit` records, defaulting to **100**. A board renders every fetched record into a lane and offers no pagination control, -so this is the author's window on the object rather than a page size: +so this is the author's window on the object — one fetch batch — rather than a +page size. The default does not follow the display page size `@objectstack/spec` +declares for `pagination.pageSize`: ```tsx import type { ObjectKanbanSchema } from '@object-ui/types' diff --git a/packages/app-shell/src/__tests__/displayPageSizeFromSpec-9853.test.tsx b/packages/app-shell/src/__tests__/displayPageSizeFromSpec-9853.test.tsx new file mode 100644 index 0000000000..819c05635e --- /dev/null +++ b/packages/app-shell/src/__tests__/displayPageSizeFromSpec-9853.test.tsx @@ -0,0 +1,258 @@ +/** + * 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. + */ + +/** + * objectui#9853, ruling 5824040487 — the display default is declared ONCE, in + * the protocol, and every paged surface reads it; the fetch batches of the + * unpaged surfaces are a different quantity and do not follow it (structure B: + * "page size" means one page only where there is a pager). + * + * ## Why this file lives in `app-shell` + * + * The surfaces sit in three packages — `ObjectGrid` in `plugin-grid`, + * `ListView` in `plugin-list`, `ObjectKanban` in `plugin-kanban` — and the + * point of the pin is ONE assertion across all of them, so that a surface + * keeping a default of its own shows up as the odd one out. `app-shell` is the + * package that already depends on all three, so the real components are + * mounted here with no new dependency edge anywhere (the same reason + * `rowClickModifierPayloadForward-9462.test.tsx` lives here). + * + * ## Why the spec's default is STOOD IN FOR + * + * Reading the real default cannot tell "reads the spec" from "hardcodes the + * number the spec says today". So the default is replaced with a number the + * spec has never shipped, and every expectation reads it back from the spec + * module the renderers import. The stand-in records that its factory ran and + * what the REAL default is, so the first row proves the substitution is live. + * With the stand-in in force the paged surfaces must move with it and the + * fetch batches must not — which is exactly the split the ruling makes. + */ +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +const { STUB_DISPLAY_DEFAULT, probe } = vi.hoisted(() => ({ + /** Deliberately nothing the spec has shipped, and neither fetch batch. */ + STUB_DISPLAY_DEFAULT: 13, + probe: { factoryRan: false, realDefault: undefined as unknown }, +})); + +vi.mock('@objectstack/spec/ui', async (importOriginal) => { + const actual = await importOriginal(); + probe.factoryRan = true; + probe.realDefault = actual.PaginationConfigSchema.parse({}).pageSize; + return { + ...actual, + PaginationConfigSchema: actual.PaginationConfigSchema.extend({ + pageSize: actual.PaginationConfigSchema.shape.pageSize.unwrap().default(STUB_DISPLAY_DEFAULT), + }), + }; +}); + +import { PaginationConfigSchema } from '@objectstack/spec/ui'; +import { ObjectGrid } from '@object-ui/plugin-grid'; +import { ListView } from '@object-ui/plugin-list'; +import { ObjectKanban } from '@object-ui/plugin-kanban'; +import { ActionProvider, SchemaRendererProvider } from '@object-ui/react'; + +/** The protocol's answer, read the way the renderers read it. */ +const SPEC_DISPLAY_DEFAULT: number = PaginationConfigSchema.parse({}).pageSize; + +/** + * The fetch batch of every unpaged list view and of the board. Spelled by + * value: the ruling keeps the value, and what it asks is that neither moves + * with the display default. + */ +const FETCH_BATCH = 100; + +const OBJECT = 'duly_task'; +const OBJECT_FIELDS = { + id: { type: 'text', label: 'Id' }, + subject: { type: 'text', label: 'Subject' }, + unit: { type: 'text', label: 'Unit' }, + status: { type: 'select', label: 'Status', options: [{ label: 'Todo', value: 'todo' }] }, +}; + +/** More rows than any number here, every row its own group. */ +const ROW_COUNT = 240; +interface Row { id: string; subject: string; unit: string; status: string } +const ROWS: Row[] = Array.from({ length: ROW_COUNT }, (_, i) => ({ + id: `r-${i}`, + subject: `Task ${String(i).padStart(3, '0')}`, + unit: `Unit ${String(i).padStart(3, '0')}`, + status: 'todo', +})); + +/** The `FilterCondition` subset the compiled group queries use. */ +function matches(row: Record, cond: unknown): boolean { + if (cond === undefined || cond === null) return true; + return Object.entries(cond as Record).every(([key, value]) => { + if (key === '$and') return (value as unknown[]).every((c) => matches(row, c)); + if (value && typeof value === 'object' && !Array.isArray(value)) { + const op = value as Record; + if ('$eq' in op) return row[key] === op.$eq; + if ('$null' in op) return op.$null ? row[key] == null : row[key] != null; + throw new Error(`double: unsupported operator ${JSON.stringify(op)}`); + } + return row[key] === value; + }); +} + +/** Pages `find` by `$top` / `$skip` and answers the group header query. */ +const makeDataSource = () => { + const calls: Array> = []; + return { + calls, + queryGroupHeaders: vi.fn(async (_object: string, query: { where?: unknown; groupBy?: string[]; aggregations?: Array<{ function: string; alias: string }> }) => { + const groupBy = query.groupBy ?? []; + const buckets = new Map(); + for (const row of ROWS.filter((r) => matches(r as any, query.where))) { + const key = JSON.stringify(groupBy.map((f) => (row as any)[f] ?? null)); + if (!buckets.has(key)) buckets.set(key, []); + buckets.get(key)!.push(row); + } + return [...buckets.entries()].map(([key, members]) => { + const values = JSON.parse(key) as unknown[]; + const out: Record = Object.fromEntries(groupBy.map((f, i) => [f, values[i]])); + for (const agg of query.aggregations ?? []) { + out[agg.alias] = agg.function === 'count' ? members.length : null; + } + return out as { count: number }; + }); + }), + find: vi.fn(async (_object: string, params: Record) => { + calls.push(params); + const where = Array.isArray(params.$filter) ? undefined : params.$filter; + const matching = ROWS.filter((r) => matches(r as any, where)); + const skip = (params.$skip as number | undefined) ?? 0; + const top = (params.$top as number | undefined) ?? matching.length; + return { data: matching.slice(skip, skip + top), total: matching.length }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: OBJECT, fields: OBJECT_FIELDS })), + }; +}; +type Ds = ReturnType; + +const mount = (node: React.ReactElement, ds?: Ds) => + render( + + {node} + , + ); + +const bodyRows = (container: HTMLElement) => Array.from(container.querySelectorAll('tbody tr')); +const groupRows = () => [...document.querySelectorAll('[data-testid^="group-row-"]')]; +const tops = (ds: Ds) => ds.calls.map((p) => p.$top); +/** The `$top` of every per-group row query (a row query carries a group `$filter`). */ +const groupRowTops = (ds: Ds) => + [...new Set(ds.calls.filter((p) => p.$filter && !Array.isArray(p.$filter)).map((p) => p.$top))]; + +afterEach(() => cleanup()); + +describe('one display default from the spec across grid, list and board; fetch batches apart (objectui#9853)', () => { + it('the spec stand-in is live: the renderers\' spec module answers it, and it is not the real default', () => { + expect(probe.factoryRan).toBe(true); + expect(SPEC_DISPLAY_DEFAULT).toBe(STUB_DISPLAY_DEFAULT); + expect(probe.realDefault).not.toBe(STUB_DISPLAY_DEFAULT); + expect(FETCH_BATCH).not.toBe(SPEC_DISPLAY_DEFAULT); + }); + + it('every paged surface shows the spec default when nothing is declared', async () => { + const gridSchema = { type: 'object-grid', objectName: OBJECT, columns: ['subject'] }; + + // The grid that fetches its own rows: the page on screen and its `$top`. + const flatDs = makeDataSource(); + const flat = mount(, flatDs); + await waitFor(() => expect(bodyRows(flat.container).length).toBeGreaterThan(0)); + const gridServerPaged = bodyRows(flat.container).length; + const gridServerTop = tops(flatDs).at(-1); + cleanup(); + + // The grid over inline rows. + const inline = mount( + , + ); + await waitFor(() => expect(bodyRows(inline.container).length).toBeGreaterThan(0)); + const gridInline = bodyRows(inline.container).length; + cleanup(); + + // The server-grouped grid: a page of groups, and each open group's page. + const groupedDs = makeDataSource(); + mount( + , + groupedDs, + ); + await waitFor(() => expect(groupRows().length).toBeGreaterThan(0)); + await waitFor(() => expect(groupRowTops(groupedDs).length).toBeGreaterThan(0)); + const groupsShown = groupRows().length; + const groupRowTop = groupRowTops(groupedDs); + cleanup(); + + // The paged list: `ListView` pages its grid view on the server and hands + // the real grid the window and the pager. + const listDs = makeDataSource(); + const list = mount( + , + listDs, + ); + await waitFor(() => expect(bodyRows(list.container).length).toBeGreaterThan(0)); + const listPaged = bodyRows(list.container).length; + const listTop = tops(listDs); + + expect({ gridServerPaged, gridServerTop, gridInline, groupsShown, groupRowTop, listPaged, listTop }).toEqual({ + gridServerPaged: SPEC_DISPLAY_DEFAULT, + gridServerTop: SPEC_DISPLAY_DEFAULT, + gridInline: SPEC_DISPLAY_DEFAULT, + groupsShown: SPEC_DISPLAY_DEFAULT, + groupRowTop: [SPEC_DISPLAY_DEFAULT], + listPaged: SPEC_DISPLAY_DEFAULT, + listTop: [SPEC_DISPLAY_DEFAULT], + }); + }); + + it('the unpaged list and the board keep their fetch batch, which does not move with the page size', async () => { + // An unpaged list view: no pager, so its one window is the batch. + const listDs = makeDataSource(); + mount( + , + listDs, + ); + await waitFor(() => expect(listDs.find).toHaveBeenCalled()); + const listUnpagedTop = tops(listDs); + cleanup(); + + // The board on its own: `limit` undeclared, its default fetch batch. + const boardDs = makeDataSource(); + mount( + , + boardDs, + ); + await waitFor(() => expect(boardDs.find).toHaveBeenCalled()); + const boardTop = tops(boardDs); + + expect({ listUnpagedTop, boardTop }).toEqual({ listUnpagedTop: [FETCH_BATCH], boardTop: [FETCH_BATCH] }); + expect([...listUnpagedTop, ...boardTop]).not.toContain(SPEC_DISPLAY_DEFAULT); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.retiredBlockProps.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.retiredBlockProps.test.tsx index e6be3a1809..631df100c5 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.retiredBlockProps.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.retiredBlockProps.test.tsx @@ -104,7 +104,7 @@ describe('object-kanban inspector · the declared controls are on screen (object expect(screen.getByLabelText('Limit')).toBeTruthy(); }); - it('the row cap states DEFAULT_KANBAN_LIMIT while it is empty, and commits a number', () => { + it('the fetch batch box states DEFAULT_KANBAN_FETCH_BATCH_SIZE while it is empty, and commits a number', () => { const onPatch = renderInspector(pageDraft({ objectName: 'opportunity' })); const box = screen.getByLabelText('Limit') as HTMLInputElement; // Empty box, and the placeholder says what applies anyway. diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-i18n.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-i18n.test.ts index 50a844ec38..aebe9886fd 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-i18n.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-i18n.test.ts @@ -389,7 +389,7 @@ describe('placeholders that must NOT be translated (#3979)', () => { 'record:related_list.limit': { literal: '10', because: 'a row limit' }, 'object-kanban.limit': { literal: '100', - because: "a row limit — DEFAULT_KANBAN_LIMIT, the board's fetch cap when the box is empty", + because: "a row limit — DEFAULT_KANBAN_FETCH_BATCH_SIZE, the board's fetch batch when the box is empty", }, 'record:details.sections.columns': { literal: '2', because: 'a column count' }, }; @@ -403,7 +403,7 @@ describe('placeholders that must NOT be translated (#3979)', () => { // PR objectstack#7115 retired `PageHeaderProps.icon`. Then 17 / 7 / 10 // until objectui#7772 gave `object-kanban` the `limit` control its schema // has declared since objectui#7322 — a VALUE placeholder (`100`, - // `DEFAULT_KANBAN_LIMIT`), so it lands on the literal side and the keyed + // `DEFAULT_KANBAN_FETCH_BATCH_SIZE`), so it lands on the literal side and the keyed // half is what stays untouched this time. Then 18 / 7 / 11 until // objectui#8280 removed the `ai:input` panel, whose `agentName` box was a // KEYED placeholder — the palette never offered that block. diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts index 50eec9b0c2..fafd9b4b65 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts @@ -589,7 +589,7 @@ describe('page:accordion `title` / items `value` — dead designer inputs (#5212 * refusal — which is precisely why the CONTROL that the panel offers a `groupBy` * box matters more than it did: the schema no longer backstops its absence. `limit` is the third declared key it never offered: * `ObjectKanban.tsx` sends it as a real `$top`, so a board over - * `DEFAULT_KANBAN_LIMIT` records was silently truncated with no way to widen it. + * `DEFAULT_KANBAN_FETCH_BATCH_SIZE` records was silently truncated with no way to widen it. * * The parse probes below are the instrument this surface otherwise lacks: they * read the CONTRACT rather than a spelling, so the next control added here is @@ -717,7 +717,7 @@ describe('object-kanban — the `groupBy` control, and the retired `groupField` /* ── the placeholder states the real default ──────────────────────────── */ - it("`limit`'s placeholder is DEFAULT_KANBAN_LIMIT, read from the renderer", () => { + it("`limit`'s placeholder is DEFAULT_KANBAN_FETCH_BATCH_SIZE, read from the renderer", () => { // The box is empty by default and the board still caps the fetch, so the // hint is only honest while it equals the constant `ObjectKanban.tsx` // actually falls back to. Read from source rather than imported: the @@ -728,8 +728,8 @@ describe('object-kanban — the `groupBy` control, and the retired `groupField` path.resolve(here, '../../../../../../plugin-kanban/src/ObjectKanban.tsx'), 'utf8', ); - const declared = /export const DEFAULT_KANBAN_LIMIT = (\d+)/.exec(src)?.[1]; - expect(declared, 'could not read DEFAULT_KANBAN_LIMIT from ObjectKanban.tsx').toBeTruthy(); + const declared = /export const DEFAULT_KANBAN_FETCH_BATCH_SIZE = (\d+)/.exec(src)?.[1]; + expect(declared, 'could not read DEFAULT_KANBAN_FETCH_BATCH_SIZE from ObjectKanban.tsx').toBeTruthy(); const limit = BLOCK_CONFIG['object-kanban'].find((f) => f.name === 'limit'); expect(limit?.kind).toBe('number'); diff --git a/packages/app-shell/src/views/metadata-admin/previews/block-config.ts b/packages/app-shell/src/views/metadata-admin/previews/block-config.ts index ce23c9e4be..4eb2e1c616 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/block-config.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/block-config.ts @@ -235,10 +235,10 @@ export const BLOCK_CONFIG: Record = { { name: 'titleField', label: 'engine.inspector.pageBlock.field.object-kanban.titleField', kind: 'field-picker', objectFrom: 'self', objectProp: 'objectName' }, { name: 'cardFields', label: 'engine.inspector.pageBlock.field.object-kanban.cardFields', kind: 'field-list', objectFrom: 'self', objectProp: 'objectName' }, // The board's fetch window, not a page size: `ObjectKanban.tsx` sends it as - // a real `$top` (`$top: schema.limit ?? DEFAULT_KANBAN_LIMIT`) and renders + // a real `$top` (`$top: resolveRowLimit(schema.limit, DEFAULT_KANBAN_FETCH_BATCH_SIZE)`) and renders // every fetched record into a lane with no pagination, so an author with // more than 100 records had no way to widen it. `{ literal: '100' }` is - // `DEFAULT_KANBAN_LIMIT`, the value that applies when the box is empty. + // `DEFAULT_KANBAN_FETCH_BATCH_SIZE`, the value that applies when the box is empty. { name: 'limit', label: 'engine.inspector.pageBlock.field.object-kanban.limit', kind: 'number', placeholder: { literal: '100' } }, ], diff --git a/packages/plugin-kanban/src/ObjectKanban.elementDataSource.test.tsx b/packages/plugin-kanban/src/ObjectKanban.elementDataSource.test.tsx index 6cfc940342..5f0e3f0f23 100644 --- a/packages/plugin-kanban/src/ObjectKanban.elementDataSource.test.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.elementDataSource.test.tsx @@ -33,7 +33,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, waitFor } from '@testing-library/react'; import React from 'react'; import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; -import { DEFAULT_KANBAN_LIMIT } from './ObjectKanban'; +import { DEFAULT_KANBAN_FETCH_BATCH_SIZE } from './ObjectKanban'; // Registers `object-kanban` (and the ElementDataSourceGate wiring under test). import './index'; // The lane titles asserted below render INSIDE `KanbanRenderer`'s `React.lazy` @@ -181,7 +181,7 @@ describe('object-kanban — the row cap reaches the wire (objectui#4025)', () => // The number is the one the board always intended; it just reaches the // adapter now (`convertQueryParams` in `@object-ui/data-objectstack` and // `ApiDataSource` both read `params.$top`, neither reads `params.options`). - expect(params.$top).toBe(DEFAULT_KANBAN_LIMIT); + expect(params.$top).toBe(DEFAULT_KANBAN_FETCH_BATCH_SIZE); expect(params.$top).toBe(100); expect(params.options).toBeUndefined(); }); diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index 8121ea0ee9..e30a3285dd 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -74,7 +74,15 @@ const KANBAN_DEFAULT_TRANSLATIONS: Record = { }; /** - * Rows fetched when the author declared no `limit`. + * The board's FETCH BATCH: the `$top` of its one query when the author declared + * no `limit`. + * + * A fetch batch, ⛔ not a page size (objectui#9853, ruling 5824040487, + * structure B): the board has no pager, so this is how many records it asks + * for at once, and rows past it are not reachable. Its value is kept, and it + * does not follow the display page size the protocol declares for + * `pagination.pageSize`, so no board silently loses reachable records when that + * default moves. Before that ruling it was named `DEFAULT_KANBAN_LIMIT`. * * The number is the one this board has always intended: until objectui#4025 the * fetch passed `{ options: { $top: 100 } }`, and `options` is not a `QueryParams` @@ -85,10 +93,10 @@ const KANBAN_DEFAULT_TRANSLATIONS: Record = { * window is now real, and authorable — same shape `object-timeline` took in * objectui#4009 for the identical defect. */ -export const DEFAULT_KANBAN_LIMIT = 100; +export const DEFAULT_KANBAN_FETCH_BATCH_SIZE = 100; /** - * What the contract admits as a row cap for this board. + * What the contract admits as this board's `limit`, the fetch batch it asks for. * * `@objectstack/spec` has already answered what `limit: 0` means. The * `object-kanban` props declare the member a POSITIVE INTEGER @@ -104,11 +112,12 @@ function isUsableRowLimit(value: unknown): value is number { } /** - * The ONE resolver for this board's row cap, for the reason objectui#9853 gave + * The ONE resolver for this board's fetch batch, for the reason objectui#9853 gave * when it landed the same shape on `ObjectGrid` and objectui#9897 repeated on * `ListView`: one resolver at every entry is what keeps the answer single. * - * Before objectui#9925 this read was a bare `schema.limit ?? DEFAULT_KANBAN_LIMIT`, + * Before objectui#9925 this read was a bare `schema.limit ?? DEFAULT_KANBAN_LIMIT` + * (the constant is `DEFAULT_KANBAN_FETCH_BATCH_SIZE` since objectui#9853), * and `??` rejects only `null` and `undefined` — so an authored `limit: 0` was * not nullish and survived as a real window. It reached the wire as `$top: 0`, * the board asked the server for nothing, and the empty board named no cause. @@ -149,10 +158,10 @@ function describeRefusedRowLimit(authored: unknown, objectName: unknown): string ? `object-kanban on ${objectName}` : 'object-kanban'; return ( - `[ObjectUI] ObjectKanban row cap: ${where} declared limit: ${String(authored)}, ` - + 'which is not a positive integer. A row cap must be a positive integer ' + `[ObjectUI] ObjectKanban fetch batch: ${where} declared limit: ${String(authored)}, ` + + 'which is not a positive integer. A fetch batch must be a positive integer ' + '(the spec refuses zero and negative values), so it was ignored and this ' - + `board fell back to its default row cap (${DEFAULT_KANBAN_LIMIT}).` + + `board fell back to its default fetch batch (${DEFAULT_KANBAN_FETCH_BATCH_SIZE}).` ); } @@ -709,7 +718,7 @@ export const ObjectKanban: React.FC = ({ // bucket records in fetch order, so this is also the in-lane // order. Absent → `undefined`, the query is unchanged. $orderby: convertSortToQueryParams(schema.sort), - $top: resolveRowLimit(schema.limit, DEFAULT_KANBAN_LIMIT), + $top: resolveRowLimit(schema.limit, DEFAULT_KANBAN_FETCH_BATCH_SIZE), ...(expand.length > 0 ? { $expand: expand } : {}), }; const results = await dataSource.find(schema.objectName, query); diff --git a/packages/plugin-kanban/src/__tests__/rowLimitNonPositive-9925.test.tsx b/packages/plugin-kanban/src/__tests__/rowLimitNonPositive-9925.test.tsx index 5ac0198fd2..3554016535 100644 --- a/packages/plugin-kanban/src/__tests__/rowLimitNonPositive-9925.test.tsx +++ b/packages/plugin-kanban/src/__tests__/rowLimitNonPositive-9925.test.tsx @@ -42,7 +42,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, cleanup, waitFor } from '@testing-library/react'; import React from 'react'; import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; -import { DEFAULT_KANBAN_LIMIT } from '../ObjectKanban'; +import { DEFAULT_KANBAN_FETCH_BATCH_SIZE } from '../ObjectKanban'; // Registers `object-kanban` (and the ElementDataSourceGate wiring that carries // the bound entrance). import '../index'; @@ -128,7 +128,9 @@ afterEach(() => { vi.restoreAllMocks(); }); -const rowCapWarnings = () => warnings.filter((w) => w.includes('ObjectKanban row cap')); +// The channel prefix the board's diagnostic carries; objectui#9853 renamed the +// board's default to a fetch batch, and the prefix with it. +const rowCapWarnings = () => warnings.filter((w) => w.includes('ObjectKanban fetch batch')); describe('object-kanban — a row cap the contract refuses never reaches the wire (objectui#9925)', () => { // ── ENTRANCE 1: AUTHORED, NO BINDING ─────────────────────────────────── @@ -154,7 +156,7 @@ describe('object-kanban — a row cap the contract refuses never reaches the wir const sent = await tops(adapter); // The relation, not the literal: whatever this site documents as its // default is what a refused declaration falls back to. - for (const top of sent) expect(top).toBe(DEFAULT_KANBAN_LIMIT); + for (const top of sent) expect(top).toBe(DEFAULT_KANBAN_FETCH_BATCH_SIZE); }); it('CONTROL — a legitimate authored limit still reaches `$top` unchanged', async () => { @@ -165,7 +167,7 @@ describe('object-kanban — a row cap the contract refuses never reaches the wir // Without this row, "never 0" is satisfied by a board that ignores the // member entirely and always sends its own default. expect(sent).toContain(7); - expect(sent).not.toContain(DEFAULT_KANBAN_LIMIT); + expect(sent).not.toContain(DEFAULT_KANBAN_FETCH_BATCH_SIZE); }); it('CONTROL — declaring no limit at all still sends the default', async () => { @@ -173,7 +175,7 @@ describe('object-kanban — a row cap the contract refuses never reaches the wir renderBlock(authoredBoard(undefined), adapter); const sent = await tops(adapter); - expect(sent).toContain(DEFAULT_KANBAN_LIMIT); + expect(sent).toContain(DEFAULT_KANBAN_FETCH_BATCH_SIZE); }); }); @@ -186,7 +188,7 @@ describe('object-kanban — a row cap the contract refuses never reaches the wir const sent = await tops(adapter); expect(sent.length).toBeGreaterThan(0); expect(sent, `the view's ${bad} reached the wire`).not.toContain(bad); - for (const top of sent) expect(top).toBe(DEFAULT_KANBAN_LIMIT); + for (const top of sent) expect(top).toBe(DEFAULT_KANBAN_FETCH_BATCH_SIZE); }); it('CONTROL — a legitimate page size still becomes the board’s window', async () => { diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index 71da03b610..d6a16d5a1c 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -473,7 +473,7 @@ export const kanbanComponents = { * What `ObjectKanban` reads for its own query: `objectName`, `filter`, `sort` * and `limit` (`ObjectKanban.tsx`, the `dataSource.find` call — `$filter: * schema.filter`, `$orderby: convertSortToQueryParams(schema.sort)`, `$top: - * resolveRowLimit(schema.limit, DEFAULT_KANBAN_LIMIT)`). + * resolveRowLimit(schema.limit, DEFAULT_KANBAN_FETCH_BATCH_SIZE)`). * * `limit` was unmapped until objectui#4025, on the rationale that the board * "fetches with a fixed `$top: 100`, so there is no key to write it to". That @@ -634,7 +634,7 @@ export const ObjectKanbanRenderer: React.FC<{ schema: any; [key: string]: any }> * faces, TS and zod; `content/docs/plugins/plugin-kanban.mdx`; the spec). * ⛔ The declaration carries NO default: a materialised `limit` would defeat the * gate's `readLimit(base) === undefined` branch, and a bound view's - * `pagination.pageSize` would then never fill it. `DEFAULT_KANBAN_LIMIT = 100` + * `pagination.pageSize` would then never fill it. `DEFAULT_KANBAN_FETCH_BATCH_SIZE = 100` * stays documented rather than declared. An unrecognised probe key draws * `unrecognized_keys` on these calls while none of the declared keys does. * diff --git a/packages/plugin-list/README.md b/packages/plugin-list/README.md index 501201abcb..712a50c0ab 100644 --- a/packages/plugin-list/README.md +++ b/packages/plugin-list/README.md @@ -227,6 +227,26 @@ const viewTypes: Record, string> = { export { view, richColumns, viewTypes }; ``` +## Page size and fetch batch + +A list view asks the server for one window of records. When +`pagination.pageSize` is not declared, what that window is depends on whether +the view pages: + +- **The grid view pages on the server.** The window is one page, and the + grid's pager turns it. Its size is the default `@objectstack/spec` declares + for `pagination.pageSize`; the list reads it from the spec rather than + keeping a number of its own. +- **Every other view does not page** — kanban, calendar, gallery and the rest, + and a grouped grid. The window is one fetch batch of **100** records, and + records past it are not reachable; the record-count bar says so when the + batch comes back full. It is a fetch size, not a page size, so it does not + follow the spec's display default. + +A declared `pagination.pageSize` sizes the window on every view, as before. +With no declared size, switching between the grid view and another view +changes the window, so the list fetches again. + ## Page binding — `dataSource` (referencing a saved view by name) On a metadata page, a `list-view` component can bind its data through the spec's diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index a6bab8af4b..af6e2d947f 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -31,7 +31,7 @@ import { useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLoc // objectui's keyed `{ key, defaultValue, params }` ref — that vocabulary lives // on the FLAT `schema.ariaLabel` and is resolved by `SchemaRenderer` instead // (objectui#5134). -import { resolveI18nLabel as resolveInlineI18nLabel, normalizeFilterOperator } from '@objectstack/spec/ui'; +import { resolveI18nLabel as resolveInlineI18nLabel, normalizeFilterOperator, PaginationConfigSchema } from '@objectstack/spec/ui'; import { usePermissions } from '@object-ui/permissions'; /** @@ -962,18 +962,46 @@ function useListFieldLabel() { } /** - * The page size this view falls back to when no usable one is declared. + * The page size a PAGED list falls back to when no usable one is declared: the + * flat grid view, which this component pages on the server (`paginate`), so + * the window it fetches is the page on screen and the size it hands the child + * grid's pager (objectui#9853, ruling 5824040487, structure B: "page size" + * means one page only where there is a pager). * - * ⚠️ Named rather than spelled inline because it is a FIFTH different default - * in this family: `ObjectGrid` carries three (a page of rows, a page of - * groups, a fetch window) and this view carries its own — the single `$top` - * window it asks the server for, which then doubles as the child grid's page - * size. ⛔ Whether 100 belongs next to the grid's numbers is NOT settled here: - * changing it changes what every list with no authored `pagination` fetches, - * which is a product decision rather than an execution seat's. It is handed - * back as a question on objectui#9897. + * READ from `@objectstack/spec`, not restated, the same read `ObjectGrid` + * makes: the protocol's pagination config declares `pageSize` with a default, + * and parsing an empty config is the spec's own way of saying what an + * undeclared member means. ⚠️ There is no local number behind the read: if a + * future spec stops declaring a positive default, this throws at module load + * rather than substituting a number nobody declared. */ -const DEFAULT_LIST_PAGE_SIZE = 100; +function readSpecDisplayPageSize(): number { + const declared: unknown = PaginationConfigSchema.parse({}).pageSize; + if (typeof declared !== 'number' || !Number.isInteger(declared) || declared <= 0) { + throw new Error( + '[ObjectUI] ListView: @objectstack/spec no longer declares a positive default ' + + `for pagination.pageSize (read ${String(declared)}); a paged list has no display ` + + 'page size to fall back to.', + ); + } + return declared; +} +const DEFAULT_LIST_DISPLAY_PAGE_SIZE = readSpecDisplayPageSize(); + +/** + * The `$top` of the ONE unpaged fetch a list view makes when no usable page + * size is declared: every view this component does not page on the server + * (`paginate` is false), which is every kind but the flat grid, plus a grouped + * grid's window. Those views have no pager, so rows past this number are not + * reachable at all (`dataLimitReached` says so). + * + * A fetch batch, ⛔ not a page size (objectui#9853, ruling 5824040487, + * structure B): its value is kept, and it does not follow the display default, + * so no view silently loses reachable records when the protocol's page size + * moves. A DECLARED `pagination.pageSize` still sizes this fetch, as it always + * has; only the undeclared fallback is split by kind. + */ +const DEFAULT_LIST_FETCH_BATCH_SIZE = 100; /** * What the contract admits as a page size. The spec's view pagination config @@ -1038,17 +1066,21 @@ function describeRefusedPageSize( chosen: unknown, authored: unknown, objectName: unknown, + paged: boolean, ): string | null { const candidate = chosen ?? authored; if (candidate === undefined || candidate === null) return null; if (isUsablePageSize(candidate)) return null; const where = typeof objectName === 'string' && objectName ? `list-view on ${objectName}` : 'list-view'; + const fellBackTo = paged + ? `its default page size (${DEFAULT_LIST_DISPLAY_PAGE_SIZE})` + : `its default fetch batch (${DEFAULT_LIST_FETCH_BATCH_SIZE}), since this view does not page`; return ( `[ObjectUI] ListView pagination: ${where} declared pageSize: ${String(candidate)}, ` + 'which is not a positive integer. A page size must be a positive integer ' + '(the spec refuses zero and negative values), so it was ignored and this ' - + `list fell back to its default page size (${DEFAULT_LIST_PAGE_SIZE}).` + + `list fell back to ${fellBackTo}.` ); } @@ -1394,27 +1426,7 @@ export const ListView = React.forwardRef(({ // Dynamic page size state (wired from pageSizeOptions selector) const [dynamicPageSize, setDynamicPageSize] = React.useState(undefined); - // [objectui#9897] Through the resolver rather than a bare `??` chain. This - // value is resolved ONCE and then feeds six consumers — the `$top` window, - // the `$skip` step that turns the page, the has-more gate behind the - // "showing first N" cap, the page size handed down to the child grid, the - // record cap printed in that banner, and the rows-per-page control's own - // displayed value. `??` rejects only null/undefined, so one refused - // declaration used to reach all six. const authoredPageSize = schema.pagination?.pageSize; - const effectivePageSize = resolvePageSize( - dynamicPageSize, - authoredPageSize, - DEFAULT_LIST_PAGE_SIZE, - ); - - // [objectui#9897] The loud half, on the channel this component already uses - // for "you declared it, the renderer dropped it". Keyed on the declaration, - // so it is one warning per declaration rather than one per render. - React.useEffect(() => { - const message = describeRefusedPageSize(dynamicPageSize, authoredPageSize, schema.objectName); - if (message) console.warn(message); - }, [dynamicPageSize, authoredPageSize, schema.objectName]); // --- Server-side pagination (objectstack-ai/objectstack#2212) --- // ListView owns the fetch, so it owns paging too: it requests one window at a @@ -1502,6 +1514,35 @@ export const ListView = React.forwardRef(({ * values every consumer sees are the ones they saw before. */ const paginate = currentView === 'grid' && !(groupingConfig?.fields?.length); + // [objectui#9897] Through the resolver rather than a bare `??` chain. This + // value is resolved ONCE and then feeds six consumers — the `$top` window, + // the `$skip` step that turns the page, the has-more gate behind the + // "showing first N" cap, the page size handed down to the child grid, the + // record cap printed in that banner, and the rows-per-page control's own + // displayed value. `??` rejects only null/undefined, so one refused + // declaration used to reach all six. + // + // [objectui#9853] Undeclared, what it falls back to depends on whether this + // surface pages (structure B): a paged surface falls back to the display + // default the spec declares, and an unpaged one to the fetch batch. So with + // no declared size, switching between the paged grid and an unpaged kind + // moves the window and re-issues the fetch, where a declared size keeps one + // window across the switch (objectui#7394). + const effectivePageSize = resolvePageSize( + dynamicPageSize, + authoredPageSize, + paginate ? DEFAULT_LIST_DISPLAY_PAGE_SIZE : DEFAULT_LIST_FETCH_BATCH_SIZE, + ); + + // [objectui#9897] The loud half, on the channel this component already uses + // for "you declared it, the renderer dropped it". Keyed on the declaration + // (and on which fallback it names), so it is one warning per declaration + // rather than one per render. + React.useEffect(() => { + const message = describeRefusedPageSize(dynamicPageSize, authoredPageSize, schema.objectName, paginate); + if (message) console.warn(message); + }, [dynamicPageSize, authoredPageSize, schema.objectName, paginate]); + const fetchSkip = paginate ? (serverPage - 1) * effectivePageSize : 0; const serverTotal = paginate ? fetchedTotal : null; diff --git a/packages/plugin-list/src/__tests__/ListView.pageSizeAndFetchBatch-9853.test.tsx b/packages/plugin-list/src/__tests__/ListView.pageSizeAndFetchBatch-9853.test.tsx new file mode 100644 index 0000000000..b9d5f5ce38 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.pageSizeAndFetchBatch-9853.test.tsx @@ -0,0 +1,236 @@ +/** + * 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. + */ + +/** + * objectui#9853, ruling 5824040487 (structure B) — "page size" means one page + * only where there is a pager. + * + * This view asks the server for ONE window. It used to fall back to one number + * for that window whatever was on screen, and the number meant two different + * things: on the grid view, which this component pages on the server, the + * window is one page and the size handed to the grid's pager; on every other + * view, which has no pager, it is the most records the view can reach at all. + * The ruling splits the undeclared fallback by that difference: + * + * - the PAGED view (the flat grid) falls back to the display default + * `@objectstack/spec` declares for `pagination.pageSize`, read from the + * spec at test time here, never spelled; + * - every UNPAGED view (kanban, calendar, gallery, timeline, a grouped grid's + * window) keeps its fetch batch, value 100, so no view silently loses + * reachable records when the page size moves. + * + * A declared `pagination.pageSize` still sizes the window on every view, as it + * always has (the CONTROL rows). The cost the split carries is pinned too: with + * no declared size, switching between the paged grid and an unpaged view moves + * the window, so the fetch is re-issued (objectui#7394 pins that a DECLARED + * size keeps one window across the same switch). + * + * ## Test-source note + * + * `plugin-grid` and `plugin-kanban` are not dependencies of `plugin-list`, so + * every child view is a registry stub that records what this view hands it — + * the arrangement `ListView.viewSwitchRefetch-7394.test.tsx` uses. The view, + * its `SchemaRenderer` hand-off and the registry are real. The cross-package + * pin that mounts the REAL grid and board under a stood-in spec default is + * `packages/app-shell/src/__tests__/displayPageSizeFromSpec-9853.test.tsx`. + */ +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { PaginationConfigSchema } from '@objectstack/spec/ui'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ListView } from '../ListView'; + +/** The protocol's display default, read the way the renderer reads it. */ +const SPEC_DISPLAY_DEFAULT: number = PaginationConfigSchema.parse({}).pageSize; + +/** The unpaged views' fetch batch: the ruling keeps its value. */ +const FETCH_BATCH = 100; + +/** More rows than either number, so every window comes back full. */ +const TOTAL = 240; + +let lastGridProps: any = null; + +function makeDataSource() { + const calls: Array> = []; + const find = vi.fn(async (_object: string, params: any) => { + calls.push(params); + const skip = params.$skip ?? 0; + const top = params.$top ?? TOTAL; + const rows = Array.from( + { length: Math.max(0, Math.min(top, TOTAL - skip)) }, + (_, i) => ({ id: `id-${skip + i}`, name: `Row ${skip + i}`, status: 'todo', due: '2026-09-01' }), + ); + return { data: rows, total: TOTAL }; + }); + return { + calls, + find, + // Declared so a grouped grid owns its grouped fetch (objectui#7189) rather + // than being refused before this view fetches (objectui#10881). + queryGroupHeaders: vi.fn(async () => []), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: async (name: string) => ({ + name, + fields: { + id: { type: 'text' }, + name: { type: 'text' }, + status: { type: 'select', options: [{ label: 'Todo', value: 'todo' }] }, + due: { type: 'date' }, + }, + }), + } as any; +} + +const STUBBED = ['object-grid', 'object-kanban', 'object-gallery', 'object-calendar', 'object-timeline'] as const; +const previous = new Map(); +beforeAll(() => { + for (const type of STUBBED) { + previous.set(type, ComponentRegistry.get(type)); + ComponentRegistry.register(type, (props: any) => { + if (type === 'object-grid') lastGridProps = props; + return
; + }); + } +}); +afterAll(() => { + for (const type of STUBBED) { + const prev = previous.get(type); + if (prev) ComponentRegistry.register(type, prev as any); + else ComponentRegistry.unregister(type); + } +}); + +let warnings: string[] = []; +beforeEach(() => { + lastGridProps = null; + warnings = []; + vi.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => { + warnings.push(args.map(String).join(' ')); + }); +}); +afterEach(() => { + cleanup(); + lastGridProps = null; + vi.restoreAllMocks(); +}); + +const renderList = (schemaExtra: Record, ds: any, props: Record = {}) => + render( + + + , + ); + +const tops = (ds: any): unknown[] => ds.calls.map((p: any) => p.$top); +const pageSizeWarnings = () => warnings.filter((w) => w.includes('ListView pagination')); + +/** Every view this component does not page on the server, as authored. */ +const UNPAGED_VIEWS: Array<[string, Record]> = [ + ['kanban', { viewType: 'kanban', kanban: { groupByField: 'status' } }], + ['gallery', { viewType: 'gallery' }], + ['calendar', { viewType: 'calendar', calendar: { startDateField: 'due' } }], + ['timeline', { viewType: 'timeline', timeline: { startDateField: 'due' } }], + ['grouped grid', { viewType: 'grid', grouping: { fields: [{ field: 'status' }] } }], +]; + +describe('ListView — the paged grid reads the spec display default, every unpaged view keeps its fetch batch (objectui#9853)', () => { + it('the spec declares a usable display default, and the batch is a different number', () => { + expect(Number.isInteger(SPEC_DISPLAY_DEFAULT) && SPEC_DISPLAY_DEFAULT > 0).toBe(true); + expect(FETCH_BATCH).not.toBe(SPEC_DISPLAY_DEFAULT); + }); + + it('the paged grid view asks for, and hands its pager, the spec display default', async () => { + const ds = makeDataSource(); + renderList({ viewType: 'grid' }, ds); + + await waitFor(() => expect(lastGridProps?.manualPagination).toBe(true)); + expect(tops(ds)).toEqual([SPEC_DISPLAY_DEFAULT]); + expect(lastGridProps.pageSize).toBe(SPEC_DISPLAY_DEFAULT); + expect(lastGridProps.data).toHaveLength(SPEC_DISPLAY_DEFAULT); + }); + + it.each(UNPAGED_VIEWS)('the unpaged %s view asks for the fetch batch, not the page size', async (_name, extra) => { + const ds = makeDataSource(); + renderList(extra, ds); + + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + expect(tops(ds)).toEqual([FETCH_BATCH]); + expect(tops(ds)).not.toContain(SPEC_DISPLAY_DEFAULT); + }); + + it('CONTROL — a declared page size still sizes the window on a paged and an unpaged view', async () => { + const paged = makeDataSource(); + renderList({ viewType: 'grid', pagination: { pageSize: 5 } }, paged); + await waitFor(() => expect(lastGridProps?.manualPagination).toBe(true)); + expect(tops(paged)).toEqual([5]); + expect(lastGridProps.pageSize).toBe(5); + cleanup(); + + const unpaged = makeDataSource(); + renderList({ viewType: 'kanban', kanban: { groupByField: 'status' }, pagination: { pageSize: 5 } }, unpaged); + await waitFor(() => expect(unpaged.find).toHaveBeenCalled()); + expect(tops(unpaged)).toEqual([5]); + }); + + it('the COST: with no declared size, switching the paged grid to an unpaged view re-issues the fetch with the batch', async () => { + const ds = makeDataSource(); + renderList( + { viewType: 'grid', kanban: { groupByField: 'status' }, appearance: { allowedVisualizations: ['grid', 'kanban'] } }, + ds, + { showViewSwitcher: true }, + ); + await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(1)); + expect(tops(ds)).toEqual([SPEC_DISPLAY_DEFAULT]); + + const tab = await screen.findByRole('tab', { name: 'Kanban' }); + await act(async () => { tab.click(); }); + await waitFor(() => expect(screen.queryByTestId('object-kanban-stub')).not.toBeNull()); + + // The window really moved, so the second query is a real one: the board + // is not handed the grid's page as if it were every record it can reach. + await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(2)); + expect(tops(ds)).toEqual([SPEC_DISPLAY_DEFAULT, FETCH_BATCH]); + }); + + it('a refused page size names the fallback that surface actually uses', async () => { + const paged = makeDataSource(); + renderList({ viewType: 'grid', pagination: { pageSize: 0 } }, paged); + await waitFor(() => expect(pageSizeWarnings().length).toBeGreaterThan(0)); + // The subject is the NUMBER the surface fell back to, not the wording. + expect(pageSizeWarnings()[0]).toContain(`(${SPEC_DISPLAY_DEFAULT})`); + expect(pageSizeWarnings()[0]).not.toContain(`(${FETCH_BATCH})`); + await waitFor(() => expect(paged.find).toHaveBeenCalled()); + expect(tops(paged)).toEqual([SPEC_DISPLAY_DEFAULT]); + cleanup(); + warnings = []; + + const unpaged = makeDataSource(); + renderList({ viewType: 'gallery', pagination: { pageSize: 0 } }, unpaged); + await waitFor(() => expect(pageSizeWarnings().length).toBeGreaterThan(0)); + expect(pageSizeWarnings()[0]).toContain(`(${FETCH_BATCH})`); + expect(pageSizeWarnings()[0]).not.toContain(`(${SPEC_DISPLAY_DEFAULT})`); + await waitFor(() => expect(unpaged.find).toHaveBeenCalled()); + expect(tops(unpaged)).toEqual([FETCH_BATCH]); + }); +}); diff --git a/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts b/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts index 13f3fa5567..c1c9214bd3 100644 --- a/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts +++ b/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts @@ -41,7 +41,8 @@ * (objectui#9925). That card made the board REFUSE a non-positive row cap * before it reaches the wire, and both halves of the reading moved with it: * the spelling is now `$top: resolveRowLimit(schema.limit, - * DEFAULT_KANBAN_LIMIT)`, and the `schema.limit` CODE read sites are FOUR, not + * DEFAULT_KANBAN_FETCH_BATCH_SIZE)` (the constant renamed by objectui#9853), and + * the `schema.limit` CODE read sites are FOUR, not * two — that `$top`, its effect's deps, the refusal diagnostic and that * diagnostic's own effect deps. The older reading is left standing rather than * rewritten away because it is the measurement that motivated the declaration; @@ -115,10 +116,13 @@ const READ_TEXT: Record = { 'if (schema.groupBy && objectDef?.fields?.[schema.groupBy]?.options) {', 'const groupBy = schema.groupBy;', ], - limit: ['$top: resolveRowLimit(schema.limit, DEFAULT_KANBAN_LIMIT)'], + limit: ['$top: resolveRowLimit(schema.limit, DEFAULT_KANBAN_FETCH_BATCH_SIZE)'], }; -/** The default the `limit` docblock names. */ -const DEFAULT_LIMIT_TEXT = 'export const DEFAULT_KANBAN_LIMIT = 100;'; +/** + * The default the `limit` docblock names. objectui#9853 renamed the constant to + * a fetch batch (ruling 5824040487, structure B) and kept its value. + */ +const DEFAULT_LIMIT_TEXT = 'export const DEFAULT_KANBAN_FETCH_BATCH_SIZE = 100;'; /** * A plausible lane-key spelling the renderer never reads (the read set below diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 94e89c9392..1a334579bc 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -4286,14 +4286,14 @@ export interface ObjectKanbanSchema extends BaseSchema { /** * Row cap — the most records the board fetches, sent as a real `$top` on * the query (`packages/plugin-kanban/src/ObjectKanban.tsx`, - * `$top: resolveRowLimit(schema.limit, DEFAULT_KANBAN_LIMIT)`; objectui#4025, + * `$top: resolveRowLimit(schema.limit, DEFAULT_KANBAN_FETCH_BATCH_SIZE)`; objectui#4025, * re-spelled by objectui#9925, which refuses a non-positive cap rather than * forwarding it). The board renders every fetched record into a lane and * offers no pagination, so this is the author's window on the object rather * than a page size. A bound `dataSource` (its own `limit`, or the named * view's `pagination.pageSize`) sets it too. Undeclared until objectui#7322. * - * @default 100 — `DEFAULT_KANBAN_LIMIT` + * @default 100 — `DEFAULT_KANBAN_FETCH_BATCH_SIZE` */ limit?: number; /** diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 8b04de1299..abaef4b297 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -2500,7 +2500,7 @@ const OBJECT_KANBAN_NEITHER_CHANNEL = neitherContentChannelGuidance( // objectui#7322 — `groupBy` and `limit` are the keys `ObjectKanban.tsx` reads // (thirteen `schema.groupBy` sites; the row cap lowered into the query as -// `$top: resolveRowLimit(schema.limit, DEFAULT_KANBAN_LIMIT)`, re-spelled by +// `$top: resolveRowLimit(schema.limit, DEFAULT_KANBAN_FETCH_BATCH_SIZE)`, re-spelled by // objectui#9925); until this card neither was declared and both rode // `BaseSchema`'s `.passthrough()` unexamined, while the REQUIRED `groupField` // had zero read sites. `groupField` is now a `retirementTombstone()` — still @@ -2527,7 +2527,7 @@ export const ObjectKanbanSchema = BaseSchema.extend({ // reasoned on `ObjectKanbanLaneSchema` above and on the TS twin. columns: z.union([z.array(z.string()), z.array(ObjectKanbanLaneSchema)]).optional() .describe('Swimlane definitions — EITHER an array of { id, title } lanes per groupBy value OR an array of bare value strings; NOT a field projection (the fields drawn on a card are cardFields), and not a mix of the two, which the renderer cannot dispatch'), - limit: z.number().int().positive().optional().describe('Row cap — the most records the board fetches, sent as a real $top on the query; default 100 (DEFAULT_KANBAN_LIMIT)'), + limit: z.number().int().positive().optional().describe('Row cap — the most records the board fetches, sent as a real $top on the query; default 100 (DEFAULT_KANBAN_FETCH_BATCH_SIZE)'), // objectui#8174 — the query key `ObjectKanban.tsx` lowers onto its own // `dataSource.find` (`$filter: schema.filter`), alongside the `$top` that // `limit` above feeds. Same position `groupBy` and `limit` were in before From a2c3e5ba792027135e87a73ff411c794d2c7870b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 06:23:11 +0000 Subject: [PATCH 6/8] docs(changeset): the objectui#9853 changeset states the measured values on @objectstack/spec 17.5.0, per surface Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm Co-authored-by: Claude --- .../9853-grid-display-page-size-from-spec.md | 76 ++++++++++++------- 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/.changeset/9853-grid-display-page-size-from-spec.md b/.changeset/9853-grid-display-page-size-from-spec.md index 94b163fba1..58f33b00d1 100644 --- a/.changeset/9853-grid-display-page-size-from-spec.md +++ b/.changeset/9853-grid-display-page-size-from-spec.md @@ -1,35 +1,59 @@ --- '@object-ui/plugin-grid': minor +'@object-ui/plugin-list': minor +'@object-ui/plugin-kanban': minor --- -`object-grid` with no declared `pageSize` now shows **25** rows (or groups) per -page — the default `@objectstack/spec` declares for `pagination.pageSize` — on -every page it renders (objectui#9853). +A page size with nothing declared is now the one `@objectstack/spec` declares for +`pagination.pageSize`, on every surface that has a pager; a fetch that has no +pager keeps its own fetch batch, which does not follow it (objectui#9853). -⚠️ **Visible change on grids that declare no page size.** Declare -`pagination: { pageSize: N }` to keep the old count: +"Page size" now means one page, and only where there is a pager. The grid, +the list and the board read the display default from the spec (`PaginationConfigSchema`) +instead of keeping numbers of their own, so it is declared once, in the protocol. +`@objectstack/spec` 17.5.0 declares **50**. The views that fetch one window and +page nothing keep a fetch batch of their own, with the value they had, so no +view loses records it could reach before. -| surface (no `pageSize` declared) | before | after | +⚠️ **Visible change where no page size is declared.** Measured on the renderer +with `@objectstack/spec` 17.5.0. Declare `pagination: { pageSize: N }` (or, on a +board, `limit`) to keep an old count: + +| surface (nothing declared) | before | after | |:--|--:|--:| -| grid that fetches its own rows (server-paged table), rows per page **and** the `$top` of each page fetch | 50 | 25 | -| grid over inline `data` (client-paged table), rows per page | 10 | 25 | -| grouped grid, groups per page | 10 | 25 | -| grouped grid, rows fetched to group (`$top`) | 50 | 50 (unchanged) | - -The grid used to fall back to three local numbers, and one of them — named a -"server window" — sized both a fetch and the page the user sees. The display -default is now read from `@objectstack/spec` (`PaginationConfigSchema`), with no -local number behind it; the fetch batch is renamed `DEFAULT_FETCH_BATCH_SIZE`, -keeps its value, and is read only by the grouped view's fetch, never as a page -size. A declared `pageSize` is honoured exactly as before, and a refused one (zero, -negative, non-integer) still warns and falls back — now to 25. - -Inside a `ListView`, the server-paged flat grid still takes its page size from the -list and does not change here. The list hands no page size to a grouped grid, -though, so a grouped list with no declared `pageSize` also moves from 10 to 25 -groups per page. The grouped view's rows-per-page selector now also lists the -size in force when it is not one of its fixed steps, as the table pager already -did. +| `object-grid` that fetches its own rows: rows per page, and the `$top` of each page fetch | 50 | 50 (unchanged) | +| `object-grid` over inline `data`: rows per page | 10 | **50** | +| grouped `object-grid`: groups per page | 10 | **50** | +| grouped `object-grid` over a data source that groups on the server: rows per group page (`$top`) | 50 | 50 (unchanged) | +| `list-view`, grid view (paged on the server): rows per page, and `$top` | 100 | **50** | +| `list-view`, every view it does not page (kanban, calendar, gallery, timeline and the rest, and a grouped grid): the one fetch's `$top` | 100 | 100 (unchanged) | +| `object-kanban` with no `limit`: the board's `$top` | 100 | 100 (unchanged) | + +What changed underneath: + +- **`@object-ui/plugin-grid`.** `ObjectGrid` falls back to the spec's display + default on every page it shows, with no local number behind it: if a future + spec stops declaring the default, the module throws at load instead of + inventing one. The old "server window" constant is renamed + `DEFAULT_FETCH_BATCH_SIZE` and keeps its value. Since grouping moved to the + server, it is read only when the grid groups a window of rows in the browser + because the server does not group it (a grouping key the principal may not + read), and never as a page size. The grouped view's rows-per-page selector + also lists the size in force when it is not one of its fixed steps. +- **`@object-ui/plugin-list`.** `ListView` splits its undeclared fallback by + whether the view pages. The grid view, which it pages on the server, falls + back to the spec's display default. Every other view falls back to a fetch + batch of 100, the value it had. A declared `pagination.pageSize` sizes the + window on every view, as before. With no declared size, switching between the + grid view and an unpaged view now changes the window, so the list fetches + again. A refused page size still warns, and the warning names the fallback + that view actually used. +- **`@object-ui/plugin-kanban`.** The board's default `limit` is renamed from + `DEFAULT_KANBAN_LIMIT` to `DEFAULT_KANBAN_FETCH_BATCH_SIZE` and stays 100. Its + diagnostic for a refused `limit` now calls it the board's fetch batch. + +The display default follows the `@objectstack/spec` a host installs. A host +that resolves an older spec gets that spec's default, on every surface alike. Released as `minor`, not `major`: this repository keeps its major aligned with -`@objectstack/spec`, so a behaviour change is spelled out here instead. +`@objectstack/spec`, so the behaviour change is spelled out here instead. From 39994581ab20f246e031b7dc633128311896710c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 10:11:05 +0000 Subject: [PATCH 7/8] test(plugin-list): the objectui#10512 switch row pins structure B's one further query, per ruling record 5909000462 (objectui#9853) The row "CONTROL (objectui#7394): with nothing held, the switch to the dataset chart re-issues no query" read red on this branch: with no pageSize declared, the paged grid's window is the spec display default and the dataset chart's is the list's fetch batch, so the switch re-issues the fetch. The maintainer accepted that cost (ruling record 5909000462 on objectui#9853). - The row is renamed for what it now pins: exactly one further query, whose $top is the fetch batch, after a first query whose $top is the spec display default. Both numbers are read, never spelled: the default from PaginationConfigSchema, the batch from DEFAULT_LIST_FETCH_BATCH_SIZE, which ListView.tsx now exports (the package index does not re-export it). The further query equals the one on screen with only $top changed. - A new CONTROL row pins the no-query half on the same switch: with a pageSize declared, grid to dataset chart re-issues nothing (objectui#7394 section 1 pins grid to kanban). No other assertion in the file changed. Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm Co-authored-by: Claude --- packages/plugin-list/src/ListView.tsx | 6 +- ...w.datasetChartAppliedFilter-10512.test.tsx | 60 +++++++++++++++++-- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index af6e2d947f..5789e6b507 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -1000,8 +1000,12 @@ const DEFAULT_LIST_DISPLAY_PAGE_SIZE = readSpecDisplayPageSize(); * so no view silently loses reachable records when the protocol's page size * moves. A DECLARED `pagination.pageSize` still sizes this fetch, as it always * has; only the undeclared fallback is split by kind. + * + * Exported for pins that assert "the fetch batch" rather than its value + * (objectui#9853, ruling record 5909000462); the package index does not + * re-export it. */ -const DEFAULT_LIST_FETCH_BATCH_SIZE = 100; +export const DEFAULT_LIST_FETCH_BATCH_SIZE = 100; /** * What the contract admits as a page size. The spec's view pagination config diff --git a/packages/plugin-list/src/__tests__/ListView.datasetChartAppliedFilter-10512.test.tsx b/packages/plugin-list/src/__tests__/ListView.datasetChartAppliedFilter-10512.test.tsx index 04e1c1eb1b..4e3bab022f 100644 --- a/packages/plugin-list/src/__tests__/ListView.datasetChartAppliedFilter-10512.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.datasetChartAppliedFilter-10512.test.tsx @@ -36,8 +36,12 @@ * - CONTROLS: on a grid, and on a chart bound to the list object (whose node * carries the effective filter, objectui#10250), the restored group still * narrows the fetch. - * - With nothing held, the switch into the dataset chart re-issues no query: - * the withholding does not reopen objectui#7394. + * - With nothing held, the withholding does not reopen objectui#7394. With a + * `pageSize` declared, the switch into the dataset chart re-issues no + * query. With none declared, it re-issues exactly one, and only because + * the window moves: the paged grid's `$top` is the spec display default + * and the chart's is the list's fetch batch (objectui#9853, structure B; + * the cost the maintainer accepted in ruling record 5909000462). * * Each case first waits for the view's stand-in, so a mount that never reached * the view's render branch reads as a broken harness, never as a pass. @@ -46,9 +50,13 @@ import React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react'; +import { PaginationConfigSchema } from '@objectstack/spec/ui'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRendererProvider } from '@object-ui/react'; -import { ListView } from '../ListView'; +import { ListView, DEFAULT_LIST_FETCH_BATCH_SIZE } from '../ListView'; + +/** The protocol's display default, read the way the renderer reads it. */ +const SPEC_DISPLAY_DEFAULT: number = PaginationConfigSchema.parse({}).pageSize; let chartNodes: Array> = []; @@ -228,7 +236,16 @@ describe('objectui#10512 — a held user filter does not narrow ListView\'s fetc expect(onFilterChange).not.toHaveBeenCalled(); }); - it('CONTROL (objectui#7394): with nothing held, the switch to the dataset chart re-issues no query', async () => { + it('with nothing held and no pageSize declared, the switch to the dataset chart re-issues exactly one query, whose $top moves from the spec display default to the fetch batch', async () => { + // [objectui#9853] Ruling record 5909000462: the maintainer accepted this + // one further query as structure B's cost. A paged view's undeclared window + // is the spec display default and an unpaged view's is the fetch batch, so + // the switch changes `$top` and the list asks again. This row pinned "no + // query" before that ruling; the no-query half now lives in the DECLARED + // control below. + // + // The row discriminates only while the two numbers differ. + expect(SPEC_DISPLAY_DEFAULT).not.toBe(DEFAULT_LIST_FETCH_BATCH_SIZE); const dataSource = mount( { viewType: 'grid', chart: DATASET_BLOCK, appearance: { allowedVisualizations: ['grid', 'chart'] } }, { showViewSwitcher: true }, @@ -236,7 +253,42 @@ describe('objectui#10512 — a held user filter does not narrow ListView\'s fetc await onScreen('grid-standin'); await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); await settle(); + const calls = () => dataSource.find.mock.calls.map((c: unknown[]) => c[1] as Record); + const before = calls().length; + expect(calls()[0].$top).toBe(SPEC_DISPLAY_DEFAULT); + + pick('Chart'); + await onScreen('chart-standin'); + const topsAfter = () => calls().slice(before).map((p) => p.$top); + await waitFor(() => expect(topsAfter()).toEqual([DEFAULT_LIST_FETCH_BATCH_SIZE])); + // Exactly one: no second copy lands once the first has. + await settle(); + expect(topsAfter()).toEqual([DEFAULT_LIST_FETCH_BATCH_SIZE]); + // Only the window moved: the further query is the one already on screen + // with the chart's `$top`, so nothing the chart withholds reached it. + expect(calls()[before]).toEqual({ ...calls()[before - 1], $top: DEFAULT_LIST_FETCH_BATCH_SIZE }); + }); + + it('CONTROL (objectui#7394 §1): with nothing held and a pageSize declared, the switch to the dataset chart re-issues no query', async () => { + // A declared size is one window for every kind, so the switch moves + // nothing and the withholding alone must not re-issue the fetch. §1 of + // `ListView.viewSwitchRefetch-7394.test.tsx` pins the same for grid to + // kanban; this row pins it for the dataset chart. + const DECLARED_PAGE_SIZE = 7; + const dataSource = mount( + { + viewType: 'grid', + chart: DATASET_BLOCK, + pagination: { pageSize: DECLARED_PAGE_SIZE }, + appearance: { allowedVisualizations: ['grid', 'chart'] }, + }, + { showViewSwitcher: true }, + ); + await onScreen('grid-standin'); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + await settle(); const before = dataSource.find.mock.calls.length; + expect((dataSource.find.mock.calls[0][1] as Record).$top).toBe(DECLARED_PAGE_SIZE); pick('Chart'); await onScreen('chart-standin'); From 5a2d1e66af11484b6adf857de3b058f69f83baa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 10:19:08 +0000 Subject: [PATCH 8/8] docs(changeset): the objectui#9853 changeset states the one further request on a view switch, and qualifies the objectui#10512 note it contradicts With no declared page size, a switch between ListView's grid view and an unpaged view issues one request whose $top is the fetch batch (ruling record 5909000462 on objectui#9853). The pending objectui#10512 changeset, which publishes in the same release, says a switch into a dataset chart with no user filter set issues no new request. That now holds only with a declared page size, so this card's own changeset says so. The objectui#10512 changeset itself is not edited. Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm Co-authored-by: Claude --- .changeset/9853-grid-display-page-size-from-spec.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.changeset/9853-grid-display-page-size-from-spec.md b/.changeset/9853-grid-display-page-size-from-spec.md index 58f33b00d1..dca2eada86 100644 --- a/.changeset/9853-grid-display-page-size-from-spec.md +++ b/.changeset/9853-grid-display-page-size-from-spec.md @@ -46,8 +46,11 @@ What changed underneath: batch of 100, the value it had. A declared `pagination.pageSize` sizes the window on every view, as before. With no declared size, switching between the grid view and an unpaged view now changes the window, so the list fetches - again. A refused page size still warns, and the warning names the fallback - that view actually used. + again: one request, whose `$top` is the fetch batch. That qualifies the + objectui#10512 note in this same release, which says a switch into a dataset + chart with no user filter set issues no new request: it still holds when a + page size is declared. A refused page size still warns, and the warning names + the fallback that view actually used. - **`@object-ui/plugin-kanban`.** The board's default `limit` is renamed from `DEFAULT_KANBAN_LIMIT` to `DEFAULT_KANBAN_FETCH_BATCH_SIZE` and stays 100. Its diagnostic for a refused `limit` now calls it the board's fetch batch.