Skip to content

Commit 2548ba5

Browse files
fix(spec): offer the view form's pagination to every view type (#19841)
Fixes #19814 Clause-②: no ## What changes `pagination.pageSize` is the row bound every view type carries, and maintainer ruling D on #19228 makes it a view's one row bound. The per-kind `kanban.limit`, `gallery.limit` and `timeline.limit` that the ruling retires are still declared on `main` until PR #19809 lands. The Studio view form (`packages/spec/src/ui/view.form.ts`) offered `pagination` only inside `table_options`, whose condition is `data.type == 'grid' || data.type == null` and whose description is `Grid-only display options.`. An author editing any other view type could not see or set that bound in the form. - `pagination` moves out of `table_options` into its own section, `pagination` (label `Pagination`, collapsible, collapsed), placed right after `table_options`. The new section has **no** `visibleWhen`. - `table_options` keeps `resizable`, `compactToolbar`, `rowHeight` and `selection` under its unchanged grid condition. - **Why a new section and not one of the other two options.** Widening `table_options`' condition would show the four grid-only fields to every view type. Moving `pagination` into `columns_filters` would put a field that is neither a column nor a filter under the label `Columns & filters`. The file already gives each concern its own section. The new description claims only what the schema guarantees: `Page size and page-size options — every view type accepts them, not only grids.` - No schema change. `view.zod.ts`, `component.zod.ts` and every per-kind `limit` are untouched; they belong to PR #19809. ## Premise, measured on `origin/main` `c1dfa5241b` - The view `type` enum is on the list-view shape (`ListViewSchema.shape.type`). The container `ViewSchema` has no `type` key, and its `list` member resolves to the same nine values: grid, kanban, gallery, calendar, timeline, gantt, map, chart, tree. - `pagination` is a member of the single list-view shape, so every kind has it. Per kind, `ListViewSchema.safeParse({ type, columns: ['name'], pagination: { pageSize: 50 } })` parsed 9/9 and kept `{ pageSize: 50 }`. As a control, `pagination: { pageSize: 50, zzBogus: 1 }` was refused 9/9 with `unrecognized_keys` at `['pagination']`, which shows the block is validated and not stripped. - At base, `view.form.ts` has no per-kind `limit` entry. Nothing PR #19809 retires is in the form, so this change does not depend on a shape that PR has not landed. ## Tests `packages/spec/src/ui/view-form-pagination.test.ts`, 58 cases: - The kind list is read at runtime from `ListViewSchema`'s `type` enum. A floor names the four types ruling D covers, so an empty derivation cannot pass. - For every kind, and for a view with no `type` yet, `pagination` is offered in exactly one visible section. The form offers it exactly once. - For every kind, the schema accepts a `pagination` block and keeps it. - The four grid-only fields are visible for `grid` and for an unset type, and hidden from each of the eight non-grid kinds. - Section predicates are read by a small reader limited to the one grammar this form uses: disjunctions of `data.type == '...'` and `data.type == null`. Any other term throws and names the predicate, so the pin fails loudly on a predicate it cannot read. **Firing control** (a one-time run, not a kept test). With the fix committed, `packages/spec/src/ui/view.form.ts` was restored to its base blob `bea0c5ab92` with `git restore --source` set to the base commit, and the pin was run. Result: `Tests 8 failed | 50 passed (58)`. The eight failures are exactly the "is visible for type" cases for kanban, gallery, calendar, timeline, gantt, map, chart and tree. The file was then restored from `HEAD` under a trap; its blob was re-checked equal to HEAD's (`bf6c3a7f93`) and `git status` was clean. ## Generated artifacts One repo generator reads `view.form.ts`: `pnpm i18n:extract`. It reaches the form through `METADATA_FORM_REGISTRY` and writes `packages/platform-objects/src/apps/translations/*.metadata-forms.generated.ts`. A search of the tree for the form's section text finds it only in the source and in `en.metadata-forms.generated.ts`. The `viewForm` export keeps its name, and `check:api-surface` reports the surface unchanged. - `17bb7c1603` is the generator output, unedited. It adds the new section's label and description to all four bundles, fills the three translated locales from the source, and adds two `metadataForms.view.sections.pagination.*` rows per locale to the source-hash tables. - `72185706b2` writes the zh-CN, ja-JP and es-ES translations. These leaf values are the only edits these files allow. Each label reuses the locale's existing label for the `pagination` field. A re-run of the extractor then dropped the source-hash rows by itself, and `pnpm check:i18n` reports the bundles in sync at head. - `2bfeee4a23`: `object-lifecycle-panel-echo-decisions.test.ts` pins the per-locale count of translated `.label` leaves across the metadata-form catalog. The count moves from 583 to 584, which is the one section label this PR translates in each locale. ## Changeset - `@objectstack/spec` ships `dist` in its `files[]`. `viewForm` is exported from `./ui` and reaches `./system` through `METADATA_FORM_REGISTRY`. After a build, the new description string is in 6 dist files (`ui/index.{js,mjs}`, `system/index.{js,mjs}`, `browser/system/index.{js,mjs}`). The positive control `Grid-only display options` is in the same 6 files. - `@objectstack/platform-objects` also ships `dist` in its `files[]`. After its rebuild the new string is in 6 dist files (`index`, `plugin` and `metadata-translations/index`, each `.js` and `.mjs`); the control is in the same 6. - Both packages ship the change, so `.changeset/19814-view-form-pagination-all-kinds.md` bumps both as `patch`. AGENTS.md Post-Task step 3: a fix in a released package takes a patch changeset, and `skip-changeset` is only for a diff that publishes nothing. `Clause-②: no`, because no accept set moves. ## Local verification - **Gate families** at head `0395fd696d` (re-run after round 2; first measured at `2bfeee4a23`, same result): `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 84 commands. All 84 were run on `2bfeee4a23` and all exited 0. `--ran`, fed the recorded exit codes, reports: "84 derived famil(ies) accounted for — 84 run, 0 NOT-MEASURED (a DERIVED zero — all 84 recorded an exit code and none of them is 3)". On the first pass, `check:i18n`, `check:type-check-debt` and `check:dual-build-cjs-loads` exited 3 (build prerequisite not met). They were measured after `turbo run build --filter='./packages/*' --filter='./packages/*/*'`. - **`@objectstack/spec`**, run at `fe2ae4cf09` (the spec package has no byte change since): - `vitest run --project local`: 522 files, 15405 passed, 1 todo. - `typecheck`: exit 0. The test layer holds 53 files / 255 errors in the ledger, and the new test is inside the `tsconfig.test.json` program. - **`@objectstack/platform-objects`** at `2bfeee4a23`: vitest 54 files, 883 passed. `typecheck` exit 0. - **Narrowed lint.** This is not the repo-wide `pnpm lint`, which CI owns. `eslint --no-inline-config --format json` over the 7 touched `.ts` files returned 7 file results, 0 errors, 0 warnings, and none of the files was ignored. The repo's one `eslint.config.mjs` enables no type-aware linting (its note at `:326-328`), so this diff cannot change the lint result of any file it does not touch. - **Left to CI**: the 7 families whose values come from the workflow (`check-issue-citations`, shard attestations, test completeness), the 11 declared-wide families, and `pnpm lint`. ## Acceptance notes - The renderer side is not this card. At the objectui pin `87af769e9`, `plugin-list/src/ListView.tsx` reads `schema.pagination?.pageSize` into the `$top` of every list fetch (`:1307-1312`, `:2356`). For non-grid views it shows a rows-per-page selector when `pageSizeOptions` is set (`:4866`). The one exception is a gantt view with an `api` data provider, which does its own fetch (`:1950-1955`). This PR does not change the plugin-view route's hard-coded `$top: 100`, which is recorded in review record `5791323483` on PR #19809. - The form still has no section for the `map` and `tree` blocks that the list-view shape declares. That falls under the top-level "declared but not offered" class, which `metadata-form-declared-rows.pin.test.ts` counts as its own census, not under this card. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8cbc3c0 commit 2548ba5

