Skip to content

Commit a52761f

Browse files
fix(console): the record header reads placedActions, so a packaged sys_user header action draws again (#29951) (#30097)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB4xjiquevJqfBXiUoqfQwa1)_ Fixes #29951 ## 维护者速读 - 改了什么:控制台的记录页头现在会读 `page:header` 的 `placedActions: true`(#30018 落地的新键),画出该对象所有放在 `record_header` / `record_more` 的动作,实时读取。用户、组织、审批请求三个平台记录页改用这个开关,去掉了 #27219 冻结的 id 列表。 - 为什么改:按你对 #29951 的裁决 A(记录 6624172200)。18.3 里别的包给 `sys_user` 加的页头动作(比如 `type: 'url'` 的链接)在设置里的用户抽屉和整页都不显示了,因为平台页面只列了自己的 id,而手写列表就是全部。 - 风险与回滚:只影响写了 `placedActions: true` 的页头,也就是这三个平台页面;原来列表里的动作都放在页头位置,所以照样显示,只是多了别的包加的。不写这个键的页面行为不变。回滚 = revert 本 PR。按你的修订,18.3.x 不发、不写发版说明。 - 先看:`ui/packages/components/src/renderers/layout/containers.tsx`(`PageHeaderActions` 读这个键)、`ui/packages/types/src/ui-action.ts`(共用的 `placedHeaderActions`,合成页头也用它)、三个页面文件。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/RecordDetailView.placedHeaderActions-29951.test.tsx`,预期 7 个用例全绿:抽屉和整页都画出别的包加的 url 动作,旧的冻结列表画不出来。 - 需要你做的:本 PR 不需要你操作,席位复核后走队列落地。`skills/objectstack-ui/rules/pages.md` 的那条限定语是受管文件,另开一个小 PR(`Refs #29951`)给你合并。 Clause-②: no Metadata-protocol: none ## What changes - **The header reads `placedActions: true`** (`ui/packages/components/src/renderers/layout/containers.tsx`). `PageHeaderRenderer` hands the key to `PageHeaderActions`. `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 through the same `useMetadataItem('object', …)` lookup an id list uses, and through the same predicate, capability and switched-off chain after it. Omitted or `false`, an authored `actions` list is still the whole set (#11592). Only the boolean `true` opts in. A `true` beside an authored list, which the spec refuses at parse, draws none of the object's actions and is reported once with `console.error`, the same fail-closed answer a refused id array gets. - **One placed-header reader.** `placedHeaderActions` (`ui/packages/types/src/ui-action.ts`, exported from `@object-ui/types`) is the rule: every action `actionRendersAt` places at `record_header` or `record_more`, the first of a name. `RecordDetailView`'s synthesized header (`recordHeaderActions`) now reads it instead of its inline filter, so the synthesized default page and a `placedActions: true` header draw the same set. No second reader. - **Registration input.** The `page:header` registration declares `placedActions` (`boolean`), and `sdui.manifest.json` is regenerated (`node scripts/gen-sdui-manifest-node.mjs`, in-tree; the only component change is that input), with `scripts/sdui-manifest.record.json` and the component reference pages it renders (`content/docs/ui/components/page.mdx` and `index.mdx`, `node scripts/gen-sdui-reference.mjs`). - **The three platform pages opt in.** `sys_user_detail` and `sys_organization_detail` (`@objectstack/platform-objects`) and `sys_approval_request_detail` (`@objectstack/plugin-approvals`) set `{ type: 'page:header', properties: { placedActions: true } }` in place of the id lists #27219 froze (11 ids on `sys_user`, 4 on `sys_organization`, none on the approval request). The tab strips #27219 introduced are unchanged. Every action the old lists named was placed at `record_header` / `record_more` (the old pin held each list equal to exactly that set), so nothing the pages drew is dropped. `sys_approval_request` places no header action of its own (its decision actions are `record_section`), so its header now draws only what an installed package places there. - **The "not drawn yet" qualifiers PR #30028 listed** (five of six; the sixth is in the skills PR): - `packages/spec/src/ui/component.zod.ts`: the `placedActions` describe drops its `[PLANNED — not yet rendered]` marker and the docblock says the reader is live; `content/docs/references/ui/component.mdx` regenerated (`gen:schema` + `gen:docs`). - `packages/spec/liveness/page.json`: the `_note`'s PLANNED RECORD sentence is rewritten as a LIVE RECORD that names `PageHeaderActions`, `placedHeaderActions` and the three producer pages. - `ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`: `UNPUBLISHED_EXEMPTIONS['page:header.placedActions']` struck (a strike note in its place, as the file does for `parentDataSource`). - `content/docs/ui/pages.mdx` and `content/docs/protocol/objectui/layout-dsl.mdx`: the qualifier is gone. - Changeset `.changeset/29951-page-header-placed-actions-reader.md`: patch for the six touched packages. ## Why Ruling A on #29951 (record 6624172200). #27219 rebuilt the platform record pages as `kind: 'full'` pages with their own tab strips and copied the platform's header action ids into `page:header.actions`. Under #11592 an authored list is the whole set, so a header action any other package places on `sys_user` (or `sys_organization`, `sys_approval_request`) stopped drawing: the 18.2 slotted page's synthesized header had read the object's placed actions live. #30018 (PR #30028) declared `placedActions: true` spec first; this is its reader and its producers. The tab strips #27219 was for stay. Nothing ships in 18.3.x. ## Tests - **Card pin** `ui/packages/app-shell/src/views/RecordDetailView.placedHeaderActions-29951.test.tsx`, through the real `RecordDetailView` → `page:header` → `PageHeaderActions`, on the full page (route) and the drawer (`embedded`, as `ObjectView` mounts it). A `sys_user` def carries the platform's `ban_user` (`record_header`) and `impersonate_user` (`record_more`), two actions placed elsewhere, and two packaged `type: 'url'` actions (`record_header` and `record_more`, `_packageId: 'com.qa.hdr'`). - Shipped header node (`placedActions: true`): the packaged `record_header` action draws inline and the `record_more` one in the ⋯ menu, beside the platform's own; the `list_toolbar` / `record_section` actions do not draw. Page and drawer. - Frozen #27219 header (an id list, no opt-in): neither packaged action draws. Page and drawer. - CONTROL on a non-platform object: `placedActions: true` draws exactly the four placed actions in declaration order; an authored `['ban_user']` without the key draws exactly `Ban User` (#11592). - The refused pair draws none of the object's actions and reports once. - `packages/platform-objects/src/pages/record-page-header-actions.test.ts` rewritten: per page, the object places header actions (non-vacuous), the header node is exactly `{ placedActions: true }`, and `ComponentPropsMap['page:header']` accepts it. `sys-approval-request-page.test.ts` pins the new node. - `packages/spec/src/ui/component.test.ts`: the marker test now asserts the describe carries no `[PLANNED` marker. - **Ablation**: the reader made to ignore the key (`const placedOptIn = false && rawPlacedActions === true;`) turns 4 of the 7 pin cases red (`Unable to find role="button" and name "Ban User"`: shipped page, shipped drawer, CONTROL placed, refused pair) and leaves the 3 list cases green. File restored byte-identical (`cmp`). ## Acceptance notes - The ui pin mirrors the shipped header node rather than importing the page: `@objectstack/platform-objects` is not a dependency of the ui tree. The platform-objects and plugin-approvals pins hold the shipped node equal to it. - `.changeset/30018-page-header-placed-actions.md` (PR #30028, unconsumed) still says the renderer does not read the key yet. Both changesets ride the same next release, so its CHANGELOG entry will carry that sentence beside this one. Not edited here (it is #30018's record); flagging it for the seat. - No 18.3.x release-notes line, per the maintainer's amendment to ruling A. ## Verification - **Live, on a dev boot** (`pnpm build` under `os-verify-lock`, 119/119; showcase backend `pnpm dev -- --fresh -p 4317`; Console from `ui/` src via vite with `DEV_PROXY_TARGET`). A writable package `com.qa.hdr` stores `hdr_user_profile` (`type: 'url'`, `record_header`, on `sys_user`) and the CONTROL `hdr_account_profile` on `showcase_account`. Served: `GET /api/v1/meta/page/sys_user_detail`, `sys_organization_detail` and `sys_approval_request_detail` each answer `kind: 'full'` with `{"type":"page:header","properties":{"placedActions":true}}`; `/meta/object/sys_user` serves `hdr_user_profile`. Playwright readings: - Setup → Users, full page: row Update Profile · Ban User · Set Password · Edit · ⋯; ⋯ = Refresh · Impersonate User · Set Manager · Change Password · Change Email · **QA User Profile** · Add to favorites · Share · Delete My Account. - Setup → Users, drawer: the same row; ⋯ = Impersonate User · Set Manager · Change Password · Change Email · **QA User Profile** · Add to favorites · Share · Open as full page · Delete My Account. The tab strip (Details · Positions · Permission Sets · Business Units · Sessions …) is unchanged. - Before the fix (round 1, main `6a4737ab`), both readings were the same lists without QA User Profile. CONTROL `showcase_account`, full page and drawer: QA Account Profile · Edit · ⋯, as before. - **ui suites** from the `ui/` root: the new pin 7/7; `packages/components/src/__tests__/page-header*`, `packages/app-shell/src/views/RecordDetailView.*`, `packages/types/src/__tests__` and the console parity test: 518 files, 11620 tests green; `registry-inputs-spec-parity.test.ts` alone: 261 green with the exemption struck. - **Package suites**: `@objectstack/platform-objects` `src/pages` 46, `@objectstack/plugin-approvals` `sys-approval-request-page.test.ts` 20, `@objectstack/spec` `src/ui/component.test.ts` 361, all green. - **Type-check**: `@object-ui/types`, `@object-ui/components`, `@object-ui/app-shell` (`type-check`, from the ui root), `@objectstack/platform-objects`, `@objectstack/plugin-approvals` (`typecheck`), `@objectstack/spec` `tsc --noEmit`: clean. - **Generated**: `gen:schema` + `gen:docs` (reference), `node scripts/gen-sdui-manifest-node.mjs` (manifest + record), `node scripts/gen-sdui-reference.mjs` (`content/docs/ui/components/`); `node scripts/check-sdui-manifest.mjs` and `pnpm check:sdui-reference` green. - **Gates**: every line of `node scripts/pm/dispatch-gates.mjs --commands` on the final diff (119), then `--ran`: 119 derived, 119 run, 0 NOT-MEASURED, 0 UNRUN. 118 exit 0. `pnpm check:platform-checklist` exits 1 on an absent `assertProtocolCompat` anchor in `packages/metadata-protocol/src/protocol.ts` (`areas/platform-core.json`); it is red the same way on merge base `d79eecfe5b` and on `origin/main` `f35b361997`, outside this diff. Three first-pass reds were re-run: `pnpm check:sdui-reference` was this diff's own (component pages regenerated and committed, then green); `pnpm --dir ui exec vitest run scripts/` (one 15 s timeout under load) and `pnpm --filter @objectstack/spec run check:generated` (`check:test-typecheck`'s tsc ran out of heap because this container's `NODE_OPTIONS` carries no heap flag) were green re-run alone under the lock, the second with `--max-old-space-size=4096` appended. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (The changed TypeScript files pass eslint with 0 errors: the seven under `packages/` with the root config and `--no-inline-config`, the six under `ui/` from the ui root. CI's `pnpm lint` covers the full tree.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English, except the `## 维护者速读` block, which is written in Chinese for the maintainer. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzh8VRbTbBnqwnrXsSL --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 987ff33 commit a52761f

22 files changed

Lines changed: 500 additions & 92 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/components': patch
3+
'@object-ui/types': patch
4+
'@object-ui/app-shell': patch
5+
'@objectstack/spec': patch
6+
'@objectstack/platform-objects': patch
7+
'@objectstack/plugin-approvals': patch
8+
---
9+
10+
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)
11+
12+
Clause-②: no
13+
Metadata-protocol: none
14+
15+
- **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.
16+
- **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.
17+
- **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.
18+
- `@objectstack/spec`: the `placedActions` describe no longer opens with `[PLANNED — not yet rendered]`, and the `page` liveness record names the reader.

