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
35 changes: 35 additions & 0 deletions .changeset/22568-page-slots-details-beside-tabs-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
'@objectstack/spec': minor
'@objectstack/platform-objects': patch
---

feat(spec)!: `PageSchema` refuses a page that authors both `slots.details` and `slots.tabs`; the `sys_user` record page carries its details grid as its first tab

Clause-②: yes (narrowing)

<!-- adr-0087: registered page-slots-details-beside-tabs-refused -->

**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.

**Why.** The slot map read as seven independent slots, and two of them are not. On a slotted record page `details` replaces the body of the Details tab, that tab lives inside the synthesized `page:tabs` strip, and `tabs` replaces the whole strip. The console's default-page synthesizer therefore reads `tabs` and never reads `details` when both are authored. A page authoring both passed `PageSchema.parse`, `objectstack validate` and the metadata save door, and its details body (its `sections`, its `hideFields`) silently never applied. The platform's own `sys_user` record page was one: its Identity and Audit sections never showed, and the ban columns it hides were never hidden by it.

**What is refused.** A page whose `slots` map carries both a `details` and a `tabs` key, at `slots.details`, with a `custom` issue that names both slots and the fix. Either slot may be one component or an array; an empty `details: []` is refused like a full one, and the page's `kind` does not matter. That covers `PageSchema`, `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), `os validate` and the metadata save door (`422 INVALID_METADATA`). The check is exported as `checkPageSlotPair`, for a mirror built from `PageSchema.shape` to re-attach.

**What is still accepted, byte for byte.** A page authoring only `details`, only `tabs`, or neither.

## FROM → TO

| you wrote | write instead |
|:--|:--|
| `slots: { details: { type: 'record:details', … }, tabs: { type: 'page:tabs', properties: { items: [ … ] } } }` | `slots: { tabs: { type: 'page:tabs', properties: { items: [ { label: 'Details', children: [ { type: 'record:details', … } ] }, … ] } } }` |
| both slots, wanting the synthesized tabs with your details body | `slots: { details: { type: 'record:details', … } }`, with no `tabs` slot |