8 files changed

Lines changed: 195 additions & 3 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/platform-objects": patch
4+
---
5+
6+
The Studio view form (`viewForm`, served by `METADATA_FORM_REGISTRY.view`) now offers `pagination` for every view type, not only grids.
7+
8+
`pagination.pageSize` is the row bound every view type carries. The form used to place `pagination` inside the grid-only `Table options` section (shown when `type` is `grid` or unset), so an author editing any other view type could not see or set it without editing the metadata by hand. It now has its own collapsed `Pagination` section with no visibility condition. `Table options` keeps `resizable`, `compactToolbar`, `rowHeight` and `selection`, still for grids only.
9+
10+
No schema changed: every view type already accepted `pagination`. `@objectstack/platform-objects` ships the new section's label and description in its metadata-form translation bundles (en, zh-CN, ja-JP, es-ES).

‎packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
730730
label: "Table options",
731731
description: "Grid-only display options."
732732
},
733+
pagination: {
734+
label: "Pagination",
735+
description: "Page size and page-size options — every view type accepts them, not only grids."
736+
},
733737
kanban: {
734738
label: "Kanban",
735739
description: "Kanban-specific board configuration."

‎packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,10 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
730730
label: "Opciones de tabla",
731731
description: "Opciones de visualización solo de cuadrícula."
732732
},
733+
pagination: {
734+
label: "Paginación",
735+
description: "Tamaño de página y opciones de tamaño de página — los aceptan todos los tipos de vista, no solo la cuadrícula."
736+
},
733737
kanban: {
734738
label: "Tablero Kanban",
735739
description: "Configuración de tablero específica de Kanban."

‎packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,10 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
730730
label: "テーブルオプション",
731731
description: "グリッド専用の表示オプション。"
732732
},
733+
pagination: {
734+
label: "ページネーション",
735+
description: "ページサイズとページサイズの選択肢 — グリッドだけでなく、すべてのビュータイプで使用できます。"
736+
},
733737
kanban: {
734738
label: "カンバン",
735739
description: "カンバン専用のボード設定。"

‎packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,8 +1109,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => {
11091109
// form row each across ten forms, and authored every one of their labels
11101110
// in all three locales rather than leaving it an extractor fill — so this
11111111
// control moves by exactly the number of rows that landed, in every
1112-
// locale, which is the reading a per-locale count is for.
1113-
expect(translated.length, `${locale} positive control`).toBe(583);
1112+
// locale, which is the reading a per-locale count is for. 584 since
1113+
// #19814: the view form's new `pagination` section, its label authored
1114+
// in all three locales.
1115+
expect(translated.length, `${locale} positive control`).toBe(584);
11141116
}
11151117
// ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the
11161118
// all-three predicate returns 0 while the per-locale one returns 1, so the

‎packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,10 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
730730
label: "表格选项",
731731
description: "仅 Grid 表格的显示选项"
732732
},
733+
pagination: {
734+
label: "分页",
735+
description: "每页条数与每页条数选项——所有视图类型都接受,不只是 Grid 表格"
736+
},
733737
kanban: {
734738
label: "看板配置",
735739
description: "看板专属配置"
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The Studio view form offers `pagination` to EVERY view type, and keeps the
5+
* grid-only display options grid-only.
6+
*
7+
* ## What is being pinned
8+
*
9+
* `pagination.pageSize` is the row bound every view type carries; maintainer
10+
* ruling D on #19228 makes it the direction for a view's row bound. The form
11+
* used to offer `pagination` only inside `table_options`
12+
* (`visibleWhen: "data.type == 'grid' || data.type == null"`), so an author
13+
* editing any other view type could not see or set that bound short of editing
14+
* the metadata by hand. Two halves, both asserted:
15+
*
16+
* - **offer**: for every value of the list-view `type` enum — and for a view
17+
* with no `type` yet — some section visible to that type offers `pagination`;
18+
* the offer is backed by the door, because every type parses a `pagination`
19+
* block clean and keeps it;
20+
* - **no leak**: the grid-only fields stay hidden from every non-grid type.
21+
*
22+
* ## The kind list is read from the enum, never written here
23+
*
24+
* A literal list would stay green through a new view type the form hides
25+
* `pagination` from. The list is the enum on {@link ListViewSchema} — the
26+
* list-view shape this form lays out, and the one `ViewSchema`'s `list` /
27+
* `listViews` entries are derived from — with a floor naming the four types
28+
* the ruling is about, so an empty derivation cannot pass.
29+
*
30+
* ## How visibility is read
31+
*
32+
* A section's `visibleWhen` is a CEL predicate over the edited record as
33+
* `data`. `packages/spec` carries no evaluator and must not grow one (no
34+
* runtime logic in spec), so {@link visibleFor} reads the ONE grammar this form
35+
* uses — a disjunction of `data.type == '<type>'` / `data.type == null` terms —
36+
* and THROWS on anything else, naming the predicate. A predicate this pin cannot
37+
* read fails it loudly instead of being guessed at.
38+
*/
39+
40+
import { describe, it, expect } from 'vitest';
41+
42+
import { viewForm } from './view.form';
43+
import { ListViewSchema } from './view.zod';
44+
45+
type Predicate = string | { dialect?: string; source?: string } | undefined;
46+
type Entry = string | { field?: string; visibleWhen?: Predicate };
47+
type Section = { name?: string; visibleWhen?: Predicate; fields?: Entry[] };
48+
49+
const SECTIONS = (viewForm.sections ?? []) as Section[];
50+
51+
/** The list-view `type` enum, read at runtime. */
52+
const VIEW_TYPES: readonly string[] = (
53+
ListViewSchema.shape.type as unknown as { unwrap(): { options: readonly string[] } }
54+
).unwrap().options;
55+
56+
/** The view types ruling D names — a floor under the derivation, not the list. */
57+
const RULING_D_TYPES = ['grid', 'kanban', 'gallery', 'timeline'] as const;
58+
59+
/**
60+
* The fields that are grid-only by design. Named here rather than read from
61+
* `table_options`, so moving one out of that section is a visible failure, not a
62+
* silently re-derived list.
63+
*/
64+
const GRID_ONLY_FIELDS = ['resizable', 'compactToolbar', 'rowHeight', 'selection'] as const;
65+
66+
const TERM = /^data\.type\s*==\s*(?:'([a-z_]+)'|(null))$/;
67+
68+
/**
69+
* Is a predicate true for a record whose `type` is `type` (`undefined` = not
70+
* set yet)? An absent predicate is always visible.
71+
*/
72+
function visibleFor(predicate: Predicate, type: string | undefined, where: string): boolean {
73+
if (predicate === undefined) return true;
74+
const source = typeof predicate === 'string' ? predicate : predicate.source;
75+
if (typeof predicate !== 'string' && predicate.dialect !== undefined && predicate.dialect !== 'cel') {
76+
throw new Error(`${where}: this pin reads CEL predicates only, got dialect ${JSON.stringify(predicate.dialect)}`);
77+
}
78+
if (typeof source !== 'string') throw new Error(`${where}: predicate has no source: ${JSON.stringify(predicate)}`);
79+
return source.split('||').map((t) => t.trim()).some((term) => {
80+
const m = TERM.exec(term);
81+
if (!m) {
82+
throw new Error(
83+
`${where}: this pin cannot read the predicate term ${JSON.stringify(term)} in ${JSON.stringify(source)} — ` +
84+
'extend visibleFor() to read it; never guess a visibility',
85+
);
86+
}
87+
return m[2] === 'null' ? type === undefined : type === m[1];
88+
});
89+
}
90+
91+
const fieldName = (e: Entry): string | undefined => (typeof e === 'string' ? e : e.field);
92+
93+
/** The sections (by name) in which `field` is offered AND visible for `type`. */
94+
function offeredTo(field: string, type: string | undefined): string[] {
95+
const hits: string[] = [];
96+
for (const section of SECTIONS) {
97+
const where = `section ${JSON.stringify(section.name)}`;
98+
if (!visibleFor(section.visibleWhen, type, where)) continue;
99+
for (const entry of section.fields ?? []) {
100+
if (fieldName(entry) !== field) continue;
101+
const own = typeof entry === 'string' ? undefined : entry.visibleWhen;
102+
if (visibleFor(own, type, `${where} field ${JSON.stringify(field)}`)) hits.push(section.name ?? '(unnamed)');
103+
}
104+
}
105+
return hits;
106+
}
107+
108+
describe('view form — the kind list is the enum', () => {
109+
it('derives a non-empty type list that contains every type ruling D names', () => {
110+
expect(VIEW_TYPES.length).toBeGreaterThanOrEqual(RULING_D_TYPES.length);
111+
for (const t of RULING_D_TYPES) expect(VIEW_TYPES, `the enum lost '${t}'`).toContain(t);
112+
});
113+
});
114+
115+
describe('view form — `pagination` is offered to every view type', () => {
116+
it('is offered exactly once in the whole form', () => {
117+
const entries = SECTIONS.flatMap((s) => (s.fields ?? []).filter((e) => fieldName(e) === 'pagination'));
118+
expect(entries).toHaveLength(1);
119+
});
120+
121+
it.each(VIEW_TYPES.map((t) => [t]))("is visible for type '%s'", (type) => {
122+
expect(offeredTo('pagination', type), `a '${type}' view cannot reach its row bound in the form`).toHaveLength(1);
123+
});
124+
125+
it('is visible for a view whose type is not set yet', () => {
126+
expect(offeredTo('pagination', undefined)).toHaveLength(1);
127+
});
128+
129+
it.each(VIEW_TYPES.map((t) => [t]))("is backed by the door: type '%s' parses a pagination block and keeps it", (type) => {
130+
const r = ListViewSchema.safeParse({ type, columns: ['name'], pagination: { pageSize: 50 } });
131+
expect(r.success, JSON.stringify(r.error?.issues ?? '')).toBe(true);
132+
expect((r.data as { pagination?: unknown }).pagination).toEqual({ pageSize: 50 });
133+
});
134+
});
135+
136+
describe('view form — grid-only fields stay grid-only', () => {
137+
it.each(GRID_ONLY_FIELDS.map((f) => [f]))("'%s' is visible for grid and for an unset type", (field) => {
138+
expect(offeredTo(field, 'grid')).toHaveLength(1);
139+
expect(offeredTo(field, undefined)).toHaveLength(1);
140+
});
141+
142+
const nonGrid = VIEW_TYPES.filter((t) => t !== 'grid');
143+
it.each(nonGrid.flatMap((t) => GRID_ONLY_FIELDS.map((f) => [f, t])))("'%s' is hidden from type '%s'", (field, type) => {
144+
expect(offeredTo(field, type)).toEqual([]);
145+
});
146+
});
147+
148+
describe('view form — the predicate reader refuses what it cannot read', () => {
149+
it('throws on a term outside its grammar rather than guessing', () => {
150+
expect(() => visibleFor("data.type != 'grid'", 'kanban', 'probe')).toThrow(/cannot read the predicate term/);
151+
});
152+
});

‎packages/spec/src/ui/view.form.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,21 @@ export const viewForm = defineForm({
5858
{ field: 'compactToolbar', colSpan: 1 },
5959
{ field: 'rowHeight', colSpan: 1 },
6060
{ field: 'selection', type: 'composite', colSpan: 2 },
61-
{ field: 'pagination', type: 'composite', colSpan: 2 },
6261
],
6362
},
63+
// `pagination` is NOT grid-only: every view type accepts it, and
64+
// `pagination.pageSize` is the row bound every view type carries (maintainer
65+
// ruling D on #19228 makes it the direction for a view's row bound). Its own
66+
// section with no `visibleWhen` puts it in front of every type; inside
67+
// `table_options` a non-grid author could not reach it.
68+
{
69+
name: 'pagination',
70+
label: 'Pagination',
71+
description: 'Page size and page-size options — every view type accepts them, not only grids.',
72+
collapsible: true,
73+
collapsed: true,
74+
fields: [{ field: 'pagination', type: 'composite' }],
75+
},
6476
{
6577
name: 'kanban',
6678
label: 'Kanban',

0 commit comments

Comments
 (0)