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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/29951-page-header-placed-actions-reader.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions content/docs/protocol/objectui/layout-dsl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1575,7 +1575,7 @@ An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules ma
| **subtitle** | `string \| Record<string, string>` | 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. |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/components/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
1 change: 1 addition & 0 deletions content/docs/ui/components/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,35 @@
// 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';
import { SysUserDetailPage } from './sys-user.page.js';

type Rec = Record<string, unknown>;

/** 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[];
Expand All @@ -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);
});
});
20 changes: 12 additions & 8 deletions packages/platform-objects/src/pages/sys-organization.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'] } },
Expand Down
36 changes: 13 additions & 23 deletions packages/platform-objects/src/pages/sys-user.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────
Expand Down
Loading
Loading