diff --git a/.changeset/view-type-page-member.md b/.changeset/view-type-page-member.md new file mode 100644 index 0000000000..7b3570bd22 --- /dev/null +++ b/.changeset/view-type-page-member.md @@ -0,0 +1,83 @@ +--- +"@objectstack/spec": minor +"@objectstack/lint": minor +"@objectstack/metadata-protocol": patch +--- + +feat(spec,lint,metadata-protocol): a `page` member on the `view` type enum — mount an already-published page on an object view (#13216) + +A custom page created and published at runtime through the metadata API had no +in-protocol way to reach an end user (#13100's evidence map). App navigation is +closed to runtime content (`app.allowOrgOverride: false`), and the `view` `type` +enum — on one of the five types the platform deliberately leaves open +(`allowOrgOverride: true`, `allowRuntimeCreate: true`) — was closed over +declarative row renderers, so a published page could not be mounted as an +object's list view or tab. + +Maintainer ruling 2026-08-29 (live director session, verbatim 「同意」), 方向 1: + +> `view` 的 `type` 枚举新增 `page` 成员——对象的列表视图/标签页可挂载一个已发布页面。走平台**有意开着**的门(`view` 本就 `allowOrgOverride=true` + 运行时可创建),零新增授权面;设计要点:`page` 型 view 需声明 `pageName` 绑定,校验目标页面存在,渲染委托既有页面渲染器 + +**Zero new authorization surface, as the ruling's basis requires.** Nothing in +this change touches a metadata type's `allowOrgOverride` / `allowRuntimeCreate` +flags, adds a write door, or adds a read door. A `page` view is a `view` written +through the door `view` already opens, and it holds a NAME — the page itself is +still fetched through the page read path it already had, and still renders +through the existing page renderer, so the page's own audience gate +(`page.assignedProfiles`) rides along unchanged. Delegation is what preserves +that: a second renderer is what would have introduced a second gate. + +**The binding, refused in both directions at parse.** `ListViewSchema` gains +`pageName`, declared with `SnakeCaseIdentifierSchema` — the same grammar +`PageSchema.name` carries, so the accepted set is exactly the set of strings that +could name a page. `checkListViewPageMount` then refuses: + +- `type: 'page'` with no `pageName` — unlike every other view type there is no + degraded rendering to fall back to, so the view would be blank; +- `pageName` on any other view type — the accepted-and-ignored shape; +- a non-empty `columns` beside a page mount — `columns` is the one required key + on a list view, and the only truthful value for a page mount is `[]`. + +The check is attached at all three list-view doors (`ListViewSchema`, +`ObjectListViewSchema`, and the flattened runtime overlay behind +`PUT /api/v1/meta/view`), with a pinned test that fails if any attachment is +dropped. + +**Existence of the target page** is answered where the collection is visible: +`defineStack`'s `validateCrossReferences` refuses at build time (same +`pageNames.size > 0` policy the two other page references in that function +already use), and the new `@objectstack/lint` rule `view-page-unresolved` +(`validateViewPageRefs`) resolves it on `os validate` / `os lint` / `os compile` +**and** at the runtime publish gate. Advisory, not gating, for its nav twin's +reason: with no curated cross-package page registry, "unresolved here" cannot be +told apart from "provided by a package this stack cannot see". + +Reaching the runtime publish gate needed the per-write snapshot to carry the +`pages` collection (`RuntimeStackContext.pages`, threaded through +`evaluateRuntimeAuthoringGate` and read off the live registry in +`saveMetaItem`'s gate call). That is the one-key widening `RuntimeStackContext` +documents, made when a rule that reads the collection crossed the wall — never +in advance — and the false-positive channel it closes is measured both ways in +`runtime-gate.view-page-refs.test.ts`. `pages` joined `NAME_KEYED_STACK_KEYS` in +the same edit, because a collection that is both context-filled and +write-targeted must have its finding paths name-keyed (#10064). + +**Downstream note (not an accept-set narrowing).** No previously valid metadata +becomes invalid: `pageName` is a new key and `page` a new enum member, so every +refusal above can only fire on a document that could not be written before. +What does change for a downstream schema author is composition: `ListViewSchema` +now carries a refinement, and zod 4 refuses `.omit()` / key-overwriting +`.extend()` on a refined object. The unrefined shape stays module-private +(publishing it would mint a duplicate protocol def and a second full set of +ratcheted authorable-surface keys), so a consumer that derived from +`ListViewSchema` by omission should compose with `.safeExtend()` or narrow after +parsing. `FormViewSchema` has had this property since its own refinement landed, +so this is the established shape for view schemas rather than a new one. + +**Deliberately out of scope**, per the same ruling: 方向 2 (registering app +navigation at publish time) is deferred to its own design card — it would +require reversing the `app.allowOrgOverride: false` authorization decision — and +with it the known limitation the ruling accepts on the record, that a page +belonging to no object still has no browse-to entry. `page` is also NOT added to +`VisualizationTypeSchema`: the switcher offers alternative ways to draw the same +rows, and a page draws none. diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index b1ed75c2a3..babae500fc 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -791,7 +791,7 @@ Metadata query with filtering, sorting, and pagination | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | -| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | +| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index dc409726c9..e509fd154d 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1595,9 +1595,9 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **name** | `string` | optional | Item name — supplied by the metadata door; for an object-scoped container it is the object name. | | **label** | `string \| Record` | optional | Human-readable label shown in metadata lists. | | **object** | `string` | optional | Object this container binds to — how a stack-level `views: [...]` entry says which object its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`. | -| **list** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }` | optional | | +| **list** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>; data?: object \| … +3 more; … }` | optional | | | **form** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }` | optional | | -| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) | +| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>; data?: object \| … +3 more; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) | | **formViews** | `Record; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>` | optional | Additional named form views | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this view. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -1614,7 +1614,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | -| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | +| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | | **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | @@ -1634,6 +1634,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | @@ -1698,7 +1699,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | -| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | +| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | | **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | @@ -1718,6 +1719,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 0884c819a1..1e95291d69 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -168,7 +168,7 @@ const result = ApiMethod.parse(data); | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | -| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | +| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | @@ -362,7 +362,7 @@ const result = ApiMethod.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | -| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | +| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | | **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | @@ -382,6 +382,7 @@ const result = ApiMethod.parse(data); | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 63a45d5ac8..5e8200e77a 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -340,7 +340,7 @@ Create a new object | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | -| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | +| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | @@ -622,7 +622,7 @@ Create a new object | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | -| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | +| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 83a4576a68..933cd798fe 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -753,7 +753,7 @@ Map view configuration | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | -| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | +| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree' \| 'page'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | | **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | @@ -774,6 +774,7 @@ Map view configuration | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | @@ -1148,7 +1149,7 @@ Tab configuration for multi-tab view interface | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | -| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | +| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree' \| 'page'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | | **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | @@ -1168,6 +1169,7 @@ Tab configuration for multi-tab view interface | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | @@ -1714,9 +1716,9 @@ Tab configuration for multi-tab view interface | **name** | `string` | optional | Item name — supplied by the metadata door; for an object-scoped container it is the object name. | | **label** | `string \| Record` | optional | Human-readable label shown in metadata lists. | | **object** | `string` | optional | Object this container binds to — how a stack-level `views: [...]` entry says which object its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`. | -| **list** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }` | optional | | +| **list** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>; data?: object \| … +3 more; … }` | optional | | | **form** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }` | optional | | -| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) | +| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>; data?: object \| … +3 more; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) | | **formViews** | `Record; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>` | optional | Additional named form views | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this view. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -1733,7 +1735,7 @@ Tab configuration for multi-tab view interface | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | -| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | +| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | | **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | @@ -1753,6 +1755,7 @@ Tab configuration for multi-tab view interface | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | @@ -1817,7 +1820,7 @@ Tab configuration for multi-tab view interface | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | -| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | +| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | | **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | @@ -1837,6 +1840,7 @@ Tab configuration for multi-tab view interface | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | @@ -2032,7 +2036,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **viewKind** | `'list'` | ✅ | | -| **config** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }` | ✅ | List-family view configuration. | +| **config** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>; data?: object \| … +3 more; … }` | ✅ | List-family view configuration. | | **name** | `string` | ✅ | Globally-unique view id, `.`. | | **object** | `string` | ✅ | Bound object name — the foreign key used to aggregate views. | | **label** | `string \| Record` | optional | Display label (supports i18n). | @@ -2056,7 +2060,7 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | -| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | +| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | | **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | @@ -2077,6 +2081,7 @@ This schema accepts one of the following structures: | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | @@ -2204,7 +2209,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **viewKind** | `'list'` | ✅ | | -| **config** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }` | ✅ | List-family view configuration. | +| **config** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>; data?: object \| … +3 more; … }` | ✅ | List-family view configuration. | | **name** | `string` | ✅ | Globally-unique view id, `.`. | | **object** | `string` | ✅ | Bound object name — the foreign key used to aggregate views. | | **label** | `string \| Record` | optional | Display label (supports i18n). | @@ -2231,7 +2236,7 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | -| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | +| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | | **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | @@ -2252,6 +2257,7 @@ This schema accepts one of the following structures: | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 397a3cbaae..25924115b2 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -351,6 +351,11 @@ export type { ObjectRefFinding, ObjectRefSeverity } from './validate-object-refe export { validateNavTargetRefs, NAV_TARGET_UNRESOLVED } from './validate-nav-target-refs.js'; export type { NavTargetRefFinding, NavTargetRefSeverity } from './validate-nav-target-refs.js'; +// [#13216] The same page reference on the OTHER surface that can carry one: a +// `type: 'page'` list view's `pageName`. Advisory, for its nav twin's reason. +export { validateViewPageRefs, VIEW_PAGE_UNRESOLVED } from './validate-view-page-refs.js'; +export type { ViewPageRefFinding, ViewPageRefSeverity } from './validate-view-page-refs.js'; + // [#7912] The servability question about an `object` nav target: not "does the // name resolve?" but "can the destination answer a list at all?". Gates, and // gates alone among the nav rules — `enable` is declared on the object in this diff --git a/packages/lint/src/reference-integrity-suite.test.ts b/packages/lint/src/reference-integrity-suite.test.ts index eed844bd79..90c92dfdb0 100644 --- a/packages/lint/src/reference-integrity-suite.test.ts +++ b/packages/lint/src/reference-integrity-suite.test.ts @@ -23,6 +23,10 @@ describe('reference-integrity suite — membership', () => { 'validateChartBindings', 'validateNavAccess', 'validateNavTargetRefs', + // [#13216] The conscious edit this written-out list exists to force: the + // page reference on a `type: 'page'` list view, placed beside the nav + // twin that asks the identical question one surface over. + 'validateViewPageRefs', 'validateNavObjectServability', 'validateTranslationReferences', 'validateTranslatableSections', diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index d593eea187..0ea6ab732e 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -84,6 +84,7 @@ import { validatePageFieldBindings } from './validate-page-field-bindings.js'; import { validateChartBindings } from './validate-chart-bindings.js'; import { validateNavAccess } from './validate-nav-access.js'; import { validateNavTargetRefs } from './validate-nav-target-refs.js'; +import { validateViewPageRefs } from './validate-view-page-refs.js'; import { validateNavObjectServability } from './validate-nav-object-servability.js'; import { validateTranslationReferences } from './validate-translation-references.js'; import { validateTranslatableSections } from './validate-translatable-sections.js'; @@ -194,6 +195,24 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ // `action` is deliberately absent (validateActionNameRefs owns it) and so is // `component` (an unregistered ref renders a named diagnostic, not silence). { name: 'validateNavTargetRefs', run: validateNavTargetRefs }, + // [#13216] The SAME reference — `{ type: 'page', pageName }` — one surface + // over: a `type: 'page'` list view mounting a published page. It restores the + // coverage its nav twin restores, when `defineStack`'s own check switches + // itself off, and carries the same advisory severity for the same reason (no + // curated cross-package page registry exists to tell "unresolved" from + // "provided by a package we cannot see"). + // + // The first member crossed onto `view` snapshots for something that is not a + // field-existence question, and the crossing is the point rather than a + // bonus: the mount is authored through `PUT /api/v1/meta/view` by an agent + // that never runs a CLI (#13100's measured path), so a build-time-only rule + // would never reach its author. Safe to cross ONLY because the per-write + // snapshot now carries `pages` (`RuntimeStackContext.pages`, landed with this + // member) — without that collection the member would report every legitimate + // mount as dead, which is the missing-collection false-positive channel + // `runtimeTypes` exists to keep closed. Measured both ways in + // `runtime-gate.view-page-refs.test.ts`. + { name: 'validateViewPageRefs', runtimeTypes: ['flow', 'view'], run: validateViewPageRefs }, // [#7912] The THIRD question about a nav entry, after "does the target // resolve?" (above) and "is it granted?" (`validateNavAccess`): can the // destination serve at all? An object's own `enable` block can make its list diff --git a/packages/lint/src/runtime-gate.test.ts b/packages/lint/src/runtime-gate.test.ts index ceac55d26d..1eb990434a 100644 --- a/packages/lint/src/runtime-gate.test.ts +++ b/packages/lint/src/runtime-gate.test.ts @@ -253,7 +253,12 @@ describe('runtime publish gate (#4463)', () => { it('an absent context still yields empty collections, never a throw', () => { const s = buildRuntimeWriteSnapshots({ type: 'book', item: { name: 'b1' } })!; - expect(s.baseline).toEqual({ objects: [], permissions: [], books: [], datasets: [] }); + // [#13216] `pages` joined CONTEXT_STACK_KEYS with `validateViewPageRefs` + // — the collection a `type: 'page'` view's `pageName` resolves against. + // The expectation moves rather than being relaxed: the invariant under + // test is "every context collection is present and empty", so it has to + // name the whole set or it stops measuring the shape it exists for. + expect(s.baseline).toEqual({ objects: [], permissions: [], books: [], datasets: [], pages: [] }); expect(s.candidate.books).toEqual([{ name: 'b1' }]); }); }); diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index e0af20fa3b..2f6f621e9c 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -164,6 +164,23 @@ export interface RuntimeStackContext { * for (#4463 D4). */ datasets?: readonly unknown[]; + /** + * The live page declarations (stack key `pages`). + * + * [#13216] The resolution universe `validateViewPageRefs` resolves a + * `type: 'page'` list view's `pageName` against. It is carried for the + * reason `datasets` is carried and states first: without it a per-write + * `view` snapshot holds NO pages at all, so every legitimate page mount + * reads as dangling. The widening is the "one-key edit here plus a + * `CONTEXT_STACK_KEYS` entry, made when a rule that reads the collection + * actually crosses the wall" this docblock describes — the rule crossed in + * the same change, never in advance. + * + * Carrying it in BOTH differential passes also cancels page-derived findings + * for every other write type, so a stored page's pre-existing condition is + * not some unrelated write's to answer for (#4463 D4). + */ + pages?: readonly unknown[]; } /** @@ -267,7 +284,7 @@ const OVERLAY_PROVENANCE_SENTINEL = 'sys_metadata'; * facts: every entry is a key of {@link RuntimeStackContext} AND a stack key * some runtime-wired rule reads (`runtime-gate.test.ts` pins membership). */ -const CONTEXT_STACK_KEYS = ['objects', 'permissions', 'books', 'datasets'] as const satisfies +const CONTEXT_STACK_KEYS = ['objects', 'permissions', 'books', 'datasets', 'pages'] as const satisfies readonly (keyof RuntimeStackContext)[]; /** One rule's verdict at the runtime surface, carrying which rule produced it. */ @@ -369,8 +386,8 @@ export function buildRuntimeWriteSnapshots(args: { const baseline: AnyRec = {}; for (const key of CONTEXT_STACK_KEYS) { // [#9612] `objects` — and only `objects` — is reduced to the written - // item's package closure. The other three collections are already bounded - // by what a tenant authors (permission sets, books, datasets), and the + // item's package closure. The other four collections are already bounded + // by what a tenant authors (permission sets, books, datasets, pages), and the // measured bill is entirely in what the rules walk over `objects`. // // ⭐ Narrowing here rather than at either call site is what makes this ONE @@ -400,15 +417,26 @@ export function buildRuntimeWriteSnapshots(args: { * The collection-resident stack keys whose TOP-LEVEL index the gate rewrites * to a name key before findings leave it (#10064). * - * These are the collections a written item lands INSIDE (`TYPE_TO_STACK_KEY` - * routes `object` / `permission` / `book` writes into them) — so a finding's - * `objects[417]` is an offset into this gate's per-write snapshot, an - * in-memory array the caller has never seen and cannot enumerate. Every other - * write type is the sole member of its own collection (`flows[0]` IS this - * write, trivially stable), and `datasets` is context-only — no write type - * maps into it — so both keep their positional spelling. + * These are the collections a written item lands INSIDE **and** that the + * context also fills (`TYPE_TO_STACK_KEY` routes `object` / `permission` / + * `book` / `page` writes into them) — so a finding's `objects[417]` is an + * offset into this gate's per-write snapshot, an in-memory array the caller has + * never seen and cannot enumerate. Every other write type is the sole member of + * its own collection (`flows[0]` IS this write, trivially stable), and + * `datasets` is context-only — no write type maps into it — so both keep their + * positional spelling. + * + * [#13216] `pages` JOINED this list in the same change that made `pages` a + * context collection, and the pairing is the rule rather than a coincidence: + * before that, a `page` write's snapshot held exactly one page, so `pages[0]` + * was this write, trivially stable, and name-keying it would have been + * pointless. The moment the live universe joins the snapshot, the index stops + * meaning anything to the caller — `validatePresetComparands` already runs on + * `page` writes and emits paths into this collection. So: adding a key to + * {@link CONTEXT_STACK_KEYS} that some write type ALSO maps into means adding + * it here too. */ -const NAME_KEYED_STACK_KEYS = ['objects', 'permissions', 'books'] as const; +const NAME_KEYED_STACK_KEYS = ['objects', 'permissions', 'books', 'pages'] as const; /** * Machine names safe to splice into a dotted path. Matches the spec's @@ -418,7 +446,7 @@ const NAME_KEYED_STACK_KEYS = ['objects', 'permissions', 'books'] as const; */ const PATH_SAFE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; -const TOP_LEVEL_INDEX = /^(objects|permissions|books)\[(\d+)\](.*)$/; +const TOP_LEVEL_INDEX = /^(objects|permissions|books|pages)\[(\d+)\](.*)$/; /** * `objects[417].sharingModel` → `objects.acme_invoice.sharingModel` (#10064). diff --git a/packages/lint/src/runtime-gate.view-page-refs.test.ts b/packages/lint/src/runtime-gate.view-page-refs.test.ts new file mode 100644 index 0000000000..24b30b973e --- /dev/null +++ b/packages/lint/src/runtime-gate.view-page-refs.test.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#13216] `validateViewPageRefs` at the runtime publish gate, and the + * `RuntimeStackContext.pages` widening it rides on — measured BOTH ways. + * + * ## Why the crossing is the point rather than a bonus + * + * The mount this rule guards is authored on exactly one door: an agent + * publishes a page through the metadata API, then writes a `type: 'page'` view + * that mounts it. Both writes are `PUT /api/v1/meta/view` / `.../meta/page` — + * no CLI is involved anywhere on that path (#13100's measured trail), so a + * build-time-only rule would never speak to the author who needs it. + * + * ## Why the widening is load-bearing, stated as a measurement + * + * `ReferenceIntegrityRule.runtimeTypes` exists to keep one channel closed: a + * member that resolves against a collection the per-write snapshot does not + * carry does not go quiet, it reports EVERY reference into that collection as + * dead. `pages` was not a snapshot collection before this card. The two tests + * under "the widening" are that channel, opened and closed: identical write, + * identical rule, and the only difference is whether the host handed the gate + * its live pages. + * + * ## What the gate does with the finding + * + * `warning` — so it lands in `advisories`, never in `errors`, and never 422s a + * write. That is deliberate and matches the nav twin: without a curated + * cross-package page registry, "unresolved here" cannot be told apart from + * "provided by a package this tenant cannot see from here", and inventing a + * refusal out of that ambiguity would break legitimate publishes. The + * `errors` assertions below pin it. + */ + +import { describe, expect, it } from 'vitest'; + +import { runRuntimeAuthoringRules } from './runtime-gate.js'; +import { VIEW_PAGE_UNRESOLVED } from './validate-view-page-refs.js'; + +/** The live object universe every `view` write is resolved against. */ +const objects = [ + { name: 'crm_lead', label: 'Lead', fields: { name: { type: 'text', label: 'Name' } } }, +]; + +/** + * A flattened standalone list overlay mounting a page, exactly as + * `saveMetaItem` stores it: a raw ListView config at the TOP level with + * `object` + `viewKind` (#7741) and the identity the write path stamps. + */ +const pageMountOverlay = (pageName: string) => ({ + name: 'crm_lead.dashboard', + object: 'crm_lead', + viewKind: 'list', + type: 'page', + pageName, + columns: [], +}); + +const gate = (item: unknown, context: Record) => + runRuntimeAuthoringRules({ type: 'view', item, context }); + +const pageFindings = (r: { errors: { rule: string }[]; advisories: { rule: string }[] }) => ({ + errors: r.errors.filter((f) => f.rule === VIEW_PAGE_UNRESOLVED), + advisories: r.advisories.filter((f) => f.rule === VIEW_PAGE_UNRESOLVED), +}); + +describe('the `pages` widening (RuntimeStackContext.pages) — measured both ways', () => { + it('WITHOUT live pages, a legitimate mount reads as dangling — the channel this closes', () => { + const { advisories } = pageFindings(gate(pageMountOverlay('sales_dashboard'), { objects })); + // Not an assertion about desired behaviour: this is the false-positive + // channel, reproduced. It is why the member could not simply declare + // `runtimeTypes: ['view']` and stop. + expect(advisories).toHaveLength(1); + }); + + it('WITH live pages carried, the same write is clean', () => { + const { errors, advisories } = pageFindings(gate( + pageMountOverlay('sales_dashboard'), + { objects, pages: [{ name: 'sales_dashboard', label: 'Sales' }] }, + )); + expect(advisories).toEqual([]); + expect(errors).toEqual([]); + }); +}); + +describe('a page mount at the runtime publish gate', () => { + it('ADVISES on a mount that resolves to no page — and never blocks the write', () => { + const { errors, advisories } = pageFindings(gate( + pageMountOverlay('typo_dashboard'), + { objects, pages: [{ name: 'sales_dashboard' }] }, + )); + expect(advisories).toHaveLength(1); + expect(advisories[0].rule).toBe(VIEW_PAGE_UNRESOLVED); + // Advisory, not gating: the honest ceiling for a page reference (see the + // module docblock). A refusal here would 422 a legitimate cross-package + // mount. + expect(errors).toEqual([]); + }); + + it('runs the suite for a `view` write at all', () => { + const { rulesRun } = runRuntimeAuthoringRules({ + type: 'view', + item: pageMountOverlay('sales_dashboard'), + context: { objects, pages: [{ name: 'sales_dashboard' }] }, + }); + expect(rulesRun).toContain('validateReferenceIntegrity'); + }); + + // The differential (#4463 D4): a stored page's own condition is not this + // write's to answer for, and a stored VIEW's dangling mount must not block an + // unrelated write either. Carrying `pages` in both passes is what cancels it. + it('does not attribute a pre-existing stored page to this write', () => { + const { errors, advisories } = pageFindings(gate( + pageMountOverlay('sales_dashboard'), + { + objects, + pages: [{ name: 'sales_dashboard' }, { name: 'unrelated_page' }], + }, + )); + expect(advisories).toEqual([]); + expect(errors).toEqual([]); + }); +}); diff --git a/packages/lint/src/runtime-gate.view-writes.test.ts b/packages/lint/src/runtime-gate.view-writes.test.ts index 41e091f3b4..2266cd4a83 100644 --- a/packages/lint/src/runtime-gate.view-writes.test.ts +++ b/packages/lint/src/runtime-gate.view-writes.test.ts @@ -166,11 +166,23 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => { expect(result.rulesRun).toContain('validateReferenceIntegrity'); }); - it('pins the member surface: exactly the two list-view field rules declare `view`', () => { + it('pins the member surface: exactly the crossed members declare `view`', () => { const crossed = REFERENCE_INTEGRITY_RULES .filter((r) => (r.runtimeTypes ?? ['flow']).includes('view')) .map((r) => r.name); - expect(crossed).toEqual(['validateSearchableFields', 'validateSortableFields']); + // [#13216] `validateViewPageRefs` is the third crossing, and the first that + // is not a field-existence question: a `type: 'page'` view's `pageName`, + // resolved against `stack.pages` — a collection the per-write snapshot + // gained in the same change (`RuntimeStackContext.pages`), which is the + // precondition every crossing owes. The list is written out, not derived, + // precisely so a fourth crossing has to be argued here; this one's + // false-positive measurement is `runtime-gate.view-page-refs.test.ts`, + // which reproduces the phantom findings the collection removes. + expect(crossed).toEqual([ + 'validateSearchableFields', + 'validateSortableFields', + 'validateViewPageRefs', + ]); // And every member still judges flow snapshots — the #4463 P1 surface is // not narrowed by the member axis existing. const offFlow = REFERENCE_INTEGRITY_RULES diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index 9d23a452d3..8569c0b546 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -531,7 +531,12 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { for (const value of Object.values(rec)) corrupt(value); }; corrupt(corrupted.views); - expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(48); + // The count tracks the CORPUS, not an issue: 49 today because #13216 added + // a `page` section to `view.form.ts` — the surface block for the new `page` + // view type, gated by `visibleWhen: "data.type == 'page'"` exactly as every + // other surface block is — so the walk has one more predicate to reach. + // Earlier measurements stay what they were: history, not the census. + expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(49); const findings = validatePredicatePathRefs(corrupted); expect(findings).toHaveLength(predicates); @@ -605,8 +610,11 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // and `in`-list literals are deliberately not this rule's (see the anchor // note above) — exactly the #11566 (PR #11989) respell of the sibling // `maxLength` row, which took the measurement from 47 to 44. Earlier - // measurements stay what they were — history, not the census. - expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(41); + // measurements stay what they were — history, not the census. It is 42 + // today: #13216's `page` section in `view.form.ts` is gated by + // `data.type == 'page'`, one more `==` literal comparison of exactly the + // shape the seven sibling surface blocks already carry. + expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(42); const rhsFindings = validatePredicatePathRefs(corrupted) .filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED); diff --git a/packages/lint/src/validate-view-page-refs.test.ts b/packages/lint/src/validate-view-page-refs.test.ts new file mode 100644 index 0000000000..d52057c0dd --- /dev/null +++ b/packages/lint/src/validate-view-page-refs.test.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Tests for the `type: 'page'` view → page reference rule (#13216). + * + * Three of these carry the rule's reason for existing: + * + * 1. The **empty-collection** case — the same hole its nav twin was written + * for. `defineStack`'s cross-reference check is gated on + * `pageNames.size > 0`, so a stack that declares no `pages` has its + * view→page validation switched off, and that is exactly the state a stack + * is in when the target was never written. "Optimise" this rule by skipping + * page-less stacks and this test goes red. + * 2. The **flattened-overlay rung**. `runtimeTypes: ['flow','view']` on the + * suite member is necessary and NOT sufficient (#9313's measured lesson): + * the shape `PUT /api/v1/meta/view` carries is a list view at the TOP level + * of a `views[]` entry, not under `list` / `listViews`. A walk that reads + * only the container rungs would make the crossing a silent no-op that + * reads as coverage. + * 3. The **object rung** (`objects[].listViews.*`), which is where an object's + * built-in views are authored and where a page tab is most likely written. + */ + +import { describe, expect, it } from 'vitest'; + +import { validateViewPageRefs, VIEW_PAGE_UNRESOLVED } from './validate-view-page-refs.js'; + +const mount = (pageName: string) => ({ type: 'page', pageName, columns: [] }); + +describe('validateViewPageRefs — the gap defineStack leaves', () => { + it('flags a mount when the stack declares NO pages (the size>0 hole)', () => { + const findings = validateViewPageRefs({ + views: [{ name: 'crm_lead', list: mount('missing_page') }], + }); + expect(findings).toHaveLength(1); + const [f] = findings; + expect(f.rule).toBe(VIEW_PAGE_UNRESOLVED); + expect(f.severity).toBe('warning'); + expect(f.path).toBe('views[0].list.pageName'); + expect(f.where).toBe('view "crm_lead" › list'); + // The message must say WHY nothing else caught it, or the author has no way + // to know this rule is the only thing speaking. + expect(f.message).toContain('NO pages at all'); + expect(f.message).toContain('size > 0'); + }); + + it('flags a mount when pages exist but the name is wrong', () => { + const findings = validateViewPageRefs({ + views: [{ name: 'crm_lead', list: mount('typo') }], + pages: [{ name: 'sales_dashboard' }], + }); + expect(findings).toHaveLength(1); + expect(findings[0].message).not.toContain('NO pages at all'); + expect(findings[0].message).toContain("mounts page 'typo'"); + }); + + it('is silent when the mount resolves', () => { + expect(validateViewPageRefs({ + views: [{ name: 'crm_lead', list: mount('sales_dashboard') }], + pages: [{ name: 'sales_dashboard' }], + })).toEqual([]); + }); + + it('is silent on every view type that is not a page mount', () => { + expect(validateViewPageRefs({ + views: [{ name: 'crm_lead', list: { type: 'grid', columns: ['name'] } }], + pages: [], + })).toEqual([]); + }); + + it('accepts a name-keyed `pages` map as well as an array', () => { + expect(validateViewPageRefs({ + views: [{ name: 'crm_lead', list: mount('sales_dashboard') }], + pages: { sales_dashboard: { label: 'Sales' } }, + })).toEqual([]); + }); +}); + +describe('validateViewPageRefs — every rung a page mount can be authored on', () => { + // The runtime write door's own shape: a list view at the TOP level of a + // `views[]` entry (`viewKind: 'list'`, no nested `config`) — the shape the + // runtime publish gate snapshots as `views: [item]`. Without this rung the + // suite member's `view` crossing reports nothing at all. + it('walks the FLATTENED list overlay (PUT /api/v1/meta/view)', () => { + const findings = validateViewPageRefs({ + views: [{ + name: 'crm_lead.dash', object: 'crm_lead', viewKind: 'list', ...mount('missing_page'), + }], + pages: [{ name: 'sales_dashboard' }], + }); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('views[0].pageName'); + expect(findings[0].where).toContain('flattened list overlay'); + }); + + it('walks the standalone ViewItem RECORD (config one level down)', () => { + const findings = validateViewPageRefs({ + views: [{ + name: 'crm_lead.dash', object: 'crm_lead', viewKind: 'list', config: mount('missing_page'), + }], + pages: [{ name: 'sales_dashboard' }], + }); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('views[0].config.pageName'); + expect(findings[0].where).toContain('ViewItem record'); + }); + + it('walks `objects[].listViews.` — the object\'s built-in views', () => { + const findings = validateViewPageRefs({ + objects: [{ name: 'crm_lead', listViews: { dash: mount('missing_page') } }], + pages: [{ name: 'sales_dashboard' }], + }); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('objects[0].listViews.dash.pageName'); + expect(findings[0].where).toBe('object "crm_lead" › listViews.dash'); + }); + + it('walks `views[].listViews.` — a defineView aggregate\'s named views', () => { + const findings = validateViewPageRefs({ + views: [{ name: 'crm_lead', listViews: { dash: mount('missing_page') } }], + pages: [{ name: 'sales_dashboard' }], + }); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('views[0].listViews.dash.pageName'); + }); + + // A record body carries `config`; the overlay body does not. The two rungs + // must not both fire on one entry — that would double-report one mount. + it('reports a ViewItem record exactly once, not once per rung', () => { + const findings = validateViewPageRefs({ + views: [{ name: 'v', object: 'crm_lead', viewKind: 'list', config: mount('missing_page') }], + pages: [{ name: 'other' }], + }); + expect(findings).toHaveLength(1); + }); +}); + +describe('validateViewPageRefs — deliberate non-findings', () => { + it('skips an interpolated target (resolved at render time, ADR-0072 D1)', () => { + expect(validateViewPageRefs({ + views: [{ name: 'v', list: { type: 'page', pageName: '${page}', columns: [] } }], + pages: [{ name: 'sales_dashboard' }], + })).toEqual([]); + }); + + it('is total on junk input rather than throwing', () => { + expect(validateViewPageRefs(undefined)).toEqual([]); + expect(validateViewPageRefs({ views: 'nope', objects: 7, pages: null })).toEqual([]); + expect(validateViewPageRefs({ views: [null, 3], objects: [null] })).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-view-page-refs.ts b/packages/lint/src/validate-view-page-refs.ts new file mode 100644 index 0000000000..16d2821502 --- /dev/null +++ b/packages/lint/src/validate-view-page-refs.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#13216 — reference resolvability] A `type: 'page'` list view's `pageName` + * must name a page the stack actually declares. + * + * ## What the schema already settles, and what it cannot + * + * `ListViewSchema`'s `checkListViewPageMount` refuses a `page` view with no + * `pageName`, refuses `pageName` on any other view type, and refuses a + * `pageName` outside `SnakeCaseIdentifierSchema`'s grammar — so by the time a + * body reaches here the key is present, well-formed, and on the one view type + * that reads it. What a per-body parse cannot ask is whether the page EXISTS, + * because the answer lives in a sibling collection the body never sees. That is + * this rule's whole question, and it is the same question + * `validate-nav-target-refs` asks about the identical reference one surface + * over — `{ type: 'page', pageName }` on an app navigation item. + * + * ## Severity: warning, for its twin's reason + * + * `validate-nav-target-refs` explains why the honest ceiling for a page + * reference is advisory: unlike objects, pages have no curated + * cross-package registry, so "unresolved here" cannot be told apart from + * "provided by a package this stack cannot see". Nothing about mounting the + * same page on a view changes that, so the verdict matches its twin rather + * than inventing a second severity for one reference kind. `defineStack`'s + * `validateCrossReferences` still hard-fails first whenever the stack DOES + * declare pages — this rule is what speaks when that check has switched itself + * off (`pageNames.size > 0`), which is exactly the state a stack is in when + * the target was never written. + * + * ## Why it runs at the runtime publish gate too (`runtimeTypes: view`) + * + * The mount this rule guards is reachable in exactly the way #13100 measured: + * an agent publishes a page through the metadata API, then writes a view that + * mounts it. Both writes go through `PUT /api/v1/meta/view` — no CLI is + * involved anywhere on that path, so a build-time-only rule would never speak + * to the author who needs it. Crossing the member onto `view` snapshots is + * therefore the point, not a bonus. + * + * It is safe to cross ONLY because the per-write snapshot now carries `pages` + * (`RuntimeStackContext.pages`, added with this rule). Without that collection + * the member would not go quiet — it would report EVERY page mount as dead, + * which is the missing-collection false-positive channel + * `ReferenceIntegrityRule.runtimeTypes` exists to keep closed, and the reason + * `validateNavTargetRefs` is NOT crossed: nothing carries a snapshot's `apps`. + * + * ## The rungs, and why the list matches the sort rule's + * + * A `pageName` can be authored on every shape that carries a list view, and the + * #9313 lesson is that declaring `runtimeTypes` is necessary and NOT sufficient + * — the WALK has to reach the flattened top-level shape the write door actually + * carries, or the crossing is a silent no-op that reads as coverage. So the + * rungs are `validate-sortable-fields`' rungs, one key over: + * + * - `objects[].listViews.` — built-in named list views; + * - `views[]` itself on a FLATTENED LIST OVERLAY (`viewKind: 'list'`, no + * nested `config`) — the shape the runtime gate snapshots as `views: [item]`; + * - `views[].config` on a standalone ViewItem RECORD (`viewKind: 'list'` with + * a record-shaped `config`) — the shape a Studio-saved view round-trips as; + * - `views[].list` — a `defineView` aggregate's default list; + * - `views[].listViews.` — its named list views. + * + * ## Not covered, deliberately + * + * An interpolated target (`${…}`) resolves at render time and is skipped — the + * conservative exemption `validate-nav-target-refs` and + * `validate-object-references` both use (ADR-0072 D1). It cannot currently + * arise (the schema's snake_case grammar refuses `$` and `{`), and is kept so + * this rule does not become the thing that has to change if the grammar ever + * widens. + * + * The view's `type` is NOT re-checked here. A body reaching a CLI command has + * been parsed; a body reaching `os lint` may not have been, and in that state + * reading `pageName` wherever it is written is the more useful answer — a + * `pageName` on a `grid` view is a schema refusal, not this rule's business, + * and reporting the dead reference underneath it as well helps rather than + * misleads. + */ + +import type { ReferenceIntegrityFinding } from './reference-integrity-suite.js'; + +export type ViewPageRefSeverity = 'error' | 'warning'; +export type ViewPageRefFinding = ReferenceIntegrityFinding; + +/** Emitted when a `type: 'page'` view mounts a page the stack cannot resolve. */ +export const VIEW_PAGE_UNRESOLVED = 'view-page-unresolved'; + +type AnyRec = Record; + +const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); + +function strName(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** Coerce an array-or-name-keyed-map collection to an array (name injected). */ +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v.filter(isRec); + if (isRec(v)) return Object.entries(v).map(([name, def]) => (isRec(def) ? { name, ...def } : { name })); + return []; +} + +/** See the module docblock — an interpolated target resolves at render time. */ +const isInterpolated = (s: string): boolean => s.includes('${') || s.includes('{'); + +function declaredPageNames(stack: AnyRec): Set { + const out = new Set(); + for (const page of asArray(stack.pages)) { + const n = strName(page.name); + if (n) out.add(n); + } + return out; +} + +export function validateViewPageRefs(stack: unknown): ViewPageRefFinding[] { + const findings: ViewPageRefFinding[] = []; + if (!isRec(stack)) return findings; + + const pages = declaredPageNames(stack); + + const check = (listView: unknown, where: string, path: string): void => { + if (!isRec(listView)) return; + const target = strName(listView.pageName); + if (!target || isInterpolated(target)) return; + if (pages.has(target)) return; + + const emptyCollection = pages.size === 0; + findings.push({ + severity: 'warning', + rule: VIEW_PAGE_UNRESOLVED, + where, + path: `${path}.pageName`, + message: + `This view mounts page '${target}', which this stack does not declare in \`pages\`. ` + + (emptyCollection + ? 'The stack declares NO pages at all, so `defineStack`\'s own cross-reference check ' + + 'skipped this entry entirely (it is gated on `pageNames.size > 0`) — nothing else ' + + 'will report it. ' + : '') + + 'The view appears in the object\'s view switcher and renders nothing when opened: a ' + + '`page` view has no rows of its own to fall back to. If another package provides this ' + + 'page, this is expected and advisory only.', + hint: + `Declare the page in \`pages\`, correct \`pageName\`, or change the view's \`type\` away ` + + 'from `page` if the mount is no longer wanted.', + }); + }; + + // ── The object's built-in named list views ── + for (const [oi, obj] of asArray(stack.objects).entries()) { + const objName = strName(obj.name); + const label = objName ? `object "${objName}"` : `objects[${oi}]`; + if (!isRec(obj.listViews)) continue; + for (const [key, lv] of Object.entries(obj.listViews)) { + check(lv, `${label} › listViews.${key}`, `objects[${oi}].listViews.${key}`); + } + } + + // ── `views[]`: the flattened overlay / ViewItem record / container rungs ── + const views = Array.isArray(stack.views) ? (stack.views as unknown[]) : []; + for (const [vi, view] of views.entries()) { + if (!isRec(view)) continue; + const viewLabel = strName(view.name) ?? strName(view.objectName) ?? `#${vi}`; + + if (view.viewKind === 'list' && !isRec(view.config)) { + check(view, `view "${viewLabel}" (flattened list overlay)`, `views[${vi}]`); + } + if (view.viewKind === 'list' && isRec(view.config)) { + check(view.config, `view "${viewLabel}" (ViewItem record)`, `views[${vi}].config`); + } + if (isRec(view.list)) { + check(view.list, `view "${viewLabel}" › list`, `views[${vi}].list`); + } + if (isRec(view.listViews)) { + for (const [key, lv] of Object.entries(view.listViews)) { + check(lv, `view "${viewLabel}" › listViews.${key}`, `views[${vi}].listViews.${key}`); + } + } + } + + return findings; +} diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 71c528b66a..385bfa40fd 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -4418,6 +4418,11 @@ export class ObjectStackProtocolImplementation implements // dashboard publish — without it every legitimate board reads as // dangling (see RuntimeStackContext.datasets). const datasets = listCollection('dataset', 'datasets'); + // [#13216] The resolution universe validateViewPageRefs needs for a + // `type: 'page'` view publish — without it every legitimate page mount + // reads as dangling (see RuntimeStackContext.pages). Gathered on the + // same terms as the four above: per write, on an `active` publish only. + const pages = listCollection('page', 'pages'); // [#9612] The closure this write is judged against. Resolved from the // package registry — the impure read — and handed to the pure gate as @@ -4433,7 +4438,8 @@ export class ObjectStackProtocolImplementation implements permissions, books, datasets, - // [#10377] The batch's own pending drafts join the four + pages, + // [#10377] The batch's own pending drafts join the five // collections above. Absent on every non-batch door. ...(evt.pending !== undefined ? { pending: evt.pending } : {}), ...(packageScope !== undefined ? { packageScope } : {}), @@ -15808,9 +15814,19 @@ export class ObjectStackProtocolImplementation implements private async collectBatchPendingDeclarations( drafts: ReadonlyArray<{ type: string; name: string; organizationId: string | null }>, ): Promise { - const pending: { - objects: unknown[]; permissions: unknown[]; books: unknown[]; datasets: unknown[]; - } = { objects: [], permissions: [], books: [], datasets: [] }; + // [#13216] Typed as a mapped type over `RuntimePendingDeclarations` + // with `-?`, not as a hand-listed literal. The literal it replaces was + // the third place the collection set is written down, and the only one + // that could fall behind SILENTLY: a key added to `RuntimeStackContext` + // and routed by `CLOSURE_CONTEXT_KEY_BY_TYPE` would simply never be + // accumulated here, so a package publishing a page beside the view that + // mounts it would keep being refused for the sibling in its own batch — + // the `shyx_customer_ds` shape #10377 was filed for. `-?` makes every + // key REQUIRED, so the next widening is a compile error at this line + // instead. + const pending: { [K in keyof RuntimePendingDeclarations]-?: unknown[] } = { + objects: [], permissions: [], books: [], datasets: [], pages: [], + }; let any = false; for (const d of drafts) { // The canonical fold, same boundary the promote applies: a stored diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.ts b/packages/metadata-protocol/src/runtime-authoring-gate.ts index 11d73354fc..5572c88aad 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.ts @@ -383,14 +383,22 @@ export type RuntimePendingDeclarations = RuntimeStackContext; * * Every metadata type NOT listed here contributes nothing to the closure, and * that is the correct answer rather than a gap: a collection is carried - * because some rule RESOLVES REFERENCES INTO IT, and only these four are read + * because some rule RESOLVES REFERENCES INTO IT, and only these five are read * that way (`RuntimeStackContext`'s own docblock records the measurement). + * + * [#13216] `page` is the fifth, and it arrives with the closure case already + * measured rather than as an afterthought: a package that ships a custom page + * together with the object view that mounts it publishes BOTH in one batch, so + * without this row `validateViewPageRefs` would report the sibling page as + * unresolved for exactly the batch that carries it — the `shyx_customer_ds` + * shape one collection over. */ export const CLOSURE_CONTEXT_KEY_BY_TYPE = { object: 'objects', permission: 'permissions', book: 'books', dataset: 'datasets', + page: 'pages', } as const satisfies Readonly>; /** @@ -520,9 +528,17 @@ export function evaluateRuntimeAuthoringGate(args: { * so the thread-through is load-bearing, not optional. */ datasets?: readonly unknown[]; + /** + * [#13216] Live page declarations — the resolution universe + * `validateViewPageRefs` resolves a `type: 'page'` list view's `pageName` + * against. Without it every legitimate page mount published through + * `PUT /api/v1/meta/view` reads as dangling, so the thread-through is + * load-bearing on exactly the door the mount is authored at. + */ + pages?: readonly unknown[]; /** * [#10377] The declarations this write's own BATCH is publishing alongside - * it — folded into the four collections above by + * it — folded into the five collections above by * {@link mergePendingDeclarations} before any rule runs. * * Stated by the batch door (`publishPackageDrafts`), which is the only @@ -585,7 +601,7 @@ export function evaluateRuntimeAuthoringGate(args: { item: args.body, ...(args.packageScope !== undefined ? { packageScope: args.packageScope } : {}), // [#10377] Live universe + this batch's own pending drafts, folded per - // collection. Uniform across all four on purpose: the closure ruling + // collection. Uniform across all five on purpose: the closure ruling // judges a package as a self-consistent UNIT, and a per-collection // closure is precisely the state that produced this card — `objects` // had been threaded, `datasets` had not, and the difference was @@ -595,6 +611,7 @@ export function evaluateRuntimeAuthoringGate(args: { permissions: mergePendingDeclarations(args.permissions ?? [], args.pending?.permissions), books: mergePendingDeclarations(args.books ?? [], args.pending?.books), datasets: mergePendingDeclarations(args.datasets ?? [], args.pending?.datasets), + pages: mergePendingDeclarations(args.pages ?? [], args.pending?.pages), }, ...(args.sduiManifest !== undefined ? { sduiManifest: args.sduiManifest } : {}), }); 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 10270c3cba..be4726fe20 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 @@ -612,6 +612,10 @@ export const enMetadataForms: NonNullable = { label: "Chart", description: "Chart-specific configuration." }, + page: { + label: "Page", + description: "The published page this view mounts." + }, end_user_controls: { label: "End-user controls", description: "What end users can do on this view — quick filters, filter tabs, visualization switching (ADR-0047, Airtable Interface parity)." @@ -693,6 +697,10 @@ export const enMetadataForms: NonNullable = { chart: { label: "Chart" }, + pageName: { + label: "Page Name", + helpText: "Name of an already-published page (lowercase snake_case). The page renders in place of the record list, so leave Columns empty." + }, userFilters: { label: "User Filters", helpText: "Quick-filter bar: element style (dropdown / tabs / toggle) + exposed fields or tab presets" 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 3667dc2b0b..1bfd66b720 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 @@ -612,6 +612,10 @@ export const esESMetadataForms: NonNullable = label: "Gráfico", description: "Configuración específica de gráfico." }, + page: { + label: "Page", + description: "The published page this view mounts." + }, end_user_controls: { label: "Controles de usuario final", description: "Lo que los usuarios finales pueden hacer en esta vista: filtros rápidos, pestañas de filtro, cambio de visualización (ADR-0047, paridad con Airtable Interface)." @@ -693,6 +697,10 @@ export const esESMetadataForms: NonNullable = chart: { label: "Gráfico" }, + pageName: { + label: "Page Name", + helpText: "Name of an already-published page (lowercase snake_case). The page renders in place of the record list, so leave Columns empty." + }, userFilters: { label: "Filtros de usuario", helpText: "Barra de filtros rápidos: estilo de elemento (desplegable / pestañas / interruptor) + campos expuestos o preajustes de pestañas" diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index b1ab1d66e8..4a9e1a3250 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -196,6 +196,10 @@ export const esESGeneratedSourceHashes: Readonly> = { "metadataForms.report.sections.dataset_binding.label": "34d97a3093d6fc56", "metadataForms.seed.description": "c22921feb0f06273", "metadataForms.seed.label": "60e0e22a54230cab", + "metadataForms.view.fields.pageName.helpText": "bd907125cd7554b6", + "metadataForms.view.fields.pageName.label": "6b8f40b2f3beb133", + "metadataForms.view.sections.page.description": "973556bc755dcfef", + "metadataForms.view.sections.page.label": "a65b2d4829365115", "objects.sys_account._actions.link_social.params.provider.options.apple": "cfdc41e15ed6699b", "objects.sys_account._actions.link_social.params.provider.options.discord": "12f931cc062e76ae", "objects.sys_account._actions.link_social.params.provider.options.facebook": "7eea009178f5b807", 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 6fa058ac70..c977db9de9 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 @@ -612,6 +612,10 @@ export const jaJPMetadataForms: NonNullable = label: "チャート", description: "チャート専用設定。" }, + page: { + label: "Page", + description: "The published page this view mounts." + }, end_user_controls: { label: "エンドユーザー操作", description: "このビューでエンドユーザーが行える操作——クイックフィルター、フィルタータブ、可視化切り替え(ADR-0047、Airtable Interface 互換)。" @@ -693,6 +697,10 @@ export const jaJPMetadataForms: NonNullable = chart: { label: "チャート" }, + pageName: { + label: "Page Name", + helpText: "Name of an already-published page (lowercase snake_case). The page renders in place of the record list, so leave Columns empty." + }, userFilters: { label: "ユーザーフィルター", helpText: "クイックフィルターバー:要素スタイル(ドロップダウン / タブ / トグル)+ 公開フィールドまたはタブプリセット" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts index 63eb5d4a9b..737d89f461 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts @@ -196,6 +196,10 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "metadataForms.report.sections.dataset_binding.label": "34d97a3093d6fc56", "metadataForms.seed.description": "c22921feb0f06273", "metadataForms.seed.label": "60e0e22a54230cab", + "metadataForms.view.fields.pageName.helpText": "bd907125cd7554b6", + "metadataForms.view.fields.pageName.label": "6b8f40b2f3beb133", + "metadataForms.view.sections.page.description": "973556bc755dcfef", + "metadataForms.view.sections.page.label": "a65b2d4829365115", "objects.sys_account._actions.link_social.params.provider.options.apple": "cfdc41e15ed6699b", "objects.sys_account._actions.link_social.params.provider.options.discord": "12f931cc062e76ae", "objects.sys_account._actions.link_social.params.provider.options.facebook": "7eea009178f5b807", 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 dd82161eeb..fa7e281102 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 @@ -612,6 +612,10 @@ export const zhCNMetadataForms: NonNullable = label: "图表配置", description: "图表专属配置" }, + page: { + label: "Page", + description: "The published page this view mounts." + }, end_user_controls: { label: "终端用户控制", description: "终端用户在此视图上可执行的操作——快速筛选、筛选标签页、可视化切换(ADR-0047,对标 Airtable Interface)。" @@ -693,6 +697,10 @@ export const zhCNMetadataForms: NonNullable = chart: { label: "图表" }, + pageName: { + label: "Page Name", + helpText: "Name of an already-published page (lowercase snake_case). The page renders in place of the record list, so leave Columns empty." + }, userFilters: { label: "用户筛选器", helpText: "快速筛选栏:控件样式(下拉 / 标签页 / 开关)+ 暴露的字段或标签页预设" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts index 9f218a2fcb..724d2dfc0d 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts @@ -164,6 +164,10 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "metadataForms.report.sections.dataset_binding.label": "34d97a3093d6fc56", "metadataForms.seed.description": "c22921feb0f06273", "metadataForms.seed.label": "60e0e22a54230cab", + "metadataForms.view.fields.pageName.helpText": "bd907125cd7554b6", + "metadataForms.view.fields.pageName.label": "6b8f40b2f3beb133", + "metadataForms.view.sections.page.description": "973556bc755dcfef", + "metadataForms.view.sections.page.label": "a65b2d4829365115", "objects.sys_account._actions.link_social.params.provider.options.apple": "cfdc41e15ed6699b", "objects.sys_account._actions.link_social.params.provider.options.discord": "12f931cc062e76ae", "objects.sys_account._actions.link_social.params.provider.options.facebook": "7eea009178f5b807", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index a688fb442a..132adfbd24 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -633,6 +633,7 @@ "ui/ListView:map", "ui/ListView:name", "ui/ListView:navigation", + "ui/ListView:pageName", "ui/ListView:pagination", "ui/ListView:performance [RETIRED]", "ui/ListView:resizable", @@ -795,6 +796,7 @@ "ui/ObjectListView:map", "ui/ObjectListView:name", "ui/ObjectListView:navigation", + "ui/ObjectListView:pageName", "ui/ObjectListView:pagination", "ui/ObjectListView:performance [RETIRED]", "ui/ObjectListView:resizable", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 2781beeaf1..1c63d166f0 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -39,7 +39,7 @@ for both corollaries. | `skill` | 16 | 0 | 1 | 0 | 17 | | `dataset` | 27 | 0 | 0 | 0 | 27 | | `page` | 23 | 0 | 0 | 1 | 24 | -| `view` | 78 | 0 | 9 | 0 | 87 | +| `view` | 79 | 0 | 9 | 0 | 88 | | `report` | 21 | 0 | 0 | 0 | 21 | | `dashboard` | 34 | 0 | 7 | 0 | 41 | | `webhook` | 19 | 0 | 0 | 0 | 19 | @@ -58,4 +58,4 @@ for both corollaries. | `capability` | 12 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 5 | 0 | 9 | | `manifest` | 22 | 0 | 16 | 0 | 38 | -| **total** | **819** | **5** | **75** | **10** | **909** | +| **total** | **820** | **5** | **75** | **10** | **910** | diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index f203b78f2d..5a24ad282a 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -34,7 +34,7 @@ }, "type": { "status": "live", - "note": "objectui: ListView.tsx routes grid/kanban/calendar/gantt/gallery/timeline/chart/tree renderers (audit L16)." + "note": "objectui: ListView.tsx routes grid/kanban/calendar/gantt/gallery/timeline/chart/tree renderers (audit L16). [#13216] The enum also carries `page`, which routes nowhere in ListView by design — it delegates to the existing page renderer via the sibling `pageName` key (maintainer ruling 2026-08-29); the mount itself is objectui's half." }, "data": { "status": "live", @@ -118,6 +118,12 @@ "status": "live", "note": "objectui: @object-ui/plugin-tree ObjectTree.tsx (spec CHANGELOG: 'Renderer ships in objectui @object-ui/plugin-tree')." }, + "pageName": { + "status": "live", + "verifiedAt": "2026-08-30", + "evidence": "packages/spec/src/ui/view.zod.ts#checkListViewPageMount (refuses a `type: 'page'` view that declares no `pageName`, refuses `pageName` on any other view type, and refuses a non-empty `columns` beside it — attached to all three list-view doors: ListViewSchema, ObjectListViewSchema, ListViewOverlayWireSchema); packages/spec/src/stack.zod.ts#validateCrossReferences (build-time refusal when the named page is not in `stack.pages`); packages/lint/src/validate-view-page-refs.ts#validateViewPageRefs (the same resolution on the CLI commands and, via `runtimeTypes: ['flow','view']`, on the runtime publish gate — `PUT /api/v1/meta/view`)", + "note": "[#13216] The page a `type: 'page'` view mounts. Enforcement is in-repo and cited above; the RENDER half is objectui's and is delegation rather than new code — the ruling of 2026-08-29 is explicit that a page view hands off to the existing page renderer (「渲染委托既有页面渲染器」), so the page keeps its own audience gate (`page.assignedProfiles`) and no new authorization surface is introduced. Necessary-not-sufficient in the ADR-0054 sense until the objectui mount lands: the refusals above are measured here, the pixels are not." + }, "map": { "status": "live", "verifiedAt": "2026-08-24", diff --git a/packages/spec/src/kernel/functional-completeness.ts b/packages/spec/src/kernel/functional-completeness.ts index 623c24506a..99d4dbead4 100644 --- a/packages/spec/src/kernel/functional-completeness.ts +++ b/packages/spec/src/kernel/functional-completeness.ts @@ -201,6 +201,16 @@ export function checkFieldCompleteness(def: unknown): CompletenessFinding[] { * * `timeline` / `tree` have config schemas too but are NOT flagged yet — same * verify-then-enforce gate; the audit's Tier-A names only these three. + * + * ⛔ [#13216] `page` does NOT belong in this table, and completing the map with + * it would be a regression in two independent ways. Its binding is `pageName`, + * a STRING — the check below asks `isRec(view[block])`, so a correct + * declaration would be read as a missing block and warned about on every page + * view. And the premise of this table does not hold for it: a `page` view with + * no binding does not degrade to a wrong-but-visible list, it renders nothing, + * so it is refused outright at parse by `checkListViewPageMount` + * (`packages/spec/src/ui/view.zod.ts`) — an error where this table can only + * advise, and already spent before a completeness pass ever runs. */ const VIEW_BINDING_BLOCKS: Readonly> = { kanban: 'kanban', diff --git a/packages/spec/src/stack.test.ts b/packages/spec/src/stack.test.ts index 0c5eabdee9..2c2a8e9139 100644 --- a/packages/spec/src/stack.test.ts +++ b/packages/spec/src/stack.test.ts @@ -1506,3 +1506,68 @@ describe('defineStack — ADR-0087 D2 conversion notices', () => { expect(warn).not.toHaveBeenCalled(); }); }); + +describe('defineStack - `type: page` view → page cross-reference (#13216)', () => { + const baseManifest = { + id: 'com.example.test', + name: 'test-project', + version: '1.0.0', + type: 'app' as const, + }; + // `as const` on the field type is load-bearing, not style: hoisted into a + // shared const, the object literal widens `type` to `string`, which + // `ObjectStackDefinitionInput` refuses — and this package's test typecheck is + // a shrink-only ratchet (`check:test-typecheck`), so a new widened literal + // reads as a debt INCREASE rather than as a red the suite would show. + const account = { name: 'account', fields: { name: { type: 'text' as const } } }; + const dashboard = { + name: 'account_dashboard', + label: 'Account Dashboard', + type: 'app' as const, + kind: 'html' as const, + source: '', + }; + const mount = (pageName: string) => ({ type: 'page' as const, pageName, columns: [] }); + + it('accepts a mount whose page the stack declares', () => { + expect(() => defineStack({ + manifest: baseManifest, + objects: [account], + pages: [dashboard], + views: [{ name: 'account', object: 'account', list: mount('account_dashboard') }], + })).not.toThrow(); + }); + + it('detects a mount naming a page that does not exist', () => { + const config = { + manifest: baseManifest, + objects: [account], + pages: [dashboard], + views: [{ name: 'account', object: 'account', list: mount('ghost_dashboard') }], + }; + expect(() => defineStack(config)).toThrow('ghost_dashboard'); + expect(() => defineStack(config)).toThrow('cross-reference validation failed'); + }); + + it('detects it on `objects[].listViews.` too', () => { + const config = { + manifest: baseManifest, + objects: [{ ...account, listViews: { dash: mount('ghost_dashboard') } }], + pages: [dashboard], + }; + expect(() => defineStack(config)).toThrow('ghost_dashboard'); + }); + + // The deliberate size gate, shared with the nav / modal-action page checks in + // the same function: a stack declaring NO pages may be mounting one another + // package provides, so the build-time check stands down and lint's + // `validateViewPageRefs` is what speaks. Pinning it here keeps the three page + // references on ONE policy instead of three. + it('stands down when the stack declares no pages at all (lint reports it instead)', () => { + expect(() => defineStack({ + manifest: baseManifest, + objects: [account], + views: [{ name: 'account', object: 'account', list: mount('provided_elsewhere') }], + })).not.toThrow(); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 2c452e7943..d27cab5da8 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -1246,6 +1246,52 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { } } + // Validate `type: 'page'` list view → page references (#13216). + // + // The THIRD surface in this function that names a page — after an app nav + // item's `pageName` and a modal action's `target` — and it is checked the + // same way for the same reasons, deliberately rather than incidentally: one + // reference kind should not answer to three different build-time policies. + // So it carries the identical `pageNames.size > 0` gate, whose meaning is + // stated once at the modal-action block above ("when no pages are defined the + // target may be provided by a plugin"), and `@objectstack/lint`'s + // `validateViewPageRefs` is what speaks when that gate has switched the check + // off — exactly the division `validate-nav-target-refs` already documents for + // the nav twin. + // + // A `page`-typed view whose `pageName` resolves to nothing is a view that + // appears in the object's view switcher and renders nothing when opened: + // unlike every other view type it has no rows to fall back to, so there is no + // degraded-but-visible state to notice it by. `ListViewSchema`'s own + // refinement has already guaranteed the key is present and well-formed by the + // time this runs; existence is the one question left, and only a whole-stack + // walk can ask it. + const checkViewPageRef = (listView: unknown, where: string): void => { + if (!listView || typeof listView !== 'object') return; + const lv = listView as { type?: unknown; pageName?: unknown }; + if (lv.type !== 'page' || typeof lv.pageName !== 'string') return; + if (pageNames.size === 0 || pageNames.has(lv.pageName)) return; + errors.push( + `${where} mounts page '${lv.pageName}' which is not defined in pages.`, + ); + }; + if (config.views) { + for (const [i, view] of config.views.entries()) { + const viewLabel = view.name ? `View '${view.name}'` : `View[${i}]`; + checkViewPageRef(view.list, `${viewLabel} list`); + for (const [key, lv] of Object.entries(view.listViews ?? {})) { + checkViewPageRef(lv, `${viewLabel} listViews.${key}`); + } + } + } + if (config.objects) { + for (const obj of config.objects) { + for (const [key, lv] of Object.entries(obj.listViews ?? {})) { + checkViewPageRef(lv, `Object '${obj.name}' listViews.${key}`); + } + } + } + if (config.actions) { for (const action of config.actions) { // Validate flow-type actions reference a defined flow diff --git a/packages/spec/src/ui/view.form.ts b/packages/spec/src/ui/view.form.ts index 1d34786747..f8dc2cdb4b 100644 --- a/packages/spec/src/ui/view.form.ts +++ b/packages/spec/src/ui/view.form.ts @@ -4,7 +4,8 @@ * View — canonical FormView layout. * * Views power most data surfaces (grid / kanban / calendar / gantt / - * gallery / timeline / chart) and each surface has its own block of + * gallery / timeline / chart — and `page`, which mounts a published page + * instead of drawing rows) and each surface has its own block of * options. We group fields by surface so the editor doesn't dump 30+ * irrelevant knobs on the user. * @@ -102,6 +103,26 @@ export const viewForm = defineForm({ visibleWhen: "data.type == 'chart'", fields: [{ field: 'chart', type: 'composite' }], }, + { + // [#13216] The `page` surface. Unlike every sibling block above, this one + // is a single binding rather than a composite: a `page` view delegates + // its whole rendering to the named page, so there is nothing else about + // it to configure here — every other control on this form describes how + // to draw ROWS, which a page view does not do. + name: 'page', + label: 'Page', + description: 'The published page this view mounts.', + visibleWhen: "data.type == 'page'", + fields: [ + { + field: 'pageName', + required: true, + helpText: + 'Name of an already-published page (lowercase snake_case). The page renders in place of ' + + 'the record list, so leave Columns empty.', + }, + ], + }, { name: 'end_user_controls', label: 'End-user controls', diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index f6bcdb6605..b8e733188c 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -3540,3 +3540,115 @@ describe('ViewSchema → JSON Schema derivation (Studio inspector path)', () => expect(() => z.toJSONSchema(FormFieldSchema, TO_JSON)).not.toThrow(); }); }); + +// ============================================================================ +// [#13216] `type: 'page'` — mounting an already-published page on an object view +// ============================================================================ + +/** + * The three doors a list view is judged by. Named ONE place because the binding + * check is attached at three separate points (zod 4 refuses `.omit()` and + * key-overwriting `.extend()` on a refined object, so the overlay and + * object-scoped shapes are built from the unrefined shape and re-attach it) — + * and a missing re-attachment is invisible: the door keeps accepting, which + * reads as "no rule violated" rather than "no rule ran". + * + * The overlay entry is the one that matters most and is the easiest to lose: + * it is `PUT /api/v1/meta/view`, the only door a Studio tenant or an MCP/AI + * author has. + */ +const viewDoorsCarryingPageMountCheck = [ + ['ListViewSchema', (body: Record) => ListViewSchema.safeParse(body)], + ['ObjectListViewSchema', (body: Record) => ObjectListViewSchema.safeParse(body)], + [ + 'flattened overlay (PUT /api/v1/meta/view)', + (body: Record) => + ViewMetadataSchema.safeParse({ name: 'crm_lead.dash', object: 'crm_lead', viewKind: 'list', ...body }), + ], +] as const; + +describe("ListViewSchema — the `page` view type (#13216)", () => { + it('accepts a page mount: `type: page` + `pageName` + empty columns', () => { + const parsed = ListViewSchema.parse({ type: 'page', pageName: 'sales_dashboard', columns: [] }); + expect(parsed.type).toBe('page'); + expect(parsed.pageName).toBe('sales_dashboard'); + }); + + it('keeps every pre-existing view type accepting exactly as before', () => { + const types = ['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'map', 'chart', 'tree'] as const; + for (const type of types) { + expect(ListViewSchema.safeParse({ type, columns: ['name'] }).success, type).toBe(true); + } + }); + + // The refusal that makes the enum member worth adding. Every OTHER view type + // degrades to a wrong-but-visible list when its binding is missing (which is + // why `checkViewCompleteness` only warns about those); a `page` view with no + // `pageName` has no rows to fall back to and renders nothing at all. + describe.each(viewDoorsCarryingPageMountCheck)('%s', (_label, parse) => { + it('REFUSES `type: page` with no `pageName`', () => { + const r = parse({ type: 'page', columns: [] }); + expect(r.success).toBe(false); + const issue = (r as { error: z.ZodError }).error.issues + .find((i) => i.path.join('.').endsWith('pageName')); + expect(issue, JSON.stringify((r as { error: z.ZodError }).error.issues)).toBeDefined(); + expect(issue!.message).toContain('needs to say WHICH page'); + }); + + it('REFUSES `pageName` on a view that is not `type: page` — the inert-key direction', () => { + const r = parse({ type: 'grid', pageName: 'sales_dashboard', columns: ['name'] }); + expect(r.success).toBe(false); + const issue = (r as { error: z.ZodError }).error.issues + .find((i) => i.path.join('.').endsWith('pageName')); + expect(issue).toBeDefined(); + expect(issue!.message).toContain("only read by a `type: 'page'` view"); + }); + + it('REFUSES a page mount that also declares columns — they are never read', () => { + const r = parse({ type: 'page', pageName: 'sales_dashboard', columns: ['name'] }); + expect(r.success).toBe(false); + const issue = (r as { error: z.ZodError }).error.issues + .find((i) => i.path.join('.').endsWith('columns')); + expect(issue).toBeDefined(); + expect(issue!.message).toContain('columns: []'); + }); + + it('accepts a well-formed page mount', () => { + expect(parse({ type: 'page', pageName: 'sales_dashboard', columns: [] }).success).toBe(true); + }); + }); + + // `pageName` carries `SnakeCaseIdentifierSchema` — the SAME grammar + // `PageSchema.name` is declared with — so the accepted set is exactly the set + // of strings that could name a page. A value outside it names nothing that + // can exist, and no collection lookup is needed to say so. + it('REFUSES a `pageName` outside the page-name grammar', () => { + const r = ListViewSchema.safeParse({ type: 'page', pageName: 'SalesDashboard', columns: [] }); + expect(r.success).toBe(false); + expect(r.success === false && r.error.issues.some((i) => i.path.join('.') === 'pageName')).toBe(true); + }); + + // `columns` stays REQUIRED on the list arm. The union tells its list and form + // overlay members apart by "no required `columns`, disjoint `type` enum" with + // the list arm tried first, so relaxing it to spare a page view the `[]` + // would let a flattened FORM overlay match the list arm and have its + // `sections` stripped. This pins the property the relaxation would break. + it('still tells a flattened FORM overlay apart from a list overlay', () => { + const form = ViewMetadataSchema.safeParse({ + name: 'crm_lead.edit', + object: 'crm_lead', + viewKind: 'form', + type: 'simple', + sections: [{ label: 'Basics', fields: [{ field: 'name' }] }], + }); + expect(form.success).toBe(true); + expect((form as { data: { sections?: unknown[] } }).data.sections).toHaveLength(1); + }); + + // `page` is deliberately NOT a visualization users can switch to: the + // switcher offers alternative ways to draw the same ROWS, and a page draws + // none. Pinned so the omission reads as a decision rather than an oversight. + it('does NOT add `page` to the visualization switcher whitelist', () => { + expect(VisualizationTypeSchema.safeParse('page').success).toBe(false); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 4e65db825e..668fb2d991 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1438,6 +1438,86 @@ const exportOptionsPdfUnionError = (issue: { input?: unknown }): string | undefi return declaresPdf ? LIST_VIEW_EXPORT_PDF_RETIRED : undefined; }; +/** + * [#13216] The `type: 'page'` mount refusals, in BOTH directions. + * + * `page` is the one member of the view `type` enum that renders nothing of its + * own: every other member describes how to draw the object's ROWS, and degrades + * to a wrong-but-visible list when its config block is missing (which is why + * `checkViewCompleteness`'s `VIEW_BINDING_BLOCKS` treats those as WARNINGs — the + * renderer falls back to literal default field names). A `page` view has no + * rows to fall back to: without `pageName` there is no page to hand the page + * renderer, so the view is not "degraded", it is empty. Nothing weaker than a + * parse refusal is honest about that, and the refusal is why `page` is + * deliberately absent from `VIEW_BINDING_BLOCKS` — see the note there. + * + * The reverse direction matters just as much and is the cheaper mistake to + * make: `pageName` on a `grid` (or any non-`page`) view is accepted-and-ignored, + * the failure mode this file's `VIEW_HISTORY` exists to record and the one that + * reads as working to whoever authored it — human or model. + * + * ## Why `columns` is checked here and the other inert keys are not + * + * `columns` is the ONLY required key on {@link ListViewSchema}, so a `page` view + * cannot be authored without writing one, and the only truthful value is `[]`. + * Leaving that unchecked would mean the platform forces an author to write a + * value and then ignores what they wrote. The optional type-specific blocks + * (`kanban` on a grid view, `chart` on a tree view, …) are NOT checked, here or + * anywhere: tolerating a stale optional block is this schema's standing + * disposition, and narrowing it is a separate decision about every view type, + * not a rider on this one. + * + * ⛔ `columns` is NOT made optional to spare the author the `[]`. The list and + * form overlay arms of {@link ViewMetadataSchema} are told apart by exactly two + * properties — "no required `columns`, disjoint `type` enum" — and the list arm + * is tried FIRST, so an optional `columns` would let a flattened FORM overlay + * that carries no explicit `type` match the LIST arm, default to `'grid'`, and + * have its `sections` silently stripped by that arm's `.strip()`. + */ +const VIEW_PAGE_MOUNT_NEEDS_PAGE_NAME = + "A `type: 'page'` view mounts a published page and has no rows of its own, so it needs to say " + + 'WHICH page: declare `pageName`. Unlike every other view type there is no fallback rendering — ' + + "without it the view is blank. Shape: `{ type: 'page', pageName: '', columns: [] }`."; + +const VIEW_PAGE_NAME_NEEDS_PAGE_TYPE = + "`pageName` is only read by a `type: 'page'` view (it names the published page that view mounts). " + + 'On any other view type it is accepted and never applied — the view renders as its own type and ' + + "the page is never reached. Set `type: 'page'` to mount the page, or remove `pageName`."; + +const VIEW_PAGE_MOUNT_HAS_COLUMNS = + "A `type: 'page'` view renders the page named by `pageName`, not a column list — the declared " + + 'columns are never read. `columns` is required on every list view, so write the empty list: ' + + '`columns: []`. (To show records in columns, use a row-rendering view type — `grid` and its ' + + 'siblings — or let the page itself declare the list it wants.)'; + +/** + * The `type` ⇄ `pageName` binding check attached to {@link ListViewSchema}. + * + * Attached with `.superRefine` rather than being folded into the shape because + * it is a relation BETWEEN two keys. Zod 4 attaches refinements to the schema + * itself, so `.extend()` and `.omit()` carry it — which is what puts this check + * on the flattened runtime overlay arm ({@link ViewMetadataSchema} member 3, + * the door a Studio tenant or an MCP/AI author writes through) and on + * {@link ObjectListViewSchema} (`objects[].listViews.*`) without a second copy. + */ +function checkListViewPageMount( + view: { type?: string; pageName?: string; columns?: unknown }, + ctx: z.RefinementCtx, +): void { + const isPageMount = view.type === 'page'; + const hasPageName = typeof view.pageName === 'string' && view.pageName.length > 0; + + if (isPageMount && !hasPageName) { + ctx.addIssue({ code: 'custom', path: ['pageName'], message: VIEW_PAGE_MOUNT_NEEDS_PAGE_NAME }); + } + if (!isPageMount && hasPageName) { + ctx.addIssue({ code: 'custom', path: ['pageName'], message: VIEW_PAGE_NAME_NEEDS_PAGE_TYPE }); + } + if (isPageMount && Array.isArray(view.columns) && view.columns.length > 0) { + ctx.addIssue({ code: 'custom', path: ['columns'], message: VIEW_PAGE_MOUNT_HAS_COLUMNS }); + } +} + /** * List View Schema (Expanded) * Defines how a collection of records is displayed to the user. @@ -1465,7 +1545,24 @@ const exportOptionsPdfUnionError = (issue: { input?: unknown }): string | undefi * } * } */ -export const ListViewSchema = lazySchema(() => strictObject({ +/** + * The unrefined SHAPE behind {@link ListViewSchema}, module-private. + * + * Split out for exactly one reason: zod 4 refuses `.omit()` on an object schema + * that carries refinements (`".omit() cannot be used on object schemas + * containing refinements"`, thrown at construction), and + * {@link ObjectListViewSchema} is built by omitting `userFilters` from this + * shape. So the shape stays refinement-free and BOTH terminals attach + * {@link checkListViewPageMount} themselves — one check function, two + * attachment points, no second copy of the rule. + * + * ⛔ Not exported, deliberately: a top-level EXPORTED schema binding mints a + * new protocol def in `json-schema.manifest/` and a full set of ratcheted + * `authorable-surface/` keys, so publishing this shape would duplicate every + * `ui/ListView` key under a second name (the same argument + * {@link VIEW_METADATA_MEMBERS} records for the union's members). + */ +const ListViewShapeSchema = lazySchema(() => strictObject({ surface: 'this list view', history: VIEW_HISTORY, guidance: { @@ -1489,7 +1586,8 @@ export const ListViewSchema = lazySchema(() => strictObject({ 'gantt', // Project Timeline 'map', // Geospatial 'chart', // Aggregate visualisation - 'tree' // Self-referencing hierarchy (tree-grid) + 'tree', // Self-referencing hierarchy (tree-grid) + 'page' // Mount an already-published `page` — see `pageName` below ]).default('grid'), /** Data Source Configuration */ @@ -1586,6 +1684,33 @@ export const ListViewSchema = lazySchema(() => strictObject({ map: ListMapConfigSchema.optional().describe('Map configuration — applies when the view renders as a map layout'), tree: TreeConfigSchema.optional().describe('Tree/hierarchy configuration — applies when the view renders as a tree layout'), + /** + * The published `page` a `type: 'page'` view mounts. + * + * Named `pageName` because that is what a page reference is already called on + * every other surface that carries one — `PageNavItemSchema.pageName` in + * `app.zod.ts`, the collection it resolves against (`stack.pages`), and the + * lint rule that resolves it (`nav-target-unresolved`). A second spelling for + * the same reference would split the one thing an author has to learn. + * + * Grammar is {@link SnakeCaseIdentifierSchema} — the SAME schema + * `PageSchema.name` is declared with — rather than the bare `z.string()` + * the nav item uses, so the set of values this key accepts is exactly the set + * of strings that could name a page. A `pageName` outside that grammar names + * nothing that can exist, and saying so at parse is cheaper than resolving it + * against a collection. (The nav item stays a bare string: its targets may be + * `${…}`-interpolated at render time, which a view mount has no path to.) + * + * Existence of the target is a separate question with its own answers, on the + * doors that can see the collection: `defineStack`'s `validateCrossReferences` + * (build-time, throws) and `validateViewPageRefs` in `@objectstack/lint` + * (the CLI commands and the runtime publish gate). + */ + pageName: SnakeCaseIdentifierSchema.optional().describe( + "Published page this view mounts — required when `type: 'page'`, and refused on every other view type. " + + 'Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience.', + ), + /** View Metadata (Airtable-style view management) */ description: I18nLabelSchema.optional().describe('View description for documentation/tooltips'), sharing: ViewSharingSchema.optional().describe('View sharing and access configuration'), @@ -1743,6 +1868,15 @@ export const ListViewSchema = lazySchema(() => strictObject({ ), })); +/** + * List View Schema (Expanded) — {@link ListViewShapeSchema} plus the + * `type: 'page'` ⇄ `pageName` binding check. See that shape for why the two are + * separate bindings, and {@link checkListViewPageMount} for what the check + * refuses. + */ +export const ListViewSchema = lazySchema(() => + ListViewShapeSchema.superRefine(checkListViewPageMount)); + /** * [#12868] Form-view select option — {@link SelectOptionSchema} minus the * per-option `default` key (maintainer-ruled narrowing, 2026-08-28, on the @@ -3088,7 +3222,14 @@ export const ObjectUserFiltersSchema = lazySchema(() => strictObject({ * reports it pre-parse. See objectui #2338. */ export const ObjectListViewSchema = lazySchema(() => - ListViewSchema.omit({ userFilters: true }).extend({ userFilters: ObjectUserFiltersSchema.optional() })); + ListViewShapeSchema.omit({ userFilters: true }) + .extend({ userFilters: ObjectUserFiltersSchema.optional() }) + // Derived from the UNREFINED shape (zod 4 refuses `.omit()` on a refined + // object), so the binding check is re-attached here rather than inherited. + // Dropping this line would leave `objects[].listViews.*` — the ADR-0047 + // authoring surface — as the one door where a `page` view with no + // `pageName` parses clean. + .superRefine(checkListViewPageMount)); /** * [#4001/#7741] The wrap remedy, ONE prose source for two doors: the container's @@ -3991,7 +4132,16 @@ const ViewContainerWireSchema = lazySchema(() => * measured serving filter that decides exactly this pair. */ const ListViewOverlayWireSchema = lazySchema(() => - ListViewSchema.extend(flattenedViewOverlayFields()).strip(), + // [#13216] Built from {@link ListViewShapeSchema}, not {@link ListViewSchema}: + // `flattenedViewOverlayFields()` re-declares `name` and `label`, and zod 4 + // refuses to overwrite a key on an object that carries refinements. So the + // binding check is re-attached AFTER `.strip()` instead of inherited — this + // is the runtime write door (`PUT /api/v1/meta/view`), the one an MCP/AI + // author reaches, so it is the last place the refusal may go missing. + // `viewDoorsCarryingPageMountCheck` in `view.test.ts` fails if any of the + // three attachment points is dropped. + ListViewShapeSchema.extend(flattenedViewOverlayFields()).strip() + .superRefine(checkListViewPageMount), ); /** diff --git a/skills/objectstack-ui/contracts/react-blocks.contract.json b/skills/objectstack-ui/contracts/react-blocks.contract.json index a15226d04a..dc37170501 100644 --- a/skills/objectstack-ui/contracts/react-blocks.contract.json +++ b/skills/objectstack-ui/contracts/react-blocks.contract.json @@ -329,7 +329,7 @@ }, { "name": "type", - "type": "'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'", + "type": "'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree' | 'page'", "kind": "data", "required": false, "description": "" diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index 7d4f560008..6e1b6923d7 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -67,7 +67,7 @@ Server-connected object table with toolbar and switchable visualizations (grid/k | `onRowClick` | `(record) => void` | callback | | Called with the clicked row's record — the hook for master/detail. | | `onNavigate` | `(recordId, action: 'view' \| 'edit') => void` | callback | | Called for page-level navigation. | | `columns` | `string[] \| object[]` | data | ✓ | Fields to display as columns | -| `type` | `'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'` | data | | | +| `type` | `'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree' \| 'page'` | data | | | | `data` | `object` | data | | Data source configuration (defaults to "object" provider) | | `sort` | `string \| object[]` | data | | | | `searchableFields` | `string[]` | data | | Fields enabled for search |