**The one-line fix: move the `record:details` component into the `tabs` items (the first item's `children`, by convention) and delete `slots.details`.** Its `sections` and `hideFields` move unchanged. The page then shows the details body it always declared, which is a visible change on every page that authored the pair.

**Who is affected, measured.** In this repository, only the `sys_user` record page (`sys_user_detail` in `@objectstack/platform-objects`) authored both slots; no example app page does. hotcrm's one slotted page authors `header` and `discussion` only. Deployed metadata was not measured. A page row already stored with the pair is replayed unchanged at load, so it renders as before (the authored tabs, without the details body); its read diagnostics name the pair, and saving it again is refused until the details body moves.

### The kit

- **The refusal.** `checkPageSlotPair`, attached to `PageSchema` by identifier beside its three other object-level checks. No new error code.
- **The producer.** `sys_user_detail` moves its `record:details`, with its `sections` and `hideFields` unchanged, in as the first `tabs` item.
- **The ledger.** The D3 semantic entry `page-slots-details-beside-tabs-refused` (protocol 18) and its step-18 rationale fragment. No key is removed, so there is no tombstone, and there is no D2 conversion: which tab item carries the details body, and under which label, is the author's decision.
84 changes: 84 additions & 0 deletions packages/platform-objects/src/pages/sys-user-details-tab.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #22568 — the user page's details body is the FIRST `tabs` item, not a
* `details` slot.
*
* The `tabs` slot replaces the whole tab strip the synthesized Details tab
* lives in, so a `details` slot authored beside it never rendered: the page's
* Identity and Audit sections never showed, and the admin-internal columns it
* hides (`ban_reason`, `ban_expires`, …) were never hidden by it.
* `PageSchema` now refuses the pair, and the page carries its `record:details`
* inside the strip it authors.
*
* Read from the shipped page metadata, through the spec's own page contract,
* so the pin fails if the page stops parsing and if the details body moves
* out of the first tab or loses a section or a hidden field.
*/

import { describe, expect, it } from 'vitest';
import { PageSchema } from '@objectstack/spec/ui';
import { SysUserDetailPage } from './sys-user.page.js';

type Rec = Record<string, unknown>;

/** The details body as the page authored it before the move — sections and hidden fields unchanged. */
const HIDE_FIELDS = [
'id',
'banned',
'ban_reason',
'ban_expires',
'email',
'phone_number',
'email_verified',
'two_factor_enabled',
'role',
];
const SECTIONS = [
{
label: { en: 'Identity', 'zh-CN': '身份', 'ja-JP': 'アイデンティティ', 'es-ES': 'Identidad' },
fields: ['name', 'image'],
},
{
label: { en: 'Audit', 'zh-CN': '审计', 'ja-JP': '監査', 'es-ES': 'Auditoría' },
fields: ['created_at', 'updated_at'],
},
];

describe('sys_user_detail — the details body lives in the first tab', () => {
const slots = SysUserDetailPage.slots as Rec;
const tabs = slots.tabs as Rec;
const items = (tabs.properties as Rec).items as Rec[];

it('parses through PageSchema — the slot-pair refusal does not fire on the shipped page', () => {
const parsed = PageSchema.safeParse(SysUserDetailPage);
expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true);
});

it('authors no `details` slot beside its `tabs` slot', () => {
expect(slots).not.toHaveProperty('details');
expect(tabs.type).toBe('page:tabs');
});

it('carries the `record:details` as the only child of the FIRST tab item, sections and hidden fields intact', () => {
const first = items[0]!;
expect((first.label as Rec).en).toBe('Details');
const children = first.children as Rec[];
expect(children.map((c) => c.type)).toEqual(['record:details']);
const props = children[0]!.properties as Rec;
expect(props.hideFields).toEqual(HIDE_FIELDS);
expect(props.sections).toEqual(SECTIONS);
});

it('is the one `record:details` on the page — no second copy anywhere in the slot map', () => {
const found: unknown[] = [];
const walk = (n: unknown): void => {
if (Array.isArray(n)) { n.forEach(walk); return; }
if (!n || typeof n !== 'object') return;
if ((n as Rec).type === 'record:details') found.push(n);
Object.values(n as Rec).forEach(walk);
};
walk(slots);
expect(found).toHaveLength(1);
});
});
98 changes: 58 additions & 40 deletions packages/platform-objects/src/pages/sys-user.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,26 @@ import type { Page } from '@objectstack/spec/ui';
*
* Strategy
* --------
* - `kind: 'slotted'` + `isDefault: true`: overrides `highlights`,
* `details`, `tabs` and `discussion`. Header / actions fall through
* - `kind: 'slotted'` + `isDefault: true`: overrides `alerts`,
* `highlights`, `tabs` and `discussion`. Header / actions fall through
* to the synthesizer so the object's declared actions
* (`update_my_profile / change_my_password / resend_verification_email
* / ban_user / impersonate_user / …`) still appear
* in the header overflow menu automatically.
* - `highlights` promotes the four signals worth scanning at the top:
* email, verification state, 2FA, platform role. Highlight fields
* are auto-dropped from the details grid below.
* - `details` re-groups remaining fields into sections and hides
* admin-internal audit columns. Banned / ban metadata is still
* editable from the header actions — we just don't show it in
* every user's body.
* - `tabs` is **explicitly curated** to the 5 related lists that matter
* on a user profile (Positions / Sessions / Linked Accounts / Organizations /
* Personal OAuth Apps). Without this override, the synthesizer
* - The FIRST `tabs` item is the details grid: a `record:details` that
* re-groups remaining fields into sections and hides admin-internal
* audit columns. Banned / ban metadata is still editable from the
* header actions — we just don't show it in every user's body. It is
* a tab item and not a `details` slot because the `tabs` slot replaces
* the whole tab strip the Details tab lives in: a `details` slot beside
* `tabs` never rendered, and `PageSchema` refuses the pair.
* - The other `tabs` items are **explicitly curated** to the related lists
* that matter on a user profile (Positions / Permission Sets / Business
* Units / Sessions / Linked Accounts / Organizations / OAuth Apps / API
* Keys) plus a Security tab. Without this override, the synthesizer
* auto-generates a tab per object that has a FK to sys_user
* (sys_position.created_by, sys_email.updated_by, sys_user_preference,
* sys_email_template.created_by, …) producing dozens of noisy
Expand Down Expand Up @@ -125,47 +129,61 @@ export const SysUserDetailPage: Page = {
},
},

// ── Body / details grid ───────────────────────────────────────
details: {
type: 'record:details',
properties: {
hideFields: [
'id',
'banned',
'ban_reason',
'ban_expires',
// already promoted to highlights:
'email',
'phone_number',
'email_verified',
'two_factor_enabled',
'role',
],
sections: [
{
label: { en: 'Identity', 'zh-CN': '身份', 'ja-JP': 'アイデンティティ', 'es-ES': 'Identidad' },
fields: ['name', 'image'],
},
{
label: { en: 'Audit', 'zh-CN': '审计', 'ja-JP': '監査', 'es-ES': 'Auditoría' },
fields: ['created_at', 'updated_at'],
},
],
},
},

// ── Tabs: curated related lists ───────────────────────────────
// Only the 4 lists that are semantically about THIS user account.
// ── Tabs: the details grid, then curated related lists ────────
// Only the lists that are semantically about THIS user account.
// Everything else (sys_position created_by, sys_email_template
// updated_by, …) is incidental authorship metadata and would only
// create noise.
//
// There is no `details` slot: the `tabs` slot replaces the whole tab
// strip the synthesized Details tab lives in, so a `details` slot beside
// it never rendered and `PageSchema` refuses the pair (#22568). The
// details grid is therefore the FIRST tab's body here.
tabs: {
type: 'page:tabs',
properties: {
// `tabStyle`, not `type` (#6776) — see sys-organization.page.ts.
tabStyle: 'line',
position: 'top',
items: [
{
// ── Body / details grid ───────────────────────────────
// Re-groups the remaining fields into sections and hides
// admin-internal columns. Banned / ban metadata is still
// editable from the header actions — it is just not shown in
// every user's body.
label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' },
icon: 'file-text',
children: [
{
type: 'record:details',
properties: {
hideFields: [
'id',
'banned',
'ban_reason',
'ban_expires',
// already promoted to highlights:
'email',
'phone_number',
'email_verified',
'two_factor_enabled',
'role',
],
sections: [
{
label: { en: 'Identity', 'zh-CN': '身份', 'ja-JP': 'アイデンティティ', 'es-ES': 'Identidad' },
fields: ['name', 'image'],
},
{
label: { en: 'Audit', 'zh-CN': '审计', 'ja-JP': '監査', 'es-ES': 'Auditoría' },
fields: ['created_at', 'updated_at'],
},
],
},
},
],
},
{
label: { en: 'Positions', 'zh-CN': '岗位', 'ja-JP': 'ポジション', 'es-ES': 'Puestos' },
icon: 'shield-check',
Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,7 @@
"checkListViewChartBinding (function)",
"checkPagePrintComposition (function)",
"checkPageRequiresKind (function)",
"checkPageSlotPair (function)",
"checkPageSourceCompleteness (function)",
"columnSummaryAlias (function)",
"compileListViewGroupQuery (function)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,7 @@
"checkListViewChartBinding": "src/ui/view.zod.ts#checkListViewChartBinding (function)",
"checkPagePrintComposition": "src/ui/page.zod.ts#checkPagePrintComposition (function)",
"checkPageRequiresKind": "src/ui/page.zod.ts#checkPageRequiresKind (function)",
"checkPageSlotPair": "src/ui/page.zod.ts#checkPageSlotPair (function)",
"checkPageSourceCompleteness": "src/ui/page.zod.ts#checkPageSourceCompleteness (function)",
"columnSummaryAlias": "src/ui/view-grouping-query.ts#columnSummaryAlias (function)",
"compileListViewGroupQuery": "src/ui/view-grouping-query.ts#compileListViewGroupQuery (function)",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// #22568 — page `slots.details` refused beside `slots.tabs`. A narrowing of
// the slot map, not a key removal: both slots stay live on their own, so there
// is no tombstone and no RETIRED_KEYS_BY_MAJOR row — the parse refuses the
// pair through `checkPageSlotPair` (ui/page.zod.ts). No D2 conversion: where
// the details body goes inside an authored tab strip (which item, under which
// label) is the author's call, and the move makes visible a body that never
// rendered.
//
// No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it
// inside a code span and a table cell.
export const entry: SemanticMigration = {
id: 'page-slots-details-beside-tabs-refused',
surface:
'page.slots.details authored beside page.slots.tabs on one page — either slot a single component '
+ 'or an array, an empty details array included, whatever the page kind',
replacement:
'One `tabs` slot whose items carry the details body: the `record:details` component (its '
+ '`sections` and `hideFields` unchanged) as the `children` of a `tabs` item, the first one by '
+ 'convention — e.g. `{ label: \'Details\', children: [{ type: \'record:details\', properties: { … } }] }` '
+ '— and no `details` slot. To keep the synthesized tab strip with the authored details body in '
+ 'its Details tab instead, delete `slots.tabs`.',
reason:
'The slot map declared `details` and `tabs` as two independent optional slots, and they are not: '
+ 'on a slotted record page `details` replaces the body of the Details tab, that tab lives inside '
+ 'the synthesized `page:tabs` strip, and `tabs` replaces the whole strip. The console\'s '
+ 'default-page synthesizer therefore reads `tabs` and never reads `details` when both are '
+ 'authored, so the pair passed `PageSchema.parse`, `objectstack validate` and the metadata save '
+ 'door while the authored details body — its sections, its hidden fields — silently never '
+ 'applied. The platform\'s own `sys_user_detail` page authored both, so its Identity and Audit '
+ 'sections never showed and the ban columns it hides were never hidden by it; it now carries its '
+ '`record:details` as the first `tabs` item. The parse now refuses the pair at `slots.details`, '
+ 'naming both slots and the fix: `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), '
+ '`objectstack validate` and the metadata save door (`422 INVALID_METADATA`). '
+ 'Measured reach before the narrowing: in this repository only `sys_user_detail` authored the '
+ 'pair (no example app page does), and hotcrm\'s one slotted page authors `header` and '
+ '`discussion` only. '
+ '⚠️ No D2 conversion: which tab item carries the details body, and under which label, is the '
+ 'author\'s decision, and moving it makes visible a body that never rendered — a change to the '
+ 'page, not a respelling. '
+ '⚠️ A page row already stored with the pair is replayed unchanged at load (no conversion '
+ 'touches it), so it renders as before — the authored tabs, without the details body — while '
+ 'its read diagnostics name the pair and saving it again is refused until the details body '
+ 'moves. ADR-0087.',
acceptanceCriteria:
'Grep every page in `defineStack` pages sources, exported stacks and every page row in '
+ '`sys_metadata` for a `slots` map carrying both a `details` and a `tabs` key. For each, move the '
+ '`details` component(s) into the `tabs` items — as the `children` of a tab item, the first one by '
+ 'convention, with its `sections` and `hideFields` unchanged — and delete `slots.details`, or '
+ 'delete `slots.tabs` to keep the synthesized tabs. Then `objectstack validate` reports nothing '
+ 'at `pages.N.slots.details`, saving each formerly affected page through the metadata API '
+ 'succeeds instead of answering a 422 that names `slots.details`, and the record page shows the '
+ 'authored details body (its sections, without its hidden fields) in the tab that now carries it. '
+ 'A page authoring only one of the two slots parses and renders byte-identically to before.',
};
Loading
Loading