Skip to content

Commit de5d400

Browse files
feat(plugin-grid): one display page size read from the spec, and a distinct fetch batch (objectui#9853) (#10278)
Fixes #9853 Clause-②: yes — the undeclared display page size changes on three surfaces (inline grid 10 → the spec default, grouped groups-per-page 10 → the spec default, paged list 100 → the spec default), as ruled. The PR waits as a draft for the director seat's contract review (`needs:contract-review` stays on both carriers). Rulings executed: `5749197629` (C-prime, items 1 and 2) as amended by `5824040487` (batch #223 item 4: the display default is **50**, declared once in the protocol; structure B). The grid, list and kanban halves all land in this PR. The first line is `Part of`, not `Fixes`, for one reason only: one pre-existing pin reads red, because structure B's paged/unpaged split re-issues the list's fetch on a view switch when nothing is declared. That needs a ruling, and it is described under **Open question** below. Once it is ruled, nothing else of the card stays open here, and the seat can switch the first line to the closing keyword. This head merges `origin/main` at `0ffc423b1` (a merge commit: no rebase, no force-push, no pushed commit rewritten). This session: `https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm`. ## What changed **Structure B: "page size" means one page, and only where there is a pager.** - **`@object-ui/plugin-grid`.** `ObjectGrid` falls back to `DEFAULT_DISPLAY_PAGE_SIZE` on every page it shows: the server-paged table, the table over inline `data`, the grouped view's page of groups, and a server-grouped group's own page of rows. `DEFAULT_DISPLAY_PAGE_SIZE` is `readSpecDisplayPageSize()`, which reads `PaginationConfigSchema.parse({}).pageSize` from `@objectstack/spec/ui`. There is no local number behind the read: if the spec stops declaring a positive default, the module throws at load. `DEFAULT_FETCH_BATCH_SIZE` (50, value kept) is the grid's fetch batch. It is read only when the grid buckets a fetched window in the browser (`bucketsFetchedWindow`), and never as a page size. - **`@object-ui/plugin-list`.** `DEFAULT_LIST_PAGE_SIZE = 100` splits in two. `DEFAULT_LIST_DISPLAY_PAGE_SIZE` is the same spec read, and applies when `paginate` is true (the flat grid view, which `ListView` pages on the server). `DEFAULT_LIST_FETCH_BATCH_SIZE = 100` (value unchanged) is the `$top` of the one unpaged fetch for every other view: kanban, calendar, gallery, timeline and the rest, plus a grouped grid's window. `effectivePageSize` now resolves after `paginate` and passes `paginate ? DEFAULT_LIST_DISPLAY_PAGE_SIZE : DEFAULT_LIST_FETCH_BATCH_SIZE` as the fallback. A declared `pagination.pageSize` still sizes every view, as before. `describeRefusedPageSize` now names the fallback the view actually used. - **`@object-ui/plugin-kanban`.** `DEFAULT_KANBAN_LIMIT` is renamed `DEFAULT_KANBAN_FETCH_BATCH_SIZE`, value 100 unchanged. The refused-`limit` diagnostic now reads "ObjectKanban fetch batch: … fell back to its default fetch batch (100)" instead of "row cap". - Docs: `packages/plugin-grid/README.md` and `content/docs/plugins/plugin-grid.mdx` (the undeclared page size, including a group's page and the bucketed window), `packages/plugin-list/README.md` (new section "Page size and fetch batch"), and `content/docs/plugins/plugin-kanban.mdx` (the section is retitled "Fetch batch (object-driven boards)" and says the default does not follow the display page size). No fenced code block was added or changed: the diff over `*.md` / `*.mdx` outside `.changeset/` has 0 changed fence lines and 0 changed import lines, and every insertion sits after an even number of fences. - Changeset `.changeset/9853-grid-display-page-size-from-spec.md` (this PR's own) is rewritten: `minor` for plugin-grid, plugin-list and plugin-kanban, with the per-surface table below, and hosts told to declare `pagination.pageSize` (or a board's `limit`) to keep an old count. ## Measured before and after (renderer, `@objectstack/spec` 17.5.0) The installed spec is 17.5.0, and `PaginationConfigSchema.parse({}).pageSize` answers 50. This table comes from a one-time reverse run. A probe (not committed) mounted the real `ObjectGrid`, `ListView` and `ObjectKanban` against a paging data source that also answers the group header query. It read rows off the screen and `$top` off the wire. It ran at head `a2c3e5ba7`, and again with `ObjectGrid.tsx`, `ListView.tsx` and `ObjectKanban.tsx` swapped to `main`'s blobs (blob hashes equal to `0ffc423b1`'s; on-disk markers `DEFAULT_SERVER_WINDOW_SIZE` 2, `DEFAULT_DISPLAY_PAGE_SIZE` 0, `DEFAULT_LIST_PAGE_SIZE` 3, `export const DEFAULT_KANBAN_LIMIT` 1). The restore was proven by hash equality with `HEAD` and an empty `git diff HEAD`. | surface, nothing declared | before (`main` `0ffc423b1`) | after | |:--|--:|--:| | `object-grid` fetching its own rows: rows per page / `$top` | 50 / 50 | 50 / 50 | | `object-grid` over inline `data`: rows per page | 10 | **50** | | grouped `object-grid`: groups per page | 10 | **50** | | server-grouped `object-grid`: `$top` of each group's row page | 50 | 50 | | `list-view`, grid view (paged): rows per page / `$top` | 100 / 100 | **50 / 50** | | `list-view` kanban, gallery, calendar, timeline, grouped grid: `$top` | 100 | 100 | | `object-kanban` with no `limit`: `$top` | 100 | 100 | Premises from the dispatch, re-measured: - The self-fetching grid is 50 → 50, the inline grid 10 → 50, and groups 10 → 50. **Holds.** - The spec default is read, not hardcoded. **Holds**: the stubbed pins below move with the spec, and ablation 2 reddens them. - "The grouped fetch batch `DEFAULT_FETCH_BATCH_SIZE` is 50 today" is **partly overtaken by `main`**. Since objectui#7189, a grouped grid that fetches its own rows groups on the server: `serverGroupedFetch` strips `$top`, and each group pages its rows by the display page size. Since objectui#10881, a data source with no `queryGroupHeaders` is refused, and no rows are fetched. The batch therefore has one reader left: a grid that buckets a window in the browser. The case that reaches it today is a grouping key the principal may not read, over a source that answers the header query. It is its own constant (50) and is not read from the spec. The pin "a window the grid groups in the browser FETCHES the batch" runs that exact path. - **The list half.** Only the flat grid view pages on the server: `paginate` is `currentView === 'grid' && !(groupingConfig?.fields?.length)`. Every other kind issues one unpaged `$top`. The previous executor's cost reproduces: with nothing declared, a paged-to-unpaged switch re-issues the fetch (`[50, 100]` on grid → kanban). See **Open question**. ## Conflict resolution (merge of `origin/main` `0ffc423b1`) `git merge-tree` reported content conflicts in the three files the dispatch named. Every hunk: 1. `ObjectGrid.tsx`, the loader effect's dependency list. Resolved as `main`'s list and comments (it adds `schemaDefaultFilters`, `invalidationNonce`, `serverGroupedFetch`, `groupingNeedsHeaderQuery`), with this branch's `fetchWindow` in place of `serverPageSize`, because the loader's `$top` / `$skip` read `fetchWindow`. 2. `ObjectGrid.tsx`, the "Is the grouping on screen page-scoped?" marker block. `main` retired it (objectui#10881). This branch had only renamed what it compared against (`groupingRowsLoaded >= fetchWindow`), so `main`'s deletion stands. Nothing else reads `groupingIsPartial` (grep: 0). 3. `packages/plugin-grid/README.md` and 4. `content/docs/plugins/plugin-grid.mdx`: this branch's undeclared-page-size paragraph, followed by `main`'s retitled "Grouping is server-side" heading. There was also one **semantic** conflict that git merged cleanly. This branch's `groupedFetch = usableGroupingFields(schema.grouping?.fields).length > 0` assumed that every grouped self-fetch buckets a window, and `main`'s server grouping made that false. Resolved in `3445f3213`: the predicate became `bucketsFetchedWindow`, which is `!serverGroupedFetch && !groupingNeedsHeaderQuery` plus a grouping entry left after known masked keys are refused. It is counted the way the two server predicates count, so the first fetch does not flip from page size to batch when the object's types land. The docs paragraph was rewritten to match. Carried on the same file: the server pagination comment's `framework #2212` is qualified as `objectstack-ai/objectstack#2212` (carrier note on this PR). ## Census of the three constants Instrument: `git grep -n` over the whole tree for each old and new name, re-run at head. - `DEFAULT_SERVER_WINDOW_SIZE`, `DEFAULT_FLAT_PAGE_SIZE`, `DEFAULT_GROUPS_PER_PAGE`, `DEFAULT_LIST_PAGE_SIZE`: 0 hits. - `DEFAULT_KANBAN_LIMIT`: every reader updated to `DEFAULT_KANBAN_FETCH_BATCH_SIZE`. That covers `ObjectKanban.tsx`, `plugin-kanban/src/index.tsx`, two plugin-kanban pins, the types docblock (`objectql.ts`) and the zod `limit` describe text (`objectql.zod.ts`), `app-shell` `block-config.ts` and three app-shell pins, and the types read-text pin `object-kanban-group-by-limit-7322.test.ts`. That pin reads `export const DEFAULT_KANBAN_FETCH_BATCH_SIZE = 100;` and `$top: resolveRowLimit(schema.limit, DEFAULT_KANBAN_FETCH_BATCH_SIZE)` off disk. The `block-config.test.ts` regex now reads the new name. - Left on purpose, all historical prose: - two docblock sentences that describe the old spelling as history (`ObjectKanban.tsx` "Before that ruling it was named…"; the objectui#9925 note); - one "re-measured then" sentence in `plugin-kanban/src/index.tsx`; - one in the 7322 types pin's header; - three pending changesets of other cards (7322, 7772, 8307), which describe the tree they were written about. ## Pins **One pin across every paged surface: `packages/app-shell/src/__tests__/displayPageSizeFromSpec-9853.test.tsx` (new).** It lives in app-shell because that package already depends on grid, list and board, so no new dependency edge is needed. It stands in a spec default the spec never shipped, and a probe records that the stand-in's factory ran and what the real default is. It then mounts the real components. - ONE assertion over grid self-fetch rows and `$top`, grid inline rows, grouped groups per page, group-row `$top`, paged list rows and `$top`. Every value equals the default read from the spec at test time. - A second row: the unpaged list view and the standalone board both send `$top` 100, and neither equals the page size. No literal 50 appears in any assertion. Per package: - `ObjectGrid.displayPageSizeDefault-9853.test.tsx` (reworked for the merged tree, stubbed spec default). Rows: stand-in live; every page the grid shows; the bucketed window fetches the batch; the selector shows the size in force (the stand-in is not a fixed step); the declared control on every surface including the bucketed fetch; the refused control. Grouped rows now use a data source that answers the group header query. - `ListView.pageSizeAndFetchBatch-9853.test.tsx` (new, real spec). Rows: paged grid `$top` and hand-off = the spec default; five unpaged views `$top` = 100; the declared control on a paged and an unpaged view; the switch cost `[default, 100]`; the refused value names the fallback number. - Kanban pins keep `$top` 100 under the new name. **Flipped, none deleted:** - `groupedPagination.test.tsx`: the fixture is two pages plus ten of the spec default. The resize step is derived (a fixed step that is not the default and splits the fixture in two), and the one-page control declares `pageSize` equal to the group count. - `ObjectGrid.pageSizeNonPositive-9853.test.tsx`: the inline fixture is the spec default plus ten rows, so `12.5` still discriminates at 50. These were the reds on head `454232890` (CI shards 4/8 and 5/8, and locally before rework: 9 failed / 17 passed across the three grid files). The grouped rows failed because `main`'s objectui#10881 refuses a grouped grid over a data source with no `queryGroupHeaders`. ## Ablations (predicted before running; each through `ablation-replace.mjs`, restore proven by blob == HEAD and an empty `git diff HEAD`) 1. **Tie the list's unpaged fallback to the page size.** Anchor `paginate ? DEFAULT_LIST_DISPLAY_PAGE_SIZE : DEFAULT_LIST_FETCH_BATCH_SIZE,` became `DEFAULT_LIST_DISPLAY_PAGE_SIZE,` (anchor 1 → 0, blob moved). - Predicted: list pin 7 red / 3 green, app-shell pin 1 red / 2 green. - Observed: **8 failed / 5 passed** = the five unpaged rows, the switch-cost row, the refused-name row, and the app-shell fetch-batch row. As predicted. 2. **Hardcode the display number in place of the spec read** in both `ObjectGrid.tsx` (`const DEFAULT_DISPLAY_PAGE_SIZE = 50;`) and `ListView.tsx`. Both anchors went 1 → 0. - Predicted: stubbed grid pin 4 red / 2 green, stubbed app-shell pin 1 red / 2 green, while the unstubbed list pin and `groupedPagination` stay green (they cannot tell a hardcoded 50 from the spec's 50). - Observed: **5 failed / 21 passed**: the grid rows every-page, bucketed, selector and refused, plus the app-shell every-paged-surface row. All unstubbed rows green. As predicted. This is why the cross-surface pin stands in the default. 3. **Root cause of the one remaining red** (below). The `10512` file at head: 1 failed / 5 passed. With the fallback collapsed back to one number for every kind (`DEFAULT_LIST_FETCH_BATCH_SIZE`): 6 passed. The extra query is exactly the kind-dependent window. ## Open question (blocks landing; this seat does not decide it) `ListView.datasetChartAppliedFilter-10512.test.tsx`, row "CONTROL (objectui#7394): with nothing held, the switch to the dataset chart re-issues no query", reads `expected 2 to be 1` at head (CI shard 3/8 on `a2c3e5ba7`, reproduced locally). - The cause, proven by ablation 3: with no declared `pageSize`, the grid view's window is the display default (50) and the chart's is the fetch batch (100). A switch therefore changes `$top`, and the list issues a real, different query. - It is not objectui#7394's defect, which was a byte-identical duplicate request. - It is inherent to structure B: the two views need different windows. The pin is untouched. The options, with their costs, are in the `os-dev-report` on the card. ## Gates (local, head `a2c3e5ba7`; exit codes captured before any pipe) - The three package suites, `pnpm exec vitest run --maxWorkers=2 packages/plugin-grid/ packages/plugin-list/ packages/plugin-kanban/`: exit 1. `Test Files 1 failed | 343 passed (344)`, `Tests 1 failed | 3202 passed (3203)`. The one red is the open question's row in `ListView.datasetChartAppliedFilter-10512.test.tsx`. - Every test outside those packages that names `ObjectGrid`, `ListView` or `ObjectKanban`, their registry types, or their source paths. The census is `git grep -lE` over test files, plus the touched app-shell and types pins: 320 paths, 318 of them test files (the other two are a `__mocks__` module and a fixture). Result: exit 0. `Test Files 318 passed (318)`, `Tests 6339 passed | 9 skipped (6348)`. - type-check: exit 0 for `@object-ui/plugin-grid`, `@object-ui/plugin-list`, `@object-ui/plugin-kanban` and `@object-ui/types`, after building the grid/list/kanban dependency closure (build exit 0). `tsconfig.test.json --listFiles` includes every touched test file (1 each) and the `ListView.tsx` control. - lint: `eslint .` exit 0 for plugin-grid, plugin-list and plugin-kanban (0 errors). The touched app-shell and types files: 0 errors. Findings on the three touched sources, `main` versus head, read through `--stdin-filename`: `ObjectGrid.tsx` 0e/207w vs 0e/207w; `ListView.tsx` 0e/188w vs 0e/188w; `ObjectKanban.tsx` 0e/30w vs 0e/30w. - exit 0: - changeset gates: `check-changeset-presence`, `check-changeset-no-major`, `check-changeset-fixed`, `check:changeset-claims` (report-only; pending bodies read, none of the touched ones is falsified by this change), `check:pending-changeset-literals`; - hygiene gates: `check:control-bytes`, `check:new-line-citations` (0 new), `check:spec-symbols`, `check:test-path-roots`, `check:phantom-deps`, `check:unreferenced-sources`, `check:element-data-source-declaration`; - mock gates: `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`; - doc gates: `check:doc-fences`, `check:doc-types`, `check:doc-example-ids`; - `check-governed-queue-guard --test` on the diff paths: NOT GOVERNED. - `check:spec-floors`, after building plugin-grid, plugin-list and plugin-kanban (build exit 0): exit 1. The 11 findings are all `[no-artifact]` for packages this PR neither touches nor built, and plugin-grid, plugin-list and plugin-kanban draw none. The gate judges declaration files, where the new runtime import does not appear, so the floor question was also read directly: `@objectstack/spec` 17.0.0 and 17.4.0, fetched from the registry, both export `PaginationConfigSchema` from `./ui`, in `index.d.mts` and in `index.mjs`. - NOT MEASURED locally, left to CI: - `check:doc-snippets`, `check:doc-examples`, `check:readme-exports`. They need the near-whole-workspace build their `--build-filter` names. The docs diff changes no fenced block and no import line, so their verdict cannot move. - The app-shell `type-check`, which needs app-shell's whole dependency closure built. - `check:eager-closure`, which needs a console build. - The full lint and test farm. ## Acceptance notes (not carded) - The display default follows the installed spec. `@object-ui/plugin-grid` declares `@objectstack/spec` `^17.4.0` and `@object-ui/plugin-list` `^17.0.0`. A host whose lockfile resolves an older spec gets that spec's default, on every surface alike. The floors are not raised here. - The earlier pending changeset of this card, `.changeset/9853-grid-non-positive-page-size.md`, ends by saying the default question "is handed back on the issue". That was true of its PR, and it publishes in the same release as this one, which answers it. Left as history. - A declared `pagination.pageSize` still caps unpaged list views and a bound board, as before. Ruling `5824040487` splits only the undeclared fallback, and this PR reads it that way. - Carried from the previous body, still true: the page-size `useState` seeds are read only at mount. The grouped view's `$skip` is `(serverPage - 1) * fetchWindow`, which now matters only on the bucketed-window path. - Commit `43d21e6b1` (a pushed commit of the previous executor) carries a model-named co-author trailer. It is left as is: pushed history is not rewritten. Every commit of this round carries the model-free pair. ## Round 2: ruling record `5909000462` (head `5a2d1e66a`) The maintainer answered the **Open question** above with A: accept the one extra fetch. Ruling record `5909000462` on the card records the ruling verbatim. The first line of this body is now the closing keyword. The paragraph under it that explains `Part of`, and the **Open question** and **Gates** sections above, describe head `a2c3e5ba7` and are superseded by this section. They are left as written. - **Merge.** `origin/main` at `574d82fdf`, merged as commit `118cdbe5a` (no rebase, no force-push, no pushed commit rewritten). Git reported **no conflict hunk**. Both sides touched five files (`ObjectGrid.tsx`, the plugin-grid `README.md` and `plugin-grid.mdx`, and types `objectql.ts` and `objectql.zod.ts`), and all five auto-merged. The one `ObjectGrid.tsx` change from `main` (objectui#11021: the search term on the group header query) reads neither the page size nor the fetch batch. The old-name census, re-run on the merged tree, is unchanged from round 1. - **Flipped row** (commit `39994581a`), in `ListView.datasetChartAppliedFilter-10512.test.tsx`. It was "CONTROL (objectui#7394): with nothing held, the switch to the dataset chart re-issues no query". It is now "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". - The first query's `$top` is read against `PaginationConfigSchema.parse({}).pageSize`. - The one further query's `$top` is read against `DEFAULT_LIST_FETCH_BATCH_SIZE`. `ListView.tsx` now exports that constant as a module export; the package index does not re-export it. No literal 100 appears in the row. - The further query equals the one on screen with only `$top` changed, so nothing the chart withholds reached it. - A comment cites ruling record `5909000462` by id. - **New control, same file:** "CONTROL (objectui#7394 §1): with nothing held and a pageSize declared, the switch to the dataset chart re-issues no query". With a declared 7, the first `$top` is 7 and no further query follows. objectui#7394's own §1 (`ListView.viewSwitchRefetch-7394.test.tsx`) pins the same for grid to kanban. - **No other assertion in the file changed.** The diff removes three lines: the old row's title, one header bullet and one import line. - **Changeset** (commit `5a2d1e66a`). The plugin-list bullet now says the switch issues one request, whose `$top` is the fetch batch. It also says that this qualifies the pending objectui#10512 note, which publishes in the same release and reads "a switch into a dataset chart with no user filter set issues no new request": that sentence now holds only when a page size is declared. The objectui#10512 changeset itself is not edited (`check-changeset-overwrite` green). `check:changeset-claims` cannot flag that note, because it names no file path. - **Ablation.** Predicted before running: the flipped row goes red on its `$top` assertion, and the other six rows stay green. - Mutation: `ablation-replace.mjs` replaced `paginate ? DEFAULT_LIST_DISPLAY_PAGE_SIZE : DEFAULT_LIST_FETCH_BATCH_SIZE,` with `DEFAULT_LIST_DISPLAY_PAGE_SIZE,`. The anchor count went 1 → 0 and the blob moved `5789e6b50742` → `85a9025b2d66`. - Observed: `Tests 1 failed | 6 passed (7)`, with `AssertionError: expected [] to deeply equal [ 100 ]` at the `$top` wait. As predicted. - Restore: the blob equals `HEAD`'s and `git diff HEAD` is empty. - No build was involved: the test imports `../ListView` from source. Gates at head `5a2d1e66a`, run after the last commit. Exit codes were captured before any pipe. | gate | exit | reading | |:--|--:|:--| | vitest `packages/plugin-grid/ packages/plugin-list/ packages/plugin-kanban/`, 4 shards (`vitest list` counts the same 345 files) | 0 ×4 | 345 files, 3207 tests passed | | cross-package union: round 1's 320 paths, plus the three `packages/types` pins `main` added that name these views | 0 ×5 parts | 321 test files, 6638 passed, 9 skipped | | `type-check` plugin-grid / plugin-list / plugin-kanban, after the dependency-closure build (exit 0) | 0 / 0 / 0 | `tsconfig.test.json --listFiles` includes the 10512 file | | `lint` (`eslint .`) plugin-grid / plugin-list / plugin-kanban | 0 / 0 / 0 | 0 errors each; `ListView.tsx` 0e/188w (as in round 1), the 10512 file 0e/0w | | `check-changeset-presence`, `-no-major`, `-fixed`, `-overwrite` | 0 | | | `check:changeset-claims` | 0 | report-only | | `check:pending-changeset-literals` | 0 | | | `check:control-bytes` | 0 | | | `check:new-line-citations` | 0 | 0 new | | `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:test-path-roots`, `check:phantom-deps`, `check:spec-symbols`, `check:unreferenced-sources`, `check:doc-fences`, `check:doc-types`, `check:doc-example-ids` | 0 | | NOT MEASURED this round: `check:spec-floors`, `check:doc-snippets`, `check:doc-examples` and `check:readme-exports`. Reason: they need the near-whole-workspace build, and this round's diff adds no import to published source (the only new `@objectstack/spec` import is in a test) and touches no doc outside `.changeset/`. --- _Generated by [Claude Code](https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3cc4fe5 commit de5d400

24 files changed

Lines changed: 1282 additions & 130 deletions
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
'@object-ui/plugin-grid': minor
3+
'@object-ui/plugin-list': minor
4+
'@object-ui/plugin-kanban': minor
5+
---
6+
7+
A page size with nothing declared is now the one `@objectstack/spec` declares for
8+
`pagination.pageSize`, on every surface that has a pager; a fetch that has no
9+
pager keeps its own fetch batch, which does not follow it (objectui#9853).
10+
11+
"Page size" now means one page, and only where there is a pager. The grid,
12+
the list and the board read the display default from the spec (`PaginationConfigSchema`)
13+
instead of keeping numbers of their own, so it is declared once, in the protocol.
14+
`@objectstack/spec` 17.5.0 declares **50**. The views that fetch one window and
15+
page nothing keep a fetch batch of their own, with the value they had, so no
16+
view loses records it could reach before.
17+
18+
⚠️ **Visible change where no page size is declared.** Measured on the renderer
19+
with `@objectstack/spec` 17.5.0. Declare `pagination: { pageSize: N }` (or, on a
20+
board, `limit`) to keep an old count:
21+
22+
| surface (nothing declared) | before | after |
23+
|:--|--:|--:|
24+
| `object-grid` that fetches its own rows: rows per page, and the `$top` of each page fetch | 50 | 50 (unchanged) |
25+
| `object-grid` over inline `data`: rows per page | 10 | **50** |
26+
| grouped `object-grid`: groups per page | 10 | **50** |
27+
| grouped `object-grid` over a data source that groups on the server: rows per group page (`$top`) | 50 | 50 (unchanged) |
28+
| `list-view`, grid view (paged on the server): rows per page, and `$top` | 100 | **50** |
29+
| `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) |
30+
| `object-kanban` with no `limit`: the board's `$top` | 100 | 100 (unchanged) |
31+
32+
What changed underneath:
33+
34+
- **`@object-ui/plugin-grid`.** `ObjectGrid` falls back to the spec's display
35+
default on every page it shows, with no local number behind it: if a future
36+
spec stops declaring the default, the module throws at load instead of
37+
inventing one. The old "server window" constant is renamed
38+
`DEFAULT_FETCH_BATCH_SIZE` and keeps its value. Since grouping moved to the
39+
server, it is read only when the grid groups a window of rows in the browser
40+
because the server does not group it (a grouping key the principal may not
41+
read), and never as a page size. The grouped view's rows-per-page selector
42+
also lists the size in force when it is not one of its fixed steps.
43+
- **`@object-ui/plugin-list`.** `ListView` splits its undeclared fallback by
44+
whether the view pages. The grid view, which it pages on the server, falls
45+
back to the spec's display default. Every other view falls back to a fetch
46+
batch of 100, the value it had. A declared `pagination.pageSize` sizes the
47+
window on every view, as before. With no declared size, switching between the
48+
grid view and an unpaged view now changes the window, so the list fetches
49+
again: one request, whose `$top` is the fetch batch. That qualifies the
50+
objectui#10512 note in this same release, which says a switch into a dataset
51+
chart with no user filter set issues no new request: it still holds when a
52+
page size is declared. A refused page size still warns, and the warning names
53+
the fallback that view actually used.
54+
- **`@object-ui/plugin-kanban`.** The board's default `limit` is renamed from
55+
`DEFAULT_KANBAN_LIMIT` to `DEFAULT_KANBAN_FETCH_BATCH_SIZE` and stays 100. Its
56+
diagnostic for a refused `limit` now calls it the board's fetch batch.
57+
58+
The display default follows the `@objectstack/spec` a host installs. A host
59+
that resolves an older spec gets that spec's default, on every surface alike.
60+
61+
Released as `minor`, not `major`: this repository keeps its major aligned with
62+
`@objectstack/spec`, so the behaviour change is spelled out here instead.

‎content/docs/plugins/plugin-grid.mdx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,15 @@ see [Row callbacks are component props](#row-callbacks-are-component-props).
367367
always carries a rows-per-page picker, and `pageSizeOptions` only replaces the
368368
choices it offers with your own.
369369

370+
With no `pageSize` declared, every page the grid shows — the server-paged table,
371+
the table over inline `data`, the grouped view's page of groups, and a group's
372+
own page of rows — uses the default `@objectstack/spec` declares for
373+
`pagination.pageSize`; the grid reads it from the spec rather than keeping a
374+
number of its own. Declare `pagination.pageSize` to choose the count yourself.
375+
A window of rows the grid groups in the browser, because the server does not
376+
group it, is not a page: undeclared, it is a fixed fetch batch of the grid's
377+
own, and it does not follow the display default.
378+
370379
### Grouping is server-side
371380

372381
`grouping` is answered by the **server**, not by bucketing the records the

‎content/docs/plugins/plugin-kanban.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,11 +208,13 @@ rendered on each card resolve in priority order:
208208
Defaulting to `highlightFields` keeps a card's contents consistent with the
209209
object's other views without every kanban view having to re-declare its fields.
210210

211-
### Row cap (object-driven boards)
211+
### Fetch batch (object-driven boards)
212212

213213
An object-driven board fetches at most `limit` records, defaulting to **100**. A
214214
board renders every fetched record into a lane and offers no pagination control,
215-
so this is the author's window on the object rather than a page size:
215+
so this is the author's window on the object — one fetch batch — rather than a
216+
page size. The default does not follow the display page size `@objectstack/spec`
217+
declares for `pagination.pageSize`:
216218

217219
```tsx
218220
import type { ObjectKanbanSchema } from '@object-ui/types'
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#9853, ruling 5824040487 — the display default is declared ONCE, in
11+
* the protocol, and every paged surface reads it; the fetch batches of the
12+
* unpaged surfaces are a different quantity and do not follow it (structure B:
13+
* "page size" means one page only where there is a pager).
14+
*
15+
* ## Why this file lives in `app-shell`
16+
*
17+
* The surfaces sit in three packages — `ObjectGrid` in `plugin-grid`,
18+
* `ListView` in `plugin-list`, `ObjectKanban` in `plugin-kanban` — and the
19+
* point of the pin is ONE assertion across all of them, so that a surface
20+
* keeping a default of its own shows up as the odd one out. `app-shell` is the
21+
* package that already depends on all three, so the real components are
22+
* mounted here with no new dependency edge anywhere (the same reason
23+
* `rowClickModifierPayloadForward-9462.test.tsx` lives here).
24+
*
25+
* ## Why the spec's default is STOOD IN FOR
26+
*
27+
* Reading the real default cannot tell "reads the spec" from "hardcodes the
28+
* number the spec says today". So the default is replaced with a number the
29+
* spec has never shipped, and every expectation reads it back from the spec
30+
* module the renderers import. The stand-in records that its factory ran and
31+
* what the REAL default is, so the first row proves the substitution is live.
32+
* With the stand-in in force the paged surfaces must move with it and the
33+
* fetch batches must not — which is exactly the split the ruling makes.
34+
*/
35+
import React from 'react';
36+
import { describe, it, expect, vi, afterEach } from 'vitest';
37+
import { render, cleanup, waitFor } from '@testing-library/react';
38+
import '@testing-library/jest-dom';
39+
40+
const { STUB_DISPLAY_DEFAULT, probe } = vi.hoisted(() => ({
41+
/** Deliberately nothing the spec has shipped, and neither fetch batch. */
42+
STUB_DISPLAY_DEFAULT: 13,
43+
probe: { factoryRan: false, realDefault: undefined as unknown },
44+
}));
45+
46+
vi.mock('@objectstack/spec/ui', async (importOriginal) => {
47+
const actual = await importOriginal<typeof import('@objectstack/spec/ui')>();
48+
probe.factoryRan = true;
49+
probe.realDefault = actual.PaginationConfigSchema.parse({}).pageSize;
50+
return {
51+
...actual,
52+
PaginationConfigSchema: actual.PaginationConfigSchema.extend({
53+
pageSize: actual.PaginationConfigSchema.shape.pageSize.unwrap().default(STUB_DISPLAY_DEFAULT),
54+
}),
55+
};
56+
});
57+
58+
import { PaginationConfigSchema } from '@objectstack/spec/ui';
59+
import { ObjectGrid } from '@object-ui/plugin-grid';
60+
import { ListView } from '@object-ui/plugin-list';
61+
import { ObjectKanban } from '@object-ui/plugin-kanban';
62+
import { ActionProvider, SchemaRendererProvider } from '@object-ui/react';
63+
64+
/** The protocol's answer, read the way the renderers read it. */
65+
const SPEC_DISPLAY_DEFAULT: number = PaginationConfigSchema.parse({}).pageSize;
66+
67+
/**
68+
* The fetch batch of every unpaged list view and of the board. Spelled by
69+
* value: the ruling keeps the value, and what it asks is that neither moves
70+
* with the display default.
71+
*/
72+
const FETCH_BATCH = 100;
73+
74+
const OBJECT = 'duly_task';
75+
const OBJECT_FIELDS = {
76+
id: { type: 'text', label: 'Id' },
77+
subject: { type: 'text', label: 'Subject' },
78+
unit: { type: 'text', label: 'Unit' },
79+
status: { type: 'select', label: 'Status', options: [{ label: 'Todo', value: 'todo' }] },
80+
};
81+
82+
/** More rows than any number here, every row its own group. */
83+
const ROW_COUNT = 240;
84+
interface Row { id: string; subject: string; unit: string; status: string }
85+
const ROWS: Row[] = Array.from({ length: ROW_COUNT }, (_, i) => ({
86+
id: `r-${i}`,
87+
subject: `Task ${String(i).padStart(3, '0')}`,
88+
unit: `Unit ${String(i).padStart(3, '0')}`,
89+
status: 'todo',
90+
}));
91+
92+
/** The `FilterCondition` subset the compiled group queries use. */
93+
function matches(row: Record<string, unknown>, cond: unknown): boolean {
94+
if (cond === undefined || cond === null) return true;
95+
return Object.entries(cond as Record<string, unknown>).every(([key, value]) => {
96+
if (key === '$and') return (value as unknown[]).every((c) => matches(row, c));
97+
if (value && typeof value === 'object' && !Array.isArray(value)) {
98+
const op = value as Record<string, unknown>;
99+
if ('$eq' in op) return row[key] === op.$eq;
100+
if ('$null' in op) return op.$null ? row[key] == null : row[key] != null;
101+
throw new Error(`double: unsupported operator ${JSON.stringify(op)}`);
102+
}
103+
return row[key] === value;
104+
});
105+
}
106+
107+
/** Pages `find` by `$top` / `$skip` and answers the group header query. */
108+
const makeDataSource = () => {
109+
const calls: Array<Record<string, unknown>> = [];
110+
return {
111+
calls,
112+
queryGroupHeaders: vi.fn(async (_object: string, query: { where?: unknown; groupBy?: string[]; aggregations?: Array<{ function: string; alias: string }> }) => {
113+
const groupBy = query.groupBy ?? [];
114+
const buckets = new Map<string, Row[]>();
115+
for (const row of ROWS.filter((r) => matches(r as any, query.where))) {
116+
const key = JSON.stringify(groupBy.map((f) => (row as any)[f] ?? null));
117+
if (!buckets.has(key)) buckets.set(key, []);
118+
buckets.get(key)!.push(row);
119+
}
120+
return [...buckets.entries()].map(([key, members]) => {
121+
const values = JSON.parse(key) as unknown[];
122+
const out: Record<string, unknown> = Object.fromEntries(groupBy.map((f, i) => [f, values[i]]));
123+
for (const agg of query.aggregations ?? []) {
124+
out[agg.alias] = agg.function === 'count' ? members.length : null;
125+
}
126+
return out as { count: number };
127+
});
128+
}),
129+
find: vi.fn(async (_object: string, params: Record<string, unknown>) => {
130+
calls.push(params);
131+
const where = Array.isArray(params.$filter) ? undefined : params.$filter;
132+
const matching = ROWS.filter((r) => matches(r as any, where));
133+
const skip = (params.$skip as number | undefined) ?? 0;
134+
const top = (params.$top as number | undefined) ?? matching.length;
135+
return { data: matching.slice(skip, skip + top), total: matching.length };
136+
}),
137+
findOne: vi.fn(),
138+
create: vi.fn(),
139+
update: vi.fn(),
140+
delete: vi.fn(),
141+
getObjectSchema: vi.fn(async () => ({ name: OBJECT, fields: OBJECT_FIELDS })),
142+
};
143+
};
144+
type Ds = ReturnType<typeof makeDataSource>;
145+
146+
const mount = (node: React.ReactElement, ds?: Ds) =>
147+
render(
148+
<SchemaRendererProvider dataSource={ds as any}>
149+
<ActionProvider>{node}</ActionProvider>
150+
</SchemaRendererProvider>,
151+
);
152+
153+
const bodyRows = (container: HTMLElement) => Array.from(container.querySelectorAll('tbody tr'));
154+
const groupRows = () => [...document.querySelectorAll('[data-testid^="group-row-"]')];
155+
const tops = (ds: Ds) => ds.calls.map((p) => p.$top);
156+
/** The `$top` of every per-group row query (a row query carries a group `$filter`). */
157+
const groupRowTops = (ds: Ds) =>
158+
[...new Set(ds.calls.filter((p) => p.$filter && !Array.isArray(p.$filter)).map((p) => p.$top))];
159+
160+
afterEach(() => cleanup());
161+
162+
describe('one display default from the spec across grid, list and board; fetch batches apart (objectui#9853)', () => {
163+
it('the spec stand-in is live: the renderers\' spec module answers it, and it is not the real default', () => {
164+
expect(probe.factoryRan).toBe(true);
165+
expect(SPEC_DISPLAY_DEFAULT).toBe(STUB_DISPLAY_DEFAULT);
166+
expect(probe.realDefault).not.toBe(STUB_DISPLAY_DEFAULT);
167+
expect(FETCH_BATCH).not.toBe(SPEC_DISPLAY_DEFAULT);
168+
});
169+
170+
it('every paged surface shows the spec default when nothing is declared', async () => {
171+
const gridSchema = { type: 'object-grid', objectName: OBJECT, columns: ['subject'] };
172+
173+
// The grid that fetches its own rows: the page on screen and its `$top`.
174+
const flatDs = makeDataSource();
175+
const flat = mount(<ObjectGrid schema={gridSchema as any} dataSource={flatDs as any} />, flatDs);
176+
await waitFor(() => expect(bodyRows(flat.container).length).toBeGreaterThan(0));
177+
const gridServerPaged = bodyRows(flat.container).length;
178+
const gridServerTop = tops(flatDs).at(-1);
179+
cleanup();
180+
181+
// The grid over inline rows.
182+
const inline = mount(
183+
<ObjectGrid schema={{ ...gridSchema, data: { provider: 'value', items: ROWS } } as any} />,
184+
);
185+
await waitFor(() => expect(bodyRows(inline.container).length).toBeGreaterThan(0));
186+
const gridInline = bodyRows(inline.container).length;
187+
cleanup();
188+
189+
// The server-grouped grid: a page of groups, and each open group's page.
190+
const groupedDs = makeDataSource();
191+
mount(
192+
<ObjectGrid
193+
schema={{ ...gridSchema, grouping: { fields: [{ field: 'unit' }] } } as any}
194+
dataSource={groupedDs as any}
195+
/>,
196+
groupedDs,
197+
);
198+
await waitFor(() => expect(groupRows().length).toBeGreaterThan(0));
199+
await waitFor(() => expect(groupRowTops(groupedDs).length).toBeGreaterThan(0));
200+
const groupsShown = groupRows().length;
201+
const groupRowTop = groupRowTops(groupedDs);
202+
cleanup();
203+
204+
// The paged list: `ListView` pages its grid view on the server and hands
205+
// the real grid the window and the pager.
206+
const listDs = makeDataSource();
207+
const list = mount(
208+
<ListView
209+
schema={{ type: 'list-view', objectName: OBJECT, viewType: 'grid', columns: ['subject'] } as any}
210+
dataSource={listDs as any}
211+
/>,
212+
listDs,
213+
);
214+
await waitFor(() => expect(bodyRows(list.container).length).toBeGreaterThan(0));
215+
const listPaged = bodyRows(list.container).length;
216+
const listTop = tops(listDs);
217+
218+
expect({ gridServerPaged, gridServerTop, gridInline, groupsShown, groupRowTop, listPaged, listTop }).toEqual({
219+
gridServerPaged: SPEC_DISPLAY_DEFAULT,
220+
gridServerTop: SPEC_DISPLAY_DEFAULT,
221+
gridInline: SPEC_DISPLAY_DEFAULT,
222+
groupsShown: SPEC_DISPLAY_DEFAULT,
223+
groupRowTop: [SPEC_DISPLAY_DEFAULT],
224+
listPaged: SPEC_DISPLAY_DEFAULT,
225+
listTop: [SPEC_DISPLAY_DEFAULT],
226+
});
227+
});
228+
229+
it('the unpaged list and the board keep their fetch batch, which does not move with the page size', async () => {
230+
// An unpaged list view: no pager, so its one window is the batch.
231+
const listDs = makeDataSource();
232+
mount(
233+
<ListView
234+
schema={{ type: 'list-view', objectName: OBJECT, viewType: 'kanban', columns: ['subject'], kanban: { groupByField: 'status' } } as any}
235+
dataSource={listDs as any}
236+
/>,
237+
listDs,
238+
);
239+
await waitFor(() => expect(listDs.find).toHaveBeenCalled());
240+
const listUnpagedTop = tops(listDs);
241+
cleanup();
242+
243+
// The board on its own: `limit` undeclared, its default fetch batch.
244+
const boardDs = makeDataSource();
245+
mount(
246+
<ObjectKanban
247+
schema={{ type: 'object-kanban', objectName: OBJECT, groupBy: 'status' } as any}
248+
dataSource={boardDs as any}
249+
/>,
250+
boardDs,
251+
);
252+
await waitFor(() => expect(boardDs.find).toHaveBeenCalled());
253+
const boardTop = tops(boardDs);
254+
255+
expect({ listUnpagedTop, boardTop }).toEqual({ listUnpagedTop: [FETCH_BATCH], boardTop: [FETCH_BATCH] });
256+
expect([...listUnpagedTop, ...boardTop]).not.toContain(SPEC_DISPLAY_DEFAULT);
257+
});
258+
});

‎packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.retiredBlockProps.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ describe('object-kanban inspector · the declared controls are on screen (object
104104
expect(screen.getByLabelText('Limit')).toBeTruthy();
105105
});
106106

107-
it('the row cap states DEFAULT_KANBAN_LIMIT while it is empty, and commits a number', () => {
107+
it('the fetch batch box states DEFAULT_KANBAN_FETCH_BATCH_SIZE while it is empty, and commits a number', () => {
108108
const onPatch = renderInspector(pageDraft({ objectName: 'opportunity' }));
109109
const box = screen.getByLabelText('Limit') as HTMLInputElement;
110110
// Empty box, and the placeholder says what applies anyway.

‎packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-i18n.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ describe('placeholders that must NOT be translated (#3979)', () => {
389389
'record:related_list.limit': { literal: '10', because: 'a row limit' },
390390
'object-kanban.limit': {
391391
literal: '100',
392-
because: "a row limit — DEFAULT_KANBAN_LIMIT, the board's fetch cap when the box is empty",
392+
because: "a row limit — DEFAULT_KANBAN_FETCH_BATCH_SIZE, the board's fetch batch when the box is empty",
393393
},
394394
'record:details.sections.columns': { literal: '2', because: 'a column count' },
395395
};
@@ -403,7 +403,7 @@ describe('placeholders that must NOT be translated (#3979)', () => {
403403
// PR objectstack#7115 retired `PageHeaderProps.icon`. Then 17 / 7 / 10
404404
// until objectui#7772 gave `object-kanban` the `limit` control its schema
405405
// has declared since objectui#7322 — a VALUE placeholder (`100`,
406-
// `DEFAULT_KANBAN_LIMIT`), so it lands on the literal side and the keyed
406+
// `DEFAULT_KANBAN_FETCH_BATCH_SIZE`), so it lands on the literal side and the keyed
407407
// half is what stays untouched this time. Then 18 / 7 / 11 until
408408
// objectui#8280 removed the `ai:input` panel, whose `agentName` box was a
409409
// KEYED placeholder — the palette never offered that block.

0 commit comments

Comments
 (0)