‎content/docs/protocol/objectui/layout-dsl.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,9 @@ Delete chrome). The list is the whole set the header draws: an action that
9393
declares `record_header` but is not named is not drawn there, and a header with
9494
no `actions` draws none of the object's own actions. `placedActions: true` in
9595
place of the list asks for every action placed at `record_header` /
96-
`record_more`, read live; it is refused beside a list, and the console's header
97-
renderer does not draw it yet. Declare the action, placed at `record_header`,
98-
then name it:
96+
`record_more`, read live, so an action another package places on the object is
97+
drawn without editing the page; it is refused beside a list. Declare the action,
98+
placed at `record_header`, then name it:
9999

100100
{/* os:check-yaml action */}
101101
```yaml

‎content/docs/references/ui/component.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1575,7 +1575,7 @@ An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules ma
15751575
| **subtitle** | `string \| Record<string, string>` | optional | Page subtitle |
15761576
| **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. |
15771577
| **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. |
1578-
| **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. |
1578+
| **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. |
15791579
| **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. |
15801580
| **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. |
15811581
| **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. |

‎content/docs/ui/components/index.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ regenerates from the renderer registry. Regenerate it with
109109
| [`page:accordion`](/docs/ui/components/page#page-accordion) | `page` | 3 | [Page Structure](/docs/ui/components/page) |
110110
| [`page:card`](/docs/ui/components/page#page-card) | `page` | 4 | [Page Structure](/docs/ui/components/page) |
111111
| [`page:footer`](/docs/ui/components/page#page-footer) | `page` | 1 | [Page Structure](/docs/ui/components/page) |
112-
| [`page:header`](/docs/ui/components/page#page-header) | `page` | 7 | [Page Structure](/docs/ui/components/page) |
112+
| [`page:header`](/docs/ui/components/page#page-header) | `page` | 8 | [Page Structure](/docs/ui/components/page) |
113113
| [`page:section`](/docs/ui/components/page#page-section) | `page` | 1 | [Page Structure](/docs/ui/components/page) |
114114
| [`page:sidebar`](/docs/ui/components/page#page-sidebar) | `page` | 1 | [Page Structure](/docs/ui/components/page) |
115115
| [`page:tabs`](/docs/ui/components/page#page-tabs) | `page` | 4 | [Page Structure](/docs/ui/components/page) |

‎content/docs/ui/components/page.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Namespace `page`.
5252
| `title` | `string` \| `object` | | Supports \{field\} interpolation and inline translation maps; falls back to the record title |
5353
| `subtitle` | `string` \| `object` | | Same interpolation as Title |
5454
| `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. |
55+
| `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. |
5556
| `recordChrome` | `boolean` | | Set false for the bare h1 header on non-record pages |
5657
| `showCopyId` | `boolean` | | — |
5758
| `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. |

