Skip to content

Commit b56888e

Browse files
committed
feat(spec)!: PageSchema refuses a page that authors both slots.details and slots.tabs
The tabs slot replaces the whole tab strip the Details tab lives in, so a details slot beside it never rendered. checkPageSlotPair refuses the pair at slots.details, naming both slots and the fix: the record:details component as a child of a tabs item. sys_user_detail moves its record:details, with its sections and hideFields unchanged, in as the first tabs item. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude <noreply@anthropic.com>
1 parent 96e4be4 commit b56888e

3 files changed

Lines changed: 157 additions & 46 deletions

File tree

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

Lines changed: 58 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,26 @@ import type { Page } from '@objectstack/spec/ui';
1717
*
1818
* Strategy
1919
* --------
20-
* - `kind: 'slotted'` + `isDefault: true`: overrides `highlights`,
21-
* `details`, `tabs` and `discussion`. Header / actions fall through
20+
* - `kind: 'slotted'` + `isDefault: true`: overrides `alerts`,
21+
* `highlights`, `tabs` and `discussion`. Header / actions fall through
2222
* to the synthesizer so the object's declared actions
2323
* (`update_my_profile / change_my_password / resend_verification_email
2424
* / ban_user / impersonate_user / …`) still appear
2525
* in the header overflow menu automatically.
2626
* - `highlights` promotes the four signals worth scanning at the top:
2727
* email, verification state, 2FA, platform role. Highlight fields
2828
* are auto-dropped from the details grid below.
29-
* - `details` re-groups remaining fields into sections and hides
30-
* admin-internal audit columns. Banned / ban metadata is still
31-
* editable from the header actions — we just don't show it in
32-
* every user's body.
33-
* - `tabs` is **explicitly curated** to the 5 related lists that matter
34-
* on a user profile (Positions / Sessions / Linked Accounts / Organizations /
35-
* Personal OAuth Apps). Without this override, the synthesizer
29+
* - The FIRST `tabs` item is the details grid: a `record:details` that
30+
* re-groups remaining fields into sections and hides admin-internal
31+
* audit columns. Banned / ban metadata is still editable from the
32+
* header actions — we just don't show it in every user's body. It is
33+
* a tab item and not a `details` slot because the `tabs` slot replaces
34+
* the whole tab strip the Details tab lives in: a `details` slot beside
35+
* `tabs` never rendered, and `PageSchema` refuses the pair.
36+
* - The other `tabs` items are **explicitly curated** to the related lists
37+
* that matter on a user profile (Positions / Permission Sets / Business
38+
* Units / Sessions / Linked Accounts / Organizations / OAuth Apps / API
39+
* Keys) plus a Security tab. Without this override, the synthesizer
3640
* auto-generates a tab per object that has a FK to sys_user
3741
* (sys_position.created_by, sys_email.updated_by, sys_user_preference,
3842
* sys_email_template.created_by, …) producing dozens of noisy
@@ -125,47 +129,61 @@ export const SysUserDetailPage: Page = {
125129
},
126130
},
127131

