diff --git a/.changeset/19814-view-form-pagination-all-kinds.md b/.changeset/19814-view-form-pagination-all-kinds.md new file mode 100644 index 00000000000..b5434a0cc71 --- /dev/null +++ b/.changeset/19814-view-form-pagination-all-kinds.md @@ -0,0 +1,10 @@ +--- +"@objectstack/spec": patch +"@objectstack/platform-objects": patch +--- + +The Studio view form (`viewForm`, served by `METADATA_FORM_REGISTRY.view`) now offers `pagination` for every view type, not only grids. + +`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. + +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). diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index f48f4c61342..f05b4d46c90 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -730,6 +730,10 @@ export const enMetadataForms: NonNullable = { label: "Table options", description: "Grid-only display options." }, + pagination: { + label: "Pagination", + description: "Page size and page-size options — every view type accepts them, not only grids." + }, kanban: { label: "Kanban", description: "Kanban-specific board configuration." diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index e1ff30a45b7..391b1e77ecc 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -730,6 +730,10 @@ export const esESMetadataForms: NonNullable = label: "Opciones de tabla", description: "Opciones de visualización solo de cuadrícula." }, + pagination: { + label: "Paginación", + 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." + }, kanban: { label: "Tablero Kanban", description: "Configuración de tablero específica de Kanban." diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 5da1b245395..32fa0f39c11 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -730,6 +730,10 @@ export const jaJPMetadataForms: NonNullable = label: "テーブルオプション", description: "グリッド専用の表示オプション。" }, + pagination: { + label: "ページネーション", + description: "ページサイズとページサイズの選択肢 — グリッドだけでなく、すべてのビュータイプで使用できます。" + }, kanban: { label: "カンバン", description: "カンバン専用のボード設定。" diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 8591668a581..193f30de939 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1109,8 +1109,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // form row each across ten forms, and authored every one of their labels // in all three locales rather than leaving it an extractor fill — so this // control moves by exactly the number of rows that landed, in every - // locale, which is the reading a per-locale count is for. - expect(translated.length, `${locale} positive control`).toBe(583); + // locale, which is the reading a per-locale count is for. 584 since + // #19814: the view form's new `pagination` section, its label authored + // in all three locales. + expect(translated.length, `${locale} positive control`).toBe(584); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index c82273d3a8b..789f6f28b69 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -730,6 +730,10 @@ export const zhCNMetadataForms: NonNullable = label: "表格选项", description: "仅 Grid 表格的显示选项" }, + pagination: { + label: "分页", + description: "每页条数与每页条数选项——所有视图类型都接受,不只是 Grid 表格" + }, kanban: { label: "看板配置", description: "看板专属配置" diff --git a/packages/spec/src/ui/view-form-pagination.test.ts b/packages/spec/src/ui/view-form-pagination.test.ts new file mode 100644 index 00000000000..808cfbd648e --- /dev/null +++ b/packages/spec/src/ui/view-form-pagination.test.ts @@ -0,0 +1,152 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The Studio view form offers `pagination` to EVERY view type, and keeps the + * grid-only display options grid-only. + * + * ## What is being pinned + * + * `pagination.pageSize` is the row bound every view type carries; maintainer + * ruling D on #19228 makes it the direction for a view's row bound. The form + * used to offer `pagination` only inside `table_options` + * (`visibleWhen: "data.type == 'grid' || data.type == null"`), so an author + * editing any other view type could not see or set that bound short of editing + * the metadata by hand. Two halves, both asserted: + * + * - **offer**: for every value of the list-view `type` enum — and for a view + * with no `type` yet — some section visible to that type offers `pagination`; + * the offer is backed by the door, because every type parses a `pagination` + * block clean and keeps it; + * - **no leak**: the grid-only fields stay hidden from every non-grid type. + * + * ## The kind list is read from the enum, never written here + * + * A literal list would stay green through a new view type the form hides + * `pagination` from. The list is the enum on {@link ListViewSchema} — the + * list-view shape this form lays out, and the one `ViewSchema`'s `list` / + * `listViews` entries are derived from — with a floor naming the four types + * the ruling is about, so an empty derivation cannot pass. + * + * ## How visibility is read + * + * A section's `visibleWhen` is a CEL predicate over the edited record as + * `data`. `packages/spec` carries no evaluator and must not grow one (no + * runtime logic in spec), so {@link visibleFor} reads the ONE grammar this form + * uses — a disjunction of `data.type == ''` / `data.type == null` terms — + * and THROWS on anything else, naming the predicate. A predicate this pin cannot + * read fails it loudly instead of being guessed at. + */ + +import { describe, it, expect } from 'vitest'; + +import { viewForm } from './view.form'; +import { ListViewSchema } from './view.zod'; + +type Predicate = string | { dialect?: string; source?: string } | undefined; +type Entry = string | { field?: string; visibleWhen?: Predicate }; +type Section = { name?: string; visibleWhen?: Predicate; fields?: Entry[] }; + +const SECTIONS = (viewForm.sections ?? []) as Section[]; + +/** The list-view `type` enum, read at runtime. */ +const VIEW_TYPES: readonly string[] = ( + ListViewSchema.shape.type as unknown as { unwrap(): { options: readonly string[] } } +).unwrap().options; + +/** The view types ruling D names — a floor under the derivation, not the list. */ +const RULING_D_TYPES = ['grid', 'kanban', 'gallery', 'timeline'] as const; + +/** + * The fields that are grid-only by design. Named here rather than read from + * `table_options`, so moving one out of that section is a visible failure, not a + * silently re-derived list. + */ +const GRID_ONLY_FIELDS = ['resizable', 'compactToolbar', 'rowHeight', 'selection'] as const; + +const TERM = /^data\.type\s*==\s*(?:'([a-z_]+)'|(null))$/; + +/** + * Is a predicate true for a record whose `type` is `type` (`undefined` = not + * set yet)? An absent predicate is always visible. + */ +function visibleFor(predicate: Predicate, type: string | undefined, where: string): boolean { + if (predicate === undefined) return true; + const source = typeof predicate === 'string' ? predicate : predicate.source; + if (typeof predicate !== 'string' && predicate.dialect !== undefined && predicate.dialect !== 'cel') { + throw new Error(`${where}: this pin reads CEL predicates only, got dialect ${JSON.stringify(predicate.dialect)}`); + } + if (typeof source !== 'string') throw new Error(`${where}: predicate has no source: ${JSON.stringify(predicate)}`); + return source.split('||').map((t) => t.trim()).some((term) => { + const m = TERM.exec(term); + if (!m) { + throw new Error( + `${where}: this pin cannot read the predicate term ${JSON.stringify(term)} in ${JSON.stringify(source)} — ` + + 'extend visibleFor() to read it; never guess a visibility', + ); + } + return m[2] === 'null' ? type === undefined : type === m[1]; + }); +} + +const fieldName = (e: Entry): string | undefined => (typeof e === 'string' ? e : e.field); + +/** The sections (by name) in which `field` is offered AND visible for `type`. */ +function offeredTo(field: string, type: string | undefined): string[] { + const hits: string[] = []; + for (const section of SECTIONS) { + const where = `section ${JSON.stringify(section.name)}`; + if (!visibleFor(section.visibleWhen, type, where)) continue; + for (const entry of section.fields ?? []) { + if (fieldName(entry) !== field) continue; + const own = typeof entry === 'string' ? undefined : entry.visibleWhen; + if (visibleFor(own, type, `${where} field ${JSON.stringify(field)}`)) hits.push(section.name ?? '(unnamed)'); + } + } + return hits; +} + +describe('view form — the kind list is the enum', () => { + it('derives a non-empty type list that contains every type ruling D names', () => { + expect(VIEW_TYPES.length).toBeGreaterThanOrEqual(RULING_D_TYPES.length); + for (const t of RULING_D_TYPES) expect(VIEW_TYPES, `the enum lost '${t}'`).toContain(t); + }); +}); + +describe('view form — `pagination` is offered to every view type', () => { + it('is offered exactly once in the whole form', () => { + const entries = SECTIONS.flatMap((s) => (s.fields ?? []).filter((e) => fieldName(e) === 'pagination')); + expect(entries).toHaveLength(1); + }); + + it.each(VIEW_TYPES.map((t) => [t]))("is visible for type '%s'", (type) => { + expect(offeredTo('pagination', type), `a '${type}' view cannot reach its row bound in the form`).toHaveLength(1); + }); + + it('is visible for a view whose type is not set yet', () => { + expect(offeredTo('pagination', undefined)).toHaveLength(1); + }); + + it.each(VIEW_TYPES.map((t) => [t]))("is backed by the door: type '%s' parses a pagination block and keeps it", (type) => { + const r = ListViewSchema.safeParse({ type, columns: ['name'], pagination: { pageSize: 50 } }); + expect(r.success, JSON.stringify(r.error?.issues ?? '')).toBe(true); + expect((r.data as { pagination?: unknown }).pagination).toEqual({ pageSize: 50 }); + }); +}); + +describe('view form — grid-only fields stay grid-only', () => { + it.each(GRID_ONLY_FIELDS.map((f) => [f]))("'%s' is visible for grid and for an unset type", (field) => { + expect(offeredTo(field, 'grid')).toHaveLength(1); + expect(offeredTo(field, undefined)).toHaveLength(1); + }); + + const nonGrid = VIEW_TYPES.filter((t) => t !== 'grid'); + it.each(nonGrid.flatMap((t) => GRID_ONLY_FIELDS.map((f) => [f, t])))("'%s' is hidden from type '%s'", (field, type) => { + expect(offeredTo(field, type)).toEqual([]); + }); +}); + +describe('view form — the predicate reader refuses what it cannot read', () => { + it('throws on a term outside its grammar rather than guessing', () => { + expect(() => visibleFor("data.type != 'grid'", 'kanban', 'probe')).toThrow(/cannot read the predicate term/); + }); +}); diff --git a/packages/spec/src/ui/view.form.ts b/packages/spec/src/ui/view.form.ts index bea0c5ab924..8fac36f4890 100644 --- a/packages/spec/src/ui/view.form.ts +++ b/packages/spec/src/ui/view.form.ts @@ -58,9 +58,21 @@ export const viewForm = defineForm({ { field: 'compactToolbar', colSpan: 1 }, { field: 'rowHeight', colSpan: 1 }, { field: 'selection', type: 'composite', colSpan: 2 }, - { field: 'pagination', type: 'composite', colSpan: 2 }, ], }, + // `pagination` is NOT grid-only: every view type accepts it, and + // `pagination.pageSize` is the row bound every view type carries (maintainer + // ruling D on #19228 makes it the direction for a view's row bound). Its own + // section with no `visibleWhen` puts it in front of every type; inside + // `table_options` a non-grid author could not reach it. + { + name: 'pagination', + label: 'Pagination', + description: 'Page size and page-size options — every view type accepts them, not only grids.', + collapsible: true, + collapsed: true, + fields: [{ field: 'pagination', type: 'composite' }], + }, { name: 'kanban', label: 'Kanban',