Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/19814-view-form-pagination-all-kinds.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,10 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,10 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "テーブルオプション",
description: "グリッド専用の表示オプション。"
},
pagination: {
label: "ページネーション",
description: "ページサイズとページサイズの選択肢 — グリッドだけでなく、すべてのビュータイプで使用できます。"
},
kanban: {
label: "カンバン",
description: "カンバン専用のボード設定。"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,10 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "表格选项",
description: "仅 Grid 表格的显示选项"
},
pagination: {
label: "分页",
description: "每页条数与每页条数选项——所有视图类型都接受,不只是 Grid 表格"
},
kanban: {
label: "看板配置",
description: "看板专属配置"
Expand Down
152 changes: 152 additions & 0 deletions packages/spec/src/ui/view-form-pagination.test.ts
Original file line number Diff line number Diff line change
@@ -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 == '<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/);
});
});
14 changes: 13 additions & 1 deletion packages/spec/src/ui/view.form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading