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..dca2eada86 --- /dev/null +++ b/.changeset/9853-grid-display-page-size-from-spec.md @@ -0,0 +1,62 @@ +--- +'@object-ui/plugin-grid': minor +'@object-ui/plugin-list': minor +'@object-ui/plugin-kanban': minor +--- + +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). + +"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. + +⚠️ **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 | +|:--|--:|--:| +| `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: 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. + +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 the behaviour change is spelled out here instead. diff --git a/content/docs/plugins/plugin-grid.mdx b/content/docs/plugins/plugin-grid.mdx index 760bd8bec5..46d4156da5 100644 --- a/content/docs/plugins/plugin-grid.mdx +++ b/content/docs/plugins/plugin-grid.mdx @@ -367,6 +367,15 @@ 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`, 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 `grouping` is answered by the **server**, not by bucketing the records the 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-grid/README.md b/packages/plugin-grid/README.md index aace0f6c95..c6223ceaa2 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -485,6 +485,15 @@ 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`, 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 `grouping` is answered by the **server**, not by bucketing the records the diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 35f06896d3..36d6bb5d6f 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -47,7 +47,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, @@ -1290,24 +1290,53 @@ 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, 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. * - * 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 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 a client-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, @@ -1485,10 +1514,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). @@ -1957,10 +1987,15 @@ 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). 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_SERVER_WINDOW_SIZE), + resolvePageSize(schema, DEFAULT_DISPLAY_PAGE_SIZE), ); // Column-header sort, when this grid fetches its own rows (objectui#3106). @@ -2156,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. @@ -2455,8 +2514,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 @@ -2716,7 +2775,7 @@ export const ObjectGrid: React.FC = ({ // `groupingNeedsHeaderQuery` (objectui#10881): it flips when the object's // types land and show the only grouping key masked, and the flat fetch it // withheld must then go out. - }, [objectName, schemaFields, schemaColumns, schemaFilter, schemaFilterRefusal, schemaDefaultFilters, schemaSort, headerSort, searchTerm, schemaPagination, schemaPageSize, serverPage, serverPageSize, dataSource, hasInlineData, dataConfig, refreshKey, perms.isLoaded, groupingProjectionKey, invalidationNonce, serverGroupedFetch, groupingNeedsHeaderQuery]); + }, [objectName, schemaFields, schemaColumns, schemaFilter, schemaFilterRefusal, schemaDefaultFilters, schemaSort, headerSort, searchTerm, schemaPagination, schemaPageSize, serverPage, fetchWindow, dataSource, hasInlineData, dataConfig, refreshKey, perms.isLoaded, groupingProjectionKey, invalidationNonce, serverGroupedFetch, groupingNeedsHeaderQuery]); // 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 @@ -5027,7 +5086,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 @@ -6157,7 +6216,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..0446feccea --- /dev/null +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.displayPageSizeDefault-9853.test.tsx @@ -0,0 +1,353 @@ +/** + * 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 — 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 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. Now: + * + * 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`). + * + * ## 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. + * + * 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'; + +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'; + +registerAllFields(); + +/** The protocol's answer, read the way the renderer reads it. */ +const SPEC_DISPLAY_DEFAULT: number = PaginationConfigSchema.parse({}).pageSize; + +/** + * 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; + +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 any number here, every row its own group. */ +const ROW_COUNT = 80; +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) ?? 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 })), + }; +}; + +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-"]')]; +/** 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(' ')); + }); +}); +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 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 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); + 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(); + + // 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 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('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. + 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('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 bucketed 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)); + await vi.waitFor(() => expect(groupRowTops(groupedDs.calls).length).toBeGreaterThan(0)); + expect(groupRows()).toHaveLength(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); + }); + + 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 e3d24b3b22..018849c8f0 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 = { @@ -150,8 +158,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, @@ -265,7 +273,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) => ({ + // + // 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', @@ -295,17 +306,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); + // 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); }); 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); }); diff --git a/packages/plugin-grid/src/__tests__/groupedPagination.test.tsx b/packages/plugin-grid/src/__tests__/groupedPagination.test.tsx index 5e289c8558..b5aca2ac47 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,33 +103,41 @@ 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 () => { 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: '20' } }); + fireEvent.change(select, { target: { value: String(resizeTo) } }); - // 25 / 20 = 2 pages, page reset to 1, first page shows 20 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(20); + expect(groupRows().length).toBe(resizeTo); }); 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)); + renderGroupedGrid({ pagination: { pageSize: GROUP_COUNT } }); + 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'); 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..5789e6b507 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,50 @@ 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. + * + * 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. + */ +export const DEFAULT_LIST_FETCH_BATCH_SIZE = 100; /** * What the contract admits as a page size. The spec's view pagination config @@ -1038,17 +1070,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 +1430,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 +1518,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.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'); 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 b8116895bb..cb016a8726 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -4362,14 +4362,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 091582208c..65539fa40b 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -2555,7 +2555,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 @@ -2587,7 +2587,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