128-
// ── Body / details grid ───────────────────────────────────────
129-
details: {
130-
type: 'record:details',
131-
properties: {
132-
hideFields: [
133-
'id',
134-
'banned',
135-
'ban_reason',
136-
'ban_expires',
137-
// already promoted to highlights:
138-
'email',
139-
'phone_number',
140-
'email_verified',
141-
'two_factor_enabled',
142-
'role',
143-
],
144-
sections: [
145-
{
146-
label: { en: 'Identity', 'zh-CN': '身份', 'ja-JP': 'アイデンティティ', 'es-ES': 'Identidad' },
147-
fields: ['name', 'image'],
148-
},
149-
{
150-
label: { en: 'Audit', 'zh-CN': '审计', 'ja-JP': '監査', 'es-ES': 'Auditoría' },
151-
fields: ['created_at', 'updated_at'],
152-
},
153-
],
154-
},
155-
},
156-
157-
// ── Tabs: curated related lists ───────────────────────────────
158-
// Only the 4 lists that are semantically about THIS user account.
132+
// ── Tabs: the details grid, then curated related lists ────────
133+
// Only the lists that are semantically about THIS user account.
159134
// Everything else (sys_position created_by, sys_email_template
160135
// updated_by, …) is incidental authorship metadata and would only
161136
// create noise.
137+
//
138+
// There is no `details` slot: the `tabs` slot replaces the whole tab
139+
// strip the synthesized Details tab lives in, so a `details` slot beside
140+
// it never rendered and `PageSchema` refuses the pair (#22568). The
141+
// details grid is therefore the FIRST tab's body here.
162142
tabs: {
163143
type: 'page:tabs',
164144
properties: {
165145
// `tabStyle`, not `type` (#6776) — see sys-organization.page.ts.
166146
tabStyle: 'line',
167147
position: 'top',
168148
items: [
149+
{
150+
// ── Body / details grid ───────────────────────────────
151+
// Re-groups the remaining fields into sections and hides
152+
// admin-internal columns. Banned / ban metadata is still
153+
// editable from the header actions — it is just not shown in
154+
// every user's body.
155+
label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' },
156+
icon: 'file-text',
157+
children: [
158+
{
159+
type: 'record:details',
160+
properties: {
161+
hideFields: [
162+
'id',
163+
'banned',
164+
'ban_reason',
165+
'ban_expires',
166+
// already promoted to highlights:
167+
'email',
168+
'phone_number',
169+
'email_verified',
170+
'two_factor_enabled',
171+
'role',
172+
],
173+
sections: [
174+
{
175+
label: { en: 'Identity', 'zh-CN': '身份', 'ja-JP': 'アイデンティティ', 'es-ES': 'Identidad' },
176+
fields: ['name', 'image'],
177+
},
178+
{
179+
label: { en: 'Audit', 'zh-CN': '审计', 'ja-JP': '監査', 'es-ES': 'Auditoría' },
180+
fields: ['created_at', 'updated_at'],
181+
},
182+
],
183+
},
184+
},
185+
],
186+
},
169187
{
170188
label: { en: 'Positions', 'zh-CN': '岗位', 'ja-JP': 'ポジション', 'es-ES': 'Puestos' },
171189
icon: 'shield-check',

‎packages/spec/src/ui/object-refinement-check-exports.test.ts‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,13 @@ import {
5656
checkListViewCalendarVisualization,
5757
checkListViewChartBinding,
5858
} from './view.zod';
59-
import { PageSchema, checkPageSourceCompleteness, checkPageRequiresKind, checkPagePrintComposition } from './page.zod';
59+
import {
60+
PageSchema,
61+
checkPageSourceCompleteness,
62+
checkPageRequiresKind,
63+
checkPagePrintComposition,
64+
checkPageSlotPair,
65+
} from './page.zod';
6066
import {
6167
GlobalFilterSchema,
6268
checkGlobalFilterDateDefaultValue,
@@ -271,6 +277,28 @@ const pagePrintFixtures: Fixture[] = [
271277
{ label: 'a `slotted` page with no `print`', value: { ...PAGE_BASE, kind: 'slotted' }, refusesAt: [] },
272278
];
273279

280+
// [#22568] `slots.details` beside `slots.tabs` is refused at `slots.details`:
281+
// the `tabs` slot replaces the strip the Details tab lives in, so the
282+
// `details` body never rendered. Every fixture is a `record` page with no
283+
// `print`, `source` or `requires`, so the three siblings stay silent and each
284+
// row exercises THIS check alone; the kind-less row is the one whose direct
285+
// call sees no `kind` while the parse sees the applied `full` default.
286+
const SLOT_DETAILS = { type: 'record:details', properties: { hideFields: ['id'] } } as const;
287+
const SLOT_TABS = {
288+
type: 'page:tabs',
289+
properties: { items: [{ label: 'Related', children: [{ type: 'record:related_list' }] }] },
290+
} as const;
291+
const SLOT_PAGE = { ...PAGE_BASE, type: 'record', object: 'account', kind: 'slotted' } as const;
292+
const pageSlotPairFixtures: Fixture[] = [
293+
{ label: '`details` beside `tabs`, one component each', value: { ...SLOT_PAGE, slots: { details: SLOT_DETAILS, tabs: SLOT_TABS } }, refusesAt: ['slots.details'] },
294+
{ label: '`details` beside `tabs`, each an array', value: { ...SLOT_PAGE, slots: { details: [SLOT_DETAILS], tabs: [SLOT_TABS] } }, refusesAt: ['slots.details'] },
295+
{ label: 'an EMPTY `details: []` beside `tabs` — a present slot is refused, not its contents', value: { ...SLOT_PAGE, slots: { details: [], tabs: SLOT_TABS } }, refusesAt: ['slots.details'] },
296+
{ label: '`details` beside `tabs` on a page with no `kind` (the `full` default)', value: { ...PAGE_BASE, type: 'record', object: 'account', slots: { details: SLOT_DETAILS, tabs: SLOT_TABS } }, refusesAt: ['slots.details'] },
297+
{ label: '`details` alone', value: { ...SLOT_PAGE, slots: { details: SLOT_DETAILS } }, refusesAt: [] },
298+
{ label: '`tabs` alone, carrying the `record:details` in its first item', value: { ...SLOT_PAGE, slots: { tabs: { type: 'page:tabs', properties: { items: [{ label: 'Details', children: [SLOT_DETAILS] }] } } } }, refusesAt: [] },
299+
{ label: 'a slot map with neither', value: { ...SLOT_PAGE, slots: { discussion: [] } }, refusesAt: [] },
300+
];
301+
274302
const DATE_FILTER = { field: 'created_at', type: 'date' } as const;
275303

276304
const dateDefaultFixtures: Fixture[] = [
@@ -485,6 +513,7 @@ const MIRRORED: MirroredSchema[] = [
485513
{ name: 'checkPageSourceCompleteness', check: checkPageSourceCompleteness, fixtures: pageSourceFixtures },
486514
{ name: 'checkPageRequiresKind', check: checkPageRequiresKind, fixtures: pageRequiresFixtures },
487515
{ name: 'checkPagePrintComposition', check: checkPagePrintComposition, fixtures: pagePrintFixtures },
516+
{ name: 'checkPageSlotPair', check: checkPageSlotPair, fixtures: pageSlotPairFixtures },
488517
],
489518
cleanFixtures: [{ ...PAGE_BASE }],
490519
},
@@ -640,9 +669,9 @@ describe('each schema attaches its export BY IDENTIFIER — no inline copy', ()
640669
expect(attachments(src, 'checkListViewPageMount')).toBe(0);
641670
});
642671

643-
it('page.zod.ts declares all three exports and attaches each to PageSchema', () => {
672+
it('page.zod.ts declares all four exports and attaches each to PageSchema', () => {
644673
const src = read('page.zod.ts');
645-
for (const name of ['checkPageSourceCompleteness', 'checkPageRequiresKind', 'checkPagePrintComposition']) {
674+
for (const name of ['checkPageSourceCompleteness', 'checkPageRequiresKind', 'checkPagePrintComposition', 'checkPageSlotPair']) {
646675
expect(src).toContain(`export function ${name}(`);
647676
expect(declarations(src, name)).toBe(1);
648677
expect(attachments(src, name)).toBe(1);
@@ -676,6 +705,7 @@ describe('`./index` (the `@objectstack/spec/ui` surface) exports the same functi
676705
['checkPageSourceCompleteness', checkPageSourceCompleteness],
677706
['checkPageRequiresKind', checkPageRequiresKind],
678707
['checkPagePrintComposition', checkPagePrintComposition],
708+
['checkPageSlotPair', checkPageSlotPair],
679709
['checkGlobalFilterDateDefaultValue', checkGlobalFilterDateDefaultValue],
680710
] as const)('%s — reference identity, and the `(value, ctx)` arity', (name, fn) => {
681711
expect((ui as Record<string, unknown>)[name]).toBe(fn);

‎packages/spec/src/ui/page.zod.ts‎

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,55 @@ export function checkPageRequiresKind(
744744
});
745745
}
746746

747+
/**
748+
* The slot-pair check attached to {@link PageSchema}: a page that authors
749+
* both `slots.details` and `slots.tabs` is refused at `slots.details`, naming
750+
* the pair and the fix.
751+
*
752+
* The two slots are not independent, whatever the slot menu's layout
753+
* suggests. On a slotted record page the `details` slot is the BODY of the
754+
* Details tab, and that tab lives inside the synthesized `page:tabs` strip;
755+
* the `tabs` slot replaces that whole strip. So a `details` authored beside a
756+
* `tabs` has nowhere to render: the console's default-page synthesizer reads
757+
* `tabs` and never reads `details` (its own doc states the precedence — "wins
758+
* over `details` when both are provided"). Before this check the pair parsed,
759+
* validated and saved, and the authored details body — its `sections`, its
760+
* `hideFields` — silently never applied. The fix is the shape the platform's
761+
* own `sys_user_detail` page uses: the `record:details` component as a child
762+
* of a `tabs` item (the first one, by convention), and no `details` slot.
763+
*
764+
* Judged on the slot map whatever the page's `kind`: the pair is never a
765+
* coherent declaration, and a page that is not slotted reads no slot at all.
766+
* A slot is present when its value is not `undefined` — the synthesizer's own
767+
* test — so an empty `details: []` beside `tabs` is refused like a full one.
768+
*
769+
* Exported for the reason {@link checkPageSourceCompleteness} is: a downstream
770+
* mirror that derives its schema from `PageSchema.shape` drops every
771+
* object-level check, and re-attaches this one with
772+
* `.superRefine(checkPageSlotPair)`. `PageSchema` attaches this same binding —
773+
* pinned in `object-refinement-check-exports.test.ts`.
774+
*/
775+
export function checkPageSlotPair(
776+
page: { slots?: unknown },
777+
ctx: z.RefinementCtx,
778+
): void {
779+
const slots = page.slots;
780+
if (slots === undefined || slots === null || typeof slots !== 'object') return;
781+
const { details, tabs } = slots as { details?: unknown; tabs?: unknown };
782+
if (details === undefined || tabs === undefined) return;
783+
ctx.addIssue({
784+
code: 'custom',
785+
path: ['slots', 'details'],
786+
message:
787+
'`slots.details` and `slots.tabs` are refused together: the `details` slot is the body of the '
788+
+ 'Details tab inside the synthesized `page:tabs` strip, and an authored `tabs` slot replaces that '
789+
+ 'whole strip, so the `details` beside it is never rendered. Move the `record:details` component '
790+
+ 'into the `tabs` items — as the `children` of the first item, e.g. '
791+
+ "`{ label: 'Details', children: [{ type: 'record:details', properties: { … } }] }` — and delete "
792+
+ '`slots.details`. To keep the synthesized tabs instead, delete `slots.tabs`.',
793+
});
794+
}
795+
747796
/**
748797
* Page Schema
749798
* Defines a composition of components for a specific context.
@@ -1285,6 +1334,15 @@ export const PageSchema = lazySchema(() => strictObject({
12851334
* custom, call the corresponding `buildDefault*` sub-builder from the
12861335
* renderer runtime (e.g. @object-ui/plugin-detail).
12871336
*
1337+
* Precedence — `details` and `tabs` are NOT independent. `details`
1338+
* replaces the body of the Details tab, and that tab lives inside the
1339+
* synthesized `page:tabs` strip; `tabs` replaces the whole strip, so it
1340+
* wins and a `details` beside it is never rendered. A page authoring both
1341+
* is therefore refused ({@link checkPageSlotPair}): to replace the tabs
1342+
* AND the details body, put the `record:details` component in the `tabs`
1343+
* items (the first item's `children`, by convention) and author no
1344+
* `details` slot.
1345+
*
12881346
* Only honored when `kind === 'slotted'`.
12891347
*/
12901348
slots: strictObject({
@@ -1355,10 +1413,15 @@ export const PageSchema = lazySchema(() => strictObject({
13551413
.superRefine(checkPageRequiresKind)
13561414
// Ruling B′ on #8346 (#22158): a print page prints exactly the blocks it
13571415
// authors. Attached by identifier for the same reason as the checks above.
1358-
.superRefine(checkPagePrintComposition));
1359-
// PageSchema's cross-field rules are the three checks above: the
1416+
.superRefine(checkPagePrintComposition)
1417+
// #22568: `slots.details` beside `slots.tabs` never renders — the `tabs`
1418+
// slot replaces the strip the Details tab lives in. Attached by identifier
1419+
// for the same reason as the checks above.
1420+
.superRefine(checkPageSlotPair));
1421+
// PageSchema's cross-field rules are the four checks above: the
13601422
// ADR-0080/0081 source completeness check, the compiled-kind `requires`
1361-
// check and the print-page composition check. It once also required
1423+
// check, the print-page composition check and the `details` + `tabs` slot
1424+
// pair check. It once also required
13621425
// `recordReview`/`blankLayout` and `slots` (all removed — unrendered roadmap /
13631426
// "required-but-unauthorable" Studio traps).
13641427

0 commit comments

Comments
 (0)