‎content/docs/ui/pages.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ unknown or retired key (`description`, `icon`, `breadcrumb`, `showStar`) is refu
296296
| `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 |
297297
| `subtitle` | — | The line under the title, interpolated the same way |
298298
| `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 |
299-
| `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 |
299+
| `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 |
300300
| `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 |
301301
| `showCopyId` | `true` | The copy-record-id button in the chip |
302302
| `maxVisible` / `mobileMaxVisible` | 3 / 1 | How many actions render inline before the rest fold into the `⋯` menu |
Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,35 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* #27053 — the two platform record pages name the header actions the
5-
* synthesizer used to read live from the object.
4+
* #29951 — the two platform record pages draw the object's placed header
5+
* actions LIVE, through `page:header.placedActions: true` (#30018, ruling
6+
* record 6624172200, letter A), never a copied id list.
67
*
7-
* Both pages were `kind: 'slotted'` until the `tabs` slot retired; the
8-
* synthesized header they rendered carried every object action declared at
9-
* `record_header` or `record_more`, in declaration order. As `kind: 'full'`
10-
* pages they name those action ids themselves, so the list is a copy. This
11-
* pin holds the copy equal to the object's declarations, so an action added
12-
* to either object at a header location cannot silently miss its page.
8+
* Both pages were `kind: 'slotted'` until the `tabs` slot retired (#27053);
9+
* the synthesized header they rendered read every object action placed at
10+
* `record_header` or `record_more` at render time, an action another package
11+
* places on the object included. Rebuilt as `kind: 'full'` pages they named
12+
* the platform's own ids instead, and an authored id list is the whole set
13+
* (#11592), so an installed package's header action on `sys_user` stopped
14+
* drawing in the 18.3 Console (#29951). This pin holds the opt-in on each page
15+
* and no list beside it (the two are refused together), so the header cannot
16+
* freeze again.
1317
*
14-
* Read from the shipped page and object metadata.
18+
* Read from the shipped page and object metadata, and parsed through the
19+
* `page:header` row the props door judges (`ComponentPropsMap`).
1520
*/
1621

1722
import { describe, expect, it } from 'vitest';
23+
import { ComponentPropsMap } from '@objectstack/spec/ui';
1824
import { SysOrganization } from '../identity/sys-organization.object.js';
1925
import { SysUser } from '../identity/sys-user.object.js';
2026
import { SysOrganizationDetailPage } from './sys-organization.page.js';
2127
import { SysUserDetailPage } from './sys-user.page.js';
2228

2329
type Rec = Record<string, unknown>;
2430

25-
/** The ids the synthesized header drew: header-located actions, declaration order, first of a name. */
26-
function headerLocatedIds(object: { actions?: unknown }): string[] {
31+
/** The object's actions placed at the header, declaration order, first of a name. */
32+
function headerPlacedIds(object: { actions?: unknown }): string[] {
2733
const ids: string[] = [];
2834
for (const action of (object.actions ?? []) as Rec[]) {
2935
const locations = (action.locations ?? []) as string[];
@@ -34,22 +40,27 @@ function headerLocatedIds(object: { actions?: unknown }): string[] {
3440
return ids;
3541
}
3642

37-
/** The `page:header` node's authored action ids. */
38-
function pageHeaderIds(page: { regions?: unknown }): unknown {
43+
/** The page's `page:header` node properties. */
44+
function headerProps(page: { regions?: unknown }): Rec | undefined {
3945
const components = ((page.regions as Rec[])[0]!.components ?? []) as Rec[];
4046
const header = components.find((c) => c.type === 'page:header');
41-
return (header?.properties as Rec | undefined)?.actions;
47+
return header?.properties as Rec | undefined;
4248
}
4349

4450
describe.each([
4551
['sys_organization_detail', SysOrganizationDetailPage, SysOrganization],
4652
['sys_user_detail', SysUserDetailPage, SysUser],
47-
] as const)('%s — the header names the object\'s header-located actions', (_name, page, object) => {
48-
it('is non-vacuous: the object declares header-located actions', () => {
49-
expect(headerLocatedIds(object).length).toBeGreaterThan(0);
53+
] as const)('%s — the header draws the object\'s placed actions live', (_name, page, object) => {
54+
it('is non-vacuous: the object places actions at the header', () => {
55+
expect(headerPlacedIds(object).length).toBeGreaterThan(0);
5056
});
5157

52-
it('names exactly those ids, in declaration order', () => {
53-
expect(pageHeaderIds(page)).toEqual(headerLocatedIds(object));
58+
it('opts in with placedActions: true and names no id list', () => {
59+
expect(headerProps(page)).toEqual({ placedActions: true });
60+
});
61+
62+
it('is a header the props door accepts', () => {
63+
const parsed = ComponentPropsMap['page:header'].safeParse(headerProps(page));
64+
expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true);
5465
});
5566
});

‎packages/platform-objects/src/pages/sys-organization.page.ts‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ import type { Page } from '@objectstack/spec/ui';
1717
* to what the slotted page rendered — the synthesized default with the strip
1818
* swapped in — node for node:
1919
*
20-
* - `page:header` carrying the object's `record_header` actions (Set Active,
21-
* Invite User, Leave Organization, Rename Organization — Edit and Delete are
22-
* row actions only), the list the synthesized header carried. A full page
23-
* renders exactly the nodes it declares, so the header is spelled out.
20+
* - `page:header` with `placedActions: true` (#29951): every action placed on
21+
* `sys_organization` at `record_header` / `record_more`, read live (Set
22+
* Active, Invite User, Leave Organization, Rename Organization — Edit and
23+
* Delete are row actions only — and any an installed package places
24+
* there), the set the synthesized header carried. A full page renders
25+
* exactly the nodes it declares, so the header node is spelled out.
2426
* - `record:highlights` over the object's `highlightFields` minus the title
2527
* field (`slug`; `name` is the page H1).
2628
* - the Members / Invitations / Teams strip, unchanged, Members at index 0
@@ -56,13 +58,15 @@ export const SysOrganizationDetailPage: Page = {
5658
name: 'main',
5759
width: 'full',
5860
components: [
59-
// The synthesized header's action list: every `sys_organization`
60-
// action declaring `record_header` or `record_more`, in declaration
61-
// order. The host appends its own system actions after these.
61+
// The synthesized header's action set, read live: every action placed
62+
// on `sys_organization` at `record_header` or `record_more`, in
63+
// declaration order, including one another package places there (a
64+
// frozen id list dropped those, #29951). The host appends its own
65+
// system actions after these.
6266
{
6367
type: 'page:header',
6468
properties: {
65-
actions: ['set_active_organization', 'invite_user', 'leave_organization', 'rename_organization'],
69+
placedActions: true,
6670
},
6771
},
6872
{ type: 'record:highlights', properties: { fields: ['slug'] } },

‎packages/platform-objects/src/pages/sys-user.page.ts‎

Lines changed: 13 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,13 @@ const TWO_FACTOR_OFFERED = P`has(features.twoFactor) && features.twoFactor == tr
4040
* so it is drawn as authored at every width. It was a `kind: 'slotted'`
4141
* page overriding `alerts`, `highlights`, `tabs` and `discussion` until the
4242
* `tabs` slot retired (#27053); the rebuild renders the same nodes in the
43-
* same order. The header the synthesizer used to supply is spelled out as
44-
* a `page:header` carrying the object's declared header actions
45-
* (`ban_user / impersonate_user / … / update_my_profile /
46-
* change_my_password / resend_verification_email / …`), so they still
47-
* appear in the header and its overflow menu.
43+
* same order. The header the synthesizer used to supply is a `page:header`
44+
* with `placedActions: true` (#29951): it draws every `sys_user` action
45+
* placed at `record_header` / `record_more`, read live, so the platform's
46+
* own (`ban_user / impersonate_user / … / update_my_profile /
47+
* change_my_password / …`) and any an installed package places on
48+
* `sys_user` appear in the header and its overflow menu, as they did on
49+
* the synthesized header.
4850
* - `record:highlights` promotes the signals worth scanning at the top:
4951
* email, phone, verification state. Highlight fields are auto-dropped
5052
* from the details grid below. 2FA state is not one of them: it is
@@ -84,27 +86,15 @@ export const SysUserDetailPage: Page = {
8486
width: 'full',
8587
components: [
8688
// ── The record header ─────────────────────────────────────────
87-
// The synthesized header's action list, spelled out because a full
88-
// page renders exactly the nodes it declares: every `sys_user` action
89-
// declaring `record_header` or `record_more`, in declaration order
90-
// (admin actions, then the self-service ones). The host appends its
91-
// own system actions after these.
89+
// The synthesized header's action set, read live: every action placed
90+
// on `sys_user` at `record_header` or `record_more`, in declaration
91+
// order (admin actions, then the self-service ones), then any action
92+
// another package places there. A frozen id list dropped those
93+
// (#29951). The host appends its own system actions after these.
9294
{
9395
type: 'page:header',
9496
properties: {
95-
actions: [
96-
'ban_user',
97-
'unban_user',
98-
'unlock_user',
99-
'set_user_password',
100-
'impersonate_user',
101-
'set_user_manager',
102-
'update_my_profile',
103-
'change_my_password',
104-
'change_my_email',
105-
'resend_verification_email',
106-
'delete_my_account',
107-
],
97+
placedActions: true,
10898
},
10999
},
110100
// ── Alert banners ─────────────────────────────────────────────

0 commit comments

Comments
 (0)