From 79a6abbc9e534baae0669e7a8d02b5641ef898d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 12:31:54 +0000 Subject: [PATCH 1/3] fix(console): the record header reads placedActions, so a packaged sys_user header action draws again (#29951) Ruling A on #29951 (record 6624172200), ui half of #30018's spec key. - PageHeaderActions (@object-ui/components) reads page:header's placedActions: true and draws every action of the page's object placed at record_header / record_more, read live, through placedHeaderActions (@object-ui/types), the one placed-header reader RecordDetailView's synthesized header now reads too. Omitted or false, an authored actions list is still the whole set (#11592). A true beside a list draws none of the object's actions and is reported once. The page:header registration publishes the input. - sys_user_detail, sys_organization_detail and sys_approval_request_detail set placedActions: true in place of the id lists #27219 froze. Tab strips unchanged. - Drop the "not drawn yet" qualifiers: the describe marker (reference regenerated), the page liveness PLANNED record (re-graded live), the registry-inputs parity exemption, pages.mdx and layout-dsl.mdx. The skills/objectstack-ui/rules/pages.md qualifier goes in a separate PR (Tier H). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzh8VRbTbBnqwnrXsSL --- ...29951-page-header-placed-actions-reader.md | 18 ++ content/docs/protocol/objectui/layout-dsl.mdx | 6 +- content/docs/references/ui/component.mdx | 2 +- content/docs/ui/components/index.mdx | 2 +- content/docs/ui/components/page.mdx | 1 + content/docs/ui/pages.mdx | 2 +- .../pages/record-page-header-actions.test.ts | 49 ++-- .../src/pages/sys-organization.page.ts | 20 +- .../src/pages/sys-user.page.ts | 36 +-- .../src/sys-approval-request-page.test.ts | 7 +- .../src/sys-approval-request.page.ts | 16 +- packages/spec/liveness/page.json | 2 +- packages/spec/src/ui/component.test.ts | 11 +- packages/spec/src/ui/component.zod.ts | 19 +- scripts/sdui-manifest.record.json | 6 +- sdui.manifest.json | 5 + .../registry-inputs-spec-parity.test.ts | 6 +- ...ailView.placedHeaderActions-29951.test.tsx | 262 ++++++++++++++++++ .../app-shell/src/views/RecordDetailView.tsx | 15 +- .../src/renderers/layout/containers.tsx | 78 +++++- ui/packages/types/src/index.ts | 1 + ui/packages/types/src/ui-action.ts | 28 ++ 22 files changed, 500 insertions(+), 92 deletions(-) create mode 100644 .changeset/29951-page-header-placed-actions-reader.md create mode 100644 ui/packages/app-shell/src/views/RecordDetailView.placedHeaderActions-29951.test.tsx diff --git a/.changeset/29951-page-header-placed-actions-reader.md b/.changeset/29951-page-header-placed-actions-reader.md new file mode 100644 index 00000000000..156bc3c88b3 --- /dev/null +++ b/.changeset/29951-page-header-placed-actions-reader.md @@ -0,0 +1,18 @@ +--- +'@object-ui/components': patch +'@object-ui/types': patch +'@object-ui/app-shell': patch +'@objectstack/spec': patch +'@objectstack/platform-objects': patch +'@objectstack/plugin-approvals': patch +--- + +fix(console): an action another package places on `sys_user` at `record_header` / `record_more` draws again on the user's record page, in the drawer and on the full page (#29951) + +Clause-②: no +Metadata-protocol: none + +- **The header reads `placedActions: true`.** `page:header`'s `placedActions` (#30018) is now drawn by the console's header renderer (`PageHeaderActions` in `@object-ui/components`): `true` draws every action of the page's object whose `locations` include `record_header` or `record_more`, in declaration order, read from the object's metadata at render time, so an action an installed package adds to the object is drawn without editing the page. Omitted or `false`, an authored `actions` list is still the whole set (#11592). A `true` beside an authored list, which the spec refuses, draws none of the object's actions and is reported once in the console. +- **One placed-header reader.** `placedHeaderActions` (`@object-ui/types`) is the rule both headers read: the synthesized default record page's header in `RecordDetailView` and a `placedActions: true` header. The `page:header` registration publishes the `placedActions` input. +- **The platform record pages opt in.** `sys_user_detail` and `sys_organization_detail` (`@objectstack/platform-objects`) and `sys_approval_request_detail` (`@objectstack/plugin-approvals`) set `placedActions: true` in place of the id lists #27219 froze, which dropped every header action another package places on those objects. The tab strips are unchanged, and every action the lists named is placed at `record_header` / `record_more`, so each still draws. +- `@objectstack/spec`: the `placedActions` describe no longer opens with `[PLANNED — not yet rendered]`, and the `page` liveness record names the reader. diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index 82ac1bf80ec..5c3dfde45cd 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -93,9 +93,9 @@ Delete chrome). The list is the whole set the header draws: an action that declares `record_header` but is not named is not drawn there, and a header with no `actions` draws none of the object's own actions. `placedActions: true` in place of the list asks for every action placed at `record_header` / -`record_more`, read live; it is refused beside a list, and the console's header -renderer does not draw it yet. Declare the action, placed at `record_header`, -then name it: +`record_more`, read live, so an action another package places on the object is +drawn without editing the page; it is refused beside a list. Declare the action, +placed at `record_header`, then name it: {/* os:check-yaml action */} ```yaml diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 039d05a2a87..7a85a1df5dc 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -1575,7 +1575,7 @@ An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules ma | **subtitle** | `string \| Record` | optional | Page subtitle | | **breadcrumb** | `never` | optional | [REMOVED] `page:header` property `breadcrumb` was removed in @objectstack/spec 17 (ADR-0087 D2) — no renderer ever drew a trail for it: objectui drew an empty slot and nothing filled it, and the navigation trail is drawn once, by the app shell's header. Delete the key, whether it was `true` or `false`; the shell's trail is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **actions** | `string[]` | optional | Action IDs the header draws, resolved against the actions declared on the page's object. An authored list is the whole set: an action that declares `record_header` (or `record_more`) in its `locations` but is not named here is not drawn, and a header with neither this key nor `placedActions: true` draws none of the object's actions. `placedActions: true` is the other way to say which actions the header draws (every one placed at `record_header` / `record_more`, read live), and the two are refused together. A named action that declares neither location renders nowhere. The host's own Edit / Share / Delete chrome is separate from this list. | -| **placedActions** | `boolean` | optional | [PLANNED — not yet rendered] Draw every action of the page's object whose `locations` include `record_header` or `record_more`, read live from the object's metadata, in place of an authored `actions` list: an action added to the object later, by any package, is drawn without editing the page. Omit it, or set false, to keep the `actions` list as the whole set. Refused as `true` beside an authored `actions` list: keep one. | +| **placedActions** | `boolean` | optional | Draw every action of the page's object whose `locations` include `record_header` or `record_more`, read live from the object's metadata, in place of an authored `actions` list: an action added to the object later, by any package, is drawn without editing the page. Omit it, or set false, to keep the `actions` list as the whole set. Refused as `true` beside an authored `actions` list: keep one. | | **recordChrome** | `boolean` | optional (default: `true`) | Render the record chrome — the title as a record chip with its copy-id button. Set false on a non-record page (dashboard, landing) to fall back to the bare heading layout. | | **showStar** | `never` | optional | [REMOVED] `page:header` property `showStar` was removed in @objectstack/spec 18 (ADR-0087 D2) — the reference console draws no star beside the record title, so the key switched nothing: the record's favorites entry is the header's ⋯ menu, and metadata does not control it. Delete the key, whether it was `true` or `false`; the ⋯ menu's favorites entry is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **showCopyId** | `boolean` | optional (default: `true`) | Show the copy-record-id button beside the record title. Part of the record chrome — no effect when `recordChrome` is false. | diff --git a/content/docs/ui/components/index.mdx b/content/docs/ui/components/index.mdx index 3f9d08f8b66..e74b3c4c9b5 100644 --- a/content/docs/ui/components/index.mdx +++ b/content/docs/ui/components/index.mdx @@ -109,7 +109,7 @@ regenerates from the renderer registry. Regenerate it with | [`page:accordion`](/docs/ui/components/page#page-accordion) | `page` | 3 | [Page Structure](/docs/ui/components/page) | | [`page:card`](/docs/ui/components/page#page-card) | `page` | 4 | [Page Structure](/docs/ui/components/page) | | [`page:footer`](/docs/ui/components/page#page-footer) | `page` | 1 | [Page Structure](/docs/ui/components/page) | -| [`page:header`](/docs/ui/components/page#page-header) | `page` | 7 | [Page Structure](/docs/ui/components/page) | +| [`page:header`](/docs/ui/components/page#page-header) | `page` | 8 | [Page Structure](/docs/ui/components/page) | | [`page:section`](/docs/ui/components/page#page-section) | `page` | 1 | [Page Structure](/docs/ui/components/page) | | [`page:sidebar`](/docs/ui/components/page#page-sidebar) | `page` | 1 | [Page Structure](/docs/ui/components/page) | | [`page:tabs`](/docs/ui/components/page#page-tabs) | `page` | 4 | [Page Structure](/docs/ui/components/page) | diff --git a/content/docs/ui/components/page.mdx b/content/docs/ui/components/page.mdx index 1858379db2b..09bd86351fa 100644 --- a/content/docs/ui/components/page.mdx +++ b/content/docs/ui/components/page.mdx @@ -52,6 +52,7 @@ Namespace `page`. | `title` | `string` \| `object` | | Supports \{field\} interpolation and inline translation maps; falls back to the record title | | `subtitle` | `string` \| `object` | | Same interpolation as Title | | `actions` | `array` of `string` | | Action IDS — the names of actions declared on the object's own metadata — rendered in the header before any host-injected system actions. An id whose action declares neither record_header nor record_more in its locations renders nowhere. | +| `placedActions` | `boolean` | | Set true to draw every action of the object placed at record_header or record_more, read live from the object's metadata, in place of an Actions list: an action another package adds to the object is drawn without editing the page. Refused beside an authored Actions list: keep one. | | `recordChrome` | `boolean` | | Set false for the bare h1 header on non-record pages | | `showCopyId` | `boolean` | | — | | `maxVisible` | `number` | | How many header actions render as inline buttons on desktop before the rest fold into the overflow menu. A positive integer — the contract rejects 0 and fractional values. Two kinds of action are routed to the overflow menu regardless of this budget and never occupy an inline slot: an action whose locations declare record_more without record_header, and any action with component action:menu. The host's own buttons (the record page's Edit) take no slot and never fold. The first inline action, or the one declaring variant primary, is the one filled primary button; the others are outline buttons. An action declaring variant danger is never the primary: inline it is a destructive button, and in the overflow menu it is a red row below a separator. | diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index 4b9b0968395..ff4d1691a8f 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -296,7 +296,7 @@ unknown or retired key (`description`, `icon`, `breadcrumb`, `showStar`) is refu | `title` | Derived from the record on a record page | Heading; `{field}` tokens interpolate against the record, and a token that resolves to nothing renders empty. Set it on a non-record page | | `subtitle` | — | The line under the title, interpolated the same way | | `actions` | — | Ids of actions declared on the page's object. An authored list is the whole set: an action that declares `record_header` but is not named is not drawn, and omitting the key draws none of the object's actions. `placedActions` is the other way to say which actions | -| `placedActions` | — | `true` asks for every action of the object placed at `record_header` / `record_more`, read live, in place of an `actions` list, so an action another package adds is drawn without editing the page. Refused beside an authored `actions` list: keep one. Declared ahead of the console's header renderer, which does not draw it yet; today only the synthesized page draws every placed action | +| `placedActions` | — | `true` asks for every action of the object placed at `record_header` / `record_more`, read live, in place of an `actions` list, so an action another package adds is drawn without editing the page. Refused beside an authored `actions` list: keep one. The platform's user, organization and approval request pages set it | | `recordChrome` | `true` | Draw the title as the record chip (object label, copy-id button, the record's picture when the object names an `imageField`); `false` keeps a bare heading | | `showCopyId` | `true` | The copy-record-id button in the chip | | `maxVisible` / `mobileMaxVisible` | 3 / 1 | How many actions render inline before the rest fold into the `⋯` menu | diff --git a/packages/platform-objects/src/pages/record-page-header-actions.test.ts b/packages/platform-objects/src/pages/record-page-header-actions.test.ts index cdf5f629066..4cdb7ec915e 100644 --- a/packages/platform-objects/src/pages/record-page-header-actions.test.ts +++ b/packages/platform-objects/src/pages/record-page-header-actions.test.ts @@ -1,20 +1,26 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #27053 — the two platform record pages name the header actions the - * synthesizer used to read live from the object. + * #29951 — the two platform record pages draw the object's placed header + * actions LIVE, through `page:header.placedActions: true` (#30018, ruling + * record 6624172200, letter A), never a copied id list. * - * Both pages were `kind: 'slotted'` until the `tabs` slot retired; the - * synthesized header they rendered carried every object action declared at - * `record_header` or `record_more`, in declaration order. As `kind: 'full'` - * pages they name those action ids themselves, so the list is a copy. This - * pin holds the copy equal to the object's declarations, so an action added - * to either object at a header location cannot silently miss its page. + * Both pages were `kind: 'slotted'` until the `tabs` slot retired (#27053); + * the synthesized header they rendered read every object action placed at + * `record_header` or `record_more` at render time, an action another package + * places on the object included. Rebuilt as `kind: 'full'` pages they named + * the platform's own ids instead, and an authored id list is the whole set + * (#11592), so an installed package's header action on `sys_user` stopped + * drawing in the 18.3 Console (#29951). This pin holds the opt-in on each page + * and no list beside it (the two are refused together), so the header cannot + * freeze again. * - * Read from the shipped page and object metadata. + * Read from the shipped page and object metadata, and parsed through the + * `page:header` row the props door judges (`ComponentPropsMap`). */ import { describe, expect, it } from 'vitest'; +import { ComponentPropsMap } from '@objectstack/spec/ui'; import { SysOrganization } from '../identity/sys-organization.object.js'; import { SysUser } from '../identity/sys-user.object.js'; import { SysOrganizationDetailPage } from './sys-organization.page.js'; @@ -22,8 +28,8 @@ import { SysUserDetailPage } from './sys-user.page.js'; type Rec = Record; -/** The ids the synthesized header drew: header-located actions, declaration order, first of a name. */ -function headerLocatedIds(object: { actions?: unknown }): string[] { +/** The object's actions placed at the header, declaration order, first of a name. */ +function headerPlacedIds(object: { actions?: unknown }): string[] { const ids: string[] = []; for (const action of (object.actions ?? []) as Rec[]) { const locations = (action.locations ?? []) as string[]; @@ -34,22 +40,27 @@ function headerLocatedIds(object: { actions?: unknown }): string[] { return ids; } -/** The `page:header` node's authored action ids. */ -function pageHeaderIds(page: { regions?: unknown }): unknown { +/** The page's `page:header` node properties. */ +function headerProps(page: { regions?: unknown }): Rec | undefined { const components = ((page.regions as Rec[])[0]!.components ?? []) as Rec[]; const header = components.find((c) => c.type === 'page:header'); - return (header?.properties as Rec | undefined)?.actions; + return header?.properties as Rec | undefined; } describe.each([ ['sys_organization_detail', SysOrganizationDetailPage, SysOrganization], ['sys_user_detail', SysUserDetailPage, SysUser], -] as const)('%s — the header names the object\'s header-located actions', (_name, page, object) => { - it('is non-vacuous: the object declares header-located actions', () => { - expect(headerLocatedIds(object).length).toBeGreaterThan(0); +] as const)('%s — the header draws the object\'s placed actions live', (_name, page, object) => { + it('is non-vacuous: the object places actions at the header', () => { + expect(headerPlacedIds(object).length).toBeGreaterThan(0); }); - it('names exactly those ids, in declaration order', () => { - expect(pageHeaderIds(page)).toEqual(headerLocatedIds(object)); + it('opts in with placedActions: true and names no id list', () => { + expect(headerProps(page)).toEqual({ placedActions: true }); + }); + + it('is a header the props door accepts', () => { + const parsed = ComponentPropsMap['page:header'].safeParse(headerProps(page)); + expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true); }); }); diff --git a/packages/platform-objects/src/pages/sys-organization.page.ts b/packages/platform-objects/src/pages/sys-organization.page.ts index 07c59a817f2..3acf7c0a1f1 100644 --- a/packages/platform-objects/src/pages/sys-organization.page.ts +++ b/packages/platform-objects/src/pages/sys-organization.page.ts @@ -17,10 +17,12 @@ import type { Page } from '@objectstack/spec/ui'; * to what the slotted page rendered — the synthesized default with the strip * swapped in — node for node: * - * - `page:header` carrying the object's `record_header` actions (Set Active, - * Invite User, Leave Organization, Rename Organization — Edit and Delete are - * row actions only), the list the synthesized header carried. A full page - * renders exactly the nodes it declares, so the header is spelled out. + * - `page:header` with `placedActions: true` (#29951): every action placed on + * `sys_organization` at `record_header` / `record_more`, read live (Set + * Active, Invite User, Leave Organization, Rename Organization — Edit and + * Delete are row actions only — and any an installed package places + * there), the set the synthesized header carried. A full page renders + * exactly the nodes it declares, so the header node is spelled out. * - `record:highlights` over the object's `highlightFields` minus the title * field (`slug`; `name` is the page H1). * - the Members / Invitations / Teams strip, unchanged, Members at index 0 @@ -56,13 +58,15 @@ export const SysOrganizationDetailPage: Page = { name: 'main', width: 'full', components: [ - // The synthesized header's action list: every `sys_organization` - // action declaring `record_header` or `record_more`, in declaration - // order. The host appends its own system actions after these. + // The synthesized header's action set, read live: every action placed + // on `sys_organization` at `record_header` or `record_more`, in + // declaration order, including one another package places there (a + // frozen id list dropped those, #29951). The host appends its own + // system actions after these. { type: 'page:header', properties: { - actions: ['set_active_organization', 'invite_user', 'leave_organization', 'rename_organization'], + placedActions: true, }, }, { type: 'record:highlights', properties: { fields: ['slug'] } }, diff --git a/packages/platform-objects/src/pages/sys-user.page.ts b/packages/platform-objects/src/pages/sys-user.page.ts index 17101336c81..8a6ce551693 100644 --- a/packages/platform-objects/src/pages/sys-user.page.ts +++ b/packages/platform-objects/src/pages/sys-user.page.ts @@ -40,11 +40,13 @@ const TWO_FACTOR_OFFERED = P`has(features.twoFactor) && features.twoFactor == tr * so it is drawn as authored at every width. It was a `kind: 'slotted'` * page overriding `alerts`, `highlights`, `tabs` and `discussion` until the * `tabs` slot retired (#27053); the rebuild renders the same nodes in the - * same order. The header the synthesizer used to supply is spelled out as - * a `page:header` carrying the object's declared header actions - * (`ban_user / impersonate_user / … / update_my_profile / - * change_my_password / resend_verification_email / …`), so they still - * appear in the header and its overflow menu. + * same order. The header the synthesizer used to supply is a `page:header` + * with `placedActions: true` (#29951): it draws every `sys_user` action + * placed at `record_header` / `record_more`, read live, so the platform's + * own (`ban_user / impersonate_user / … / update_my_profile / + * change_my_password / …`) and any an installed package places on + * `sys_user` appear in the header and its overflow menu, as they did on + * the synthesized header. * - `record:highlights` promotes the signals worth scanning at the top: * email, phone, verification state. Highlight fields are auto-dropped * from the details grid below. 2FA state is not one of them: it is @@ -84,27 +86,15 @@ export const SysUserDetailPage: Page = { width: 'full', components: [ // ── The record header ───────────────────────────────────────── - // The synthesized header's action list, spelled out because a full - // page renders exactly the nodes it declares: every `sys_user` action - // declaring `record_header` or `record_more`, in declaration order - // (admin actions, then the self-service ones). The host appends its - // own system actions after these. + // The synthesized header's action set, read live: every action placed + // on `sys_user` at `record_header` or `record_more`, in declaration + // order (admin actions, then the self-service ones), then any action + // another package places there. A frozen id list dropped those + // (#29951). The host appends its own system actions after these. { type: 'page:header', properties: { - actions: [ - 'ban_user', - 'unban_user', - 'unlock_user', - 'set_user_password', - 'impersonate_user', - 'set_user_manager', - 'update_my_profile', - 'change_my_password', - 'change_my_email', - 'resend_verification_email', - 'delete_my_account', - ], + placedActions: true, }, }, // ── Alert banners ───────────────────────────────────────────── diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts index d292cf14fc1..0d88774f5e0 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts @@ -138,9 +138,12 @@ describe('sys_approval_request detail page — the page body', () => { // The nodes the slotted page rendered, in the synthesizer's order: the // header it fell through to, its `actions` slot, its `highlights` slot and // its tab strip, whose first tab is the details body. Its `discussion` - // slot was empty, so there is no discussion node. + // slot was empty, so there is no discussion node. The header reads the + // object's placed header actions live, as the synthesized one did: a bare + // `page:header` drew none, so an action another package places on the + // object at `record_header` was dropped (#29951). expect(COMPONENTS.map((c) => c.type)).toEqual(['page:header', 'record:approval_decision', 'record:highlights', 'page:tabs']); - expect(nodeOf('page:header')).toEqual({ type: 'page:header' }); + expect(nodeOf('page:header')).toEqual({ type: 'page:header', properties: { placedActions: true } }); }); it('holds ONE record:approval_decision node, with no properties (its props row is empty and strict)', () => { diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request.page.ts b/packages/plugins/plugin-approvals/src/sys-approval-request.page.ts index 9a571e5871c..ecca1cecd85 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request.page.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request.page.ts @@ -21,7 +21,11 @@ import type { Page } from '@objectstack/spec/ui'; * drawn as authored at every width. It was a `kind: 'slotted'` page until * the `tabs` slot retired (#27053); the rebuild renders the same nodes in * the same order. Its `page:header` is the one the synthesizer supplied: - * the request's title, and the header actions the object declares (none). + * the request's title, and the object's placed header actions read live + * (`placedActions: true`, #29951). The object places none of its own (its + * decision actions are `record_section` ones, drawn by the panel below), so + * the header draws only what an installed package places on + * `sys_approval_request` at `record_header` / `record_more`. * - After the header, ONE `record:approval_decision` node: the request's * decision progress and its declared decision actions, the reply * (`approval_comment`) included, in one panel. The type is declared with an @@ -92,10 +96,12 @@ export const SysApprovalRequestDetailPage: Page = { name: 'main', width: 'full', components: [ - // ── The record header: the request's title. The object declares no - // `record_header` / `record_more` action, so it carries none, exactly - // as the synthesized header did; the host appends its system actions. - { type: 'page:header' }, + // ── The record header: the request's title, and every action placed + // on the object at `record_header` / `record_more`, read live, as the + // synthesized header drew them. The object places none of its own, so + // only an action another package places there shows (a bare header + // dropped it, #29951); the host appends its system actions. + { type: 'page:header', properties: { placedActions: true } }, // ── The decision panel ──────────────────────────────────────── { type: 'record:approval_decision' }, diff --git a/packages/spec/liveness/page.json b/packages/spec/liveness/page.json index f2679bbdab8..23a9864e0ba 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -1,6 +1,6 @@ { "type": "page", - "_note": "PageSchema (UI). Renderers live in objectui; objectui paths are cited with the `objectui:` realm marker (in `evidence` or `note` prose) and counted, not resolved against this repo (#5623). Seeded from the Studio page-design dogfood (framework#2254/#2261/#2265). Containers (variables/regions/interfaceConfig/slots/aria) classified at top level — one drill level, no divergent sub-statuses. The removed roadmap page types (record_review/blank) and their config fields (recordReview/blankLayout) were hard-removed — no longer authorable. RETIREMENT RECORD (#11027, 2026-08-24): `regions[].components[].responsive` is a retiredKey tombstone (ADR-0049 D2) — measured dead across both repos (zero callers of either objectui consumer implementation); the shared `ResponsiveConfigSchema` left with it. The walk drills one level only, so no component-level key has a row here (`page/regions` stays RECORDED in undrilled-containers.baseline.json — #4956's page-side instance, still open for the other component keys); the checkable record of THIS retirement is the authorable-surface `ui/PageComponent:responsive [RETIRED]` row, RETIRED_KEYS_BY_MAJOR[18], and the `page-component-responsive-removed` conversion. LIVE RECORD (#23016, 2026-10-12, maintainer ruling in the card body; rewritten from its PLANNED record by #24300 once the renderer shipped): `regions[].components[].dataSource` gained a second shape, the API binding `ElementApiDataSourceSchema` (`{ provider: 'api', read: { url, method? }, path? }`, `read.url` a same-origin API path refused at parse otherwise, no credential key) — LIVE: measured at the console pin `.objectui-sha` @0707b4ae3712, which carries the renderer, objectui @c4dd7078ed (objectstack-ai/objectui#12197). The console reads it on every page element (objectui: packages/react/src/SchemaRenderer.tsx calls useElementApiDataSource for each node it renders; the read is objectui: packages/react/src/element-data-source/useElementApiDataSource.ts, which parses the binding with this schema first, sends one GET to `read.url` through the host's signed-in fetch and selects `path`) and binds the selected value as `data` in the element's expression holes, in its `visibleWhen` and in its subtree, drawing a loading or error panel until the value is in; the generic stat / usage-bar / checklist blocks take it as their `dataSource` (objectui: packages/app-shell/src/console/blocks/productBlocks.tsx). No row carries it because the walk never reaches a component-level key (`page/regions` and `page/slots` are recorded undrilled containers) and `childShape()` does not descend the `z.union` the key now is; the checkable record is the authorable-surface `ui/ElementApiDataSource:*` rows and the schema's own describe, which no longer carries the `[PLANNED — not yet rendered]` marker. LIVE RECORD (#23134, 2026-10-13, ruling B on objectstack-ai/objectui#12296, ruling comment 6137226306; rewritten from its PLANNED record by #23585 once the renderer shipped): `regions[].components[].properties.parentDataSource` on `record:related_list` (`RecordRelatedListProps` in packages/spec/src/ui/component.zod.ts) is the block's PARENT-record binding, the element object binding `ElementDataSourceSchema` by reference, read by the single-record rule (the first row by `sort`; no row is a loud absence, never an empty list); the node-level `dataSource` on the block keeps its child-row meaning (#6953). LIVE: measured at the console pin `.objectui-sha` @4016370af3c9, which carries the renderer, objectui @d81e46043 (objectstack-ai/objectui#12407, the related-list leg of objectstack-ai/objectui#12294). The block hands the key to its parent read (objectui: packages/plugin-detail/src/renderers/record-related-list.tsx, whose RecordRelatedListRenderer passes `schema.parentDataSource` to ParentRecordBinding; the read is objectui: packages/plugin-detail/src/renderers/recordBinding.tsx, whose BoundRecordProvider asks the page's own adapter, so under the caller's identity, for one row (`$top: 1`) by the binding's `sort` and provides it as the record context the list body reads, `relationshipValueField` included). No row, or a read the server refuses as forbidden, draws the block's loud absence (NoParentRecord) in its place and queries no child rows; with the key absent, ParentRecordBinding leaves the list as it is and the record page's record context remains the parent. No row carries it for the reason above (component-level keys sit under the recorded undrilled `page/regions` container); the checkable record is the authorable-surface `ui/RecordRelatedListProps:parentDataSource` row and the key's own describe, which no longer carries the `[PLANNED — not yet rendered]` marker. PLANNED RECORD (#24266, 2026-10-18, objectstack-ai/cloud#2774 Q2 = B as the card quotes it): `regions[].components[].properties.emptyState` on the three bound record blocks `record:details`, `record:highlights` and `record:quick_actions` (`RecordDetailsProps`, `RecordHighlightsProps`, `RecordQuickActionsProps` in packages/spec/src/ui/component.zod.ts) is the list view's own `EmptyStateSchema` by reference, drawn only when the block's node-level `dataSource` binding answers no row. PLANNED, not live: measured at objectui @79cb8c03e, the three blocks take their binding through objectui: packages/plugin-detail/src/renderers/recordBinding.tsx, which mounts a 0-row answer as a record context with no record, so each draws its own placeholder and nothing reads the key. Carrier: objectstack-ai/objectui#12932, dispatched once a spec release carrying the key is installable. No row carries it for the reason above (component-level keys sit under the recorded undrilled `page/regions` container); the checkable record is the authorable-surface `ui/RecordDetailsProps:emptyState`, `ui/RecordHighlightsProps:emptyState` and `ui/RecordQuickActionsProps:emptyState` rows and the keys' own `[PLANNED — not yet rendered]` describe. When that card lands, the marker leaves and this sentence is rewritten. PLANNED RECORD (#30018, 2026-11-04, ruling record 6624172200 on #29951, letter A): `regions[].components[].properties.placedActions` on `page:header` (`PageHeaderProps` in packages/spec/src/ui/component.zod.ts) is the explicit opt-in to draw every action of the bound object whose `locations` include `record_header` or `record_more`, read live, in place of an authored `actions` id list; `true` beside an authored `actions` list is refused at the key by the props door (`@objectstack/lint` `validateComponentProps`, rule `component-props-invalid`). PLANNED, not live: measured at main eee41490, the header renderer (ui/packages/components/src/renderers/layout/containers.tsx, `PageHeaderRenderer`) resolves only the authored `actions` ids and reads no `placedActions`, and the `page:header` registration publishes no such input (booked in ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts `UNPUBLISHED_EXEMPTIONS`). Carrier: #29951, its ui half (the reader, then `sys_user_detail`, `sys_organization_detail` and `sys_approval_request_detail` switching to the key), dispatched once this lands. No row carries it for the reason above (component-level keys sit under the recorded undrilled `page/regions` container); the checkable record is the authorable-surface `ui/PageHeaderProps:placedActions` row and the key's own `[PLANNED — not yet rendered]` describe. When the reader lands, the marker leaves, the exemption is struck and this sentence is rewritten.", + "_note": "PageSchema (UI). Renderers live in objectui; objectui paths are cited with the `objectui:` realm marker (in `evidence` or `note` prose) and counted, not resolved against this repo (#5623). Seeded from the Studio page-design dogfood (framework#2254/#2261/#2265). Containers (variables/regions/interfaceConfig/slots/aria) classified at top level — one drill level, no divergent sub-statuses. The removed roadmap page types (record_review/blank) and their config fields (recordReview/blankLayout) were hard-removed — no longer authorable. RETIREMENT RECORD (#11027, 2026-08-24): `regions[].components[].responsive` is a retiredKey tombstone (ADR-0049 D2) — measured dead across both repos (zero callers of either objectui consumer implementation); the shared `ResponsiveConfigSchema` left with it. The walk drills one level only, so no component-level key has a row here (`page/regions` stays RECORDED in undrilled-containers.baseline.json — #4956's page-side instance, still open for the other component keys); the checkable record of THIS retirement is the authorable-surface `ui/PageComponent:responsive [RETIRED]` row, RETIRED_KEYS_BY_MAJOR[18], and the `page-component-responsive-removed` conversion. LIVE RECORD (#23016, 2026-10-12, maintainer ruling in the card body; rewritten from its PLANNED record by #24300 once the renderer shipped): `regions[].components[].dataSource` gained a second shape, the API binding `ElementApiDataSourceSchema` (`{ provider: 'api', read: { url, method? }, path? }`, `read.url` a same-origin API path refused at parse otherwise, no credential key) — LIVE: measured at the console pin `.objectui-sha` @0707b4ae3712, which carries the renderer, objectui @c4dd7078ed (objectstack-ai/objectui#12197). The console reads it on every page element (objectui: packages/react/src/SchemaRenderer.tsx calls useElementApiDataSource for each node it renders; the read is objectui: packages/react/src/element-data-source/useElementApiDataSource.ts, which parses the binding with this schema first, sends one GET to `read.url` through the host's signed-in fetch and selects `path`) and binds the selected value as `data` in the element's expression holes, in its `visibleWhen` and in its subtree, drawing a loading or error panel until the value is in; the generic stat / usage-bar / checklist blocks take it as their `dataSource` (objectui: packages/app-shell/src/console/blocks/productBlocks.tsx). No row carries it because the walk never reaches a component-level key (`page/regions` and `page/slots` are recorded undrilled containers) and `childShape()` does not descend the `z.union` the key now is; the checkable record is the authorable-surface `ui/ElementApiDataSource:*` rows and the schema's own describe, which no longer carries the `[PLANNED — not yet rendered]` marker. LIVE RECORD (#23134, 2026-10-13, ruling B on objectstack-ai/objectui#12296, ruling comment 6137226306; rewritten from its PLANNED record by #23585 once the renderer shipped): `regions[].components[].properties.parentDataSource` on `record:related_list` (`RecordRelatedListProps` in packages/spec/src/ui/component.zod.ts) is the block's PARENT-record binding, the element object binding `ElementDataSourceSchema` by reference, read by the single-record rule (the first row by `sort`; no row is a loud absence, never an empty list); the node-level `dataSource` on the block keeps its child-row meaning (#6953). LIVE: measured at the console pin `.objectui-sha` @4016370af3c9, which carries the renderer, objectui @d81e46043 (objectstack-ai/objectui#12407, the related-list leg of objectstack-ai/objectui#12294). The block hands the key to its parent read (objectui: packages/plugin-detail/src/renderers/record-related-list.tsx, whose RecordRelatedListRenderer passes `schema.parentDataSource` to ParentRecordBinding; the read is objectui: packages/plugin-detail/src/renderers/recordBinding.tsx, whose BoundRecordProvider asks the page's own adapter, so under the caller's identity, for one row (`$top: 1`) by the binding's `sort` and provides it as the record context the list body reads, `relationshipValueField` included). No row, or a read the server refuses as forbidden, draws the block's loud absence (NoParentRecord) in its place and queries no child rows; with the key absent, ParentRecordBinding leaves the list as it is and the record page's record context remains the parent. No row carries it for the reason above (component-level keys sit under the recorded undrilled `page/regions` container); the checkable record is the authorable-surface `ui/RecordRelatedListProps:parentDataSource` row and the key's own describe, which no longer carries the `[PLANNED — not yet rendered]` marker. PLANNED RECORD (#24266, 2026-10-18, objectstack-ai/cloud#2774 Q2 = B as the card quotes it): `regions[].components[].properties.emptyState` on the three bound record blocks `record:details`, `record:highlights` and `record:quick_actions` (`RecordDetailsProps`, `RecordHighlightsProps`, `RecordQuickActionsProps` in packages/spec/src/ui/component.zod.ts) is the list view's own `EmptyStateSchema` by reference, drawn only when the block's node-level `dataSource` binding answers no row. PLANNED, not live: measured at objectui @79cb8c03e, the three blocks take their binding through objectui: packages/plugin-detail/src/renderers/recordBinding.tsx, which mounts a 0-row answer as a record context with no record, so each draws its own placeholder and nothing reads the key. Carrier: objectstack-ai/objectui#12932, dispatched once a spec release carrying the key is installable. No row carries it for the reason above (component-level keys sit under the recorded undrilled `page/regions` container); the checkable record is the authorable-surface `ui/RecordDetailsProps:emptyState`, `ui/RecordHighlightsProps:emptyState` and `ui/RecordQuickActionsProps:emptyState` rows and the keys' own `[PLANNED — not yet rendered]` describe. When that card lands, the marker leaves and this sentence is rewritten. LIVE RECORD (#30018, 2026-11-04, ruling record 6624172200 on #29951, letter A; rewritten from its PLANNED record by #29951's ui half once the reader landed): `regions[].components[].properties.placedActions` on `page:header` (`PageHeaderProps` in packages/spec/src/ui/component.zod.ts) is the explicit opt-in to draw every action of the bound object whose `locations` include `record_header` or `record_more`, read live, in place of an authored `actions` id list; `true` beside an authored `actions` list is refused at the key by the props door (`@objectstack/lint` `validateComponentProps`, rule `component-props-invalid`). LIVE: the header renderer reads it (ui/packages/components/src/renderers/layout/containers.tsx, `PageHeaderActions`, which `PageHeaderRenderer` hands the key: `true` draws `placedHeaderActions` from ui/packages/types/src/ui-action.ts over the object's actions as the metadata read serves them, the one placed-header reader `RecordDetailView`'s synthesized header reads too; a pair that skipped validation draws none of the object's actions and is reported once), and the `page:header` registration publishes the input. Its producers are the platform record pages `sys_user_detail` and `sys_organization_detail` (@objectstack/platform-objects) and `sys_approval_request_detail` (@objectstack/plugin-approvals), which set it in place of the frozen id lists that dropped another package's header actions. No row carries it for the reason above (component-level keys sit under the recorded undrilled `page/regions` container); the checkable record is the authorable-surface `ui/PageHeaderProps:placedActions` row and the key's own describe, which no longer carries the `[PLANNED — not yet rendered]` marker.", "props": { "source": { "status": "live", diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 0cd04fb0ad7..a6bed06c868 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -208,11 +208,14 @@ describe('PageHeaderProps placedActions — every placed header action, or the a }); // The one part of the prose a consumer parses: `check-liveness.mts` - // `MARKER_RE.planned` reads it as the key's liveness status, and the - // `liveness/page.json` `_note` PLANNED RECORD names the reader that removes it. - it('opens its describe with the PLANNED marker until the header renderer reads it', () => { + // `MARKER_RE.planned` reads a `[PLANNED — not yet rendered]` opener as the + // key's liveness status. The header renderer reads the key since #29951 (the + // `liveness/page.json` `_note` LIVE RECORD names the reader), so the marker + // is gone and must not come back. + it('carries no PLANNED marker: the header renderer reads it (#29951)', () => { const member = (PageHeaderProps as unknown as { shape: Record }).shape.placedActions; - expect(member.description?.startsWith('[PLANNED — not yet rendered] ')).toBe(true); + expect(member.description).toBeTruthy(); + expect(member.description).not.toContain('[PLANNED'); }); }); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 68041b887e9..ac6a3197004 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -760,17 +760,18 @@ export const PageHeaderProps = strictObject({ * the key on every parsed header, turning an unset key into an authored one * (the `maxVisible` docblock below records the same rule for its own 3). * - * PLANNED, not yet read: no renderer reads this key. Its reader is the - * objectui header half of #29951 (the header renderer, then the three - * platform record pages that froze their header id lists switching to it), - * dispatched once this spec lands. The liveness record is the - * `liveness/page.json` `_note` (a component-level key carries no ledger row: - * the walk stops at the undrilled `page/regions` container) and the - * `[PLANNED — not yet rendered]` marker this describe opens with; when the - * reader lands, the marker leaves and the `_note` sentence is rewritten. + * LIVE (#29951, the objectui header half): the header renderer + * (`PageHeaderActions` in + * ui/packages/components/src/renderers/layout/containers.tsx) reads it and + * draws `placedHeaderActions` (`@object-ui/types`), the one placed-header + * reader the synthesized default record page reads too; `sys_user_detail`, + * `sys_organization_detail` and `sys_approval_request_detail` set it. The + * liveness record is the `liveness/page.json` `_note` (a component-level key + * carries no ledger row: the walk stops at the undrilled `page/regions` + * container). */ placedActions: z.boolean().optional().describe( - '[PLANNED — not yet rendered] Draw every action of the page\'s object whose `locations` include `record_header` or `record_more`, read live from the object\'s metadata, in place of an authored `actions` list: an action added to the object later, by any package, is drawn without editing the page. Omit it, or set false, to keep the `actions` list as the whole set. Refused as `true` beside an authored `actions` list: keep one.', + 'Draw every action of the page\'s object whose `locations` include `record_header` or `record_more`, read live from the object\'s metadata, in place of an authored `actions` list: an action added to the object later, by any package, is drawn without editing the page. Omit it, or set false, to keep the `actions` list as the whole set. Refused as `true` beside an authored `actions` list: keep one.', ), /** * Which of the two page-header layouts the renderer builds (#6776). diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 0cc5f751861..d3c660d2fdb 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -13,11 +13,11 @@ ], "source": "built-tree", "generator": "scripts/gen-sdui-manifest-node.mjs", - "generatedAt": "2026-10-31", - "sha256": "029ea9f8fca79fd46c518d889c713cec8e3487606c9ddbb1fe57972cf8090df8", + "generatedAt": "2026-11-04", + "sha256": "dbe784f92f9b28de779d520da827f8b43ba780ce28684a9dcfb9b8662fe44541", "components": 106, "inTree": { "modulesRoot": "ui/apps/console", - "objectuiWorkspaceVersion": "18.2.0" + "objectuiWorkspaceVersion": "18.3.0" } } diff --git a/sdui.manifest.json b/sdui.manifest.json index 55c7aa1865c..283123c753f 100644 --- a/sdui.manifest.json +++ b/sdui.manifest.json @@ -3513,6 +3513,11 @@ "of": "string", "description": "Action IDS — the names of actions declared on the object's own metadata — rendered in the header before any host-injected system actions. An id whose action declares neither record_header nor record_more in its locations renders nowhere." }, + { + "name": "placedActions", + "type": "boolean", + "description": "Set true to draw every action of the object placed at record_header or record_more, read live from the object's metadata, in place of an Actions list: an action another package adds to the object is drawn without editing the page. Refused beside an authored Actions list: keep one." + }, { "name": "recordChrome", "type": "boolean", diff --git a/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 7b609b53358..1b10a05c2a8 100644 --- a/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -1566,7 +1566,11 @@ const UNPUBLISHED_EXEMPTIONS: Record = { // (`ParentRecordBinding`) and declared the key on the registration, with its member pin in // `MEMBER_PINS`, so the entry went stale and `carries no stale unpublished-key exemption` would // refuse it. - 'page:header.placedActions': 'Booked while no renderer reads it: objectstack#30018 declared the key spec first (ruling record 6624172200, letter A), and PageHeaderRenderer still resolves only the authored `actions` ids. objectstack#29951\'s ui half wires the read and declares the input on the page:header registration, and strikes this entry in that PR (`carries no stale unpublished-key exemption` refuses it once the input is published).', + // `page:header.placedActions` — booked here by objectstack#30018 (ruling record 6624172200, + // letter A) while no renderer read it — is STRUCK: objectstack#29951's ui half wired the read + // (`PageHeaderActions` draws `placedHeaderActions` from `@object-ui/types`) and declared the + // key on the page:header registration, so the entry went stale and `carries no stale + // unpublished-key exemption` would refuse it. /* * ⚠️ THE 17.5.0 BOOKINGS — objectui#11111 decision 3 = B (record 5902351047). diff --git a/ui/packages/app-shell/src/views/RecordDetailView.placedHeaderActions-29951.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.placedHeaderActions-29951.test.tsx new file mode 100644 index 00000000000..b2eddee1892 --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordDetailView.placedHeaderActions-29951.test.tsx @@ -0,0 +1,262 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#29951 — a header action another package places on `sys_user` + * is drawn again on the user's record page, in the drawer and on the full page. + * + * #27219 rebuilt the platform record pages as `kind: 'full'` pages whose + * `page:header` named the platform's own action ids, and an authored id list + * is the whole set (#11592), so a `type: 'url'` action an installed package + * places on `sys_user` at `record_header` stopped drawing in the 18.3 Console. + * Ruling A (record 6624172200) gave the header an opt-in instead, + * `placedActions: true` (objectstack#30018): every action of the page's object + * placed at `record_header` / `record_more`, read live. The three platform + * pages now ship exactly `{ type: 'page:header', properties: { placedActions: + * true } }` (pinned in platform-objects' `record-page-header-actions.test.ts` + * and plugin-approvals' `sys-approval-request-page.test.ts`; that package is + * not a dependency of this tree, so the node is mirrored here, never imported). + * + * Read through the real `RecordDetailView` → `page:header` renderer → + * `PageHeaderActions`, as the Console mounts it: the full page by its route, + * and the drawer as `ObjectView`'s drawer mounts it (`embedded`, with the + * object and record handed in). + * + * - The card's pin: on the shipped header node, the packaged url action + * draws inline and the packaged `record_more` one in the ⋯ menu, beside + * the platform's own; actions placed elsewhere do not draw. On the frozen + * #27219 header (an id list, no opt-in) the same object draws neither + * packaged action, which is the regression this pin is red on. + * - CONTROL (#11592 unchanged): on a non-platform object `placedActions: + * true` draws its placed actions, and an authored list without the key + * draws exactly that list. + * - The refused pair (`true` beside a list) draws none of the object's + * actions and says so once. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { MockInstance } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: null, activeOrganization: null }), + createAuthenticatedFetch: () => vi.fn(), +})); +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); +vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null })); +vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('./FlowRunner', () => ({ FlowRunner: () => null })); +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); + +import { I18nProvider } from '@object-ui/i18n'; +import { MetadataCtx } from '@object-ui/react'; +import { RecordDetailView } from './RecordDetailView'; +import { FavoritesProvider } from '../context/FavoritesProvider'; + +const RECORD_ID = 'rec-1'; + +// The platform's own header actions, as `sys_user` places them. +const BAN_USER = { name: 'ban_user', label: 'Ban User', type: 'api', target: '/api/v1/x', locations: ['list_item', 'record_header'] }; +const IMPERSONATE = { name: 'impersonate_user', label: 'Impersonate User', type: 'api', target: '/api/v1/y', locations: ['record_more'] }; +// Placed elsewhere: never a header action. +const LIST_ONLY = { name: 'export_users', label: 'Export Users', type: 'api', target: '/api/v1/z', locations: ['list_toolbar'] }; +const SECTION_ONLY = { name: 'reset_mfa', label: 'Reset MFA', type: 'api', target: '/api/v1/w', locations: ['record_section'] }; +// What another package adds to `sys_user` (the folded registry appends it). +const PACKAGED_URL = { name: 'qa_open_profile', label: 'Open Profile', type: 'url', target: 'https://example.com/u', locations: ['record_header'], _packageId: 'com.qa.hdr' }; +const PACKAGED_MORE = { name: 'qa_profile_link', label: 'Copy Profile Link', type: 'url', target: 'https://example.com/l', locations: ['record_more'], _packageId: 'com.qa.hdr' }; + +const ACTIONS = [BAN_USER, IMPERSONATE, LIST_ONLY, SECTION_ONLY, PACKAGED_URL, PACKAGED_MORE]; + +/** `sys_user_detail`'s shipped header node (#29951). */ +const SHIPPED_HEADER = { type: 'page:header', properties: { placedActions: true } }; +/** The frozen #27219 shape: the platform's own ids, no opt-in. */ +const FROZEN_HEADER = { type: 'page:header', properties: { actions: ['ban_user', 'impersonate_user'] } }; + +function objectDef(name: string) { + return { + name, + label: name, + fields: { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' } }, + actions: ACTIONS, + }; +} + +function fullPage(object: string, header: Record) { + return { + name: `${object}_detail`, + label: 'Detail', + type: 'record', + object, + kind: 'full', + isDefault: true, + regions: [{ name: 'main', width: 'full', components: [header, { type: 'record:details', properties: {} }] }], + }; +} + +type Face = 'page' | 'drawer'; + +function mount(objectName: string, header: Record, face: Face) { + const objects = [objectDef(objectName)]; + const pages = [fullPage(objectName, header)]; + const metadata = { + objects, + pages, + apps: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async (type: string, name: string) => (type === 'object' ? objects.find((o) => o.name === name) ?? null : null), + getItemsByType: () => [], + } as any; + const dataSource = { + find: vi.fn(async () => ({ data: [] })), + findOne: vi.fn(async () => ({ id: RECORD_ID, name: 'Ada' })), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + } as any; + const view = + face === 'page' ? ( + + + } + /> + + + ) : ( + // The drawer: `ObjectView` mounts the detail embedded over its list route. + + + + ); + render( + + + {view} + + , + ); +} + +/** The header's inline buttons, then its ⋯ menu rows. */ +async function headerFaces(): Promise<{ inline: string[]; menu: string[] }> { + const bar = document.querySelector('[role="toolbar"]') as HTMLElement; + const inline = Array.from(bar.querySelectorAll('button')).map( + (b) => b.getAttribute('aria-label') ?? (b.textContent ?? '').trim(), + ); + const trigger = screen.getByRole('button', { name: 'More actions' }); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' }); + await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); + const menu = Array.from(screen.getByRole('menu').querySelectorAll('[role="menuitem"]')).map((e) => + (e.textContent ?? '').trim(), + ); + return { inline, menu }; +} + +/** Mount and wait until the object's actions have resolved into the header. */ +async function mountResolved(objectName: string, header: Record, face: Face) { + mount(objectName, header, face); + await waitFor(() => expect(document.querySelector('[role="toolbar"]')).toBeTruthy()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Ban User' })).toBeInTheDocument()); + return headerFaces(); +} + +let consoleError: MockInstance; + +beforeEach(() => { + cleanup(); + localStorage.clear(); + consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } })), + ); +}); + +afterEach(() => { + consoleError.mockRestore(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe.each(['page', 'drawer'])('#29951 sys_user record header (%s)', (face) => { + it('draws the action another package places on sys_user, on the shipped placedActions header', async () => { + const { inline, menu } = await mountResolved('sys_user', SHIPPED_HEADER, face); + expect(inline).toContain('Open Profile'); + expect(menu).toContain('Copy Profile Link'); + // The platform's own placed actions stay, and nothing placed elsewhere draws. + expect(inline).toContain('Ban User'); + expect(menu).toContain('Impersonate User'); + for (const label of ['Export Users', 'Reset MFA']) { + expect(inline).not.toContain(label); + expect(menu).not.toContain(label); + } + }); + + it('drew neither packaged action on the frozen #27219 header (the regression)', async () => { + const { inline, menu } = await mountResolved('sys_user', FROZEN_HEADER, face); + expect(inline).toContain('Ban User'); + expect(menu).toContain('Impersonate User'); + expect([...inline, ...menu]).not.toContain('Open Profile'); + expect([...inline, ...menu]).not.toContain('Copy Profile Link'); + }); +}); + +describe('#29951 CONTROL — a non-platform page', () => { + it('placedActions: true draws the object\'s placed actions, in declaration order', async () => { + const { inline, menu } = await mountResolved('qa_member', SHIPPED_HEADER, 'page'); + const drawn = [...inline, ...menu].filter((l) => + ACTIONS.some((a) => a.label === l), + ); + expect(drawn).toEqual(['Ban User', 'Open Profile', 'Impersonate User', 'Copy Profile Link']); + }); + + it('an authored list without the key draws exactly that list (#11592)', async () => { + const { inline, menu } = await mountResolved( + 'qa_member', + { type: 'page:header', properties: { actions: ['ban_user'] } }, + 'page', + ); + const drawn = [...inline, ...menu].filter((l) => ACTIONS.some((a) => a.label === l)); + expect(drawn).toEqual(['Ban User']); + }); + + it('refuses placedActions: true beside a list: none of the object\'s actions draws, reported once', async () => { + mount('qa_refused', { type: 'page:header', properties: { placedActions: true, actions: ['ban_user'] } }, 'page'); + await waitFor(() => expect(document.querySelector('[role="toolbar"]')).toBeTruthy()); + await waitFor(() => + expect(consoleError.mock.calls.some((c) => String(c[0]).includes('`placedActions: true` refused'))).toBe(true), + ); + const { inline, menu } = await headerFaces(); + expect([...inline, ...menu].filter((l) => ACTIONS.some((a) => a.label === l))).toEqual([]); + expect( + consoleError.mock.calls.filter((c) => String(c[0]).includes('`placedActions: true` refused')), + ).toHaveLength(1); + }); +}); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index db5d4e9edcd..eb019058348 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -66,6 +66,7 @@ import { useNavigationContext, useRecordBreadcrumbTitle } from '../context/Navig // Both sets are derived, not restated — see record-detail-system-fields.ts. import { deriveRecordDetailSections } from './recordDetailSections.js'; import type { DataSource, FeedItem, Reaction, RecordPageOperation, RecordPagePayload } from '@object-ui/types'; +import { placedHeaderActions } from '@object-ui/types'; import type { ActionDef, ActionParamDef, ConfirmationHandler, ParamCollectionHandler } from '@object-ui/core'; import type { ConsoleActionDispatch } from '../consoleActionDispatch.js'; import { useRecordApprovals, openApprovalOf, isSubmitterOf } from '../hooks/useRecordApprovals.js'; @@ -3930,15 +3931,13 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // `record_more` renders inside the header's ⋯ overflow menu (the spec's // "overflow menu under the More/⋯ button" location — PageHeaderRenderer // routes record_more-only actions into the overflow; #2358 trap 2). + // The set is `placedHeaderActions` (`@object-ui/types`): the ONE reader an + // authored `page:header` with `placedActions: true` reads too + // (objectstack#29951), so the synthesized header and such a page draw the + // same actions, an action another package places on the object included. const recordHeaderActions = (() => { - const seen = new Set(); - const base = (objectDef.actions || []).filter((a: any) => { - if (!a.locations?.includes('record_header') && !a.locations?.includes('record_more')) return false; - if (!a.name) return true; - if (seen.has(a.name)) return false; - seen.add(a.name); - return true; - }).map((a: any) => localizeActionTexts(objectDef.name, a)); + const base = placedHeaderActions(objectDef.actions as any[] | undefined) + .map((a: any) => localizeActionTexts(objectDef.name, a)); // ⛔ No approval actions are injected here any more (objectui#3055). // They used to be two hand-written buttons (approve/reject only, no diff --git a/ui/packages/components/src/renderers/layout/containers.tsx b/ui/packages/components/src/renderers/layout/containers.tsx index 1279036a0bb..f2c8df73f59 100644 --- a/ui/packages/components/src/renderers/layout/containers.tsx +++ b/ui/packages/components/src/renderers/layout/containers.tsx @@ -22,7 +22,7 @@ import React from 'react'; import { createPortal } from 'react-dom'; import { ComponentRegistry, ExpressionEvaluator, declaredNameField, evalRowPredicate, formatTitleTemplate, hasDeclaredPredicate, getRecordDisplayName, isActionSwitchedOff, recordDisplayValueAt, resolveNameField, toPredicateRecord } from '@object-ui/core'; import type { ComponentInput } from '@object-ui/core'; -import { actionRendersAt, resolveDeclaredActionIds } from '@object-ui/types'; +import { actionRendersAt, placedHeaderActions, resolveDeclaredActionIds } from '@object-ui/types'; import type { DeclaredActionsRefusal } from '@object-ui/types'; import { useRecordContext, useAction, useCapabilityGate, usePredicateScope, usePageVariables, useInlineEdit, useActionTextLocalizer, useMetadataItem, reportUnresolvableVisibilityPredicate, resolveInlineAriaProps, buildNodeEvaluationScope } from '@object-ui/react'; import { renderChildren, renderNodeSlot, cn } from '../../lib/utils'; @@ -1691,6 +1691,31 @@ function reportRefusedHeaderActions( ); } +/** + * Warn-once ledger for a refused `placedActions: true` + `actions` pair + * (objectstack#30018), keyed by object. + */ +const _refusedPlacedActionPairs = new Set(); + +/** + * Report a `page:header` that sets `placedActions: true` beside an authored + * `actions` list. `@objectstack/spec` refuses the pair at parse (the list says + * "only these", the switch says "every placed one"), so it reaches here only + * from metadata that skipped validation; the header then draws none of the + * object's actions, as it does for a refused id array above. + */ +function reportRefusedPlacedActions(objectName: string | undefined): void { + const key = objectName ?? ''; + if (_refusedPlacedActionPairs.has(key)) return; + _refusedPlacedActionPairs.add(key); + console.error( + `[page:header] \`placedActions: true\` refused beside an authored \`actions\` list` + + `${objectName ? ` (object "${objectName}")` : ''}. None of the object's actions is rendered. ` + + 'Keep one: `actions` for a curated list that is the whole set, or `placedActions: true` ' + + 'for every action placed at record_header / record_more.', + ); +} + /** * The download path a bare `sys_file` id resolves to. A private copy of * `@object-ui/fields`' `fileUrlFromId` rule; `recordPictureUrl` below says why @@ -1758,6 +1783,12 @@ const PHONE_ACTION_BAR_BUTTONS = 2; export interface PageHeaderActionsProps { /** The authored `actions` array: action ids (the contract) or, during the transition, inline action objects. */ actions?: unknown; + /** + * `page:header`'s `placedActions` (objectstack#30018): `true` draws every + * action of the bound object placed at `record_header` / `record_more`, read + * live, in place of an `actions` list. Only the boolean `true` opts in. + */ + placedActions?: unknown; /** The inline-button budget on desktop, as `page:header`'s `maxVisible`; an out-of-contract value falls back to 3. */ maxVisible?: unknown; /** The inline-button budget on mobile, as `page:header`'s `mobileMaxVisible`; an out-of-contract value falls back to 1. */ @@ -1812,6 +1843,7 @@ export interface PageHeaderRefreshItem { */ export const PageHeaderActions: React.FC = ({ actions: rawHeaderActions, + placedActions: rawPlacedActions, maxVisible: rawMaxVisible, mobileMaxVisible: rawMobileMaxVisible, emptySlot = null, @@ -1907,11 +1939,35 @@ export const PageHeaderActions: React.FC = ({ () => resolveDeclaredActionIds(authoredHeaderActions, undefined), [authoredHeaderActions], ); + // ── `placedActions: true` — every placed action, read live (objectstack#30018) ── + // + // The other way `@objectstack/spec` lets a header say which of the object's + // actions it draws (ruling record 6624172200 on objectstack#29951, letter A): + // every action of the bound object whose `locations` include `record_header` + // or `record_more`, in declaration order, read from the object's metadata at + // render time. An action another package places on the object is therefore + // drawn without editing the page, which a frozen id list cannot do: the + // platform's own record pages froze one and dropped exactly those actions + // (objectstack#29951). It is the set the synthesized default record page + // draws, read by the same `placedHeaderActions` (`@object-ui/types`) that + // `RecordDetailView`'s header list reads, and it runs through the same one + // filter chain below as an id list does. + // + // Only the boolean `true` opts in. The spec refuses `true` beside an authored + // `actions` list, an empty one included ("keep one"); a pair that still + // reaches this renderer draws NONE of the object's actions and says so once — + // the same fail-closed answer a refused id array gets above, never a silent + // pick of one of the two meanings. + const placedOptIn = rawPlacedActions === true; + const placedRefused = placedOptIn && rawHeaderActions !== undefined; + const drawsPlacedActions = placedOptIn && !placedRefused; // `useMetadataItem` is the SAME entry `record:quick_actions` resolves through // — not a second lookup path. Passing `null` for the name is its documented - // no-op, so a header with no ids (or no object bound) does not fetch. + // no-op, so a header with no ids and no `placedActions` (or no object bound) + // does not fetch. const needsActionLookup = - headerActionsShape.kind === 'ids' && headerActionsShape.ids.length > 0 && !!headerObjectName; + (drawsPlacedActions || (headerActionsShape.kind === 'ids' && headerActionsShape.ids.length > 0)) + && !!headerObjectName; const { item: headerActionsObjectMeta, loading: headerActionsMetaLoading } = useMetadataItem( 'object', needsActionLookup ? headerObjectName : null, @@ -1920,6 +1976,12 @@ export const PageHeaderActions: React.FC = ({ const registered: any[] = Array.isArray(headerActionsObjectMeta?.actions) ? (headerActionsObjectMeta as any).actions : []; + if (placedRefused) { + reportRefusedPlacedActions(headerObjectName); + return []; + } + // The ONE placed-header reader the synthesized header reads too. + if (drawsPlacedActions) return placedHeaderActions(registered); const declared = resolveDeclaredActionIds(authoredHeaderActions, registered); if (declared.kind === 'refused') { reportRefusedHeaderActions(headerObjectName, declared); @@ -1955,6 +2017,8 @@ export const PageHeaderActions: React.FC = ({ headerActionsMetaLoading, needsActionLookup, headerObjectName, + placedRefused, + drawsPlacedActions, ]); // ── Action predicates: ONE dialect, ONE entry (objectui#3521) ────────────── @@ -2763,6 +2827,9 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { // detail pages (lead → Convert Lead, opportunity → Mark Won/Lost, …) // rely on this slot. Without this rendering they would silently disappear. const rawHeaderActions = schema?.actions ?? schema?.properties?.actions; + // `placedActions: true` (objectstack#30018) — every placed action, read live; + // resolved in `PageHeaderActions` beside the ids. + const rawPlacedActions = schema?.placedActions ?? schema?.properties?.placedActions; /** * The header's trailing slot: the action row (or the empty layout slot that @@ -2781,6 +2848,7 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { const row = ( ( + actions: readonly T[] | null | undefined, +): T[] { + const seen = new Set(); + return (actions ?? []).filter((action) => { + if (!actionRendersAt(action, 'record_header') && !actionRendersAt(action, 'record_more')) return false; + const name = typeof action?.name === 'string' ? action.name : ''; + if (name === '') return true; + if (seen.has(name)) return false; + seen.add(name); + return true; + }); +} + // ============================================================================ // Declared action arrays — ONE rule for every surface that authors a list of // actions by id (objectui#7182) From 3d6eebd40824afb5443b137e93cae4f6d93c29f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 13:41:55 +0000 Subject: [PATCH 2/3] docs(changeset): the 30018 changeset says the console reads placedActions The unconsumed #30018 changeset rides the same release as this reader, so its "declared ahead of its reader" bullet now says the console's page:header renderer reads the key and the PLANNED marker is gone. The @objectstack/skills bullet stays until the skills follow-up. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzh8VRbTbBnqwnrXsSL --- .changeset/30018-page-header-placed-actions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/30018-page-header-placed-actions.md b/.changeset/30018-page-header-placed-actions.md index 2e8105d8465..319cb4fd9c0 100644 --- a/.changeset/30018-page-header-placed-actions.md +++ b/.changeset/30018-page-header-placed-actions.md @@ -11,6 +11,6 @@ Metadata-protocol: added - **`placedActions`** on a `page:header` component's `properties` (`PageHeaderProps`, the `ComponentPropsMap['page:header']` row): `true` asks the header to draw every action of the page's object whose `locations` include `record_header` or `record_more`, read live from the object's metadata, in place of an authored `actions` id list. An action another package places on the object is then drawn without editing the page. Optional with no default: an unset key stays unset in the parsed output. - **The `actions` list keeps its meaning.** An authored list is the whole set the header draws, and a header with neither `actions` nor `placedActions: true` draws none of the object's actions. `placedActions: false` beside a list is accepted and changes nothing. - **Refused together.** `placedActions: true` beside an authored `actions` list, an empty one included, is refused at `properties.placedActions` by `os validate`, `os build`, `os lint` and the page save door (`component-props-invalid`, an error). The message names both keys and asks the author to keep one: `actions` for a curated list that is the whole set, or `placedActions: true` for every placed action. Nothing that parsed before is refused: until this release `placedActions` was an unknown key on the closed shape. -- **Declared ahead of its reader.** The console's header renderer does not read the key yet, so a header that sets it draws what the same header without it draws (none of the object's actions unless `actions` names them). The key's describe opens with `[PLANNED — not yet rendered]` until the renderer reads it. +- **Read by the console.** The console's `page:header` renderer reads the key (#29951): a header that sets `placedActions: true` draws every action of its object placed at `record_header` / `record_more`, and the key's describe no longer carries the `[PLANNED — not yet rendered]` marker. - The published JSON Schema `ui/PageHeaderProps` still accepts the pair: the rule depends on the value `true`, which no projected refinement pattern states, so the parse refuses it and `dropped-refinements.baseline.json` records the site. - `@objectstack/skills`: `objectstack-ui` `rules/pages.md` names the key in the component catalogue, marked not drawn yet, and drops a refused-shape clause from the print-page paragraph that the sentence before it already states. From 6d81dac28c962ac76b435e04d3879154023df545 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 13:46:51 +0000 Subject: [PATCH 3/3] docs(changeset): restore the 30018 changeset as it is on main Check Changeset refuses an edit to a changeset already on the merge base (the foreign-changeset rule, #17712), so the reworded bullet goes back. This PR's own changeset says the console reads placedActions; the stale sentence in 30018's entry is left to the release-time notes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzh8VRbTbBnqwnrXsSL --- .changeset/30018-page-header-placed-actions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/30018-page-header-placed-actions.md b/.changeset/30018-page-header-placed-actions.md index 319cb4fd9c0..2e8105d8465 100644 --- a/.changeset/30018-page-header-placed-actions.md +++ b/.changeset/30018-page-header-placed-actions.md @@ -11,6 +11,6 @@ Metadata-protocol: added - **`placedActions`** on a `page:header` component's `properties` (`PageHeaderProps`, the `ComponentPropsMap['page:header']` row): `true` asks the header to draw every action of the page's object whose `locations` include `record_header` or `record_more`, read live from the object's metadata, in place of an authored `actions` id list. An action another package places on the object is then drawn without editing the page. Optional with no default: an unset key stays unset in the parsed output. - **The `actions` list keeps its meaning.** An authored list is the whole set the header draws, and a header with neither `actions` nor `placedActions: true` draws none of the object's actions. `placedActions: false` beside a list is accepted and changes nothing. - **Refused together.** `placedActions: true` beside an authored `actions` list, an empty one included, is refused at `properties.placedActions` by `os validate`, `os build`, `os lint` and the page save door (`component-props-invalid`, an error). The message names both keys and asks the author to keep one: `actions` for a curated list that is the whole set, or `placedActions: true` for every placed action. Nothing that parsed before is refused: until this release `placedActions` was an unknown key on the closed shape. -- **Read by the console.** The console's `page:header` renderer reads the key (#29951): a header that sets `placedActions: true` draws every action of its object placed at `record_header` / `record_more`, and the key's describe no longer carries the `[PLANNED — not yet rendered]` marker. +- **Declared ahead of its reader.** The console's header renderer does not read the key yet, so a header that sets it draws what the same header without it draws (none of the object's actions unless `actions` names them). The key's describe opens with `[PLANNED — not yet rendered]` until the renderer reads it. - The published JSON Schema `ui/PageHeaderProps` still accepts the pair: the rule depends on the value `true`, which no projected refinement pattern states, so the parse refuses it and `dropped-refinements.baseline.json` records the site. - `@objectstack/skills`: `objectstack-ui` `rules/pages.md` names the key in the component catalogue, marked not drawn yet, and drops a refused-shape clause from the print-page paragraph that the sentence before it already states.