From e07200b8ae384d357271d34876555a64038dde95 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:55:51 +0000 Subject: [PATCH 01/15] refactor(plugin-detail): BuildPageOptions loses the dead surface hints and the rightRail slot The default record page is drawn from the object's semantic roles alone (ADR-0085): hideHighlights, hidePath, showReferenceRail, hideReferenceRail, hideRelatedTab, relatedLayout, recordChrome and slots.rightRail had no console caller and are removed from the options type, its sub-builders and the docs. The synthesized page now has one region; record:reference_rail stays for authored pages. Claude-Session: https://claude.ai/code/session_01Q4EjjN14dHBs3CJce73zNm Co-authored-by: Claude --- ui/content/docs/guide/slotted-pages.md | 2 - .../app-shell/src/views/RecordDetailView.tsx | 9 +- ui/packages/plugin-detail/README.md | 94 +------- .../__tests__/referenceRailRoute-9816.test.ts | 82 +++---- .../record-path.declaredKind-12907.test.tsx | 4 +- .../record-path.derivedStages-11106.test.tsx | 2 +- .../src/renderers/record-reference-rail.tsx | 10 +- ...buildDefaultPageSchema.relatedSort.test.ts | 27 --- ...faultPageSchema.retiredHints-27055.test.ts | 104 +++++++++ ...ildDefaultPageSchema.strictPayload.test.ts | 31 +-- .../__tests__/buildDefaultPageSchema.test.ts | 209 +----------------- .../synth/__tests__/recordPageColumns.test.ts | 28 +-- .../src/synth/buildDefaultPageSchema.ts | 187 +++------------- .../src/synth/recordPageColumns.ts | 32 ++- 14 files changed, 232 insertions(+), 589 deletions(-) create mode 100644 ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.retiredHints-27055.test.ts diff --git a/ui/content/docs/guide/slotted-pages.md b/ui/content/docs/guide/slotted-pages.md index 85be2130dad..1c817a00588 100644 --- a/ui/content/docs/guide/slotted-pages.md +++ b/ui/content/docs/guide/slotted-pages.md @@ -123,8 +123,6 @@ if you'd authored nothing else. The default tab strip is **Details** → one tab per related list flagged `relatedList: 'primary'` on its relationship → a shared **Related** tab for the rest → **History**; so promoting a child table to its own tab is a one-word change on the relationship, not a page-authoring task. -`relatedLayout: 'tabs' | 'stack'` remains an app-level override (force -all-own-tabs / all-stacked). That strip is the page at its narrow width. The console lays the same page out by the width of the view it occupies (the drawer, the split pane or the diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index de4f1de4dbb..2c7633943cc 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -3824,11 +3824,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri related: synthParts.related, history: synthParts.history, approvals: synthParts.approvals, - // ADR-0085 removed the per-object `detail.*` presentation - // toggles (show/hideReferenceRail, hideRelatedTab, relatedLayout) - // — the synth defaults apply; per-page layout goes through an - // assigned Page schema (`record:reference_rail` stays available - // there as a renderer capability). + // ADR-0085 admits no surface hints: the default page is drawn + // from the object's semantic roles, and per-page layout goes + // through an assigned Page schema (`record:reference_rail` stays + // available there as a renderer capability). ...(assignedSlots ? { slots: assignedSlots } : {}), }); diff --git a/ui/packages/plugin-detail/README.md b/ui/packages/plugin-detail/README.md index c799b8c7418..dd7dcc103f9 100644 --- a/ui/packages/plugin-detail/README.md +++ b/ui/packages/plugin-detail/README.md @@ -803,7 +803,7 @@ viewport: | Container width | Layout | |---|---| -| 1280 px and up | Three resident columns: details · timeline (the discussion composer on top, then Activity and History) · related (each related list as a card, the reference rail and `rightRail` slot folded in, Approvals, Attachments, the provenance footer) | +| 1280 px and up | Three resident columns: details · timeline (the discussion composer on top, then Activity and History) · related (each related list as a card, Approvals, Attachments, the provenance footer) | | 1024 – 1279 px | Details · timeline; the related column folds into a rail opened from a **Related (N)** toggle above the columns | | below 1024 px, or embedded (drawer / split pane) | The tab strip, exactly as before | @@ -846,95 +846,23 @@ export function RecordPage({ embedded }: { embedded: boolean }) { } ``` -## Reference Rail decision matrix - -The "Reference Rail" is the right-hand column on the record detail page -that surfaces summary cards for related collections (similar to -Salesforce's **Related** rail and HubSpot's **About this record** -sidebar). It is rendered by the `record:reference_rail` component. The -rail is **off by default** — it fans out a collection query per related -list — so the synth emits it only when the `buildDefaultPageSchema` call -opts in with `showReferenceRail: true`, and then only when: - -1. The page is generated by the synth (`buildDefaultPageSchema`) — i.e. - no explicit `Page` overrides the object's detail view. -2. That same synth call passes **≥2 entries** in its `related` option — - the count is read off the caller's option, **not** off the objectDef. -3. The viewport is **≥ xl (1280 px)** — below that the rail collapses and - the **Related** tab keeps full coverage. This condition holds in the - tabbed tier only: in the column tiers (see *One record page at two widths* - above) the rail is folded into the related column at any viewport — one - rail, not two. -4. The synth call does **not** opt out via `hideReferenceRail`. - -Conditions 1, 2 and 4 are one conjunction inside `buildDefaultPageSchema`, -quoted here verbatim so that a later change to it shows up as a diff -against this page: `options.showReferenceRail === true`, -`!options.hideReferenceRail`, `Array.isArray(options.related)`, -`options.related.length >= 2`. Condition 3 is not part of that -expression — it is the `hidden xl:flex` class the synth puts on the -`aside` region. - -### Opting in - -The opt-in flag and the ≥2 count are both `buildDefaultPageSchema` -**options**, never objectDef keys: +## Reference Rail on an authored page -```ts -import { buildDefaultPageSchema } from '@object-ui/plugin-detail'; -import type { ObjectDefLike } from '@object-ui/plugin-detail'; - -declare const opportunityDef: ObjectDefLike; - -const page = buildDefaultPageSchema(opportunityDef, { - showReferenceRail: true, // required — omit it and no rail is emitted - related: [ - { objectName: 'quote', relationshipField: 'opportunity' }, - { objectName: 'task', relationshipField: 'related_to_opportunity' }, - ], -}); -``` - -When the rail emits, the synth automatically suppresses the **Related** -tab so the same information isn't shown twice. - -### Opting out - -The opt-out is a `buildDefaultPageSchema` **option**, not an objectDef key: - -```ts -import { buildDefaultPageSchema } from '@object-ui/plugin-detail'; -import type { ObjectDefLike } from '@object-ui/plugin-detail'; - -declare const productDef: ObjectDefLike; - -const page = buildDefaultPageSchema(productDef, { - hideReferenceRail: true, // hide the rail; restore the Related tab - hideRelatedTab: true, // (optional) force-hide the Related tab too -}); -``` - -### CRM business-domain guidance - -| Object type | Rail | Why | -|--------------------|--------|--------------------------------------------------------------| -| Hub objects | **on** | Account / Opportunity / Contact / Case — users browse laterally to quotes, contacts, activities | -| Transactional | **on** | Quote / Contract / Order — show line-items + related parties at a glance | -| Campaign / Event | **on** | Members, responses, child campaigns | -| Catalog | **off**| Product / Price Book — users edit attributes; lateral relationships are noise | -| Atomic action | **off**| Task / Note — focused single-column edit beats a related-list rail | -| Lead (unconverted) | **off**| Pre-conversion records have no children — keep it focused on the form | - -### Adding the rail to a custom `Page` +The "Reference Rail" is a right-hand column of summary cards for related +collections, drawn by the `record:reference_rail` component. The synthesized +default page never emits it: its related column already shows every related +list as a compact card (see *One record page at two widths* above). The +`buildDefaultPageSchema` options that used to opt into the rail +(`showReferenceRail`, `hideReferenceRail`, `hideRelatedTab`) and the `rightRail` +slot were removed with the other surface hints ADR-0085 does not admit +(objectstack#27055), so the rail's one route is an authored node. -For explicit (non-synth) Pages, add an `aside` region after the `main` -region: +An authored `Page` places it in an `aside` region after the `main` region: ```ts const asideRegion = { name: 'aside', width: 'small', - className: 'hidden xl:flex flex-col gap-4', components: [ { type: 'record:reference_rail', diff --git a/ui/packages/plugin-detail/src/__tests__/referenceRailRoute-9816.test.ts b/ui/packages/plugin-detail/src/__tests__/referenceRailRoute-9816.test.ts index 10032514a92..842c575b170 100644 --- a/ui/packages/plugin-detail/src/__tests__/referenceRailRoute-9816.test.ts +++ b/ui/packages/plugin-detail/src/__tests__/referenceRailRoute-9816.test.ts @@ -27,18 +27,13 @@ * - **The retired route** is prose. It is asserted ABSENT over the raw text of * every file in this package, comments included — a comment carrier is * exactly the carrier that bit three times. - * - **The live route** is a read in code. It is asserted PRESENT over the - * source with whole-line comments STRIPPED, because this very file's subject - * is a docblock that spells `showReferenceRail` out: over the raw text the - * assertion would pass off the prose while the read it claims to find was - * gone. That shape was measured inside objectui#7181's repair — green before - * the ablation and green after it, invisible to the assertion's own result — - * and `scripts/__tests__/body-dialect-census.test.ts` carries the `readCode` - * helper this one follows. - * - * Each direction was ablated: restoring the retired parenthetical reddens the - * absence pins, and deleting the live read reddens the presence pin while the - * absence pins stay green (that green being the trap, made visible). + * - **The synthesizer's own route** (`showReferenceRail` and its siblings) + * was deleted by objectstack#27055. It is asserted ABSENT over the source + * with whole-line comments STRIPPED, because the synthesizer keeps a note + * naming the deleted hints: over the raw text the assertion would fail on + * the note while the code was clean, and a stripper that silently kept the + * note would hide nothing. `scripts/__tests__/body-dialect-census.test.ts` + * carries the `readCode` helper this one follows. * * ## Boundary, stated rather than implied * @@ -153,8 +148,8 @@ describe('objectui#9816 — the retired `detail.`-scoped rail route stays out of expect(RETIRED_ROUTE.test('(per object via `detail.showReferenceRail`)')).toBe(true); expect(RETIRED_ROUTE.test('`detail.hideReferenceRail`')).toBe(true); expect(RETIRED_ROUTE.test('`detail.hideRelatedTab`')).toBe(true); - // The LIVE option must never be caught by it — a pattern that did would - // demand the deletion of the read this card exists to point at. + // The synthesizer's own (now deleted) option spelling is a different + // pattern, pinned absent by the case below on the code side. expect(RETIRED_ROUTE.test('options.showReferenceRail === true &&')).toBe(false); // Nor this package's i18n keys, which are `detail.`-scoped for an // unrelated reason (`useDetailTranslation`). @@ -162,45 +157,30 @@ describe('objectui#9816 — the retired `detail.`-scoped rail route stays out of }); }); -/** The docblock immediately above a member, re-derived rather than transcribed. */ -function docblockAbove(source: string, member: string): string { - const at = source.indexOf(`\n ${member}`); - expect(at, `member \`${member}\` not found — the docblock reader has nothing to read`).toBeGreaterThan(-1); - const before = source.slice(0, at); - const opens = before.lastIndexOf('/**'); - expect(opens, `no docblock above \`${member}\``).toBeGreaterThan(-1); - const block = before.slice(opens); - expect(block.trimEnd().endsWith('*/'), `the block above \`${member}\` is not a closed docblock`).toBe(true); - return block; -} - -describe('objectui#9816 — the `showReferenceRail` docblock, where the comment IS the subject', () => { - it('names the ruling that removed the object-definition route, and no route it removed', () => { - const block = docblockAbove(sourceOf(SUBJECT), 'showReferenceRail?: boolean;'); - // Landing proof: a block this reader failed to find would satisfy the two - // assertions below by describing nothing. - expect(block, 'the extracted block is not the Reference Rail docblock').toContain('Reference Rail'); - - expect( - RETIRED_ROUTE.test(block), - 'the docblock teaches a `detail.`-scoped route again — ADR-0085 removed the read', - ).toBe(false); - expect(block, 'the docblock no longer says what removed the objectDef route').toContain('ADR-0085'); - }); - - it('the LIVE route is a read in code, not a sentence about one', () => { - // Asserted over the code side on purpose — see this file's header. - expect( - codeOf(SUBJECT), - 'nothing reads `options.showReferenceRail` — the docblock now describes a route of its own', - ).toContain('options.showReferenceRail'); +/** + * objectstack#27055 (ADR-0085) deleted the synthesizer's own rail route too: + * `showReferenceRail` / `hideReferenceRail` / `hideRelatedTab` and the + * `rightRail` slot are gone from `BuildPageOptions`, so the rail's ONE route + * is now an authored `record:reference_rail` node in an assigned page. The + * code side of the synthesizer must therefore read no rail hint and emit no + * rail node; the note that names the deleted hints is a comment, which is + * why the assertion runs over the code side and the discriminator proves the + * stripper removes that note. + */ +describe('objectstack#27055 — the synthesizer has no rail route of its own', () => { + it('no code in the synthesizer reads a rail hint or emits a rail node', () => { + const code = codeOf(SUBJECT); + expect(code, 'the synthesizer reads a deleted rail hint again').not.toContain('showReferenceRail'); + expect(code, 'the synthesizer reads a deleted rail hint again').not.toContain('hideReferenceRail'); + expect(code, 'the synthesizer emits a reference rail again').not.toContain("'record:reference_rail'"); + expect(code, 'the synthesizer takes the deleted `rightRail` slot again').not.toContain('rightRail'); }); - it('DISCRIMINATOR — the comment stripper really removes the docblock this file is about', () => { - // Without this, the case above could be reading the very prose it exists to - // check, and would survive the read being deleted. + it('DISCRIMINATOR — the comment stripper really removes the note naming the deleted hints', () => { + // Without this, the case above could pass because the stripper kept the + // note and the note happened not to spell a hint the way it asserts. const raw = sourceOf(SUBJECT); - expect(raw).toContain('Opt in to the Reference Rail'); - expect(codeOf(SUBJECT)).not.toContain('Opt in to the Reference Rail'); + expect(raw).toContain('`showReferenceRail`, `hideReferenceRail`, `hideRelatedTab`'); + expect(codeOf(SUBJECT)).not.toContain('`showReferenceRail`, `hideReferenceRail`, `hideRelatedTab`'); }); }); diff --git a/ui/packages/plugin-detail/src/renderers/__tests__/record-path.declaredKind-12907.test.tsx b/ui/packages/plugin-detail/src/renderers/__tests__/record-path.declaredKind-12907.test.tsx index 6be5619558e..cd1c2323486 100644 --- a/ui/packages/plugin-detail/src/renderers/__tests__/record-path.declaredKind-12907.test.tsx +++ b/ui/packages/plugin-detail/src/renderers/__tests__/record-path.declaredKind-12907.test.tsx @@ -143,7 +143,7 @@ describe('(a) a declared kind decides the terminus, whatever the spelling (objec }); it('the synthesized default page node carries the terminal itself, with no object definition bound', () => { - const [node] = buildDefaultHighlights(LOAN_DECLARED, { statusField: 'status', hideHighlights: true }); + const [node] = buildDefaultHighlights(LOAN_DECLARED, { statusField: 'status', highlightFields: [] }); expect(node.properties.stages).toEqual([ { value: 'borrowed', label: '借出' }, { value: 'returned', label: '已还', terminal: 'won' }, @@ -169,7 +169,7 @@ describe('(b) a declared open beats a terminal-sounding spelling (objectui#12907 }); it('the same holds for the synthesized default page node, read against the bound definition', () => { - const [node] = buildDefaultHighlights(DECLARED_OPEN, { statusField: 'status', hideHighlights: true }); + const [node] = buildDefaultHighlights(DECLARED_OPEN, { statusField: 'status', highlightFields: [] }); const [desktop] = rowsOf(mount(node, DECLARED_OPEN, 'prospecting')); expect(desktopGroups(desktop).separated).toEqual([]); expect(terminalsOf(desktop)).toEqual([null, null, null, 'won']); diff --git a/ui/packages/plugin-detail/src/renderers/__tests__/record-path.derivedStages-11106.test.tsx b/ui/packages/plugin-detail/src/renderers/__tests__/record-path.derivedStages-11106.test.tsx index 2bab4cfb307..b02ce4e4945 100644 --- a/ui/packages/plugin-detail/src/renderers/__tests__/record-path.derivedStages-11106.test.tsx +++ b/ui/packages/plugin-detail/src/renderers/__tests__/record-path.derivedStages-11106.test.tsx @@ -194,7 +194,7 @@ describe('record:path with only statusField derives its stages from the picklist }); it('ONE RULE: the synthesized default page\'s record:path node renders the stages the authored node derives', () => { - const synthesized = buildDefaultHighlights(OPPORTUNITY, { statusField: 'stage', hideHighlights: true }); + const synthesized = buildDefaultHighlights(OPPORTUNITY, { statusField: 'stage', highlightFields: [] }); expect(synthesized).toHaveLength(1); expect(synthesized[0].type).toBe('record:path'); diff --git a/ui/packages/plugin-detail/src/renderers/record-reference-rail.tsx b/ui/packages/plugin-detail/src/renderers/record-reference-rail.tsx index 73afeca6d2b..0cea3c583f0 100644 --- a/ui/packages/plugin-detail/src/renderers/record-reference-rail.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-reference-rail.tsx @@ -310,11 +310,11 @@ export const RecordReferenceRailRenderer: React.FC>({}); - // PERF: gate the rail's per-entry queries on visibility. The rail lives in - // an aside region that is `hidden xl:flex` (see buildDefaultPageSchema), so - // on sub-`xl` viewports (the common laptop width) it is `display:none` and - // the IntersectionObserver never reports intersection → zero queries on - // record open. On `xl`+ it fetches once the rail scrolls near the viewport. + // PERF: gate the rail's per-entry queries on visibility. An authored page + // places the rail in an aside region, which may be hidden on narrow + // viewports; then it is `display:none` and the IntersectionObserver never + // reports intersection → zero queries on record open. Where it is drawn it + // fetches once the rail scrolls near the viewport. // Without this, every related collection was fetched eagerly on mount, // which (together with RecordDetailView's preload, now removed for this // layout) produced ~50 concurrent requests that head-of-line-blocked one diff --git a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.relatedSort.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.relatedSort.test.ts index 10aa1b8235e..12a5cf9023c 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.relatedSort.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.relatedSort.test.ts @@ -117,7 +117,6 @@ describe('buildDefaultTabs — related-list sort carriage (objectui#5795)', () = it('carries a per-list sort, not a page-wide one', () => { const tabs = buildDefaultTabs(checkItemDef, { - relatedLayout: 'stack', related: [ { objectName: 'check_item', relationshipField: 'task_version', sort: SORT }, { objectName: 'attachment_note', relationshipField: 'task_version' }, @@ -149,30 +148,4 @@ describe('buildDefaultTabs — related-list sort carriage (objectui#5795)', () = }; expect(issuesOf(PageSchema.safeParse(body) as any)).toEqual([]); }); - - it('THE THIRD SITE — reference-rail entries do NOT gain a sort', () => { - // The rail is opt-in and needs at least two related lists (it also - // suppresses the Related tab), so this is the branch where the SAME - // `related[]` array reaches the rail's own strict entry shape. - const synth = buildDefaultPageSchema(checkItemDef, { - showReferenceRail: true, - related: [ - { objectName: 'check_item', relationshipField: 'task_version', sort: SORT }, - { objectName: 'attachment_note', relationshipField: 'task_version', sort: SORT }, - ], - }); - const aside = (synth.regions as any[]).find((r) => r.name === 'aside'); - const rail = aside?.components?.find((c: any) => c.type === 'record:reference_rail'); - expect(rail).toBeTruthy(); - for (const entry of propsOf(rail).entries) { - expect('sort' in entry).toBe(false); - } - // Deliberately NOT a whole-page `PageSchema` parse here, unlike the leg - // above: a Reference Rail page is already unpersistable for an unrelated, - // pre-existing reason — the synthesized `aside` region carries a - // `className` that `PageRegionSchema` refuses (objectui#4286, open). - // Measured on this branch with NO `sort` anywhere, so it is not this - // change; asserting a clean parse here would fail on someone else's - // defect and asserting the failure would pin it in place. - }); }); diff --git a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.retiredHints-27055.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.retiredHints-27055.test.ts new file mode 100644 index 00000000000..57c5f25c63e --- /dev/null +++ b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.retiredHints-27055.test.ts @@ -0,0 +1,104 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27055 (ADR-0085) — the dead surface hints are gone from + * `BuildPageOptions`. + * + * `hideHighlights`, `hidePath`, `showReferenceRail`, `hideReferenceRail`, + * `hideRelatedTab`, `relatedLayout`, `recordChrome` and the `slots.rightRail` + * slot had no console caller; ADR-0085 admits no surface hints, and + * `rightRail` is not a slot the spec names. The compile-time half below is + * the acceptance pin: each removed key is refused by the type, so a caller + * that still passes one fails `tsc` (this file is compiled by + * `tsconfig.test.json`). A `@ts-expect-error` that stops erroring fails that + * compile too, which is what makes the pin two-sided. + * + * The runtime half pins what the page looks like without them: one region, + * the header drawn with the record chrome, and a key still passed through an + * untyped caller (a JS host, a stale cast) changing nothing. + */ + +import { describe, expect, it } from 'vitest'; +import { buildDefaultPageSchema, type BuildPageOptions } from '../buildDefaultPageSchema'; + +const leadDef = { + name: 'lead', + fields: { + name: { type: 'text', label: 'Name' }, + email: { type: 'email', label: 'Email' }, + status: { + type: 'select', + label: 'Status', + options: [ + { value: 'new', label: 'New' }, + { value: 'won', label: 'Won' }, + ], + }, + }, +}; + +const RELATED = [ + { objectName: 'task', relationshipField: 'lead_id' }, + { objectName: 'note', relationshipField: 'parent_id' }, +]; + +describe('objectstack#27055 — BuildPageOptions no longer accepts the removed hints (tsc)', () => { + it('each removed key is a type error', () => { + // @ts-expect-error — removed: `hideHighlights` + const a: BuildPageOptions = { hideHighlights: true }; + // @ts-expect-error — removed: `hidePath` + const b: BuildPageOptions = { hidePath: true }; + // @ts-expect-error — removed: `showReferenceRail` + const c: BuildPageOptions = { showReferenceRail: true }; + // @ts-expect-error — removed: `hideReferenceRail` + const d: BuildPageOptions = { hideReferenceRail: true }; + // @ts-expect-error — removed: `hideRelatedTab` + const e: BuildPageOptions = { hideRelatedTab: true }; + // @ts-expect-error — removed: `relatedLayout` + const f: BuildPageOptions = { relatedLayout: 'tabs' }; + // @ts-expect-error — removed: `recordChrome` + const g: BuildPageOptions = { recordChrome: false }; + // @ts-expect-error — removed: the `rightRail` slot + const h: BuildPageOptions = { slots: { rightRail: { type: 'card' } } }; + expect([a, b, c, d, e, f, g, h]).toHaveLength(8); + }); + + it('CONTROL — the options that stay are still accepted', () => { + const kept: BuildPageOptions = { + hideDiscussion: true, + hideAttachments: true, + related: RELATED, + slots: { details: { type: 'record:details' } }, + }; + expect(kept.hideDiscussion).toBe(true); + }); +}); + +describe('objectstack#27055 — the page without the hints', () => { + it('has one region, and an untyped caller passing a removed hint changes nothing', () => { + const plain = buildDefaultPageSchema(leadDef, { related: RELATED }); + const withHints = buildDefaultPageSchema(leadDef, { + related: RELATED, + showReferenceRail: true, + hideRelatedTab: true, + relatedLayout: 'tabs', + hideHighlights: true, + hidePath: true, + recordChrome: false, + slots: { rightRail: { type: 'card' } }, + } as unknown as BuildPageOptions); + expect(plain.regions.map((r: { name: string }) => r.name)).toEqual(['main']); + expect(withHints).toEqual(plain); + }); + + it('draws the header with the record chrome', () => { + const header = buildDefaultPageSchema(leadDef).regions[0].components[0]; + expect(header).toEqual({ type: 'page:header', properties: { recordChrome: true } }); + }); +}); diff --git a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.strictPayload.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.strictPayload.test.ts index 7b287ade3cd..c5a5f10179a 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.strictPayload.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.strictPayload.test.ts @@ -120,11 +120,7 @@ describe('ADR-0089 D3a — the Studio create payload parses server-side (objectu }); it('is accepted when the caller suppresses every optional slot', () => { - const body = createBody(leadDef, { - hideDiscussion: true, - hideHighlights: true, - hidePath: true, - }); + const body = createBody(leadDef, { hideDiscussion: true, hideAttachments: true }); expect(issuesOf(PageSchema.safeParse(body))).toEqual([]); }); @@ -145,13 +141,9 @@ describe('ADR-0089 D3a — the Studio create payload parses server-side (objectu approvals: { node: { currentUserId: 'u_1' } }, }; const filesDef = { ...leadDef, enable: { files: true } }; - // Two pages, because the two branches are mutually exclusive by design: - // turning the Reference Rail on suppresses the Related tab, so - // `record:related_list` and `record:reference_rail` never co-occur. - const pages = [ - buildDefaultPageSchema(filesDef, common), - buildDefaultPageSchema(filesDef, { ...common, showReferenceRail: true }), - ]; + // One page: the synthesizer emits no reference rail any more + // (objectstack#27055), so every branch it has co-occurs here. + const pages = [buildDefaultPageSchema(filesDef, common)]; const nodes: any[] = []; const walk = (n: any): void => { if (Array.isArray(n)) return n.forEach(walk); @@ -173,7 +165,6 @@ describe('ADR-0089 D3a — the Studio create payload parses server-side (objectu 'record:highlights', 'record:history', 'record:path', - 'record:reference_rail', 'record:related_list', ]); for (const node of nodes) { @@ -184,7 +175,6 @@ describe('ADR-0089 D3a — the Studio create payload parses server-side (objectu it('the sub-builders emit accepted nodes on their own (slot authors compose these)', () => { const nodes = [ buildDefaultHeader(leadDef), - buildDefaultHeader(leadDef, { recordChrome: false }), buildDefaultActions(leadDef, [{ name: 'edit', label: 'Edit' }]), ...buildDefaultHighlights(leadDef), buildDefaultDetails(leadDef), @@ -216,17 +206,14 @@ describe('the accepted shape still carries the semantics (objectui#4232)', () => * turned the record chrome OFF (a dashboard / landing page) gets that choice * PERSISTED, because `recordChrome` is a declared `page:header` prop * (`ComponentPropsMap['page:header'].recordChrome`, #6776) and the bag it - * lives in is the spec's own carrier. + * lives in is the spec's own carrier. The synthesizer no longer takes a + * `recordChrome` hint (objectstack#27055), so the OFF node is the authored + * one. */ - it('record chrome turned OFF survives the parse rather than silently reverting', () => { - const node = buildDefaultHeader(leadDef, { recordChrome: false }); - expect(node).toEqual({ type: 'page:header', properties: { recordChrome: false } }); + it('record chrome turned OFF on an authored header survives the parse rather than silently reverting', () => { + const node = { type: 'page:header', properties: { recordChrome: false } }; const parsed = PageComponentSchema.parse(node) as any; expect(parsed.properties.recordChrome).toBe(false); - // …and it reaches the page body the console PUTs. - const body = createBody(leadDef, { recordChrome: false }); - const page = PageSchema.parse(body) as any; - expect(page.regions[0].components[0].properties.recordChrome).toBe(false); }); /** diff --git a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts index ae75050f42a..064d6046fd9 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts @@ -283,15 +283,6 @@ describe('buildDefaultPageSchema', () => { expect(types).not.toContain('record:discussion'); }); - it('hideHighlights / hidePath each drop their component', () => { - const types = buildDefaultPageSchema(leadDef, { - hideHighlights: true, - hidePath: true, - }).regions[0].components.map((c: any) => c.type); - expect(types).not.toContain('record:highlights'); - expect(types).not.toContain('record:path'); - }); - it('options override auto-derivation', () => { const page = buildDefaultPageSchema(leadDef, { highlightFields: ['email'], @@ -305,11 +296,9 @@ describe('buildDefaultPageSchema', () => { expect(props(path).stages).toEqual([{ value: 'hot', label: 'Hot' }]); }); - it('page:header.recordChrome defaults to true and can be turned off', () => { - const on = buildDefaultPageSchema(leadDef).regions[0].components[0]; - const off = buildDefaultPageSchema(leadDef, { recordChrome: false }).regions[0].components[0]; - expect(props(on).recordChrome).toBe(true); - expect(props(off).recordChrome).toBe(false); + it('page:header.recordChrome is always true on the synthesized page', () => { + const header = buildDefaultPageSchema(leadDef).regions[0].components[0]; + expect(props(header).recordChrome).toBe(true); }); it('page:tabs always carries a details tab containing record:details', () => { @@ -359,9 +348,8 @@ describe('buildDefaultPageSchema', () => { it('emits Related tab with one record:related_list per entry', () => { const page = buildDefaultPageSchema(leadDef, { - // The Related tab is the default home for related lists. The - // Reference Rail is opt-in (`showReferenceRail`), so it stays off - // here and the Related tab renders even with 2+ related lists. + // The Related tab is the default home for related lists; the + // synthesized page emits no reference rail (objectstack#27055). related: [ { objectName: 'task', @@ -411,102 +399,21 @@ describe('buildDefaultPageSchema', () => { expect(related.children.map((c: any) => props(c).objectName)).toEqual(['task', 'note']); }); - it("relatedLayout:'tabs' gives every related list its own tab, ignoring isPrimary", () => { - const tabs = buildDefaultTabs(leadDef, { - relatedLayout: 'tabs', - related: [ - { objectName: 'task', relationshipField: 'lead_id', title: 'Tasks' }, - { objectName: 'note', relationshipField: 'parent_id', title: 'Notes' }, - ], - }); - const labels = tabItems(tabs).map((t: any) => t.label); - expect(labels).toEqual(['Details', 'Tasks', 'Notes']); - expect(labels).not.toContain('Related'); - }); - - it("relatedLayout:'stack' collapses all lists into one Related tab, ignoring isPrimary", () => { - const tabs = buildDefaultTabs(leadDef, { - relatedLayout: 'stack', - related: [ - { objectName: 'opportunity', relationshipField: 'lead_id', title: 'Opportunities', isPrimary: true }, - { objectName: 'task', relationshipField: 'lead_id', title: 'Tasks' }, - ], - }); - const labels = tabItems(tabs).map((t: any) => t.label); - expect(labels).toEqual(['Details', 'Related']); - const related = tabItems(tabs).find((t: any) => t.label === 'Related'); - expect(related.children).toHaveLength(2); - }); - - it('does NOT emit a Reference Rail by default, keeping the Related tab', () => { + it('emits no reference rail and no aside region, keeping the Related tab', () => { const page = buildDefaultPageSchema(leadDef, { related: [ { objectName: 'task', relationshipField: 'lead_id' }, { objectName: 'note', relationshipField: 'parent_id' }, ], }); - // No aside region — the rail is opt-in. - expect(page.regions.find((r: any) => r.name === 'aside')).toBeUndefined(); - // Related tab survives (Details + Related). + // One region: the opt-in rail and the `rightRail` slot are gone + // (objectstack#27055, ADR-0085). + expect(page.regions.map((r: any) => r.name)).toEqual(['main']); const tabs = page.regions[0].components.find((c: any) => c.type === 'page:tabs'); const labels = tabItems(tabs).map((t: any) => t.label); expect(labels).toContain('Related'); }); - it('emits a Reference Rail aside region and suppresses the duplicate Related tab when showReferenceRail is on and 2+ related lists are present', () => { - const page = buildDefaultPageSchema(leadDef, { - showReferenceRail: true, - related: [ - { objectName: 'task', relationshipField: 'lead_id' }, - { objectName: 'note', relationshipField: 'parent_id' }, - ], - }); - // Related tab is suppressed (Details only) - const tabs = page.regions[0].components.find((c: any) => c.type === 'page:tabs'); - expect(tabItems(tabs)).toHaveLength(1); - expect(tabItems(tabs)[0].label).toBe('Details'); - // Aside region emitted with the rail - const aside = page.regions.find((r: any) => r.name === 'aside'); - expect(aside).toBeDefined(); - expect(aside.components[0].type).toBe('record:reference_rail'); - expect(props(aside.components[0]).entries).toHaveLength(2); - expect(props(aside.components[0]).entries[0].objectName).toBe('task'); - }); - - it('emits aside region from slots.rightRail even without any related lists', () => { - const page = buildDefaultPageSchema(leadDef, { - slots: { - rightRail: [ - { type: 'card', title: 'Workflow status', children: [] }, - ], - }, - }); - const aside = page.regions.find((r: any) => r.name === 'aside'); - expect(aside).toBeDefined(); - // No reference rail (no related lists), only the slot contribution - expect(aside.components).toHaveLength(1); - expect(aside.components[0].type).toBe('card'); - expect(aside.components[0].title).toBe('Workflow status'); - }); - - it('appends slots.rightRail after the auto-emitted reference rail', () => { - const page = buildDefaultPageSchema(leadDef, { - showReferenceRail: true, - related: [ - { objectName: 'task', relationshipField: 'lead_id' }, - { objectName: 'note', relationshipField: 'parent_id' }, - ], - slots: { - rightRail: { type: 'card', title: 'Activity' }, - }, - }); - const aside = page.regions.find((r: any) => r.name === 'aside'); - expect(aside).toBeDefined(); - expect(aside.components).toHaveLength(2); - expect(aside.components[0].type).toBe('record:reference_rail'); - expect(aside.components[1].type).toBe('card'); - }); - it('emits Activity tab when showActivity is true', () => { const page = buildDefaultPageSchema(leadDef, { showActivity: true }); const tabs = page.regions[0].components.find((c: any) => c.type === 'page:tabs'); @@ -546,89 +453,6 @@ describe('buildDefaultPageSchema', () => { ]); }); - describe("relatedLayout: 'tabs'", () => { - const related = [ - { - objectName: 'task', - relationshipField: 'lead_id', - title: 'Tasks', - limit: 10, - icon: 'check', - }, - { - objectName: 'note', - relationshipField: 'parent_id', - }, - ]; - - it("gives each related list its own peer tab instead of one shared Related tab", () => { - const page = buildDefaultPageSchema(leadDef, { - related, - relatedLayout: 'tabs', - }); - const tabs = page.regions[0].components.find( - (c: any) => c.type === 'page:tabs', - ); - // Details + one tab per related child (no shared 'Related' tab). - expect(tabItems(tabs).map((t: any) => t.label)).toEqual([ - 'Details', - 'Tasks', - 'note', - ]); - const tasksTab = tabItems(tabs)[1]; - expect(tasksTab.icon).toBe('check'); - expect(tasksTab.children).toHaveLength(1); - expect(tasksTab.children[0].type).toBe('record:related_list'); - expect(props(tasksTab.children[0]).objectName).toBe('task'); - expect(props(tasksTab.children[0]).relationshipField).toBe('lead_id'); - expect(props(tasksTab.children[0]).limit).toBe(10); - // The second related child (no title) falls back to its objectName. - expect(props(tabItems(tabs)[2].children[0]).objectName).toBe('note'); - }); - - it("defaults to the stacked 'Related' tab when relatedLayout is omitted", () => { - const page = buildDefaultPageSchema(leadDef, { related }); - const tabs = page.regions[0].components.find( - (c: any) => c.type === 'page:tabs', - ); - expect(tabItems(tabs).map((t: any) => t.label)).toEqual([ - 'Details', - 'Related', - ]); - expect(tabItems(tabs)[1].children).toHaveLength(2); - }); - - it("still honours hideRelatedTab (no related tabs emitted)", () => { - const page = buildDefaultPageSchema(leadDef, { - related, - relatedLayout: 'tabs', - hideRelatedTab: true, - }); - const tabs = page.regions[0].components.find( - (c: any) => c.type === 'page:tabs', - ); - expect(tabItems(tabs).map((t: any) => t.label)).toEqual(['Details']); - }); - - it("keeps Activity / History after the per-table tabs", () => { - const page = buildDefaultPageSchema(leadDef, { - related, - relatedLayout: 'tabs', - showActivity: true, - history: { entries: [], loading: false }, - }); - const tabs = page.regions[0].components.find( - (c: any) => c.type === 'page:tabs', - ); - expect(tabItems(tabs).map((t: any) => t.label)).toEqual([ - 'Details', - 'Tasks', - 'note', - 'Activity', - 'History', - ]); - }); - }); }); describe('slice I — slot overrides', () => { @@ -770,14 +594,6 @@ describe('buildDefaultPageSchema', () => { expect(types).toContain('record:path'); }); - it('buildDefaultHighlights respects hideHighlights / hidePath flags', () => { - const nodes = buildDefaultHighlights(leadDef, { - hideHighlights: true, - hidePath: true, - }); - expect(nodes).toHaveLength(0); - }); - it('buildDefaultTabs emits Details/Related/Activity/History in order', () => { const tabs = buildDefaultTabs(leadDef, { related: [{ objectName: 'task', relationshipField: 'lead_id' }], @@ -1200,13 +1016,6 @@ describe('buildDefaultTabs — stable tab values (objectui#2257)', () => { ]); }); - it('relatedLayout tabs → every child gets related:; stack → one related', () => { - const asTabs = buildDefaultTabs(undefined, { related: [relA, relB], relatedLayout: 'tabs' }); - expect(tabItems(asTabs).map((i: any) => i.value)).toEqual(['details', 'related:invoice', 'related:note']); - const stacked = buildDefaultTabs(undefined, { related: [relA, relB], relatedLayout: 'stack' }); - expect(tabItems(stacked).map((i: any) => i.value)).toEqual(['details', 'related']); - }); - it('values stay stable when the related list shrinks (URL tokens must not shift)', () => { const both = buildDefaultTabs(undefined, { related: [relA, relB] }); const onlyB = buildDefaultTabs(undefined, { related: [relB] }); diff --git a/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.test.ts index d9ea565ecac..bdd555e5263 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.test.ts @@ -87,11 +87,8 @@ describe('recordPageTierFor (objectstack#26678)', () => { }); describe('recordPageColumnOfTab — every synthesized tab has a column', () => { - it.each(['default', 'stack', 'tabs'] as const)('relatedLayout %s', (layout) => { - const tabs = buildDefaultTabs(leadDef, { - ...maximal, - ...(layout === 'default' ? {} : { relatedLayout: layout }), - }); + it('from a maximal synthesis', () => { + const tabs = buildDefaultTabs(leadDef, maximal); const values = tabs.properties.items.map((item: any) => item.value); // The maximal synthesis really emitted the whole strip. expect(values).toEqual(expect.arrayContaining(['details', 'approvals', 'attachments', 'activity', 'history'])); @@ -131,7 +128,7 @@ describe('buildDefaultPageColumns (objectstack#26678)', () => { }); it('places every node the page holds exactly once', () => { - const page = buildDefaultPageSchema(leadDef, { ...maximal, relatedLayout: 'tabs' }); + const page = buildDefaultPageSchema(leadDef, maximal); const columns = buildDefaultPageColumns(page)!; const placed = [...columns.header, ...columns.details, ...columns.timeline, ...columns.related]; const main = page.regions.find((r: any) => r.name === 'main'); @@ -144,20 +141,11 @@ describe('buildDefaultPageColumns (objectstack#26678)', () => { for (const node of expected) expect(placed.filter((n) => n === node)).toHaveLength(1); }); - it('folds the opt-in reference rail into the related column — one rail, not two', () => { - const page = buildDefaultPageSchema(leadDef, { - showReferenceRail: true, - related: RELATED, - slots: { rightRail: { type: 'card', title: 'Watchers' } }, - }); - // The tabbed page keeps today's aside, and the rail suppresses the Related tab. - expect(page.regions.map((r: any) => r.name)).toEqual(['main', 'aside']); - const columns = buildDefaultPageColumns(page)!; - // The rail and the `rightRail` slot, then the Attachments tab (`enable.files`). - expect(types(columns.related)).toEqual(['record:reference_rail', 'card', 'record:attachments']); - expect(columns.relatedCount).toBe(RELATED.length); - // No related list is drawn twice: the rail stands in for the suppressed tab. - expect(types(columns.related)).not.toContain('record:related_list'); + it('answers null for a page with a region besides `main` — the synthesizer emits none (objectstack#27055)', () => { + const page = buildDefaultPageSchema(leadDef, { related: RELATED }); + expect(page.regions.map((r: any) => r.name)).toEqual(['main']); + const withAside = { ...page, regions: [...page.regions, { name: 'aside', components: [{ type: 'record:reference_rail' }] }] }; + expect(buildDefaultPageColumns(withAside)).toBeNull(); }); it('a slotted details body lands in the details column', () => { diff --git a/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts b/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts index e3fb48c34d0..1680869382e 100644 --- a/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts +++ b/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts @@ -154,37 +154,15 @@ export interface BuildPageOptions { hideDiscussion?: boolean; /** Suppress the auto-emitted Attachments tab (`enable.files`, objectstack#4358). */ hideAttachments?: boolean; - /** Suppress the auto-prepended `record:highlights` strip. */ - hideHighlights?: boolean; - /** Suppress the auto-prepended `record:path` stepper. */ - hidePath?: boolean; - /** - * Opt in to the Reference Rail (aside region with - * `record:reference_rail`). The rail is OFF by default — it fans out a - * collection query per related list, which is wasteful on records the - * author never intended to summarize. Set `showReferenceRail: true` on - * this options object to surface it — that is the only route: ADR-0085 - * removed the per-object `detail` hints block, so no object definition - * can switch the rail on, and a per-page rail is an authored - * `record:reference_rail` node in an assigned Page schema. It then - * emits only when `related` has at least 2 entries. - * Pinned by `__tests__/referenceRailRoute-9816.test.ts`. - */ - showReferenceRail?: boolean; - /** - * Force-suppress the Reference Rail even when `showReferenceRail` is on. - * Retained for callers that toggle the rail off contextually. - */ - hideReferenceRail?: boolean; - /** - * Suppress the auto-emitted `Related` tab in `buildDefaultTabs`. - * Auto-set to `true` by `buildDefaultPageSchema` when the Reference - * Rail is being emitted, to avoid showing the same related-list data - * in two places (the rail and the tab). Authors can override. - */ - hideRelatedTab?: boolean; - /** Pass-through to `page:header.recordChrome`. Defaults to true. */ - recordChrome?: boolean; + // objectstack#27055 (ADR-0085) — the per-surface presentation hints this + // options bag used to carry are GONE: `hideHighlights`, `hidePath`, + // `showReferenceRail`, `hideReferenceRail`, `hideRelatedTab`, + // `relatedLayout`, `recordChrome` and the `slots.rightRail` slot. No console + // caller passed any of them, ADR-0085 admits no surface hints, and + // `rightRail` is not a slot the spec's strict slot enum names. The default + // page is drawn from the object's semantic roles alone; a page that wants + // a reference rail places an authored `record:reference_rail` node (the + // renderer stays registered for exactly that). /** * Action definitions to surface in the header. When provided and * non-empty, the synthesizer emits a `record:quick_actions` node @@ -241,30 +219,10 @@ export interface BuildPageOptions { /** * `relatedList: 'primary'` — a CORE relationship. Under the default * layout this list is promoted to its OWN tab; non-primary lists collapse - * into a single "Related" tab. Ignored when `relatedLayout` forces a - * uniform layout. + * into a single "Related" tab. */ isPrimary?: boolean; }>; - /** - * How the related child lists are laid out under the tab strip. - * - * - **default (unset)** — the ADR-0085 prominence rule: lists flagged - * `relatedList: 'primary'` (`isPrimary`) each get their OWN tab; every - * other related list collapses into a single stacked `Related` tab. With - * no primary lists this is identical to the legacy stacked behavior, so - * the change is opt-in per relationship — never a surprise. - * - `'stack'` — app-level override: ALL related lists stack vertically - * inside one `Related` tab, ignoring `isPrimary`. - * - `'tabs'` — app-level override: EVERY related child gets its own peer - * tab (label = the child's `title`, falling back to `objectName`), - * ignoring `isPrimary`. - * - * Precise per-tab ordering / filtered splits / non-relationship tabs are a - * custom Page concern (Tier 2) — this synthesizer only covers the derived - * default. Ignored when the `Related` tab is suppressed (`hideRelatedTab`). - */ - relatedLayout?: 'stack' | 'tabs'; /** * When true, emit an Activity tab that renders `record:activity`. * The activity renderer fetches its own data via RecordContext. @@ -309,14 +267,6 @@ export interface BuildPageOptions { * - `discussion` — replaces `record:discussion`. Fires even when * `hideDiscussion` is true (the override is explicit intent to * surface a custom footer). - * - `rightRail` — additional component(s) to drop into the - * right-side `aside` region (the same region that hosts the - * auto-emitted `record:reference_rail`). When provided, the - * `aside` region is always rendered — even when no reference - * rail would otherwise be emitted — so plugins can contribute - * contextual side panels (activity feed, related summary, - * workflow status, presence list, etc.) without depending on - * the related-list heuristic. */ slots?: { header?: any | any[]; @@ -326,7 +276,6 @@ export interface BuildPageOptions { details?: any | any[]; tabs?: any | any[]; discussion?: any | any[]; - rightRail?: any | any[]; }; } @@ -689,10 +638,13 @@ export function deriveHighlightFields( */ export function buildDefaultHeader( _def: ObjectDefLike | undefined, - options: Pick & { actions?: any[] } = {}, + options: { actions?: any[] } = {}, ): any { + // `recordChrome` is the `page:header` prop that draws the title as the + // record chip; the default record page always wants it, so it is written + // as `true` rather than read off a caller hint (objectstack#27055). return componentNode('page:header', { - recordChrome: options.recordChrome !== false, + recordChrome: true, ...(Array.isArray(options.actions) && options.actions.length > 0 ? { actions: options.actions } : {}), @@ -725,19 +677,17 @@ export function buildDefaultActions( */ export function buildDefaultHighlights( def: ObjectDefLike | undefined, - options: Pick = {}, + options: Pick = {}, ): any[] { const statusField = options.statusField ?? detectStatusField(def); const stages = options.stages ?? (statusField ? deriveStages(def, statusField) : null); const highlightFields = options.highlightFields ?? deriveHighlightFields(def, statusField); const out: any[] = []; - if (!options.hideHighlights && highlightFields.length > 0) { + if (highlightFields.length > 0) { out.push(componentNode('record:highlights', { fields: highlightFields })); } - if (!options.hidePath && statusField && stages && stages.length > 0) { + if (statusField && stages && stages.length > 0) { out.push(componentNode('record:path', { statusField, stages })); } return out; @@ -950,7 +900,7 @@ export function buildDefaultDetails( export function buildDefaultTabs( def: ObjectDefLike | undefined, options: Pick = {}, ): any { const statusField = options.statusField ?? detectStatusField(def); @@ -963,11 +913,7 @@ export function buildDefaultTabs( const items: any[] = [ { label: 'Details', value: 'details', children: [buildDefaultDetails(def, options.sections, highlightFields)] }, ]; - if ( - !options.hideRelatedTab && - Array.isArray(options.related) && - options.related.length > 0 - ) { + if (Array.isArray(options.related) && options.related.length > 0) { const relatedNode = (rel: NonNullable[number]) => componentNode('record:related_list', { title: rel.title, @@ -993,24 +939,15 @@ export function buildDefaultTabs( ...(rel.icon ? { icon: rel.icon } : {}), children: [relatedNode(rel)], }); - if (options.relatedLayout === 'tabs') { - // App-level override: one peer tab per related child. - for (const rel of options.related) items.push(asOwnTab(rel)); - } else if (options.relatedLayout === 'stack') { - // App-level override: all related lists share one stacked `Related` tab. - items.push({ label: 'Related', value: 'related', children: options.related.map(relatedNode) }); - } else { - // DEFAULT (rule Z, ADR-0085 prominence): `isPrimary` lists become their - // own tab; the rest collapse into one `Related` tab. Owned-before-lookup - // order is preserved by deriveRelatedLists upstream. With no primary - // lists this is identical to the legacy stacked default (opt-in per - // relationship, never a surprise). - const primary = options.related.filter((r) => r.isPrimary); - const rest = options.related.filter((r) => !r.isPrimary); - for (const rel of primary) items.push(asOwnTab(rel)); - if (rest.length > 0) { - items.push({ label: 'Related', value: 'related', children: rest.map(relatedNode) }); - } + // Rule Z, ADR-0085 prominence: `isPrimary` lists become their own tab; + // the rest collapse into one `Related` tab. Owned-before-lookup order is + // preserved by deriveRelatedLists upstream. With no primary lists this is + // the stacked default (opt-in per relationship, never a surprise). + const primary = options.related.filter((r) => r.isPrimary); + const rest = options.related.filter((r) => !r.isPrimary); + for (const rel of primary) items.push(asOwnTab(rel)); + if (rest.length > 0) { + items.push({ label: 'Related', value: 'related', children: rest.map(relatedNode) }); } } // Approvals tab (objectui#3461) — emitted only when the host reports the @@ -1115,12 +1052,7 @@ export function buildDefaultPageSchema( if ('header' in slots && slots.header !== undefined) { components.push(...toNodeArray(slots.header)); } else { - components.push( - buildDefaultHeader(def, { - recordChrome: options.recordChrome, - actions: options.headerActions, - }), - ); + components.push(buildDefaultHeader(def, { actions: options.headerActions })); } // 2) Header action bar — only emitted as a separate node when an @@ -1147,23 +1079,9 @@ export function buildDefaultPageSchema( highlightFields: options.highlightFields, statusField: options.statusField, stages: options.stages, - hideHighlights: options.hideHighlights, - hidePath: options.hidePath, })); } - // Decide whether to emit the Reference Rail. When emitted, suppress - // the duplicate Related tab to avoid showing the same data twice - // (HubSpot/Dynamics convention). Authors can opt out of either via - // `hideReferenceRail` / `hideRelatedTab`. - const willEmitRail = - options.showReferenceRail === true && - !options.hideReferenceRail && - Array.isArray(options.related) && - options.related.length >= 2; - const hideRelatedTab = - options.hideRelatedTab ?? willEmitRail; - // 4) Tabs — `tabs` slot wins over `details` slot when both are // provided (broader override). When only `details` is provided, // splice it into the Details tab body and keep Related/Activity/ @@ -1180,8 +1098,6 @@ export function buildDefaultPageSchema( approvals: options.approvals, highlightFields: options.highlightFields, statusField: options.statusField, - hideRelatedTab, - relatedLayout: options.relatedLayout, hideAttachments: options.hideAttachments, }); // Replace the first tab's children (Details) with the override. The items @@ -1201,8 +1117,6 @@ export function buildDefaultPageSchema( approvals: options.approvals, highlightFields: options.highlightFields, statusField: options.statusField, - hideRelatedTab, - relatedLayout: options.relatedLayout, hideAttachments: options.hideAttachments, })); } @@ -1216,11 +1130,10 @@ export function buildDefaultPageSchema( components.push(buildDefaultDiscussion()); } - // 6) Reference Rail — Salesforce/HubSpot-style aside summary of - // related collections. Auto-emit only when the record has at - // least 2 related lists; otherwise the rail is mostly noise. - // Hidden on screens below `xl` so the main column owns full width - // at common laptop widths. + // ADR-0085 (objectstack#27055): the synthesized page has ONE region. The + // opt-in reference rail and the `rightRail` slot that used to add an + // `aside` here are gone; an authored page places `record:reference_rail` + // itself. const regions: any[] = [ { name: 'main', @@ -1229,38 +1142,6 @@ export function buildDefaultPageSchema( }, ]; - const rightRailExtras = toNodeArray(slots.rightRail); - const willEmitAside = willEmitRail || rightRailExtras.length > 0; - - if (willEmitAside) { - const asideComponents: any[] = []; - if (willEmitRail) { - // No `icon` on rail entries: `ReferenceRailEntrySchema` is `$strict` - // without it, so emitting `rel.icon` here wrote a key the spec refuses - // at save — and no render path ever read it (objectui#5494). - asideComponents.push(componentNode('record:reference_rail', { - entries: (options.related as any[]).map((rel) => ({ - objectName: rel.objectName, - relationshipField: rel.relationshipField, - title: rel.title, - displayField: rel.displayField, - limit: 3, - })), - })); - } - // Author-contributed right-rail nodes follow the reference rail so the - // canonical "related" summary stays anchored at the top. - if (rightRailExtras.length > 0) { - asideComponents.push(...rightRailExtras); - } - regions.push({ - name: 'aside', - width: 'small', - className: 'hidden xl:flex flex-col gap-4', - components: asideComponents, - }); - } - return { type: 'record', pageType: 'record', diff --git a/ui/packages/plugin-detail/src/synth/recordPageColumns.ts b/ui/packages/plugin-detail/src/synth/recordPageColumns.ts index 3f8db83bfe6..2b6acc2ad95 100644 --- a/ui/packages/plugin-detail/src/synth/recordPageColumns.ts +++ b/ui/packages/plugin-detail/src/synth/recordPageColumns.ts @@ -30,8 +30,9 @@ * carries (`buildDefaultTabs`, objectui#2257): `details` is the details * column, `activity` / `history` join the discussion footer in the timeline * column, and every related tab, `approvals` and `attachments` go to the - * related column. The page's opt-in `aside` (the reference rail and the - * `rightRail` slot) folds into that same related column — one rail, not two. + * related column. The synthesized page has one region (`main`); a page with + * any other region is not the synthesized page (objectstack#27055 removed the + * opt-in rail and the `rightRail` slot that used to add an `aside`). * * A page this module cannot read — no synthesized tab strip, or a tab whose * value it does not know (an authored `slots.tabs` strip) — answers `null`, @@ -211,14 +212,11 @@ export interface DefaultPageColumns { details: any[]; /** The discussion footer first (the composer on top), then Activity and History. */ timeline: any[]; - /** - * Related lists in the strip's order, then the folded `aside` (reference - * rail, `rightRail` slot), then Approvals and Attachments. - */ + /** Related lists in the strip's order, then Approvals and Attachments. */ related: any[]; /** * How many related collections the related column shows: one per - * `record:related_list`, one per reference-rail entry. Labels the + * `record:related_list`. Labels the * two-column tier's rail toggle. */ relatedCount: number; @@ -238,7 +236,6 @@ function relatedCollectionsIn(nodes: any[]): number { const node = asNode(raw); if (!node) continue; if (node.type === 'record:related_list') count += 1; - else if (node.type === 'record:reference_rail') count += nodeList(asNode(node.properties)?.entries).length; } return count; } @@ -247,17 +244,18 @@ function relatedCollectionsIn(nodes: any[]): number { * Partition a synthesized default record page (`buildDefaultPageSchema`'s * output, after any host pruning) into the column tiers' header and columns. * - * Reads the page exactly as the synthesizer writes it: a `main` region whose - * one `page:tabs` node carries its items in the node's `properties` bag - * (`componentNode`, ADR-0089 D3a), and an optional `aside` region. Answers - * `null` — draw the tabs — for anything else: no `main` region, no tab strip - * or more than one, a strip carrying its items elsewhere, an item gated by + * Reads the page exactly as the synthesizer writes it: one `main` region + * whose one `page:tabs` node carries its items in the node's `properties` + * bag (`componentNode`, ADR-0089 D3a). Answers `null` — draw the tabs — for + * anything else: no `main` region or a second region, no tab strip or more + * than one, a strip carrying its items elsewhere, an item gated by * `visibleWhen`, or an item whose value has no column. */ export function buildDefaultPageColumns(page: unknown): DefaultPageColumns | null { const regions = nodeList(asNode(page)?.regions); - const main = regions.map(asNode).find((r) => r?.name === 'main'); - if (!main) return null; + if (regions.length !== 1) return null; + const main = asNode(regions[0]); + if (!main || main.name !== 'main') return null; const components = nodeList(main.components); const tabIndexes = components .map((node, index) => (asNode(node)?.type === 'page:tabs' ? index : -1)) @@ -284,9 +282,7 @@ export function buildDefaultPageColumns(page: unknown): DefaultPageColumns | nul else relatedLists.push(...children); } - const aside = regions.map(asNode).find((r) => r?.name === 'aside'); - const folded = nodeList(aside?.components); - const related = [...relatedLists, ...folded, ...relatedPanels]; + const related = [...relatedLists, ...relatedPanels]; return { header: components.slice(0, tabIndex), details, From 81962fec4af6de761a1a955712a87479982f4d0d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:11:27 +0000 Subject: [PATCH 02/15] =?UTF-8?q?wip(plugin-detail,components,app-shell):?= =?UTF-8?q?=20record=20page=20v3=20face=20=E2=80=94=20frameless=20sections?= =?UTF-8?q?,=20empty-sections=20line,=20highlight=20grid,=20meta=20line,?= =?UTF-8?q?=20refresh=20in=20the=20header=20=E2=8B=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Q4EjjN14dHBs3CJce73zNm Co-authored-by: Claude --- .../RecordDetailView.headerRefresh.test.tsx | 39 +++- .../app-shell/src/views/RecordDetailView.tsx | 22 +- .../__tests__/page-header-refresh.test.tsx | 196 +++++++----------- .../src/renderers/layout/containers.tsx | 133 ++++-------- .../plugin-detail/src/DetailSection.tsx | 57 ++++- ui/packages/plugin-detail/src/DetailView.tsx | 40 ++-- .../plugin-detail/src/EmptySectionsFold.tsx | 89 ++++++++ .../plugin-detail/src/HeaderHighlight.tsx | 10 +- .../plugin-detail/src/RecordMetaFooter.tsx | 27 ++- .../plugin-detail/src/RecordPageColumns.tsx | 50 ++--- .../__tests__/recordPageV3Face-27055.test.tsx | 191 +++++++++++++++++ .../plugin-detail/src/defaultRecordPage.ts | 43 ++++ ui/packages/plugin-detail/src/index.tsx | 3 + .../src/synth/buildDefaultPageSchema.ts | 17 +- 14 files changed, 637 insertions(+), 280 deletions(-) create mode 100644 ui/packages/plugin-detail/src/__tests__/recordPageV3Face-27055.test.tsx create mode 100644 ui/packages/plugin-detail/src/defaultRecordPage.ts diff --git a/ui/packages/app-shell/src/views/RecordDetailView.headerRefresh.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.headerRefresh.test.tsx index c44714e6327..24970442ada 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.headerRefresh.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.headerRefresh.test.tsx @@ -2,7 +2,8 @@ * ObjectUI * Copyright (c) 2024-present ObjectStack Inc. * - * RecordDetailView — the PRODUCER for the header's manual ⟳ (objectui#3460). + * RecordDetailView — the PRODUCER for the header's manual refresh (objectui#3460), + * the first item of the header's `⋯` menu since objectstack#27055. * * `RecordContextValue.refresh` has been declared (and consumed nowhere) since * the context was introduced: a field the types, the memo dep list and lint all @@ -136,7 +137,19 @@ function renderDetail(dataSource: any, opts?: { embedded?: boolean }) { ); } -const refreshButton = () => document.querySelector('[data-page-refresh]') as HTMLButtonElement | null; +const moreTrigger = () => document.querySelector('header [aria-label="More actions"]') as HTMLElement | null; + +/** Open the header's `⋯` and answer its Refresh item (objectstack#27055). */ +async function openRefreshItem(): Promise { + await waitFor(() => expect(moreTrigger()).toBeTruthy()); + fireEvent.pointerDown(moreTrigger()!, { button: 0, ctrlKey: false, pointerType: 'mouse' }); + let item: HTMLElement | null = null; + await waitFor(() => { + item = document.querySelector('[data-page-refresh]') as HTMLElement | null; + expect(item).toBeTruthy(); + }); + return item!; +} beforeEach(() => { cleanup(); @@ -160,11 +173,11 @@ afterEach(() => { }); describe('RecordDetailView — the header ⟳ refetches in place (objectui#3460)', () => { - it('renders the ⟳ on the standalone record route', async () => { + it('offers Refresh in the header ⋯ on the standalone record route', async () => { const dataSource = makeDataSource(); renderDetail(dataSource); - await waitFor(() => expect(refreshButton()).toBeTruthy()); + expect(await openRefreshItem()).toBeTruthy(); }); it('publishes the WILDCARD scope on the invalidation bus, not this record', async () => { @@ -173,11 +186,11 @@ describe('RecordDetailView — the header ⟳ refetches in place (objectui#3460) const unsubscribe = subscribeDataChanges((c) => changes.push(c)); try { renderDetail(dataSource); - await waitFor(() => expect(refreshButton()).toBeTruthy()); + const item = await openRefreshItem(); - fireEvent.click(refreshButton()!); + fireEvent.click(item); - expect(changes).toEqual([{ objectName: '*' }]); + await waitFor(() => expect(changes).toEqual([{ objectName: '*' }])); // Explicitly NOT the record scope: that would refresh the main record and // leave every related list and count badge stale. expect(changes[0]).not.toHaveProperty('recordId', RECORD_ID); @@ -190,12 +203,11 @@ describe('RecordDetailView — the header ⟳ refetches in place (objectui#3460) const dataSource = makeDataSource(); renderDetail(dataSource); - await waitFor(() => expect(refreshButton()).toBeTruthy()); await waitFor(() => expect(dataSource.findOne).toHaveBeenCalledTimes(1)); const headerBefore = document.querySelector('header'); expect(headerBefore).toBeTruthy(); - fireEvent.click(refreshButton()!); + fireEvent.click(await openRefreshItem()); // The bus re-runs the SAME load effect… await waitFor(() => expect(dataSource.findOne).toHaveBeenCalledTimes(2)); @@ -203,7 +215,6 @@ describe('RecordDetailView — the header ⟳ refetches in place (objectui#3460) // bump would have refetched too, while throwing away tab/scroll/inline-edit // state (AGENTS.md #8). expect(document.querySelector('header')).toBe(headerBefore); - expect(refreshButton()).toBeTruthy(); }); }); @@ -216,7 +227,13 @@ describe('RecordDetailView — embedded hosts opt out of the ⟳ (objectui#3460 // has no ⟳", not "the header hasn't rendered yet". await waitFor(() => expect(dataSource.findOne).toHaveBeenCalled()); await waitFor(() => expect(document.querySelector('header')).toBeTruthy()); - expect(refreshButton()).toBeNull(); + // The ⋯ may still hold the host's other system actions; Refresh is not one. + const trigger = moreTrigger(); + if (trigger) { + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' }); + await waitFor(() => expect(document.querySelector('[role="menu"]')).toBeTruthy()); + } + expect(document.querySelector('[data-page-refresh]')).toBeNull(); }); it('publishes nothing on the bus for an embedded host', async () => { diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index 2c7633943cc..4b2745c770b 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -10,7 +10,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'; -import { activityRowToFeedItem, InlineEditSaveBar, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, recordPageColumnDefaults, resolveTitleField, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail'; +import { activityRowToFeedItem, DefaultRecordPageContext, InlineEditSaveBar, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, recordPageColumnDefaults, resolveTitleField, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail'; import { Empty, EmptyTitle, EmptyDescription, Button, PageHeaderActions, PanelToggles, type PanelToggleItem } from '@object-ui/components'; import { useAuth, createAuthenticatedFetch } from '@object-ui/auth'; import { usePermissions } from '@object-ui/permissions'; @@ -4203,7 +4203,13 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri } /> ) : recordPageTier === null ? null : ( - + // objectstack#27055 — the synthesized default page is the + // record page v3 in its tabbed tier too (frameless sections, + // the empty-sections line, the highlight grid); an authored + // page keeps its own face. + + + )} {/* Approval visibility fallback (objectui#3461) — synthesized @@ -4250,11 +4256,13 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri renders nothing when the record carries no audit field. */} {/* In a column tier the columns draw it (objectstack#26678). */} {!pageColumns && ( - + + + )} {/* Record-level inline-edit Save/Cancel bar (objectui#2407 P2) — commits the whole draft (highlights + body) in ONE atomic OCC diff --git a/ui/packages/components/src/__tests__/page-header-refresh.test.tsx b/ui/packages/components/src/__tests__/page-header-refresh.test.tsx index 924fc416b96..3cb7c5ee42b 100644 --- a/ui/packages/components/src/__tests__/page-header-refresh.test.tsx +++ b/ui/packages/components/src/__tests__/page-header-refresh.test.tsx @@ -7,35 +7,34 @@ */ /** - * `page:header` — the manual refresh ⟳ (objectui#3460). + * `page:header` — the manual refresh (objectui#3460), drawn as the first item + * of the `⋯` menu (objectstack#27055). * * MES shop-floor scenario: operator A sits on a work-order detail page while * operator B starts/reports the same order. A had no way to see it but F5. - * The header now offers a ⟳ that publishes on the #2269 invalidation bus, so - * the record, its related lists and the tab-count badges refetch IN PLACE. + * The header offers a Refresh that publishes on the #2269 invalidation bus, + * so the record, its related lists and the tab-count badges refetch IN PLACE. * - * What this file pins, and why each one is a separate failure mode: + * The v3 header (objectstack#27055, the maintainer-approved design: 「1 主 + + * 2 次 + ⋯(刷新收进 ⋯)」) moved it from a ⟳ button at the end of the row + * into the `⋯`. What this file pins, and why each one is a separate failure + * mode: * - * 1. The button is HOST-OPTED-IN — it renders iff `RecordContext.refresh` - * exists. Every other host (previews, the embedded drawer, non-record - * pages) must render byte-for-byte what it rendered before, so the - * no-refresh cases assert the *absence* of the button AND the survival of - * the `data-page-actions-slot` layout div. - * 2. It is page CHROME, not a header action: it lives OUTSIDE the actions - * `role="toolbar"`, at the far end of the row, and therefore cannot be - * collapsed into the `⋯` overflow when an object declares more actions - * than `maxVisible`. That property is the whole reason it isn't wired - * through the action pipeline, so it is asserted rather than assumed. + * 1. It is HOST-OPTED-IN — it renders iff `RecordContext.refresh` exists. + * Every other host (previews, the embedded drawer, non-record pages) + * must render what it rendered before, so the no-refresh cases assert + * the *absence* of the item AND the survival of the + * `data-page-actions-slot` layout div. + * 2. It is the FIRST item of the `⋯` and never takes an inline slot: the + * row is 1 primary + 2 secondary, the rest folds. With no action at all + * the `⋯` still draws, holding the refresh alone. * 3. Its accessible name comes from `common.refresh` — an existing key in all - * ten packs — so an icon-only button is not English-only for the nine - * other locales (the same failure objectstack#5407 fixed for `⋯`). - * 4. Clicking calls the host's refresh exactly once and spins for a floor of - * ~650ms. The bus is fire-and-forget, so without the floor a warm backend - * renders the click as "nothing happened". + * ten packs. + * 4. Choosing it calls the host's refresh exactly once. */ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { act, render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; import { ComponentRegistry } from '@object-ui/core'; import { ActionProvider, RecordContextProvider } from '@object-ui/react'; import { I18nProvider } from '@object-ui/i18n'; @@ -44,9 +43,6 @@ import { I18nProvider } from '@object-ui/i18n'; // (object-ui/no-dynamic-import-in-test-hook, objectui#3010). import '../renderers'; -/** Must track MANUAL_REFRESH_SPIN_MS in `renderers/layout/containers.tsx`. */ -const SPIN_MS = 650; - function PageHeader({ schema }: { schema: any }) { const Component = ComponentRegistry.get('page:header'); if (!Component) throw new Error('page:header not registered'); @@ -87,167 +83,115 @@ function renderHeader(opts: Opts = {}) { ); } -const refreshButton = () => document.querySelector('[data-page-refresh]') as HTMLButtonElement | null; -const spinIcon = () => refreshButton()?.querySelector('svg')?.getAttribute('class') ?? ''; +const refreshItem = () => document.querySelector('[data-page-refresh]') as HTMLElement | null; +const moreTrigger = () => screen.queryByRole('button', { name: /More actions|更多操作|その他の操作/ }); + +async function openMore(): Promise { + const trigger = moreTrigger(); + expect(trigger, 'the ⋯ trigger is drawn').toBeTruthy(); + fireEvent.pointerDown(trigger!, { button: 0, ctrlKey: false, pointerType: 'mouse' }); + await waitFor(() => expect(screen.getByRole('menu')).toBeTruthy()); +} afterEach(() => { cleanup(); - vi.useRealTimers(); }); -describe('page:header — the ⟳ is host-opted-in (objectui#3460)', () => { - it('renders no ⟳ when the host provides no refresh', () => { +describe('page:header — the refresh is host-opted-in (objectui#3460)', () => { + it('draws no ⋯ and no refresh when the host provides no refresh and the page no action', () => { renderHeader(); - expect(refreshButton()).toBeNull(); - expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull(); + expect(moreTrigger()).toBeNull(); + expect(refreshItem()).toBeNull(); }); it('leaves a no-refresh header exactly as it was — actions slot included', () => { const { container } = renderHeader(); - // The empty-actions layout placeholder must still be the header's own - // trailing child, not moved inside a new wrapper: this is the regression - // guard for every host that never opts in. const slot = container.querySelector('[data-page-actions-slot]'); expect(slot).toBeTruthy(); expect(slot!.parentElement?.tagName).toBe('HEADER'); }); - it('renders the ⟳ once the host provides refresh', () => { + it('draws the ⋯ holding the refresh alone once the host provides refresh', async () => { renderHeader({ refresh: vi.fn() }); - expect(refreshButton()).toBeTruthy(); - expect(screen.getByRole('button', { name: 'Refresh' })).toBeTruthy(); + // No ⟳ in the row any more (objectstack#27055). + expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull(); + await openMore(); + const items = screen.getAllByRole('menuitem'); + expect(items.map((item) => item.textContent)).toEqual(['Refresh']); }); - it('renders the ⟳ before the record has loaded (bare-title header branch)', () => { - // A record page's first paint has no `ctx.data`, so `page:header` takes its - // non-record layout. The button must not pop in and out between branches. + it('draws it before the record has loaded (bare-title header branch)', async () => { renderHeader({ refresh: vi.fn(), record: undefined }); - expect(refreshButton()).toBeTruthy(); + await openMore(); + expect(refreshItem()).toBeTruthy(); }); }); -describe('page:header — the ⟳ is chrome, not an action (objectui#3460)', () => { - it('sits at the far end of the header row, after the ⋯ overflow trigger', () => { - // Four actions against the default maxVisible of 3 → three inline buttons - // plus the ⋯ trigger. The ⟳ comes last of all. +describe('page:header — 1 primary + 2 secondary + ⋯, refresh first in the ⋯ (objectstack#27055)', () => { + it('four actions → one filled primary, two outline buttons, the fourth and the refresh in the ⋯', async () => { renderHeader({ refresh: vi.fn(), actions: [ { name: 'start', locations: ['record_header'], label: 'Start' }, { name: 'expedite', locations: ['record_header'], label: 'Expedite' }, - { name: 'edit', locations: ['record_header'], label: 'Edit' }, + { name: 'report', locations: ['record_header'], label: 'Report' }, { name: 'archive', locations: ['record_header'], label: 'Archive' }, ], }); - const buttons = Array.from(document.querySelectorAll('header button')); - expect(buttons.length).toBeGreaterThan(4); - expect(buttons[buttons.length - 1]).toBe(refreshButton()); - // …and the one before it is the overflow trigger, i.e. the ⟳ did not steal - // an inline slot from the actions. - expect(buttons[buttons.length - 2]?.getAttribute('aria-label')).toBe('More actions'); + const toolbar = document.querySelector('[role="toolbar"]')!; + const rowNames = Array.from(toolbar.querySelectorAll(':scope > button, :scope > span > button')).map( + (button) => button.getAttribute('aria-label') ?? button.textContent, + ); + expect(rowNames).toEqual(['Start', 'Expedite', 'Report', 'More actions']); + await openMore(); + const items = screen.getAllByRole('menuitem').map((item) => item.textContent); + expect(items).toEqual(['Refresh', 'Archive']); }); - it('stays out of the actions toolbar so the overflow budget cannot swallow it', () => { + it('a record_more-only action folds behind the refresh', async () => { renderHeader({ refresh: vi.fn(), actions: [ - { name: 'a', locations: ['record_header'], label: 'A' }, - { name: 'b', locations: ['record_header'], label: 'B' }, - { name: 'c', locations: ['record_header'], label: 'C' }, - { name: 'd', locations: ['record_header'], label: 'D' }, + { name: 'start', locations: ['record_header'], label: 'Start' }, + { name: 'clone', locations: ['record_more'], label: 'Clone' }, ], }); - const toolbar = document.querySelector('[role="toolbar"]'); - expect(toolbar).toBeTruthy(); - expect(toolbar!.contains(refreshButton())).toBe(false); - // Not routed into the ⋯ menu either — it is still a visible button. - expect(refreshButton()).toBeTruthy(); - }); - - it('renders alongside the actions when the host also injects system actions', () => { - renderHeader({ - refresh: vi.fn(), - actions: [{ name: 'start', locations: ['record_header'], label: 'Start' }], - }); - expect(screen.getByRole('button', { name: 'Start' })).toBeTruthy(); - expect(refreshButton()).toBeTruthy(); + await openMore(); + expect(screen.getAllByRole('menuitem').map((item) => item.textContent)).toEqual(['Refresh', 'Clone']); }); }); -describe('page:header — the ⟳ speaks the session locale (objectui#3460)', () => { - it('reads common.refresh under a zh session', () => { +describe('page:header — the refresh speaks the session locale (objectui#3460)', () => { + it('reads common.refresh under a zh session', async () => { renderHeader({ refresh: vi.fn(), language: 'zh' }); - expect(screen.getByRole('button', { name: '刷新' })).toBeTruthy(); - // The English literal asserted negatively too — a re-inlined default would - // otherwise pass the positive assertion on a header with two buttons. - expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull(); + await openMore(); + expect(screen.getByRole('menuitem', { name: '刷新' })).toBeTruthy(); + expect(screen.queryByRole('menuitem', { name: 'Refresh' })).toBeNull(); }); - it('reads common.refresh under a ja session', () => { + it('reads common.refresh under a ja session', async () => { renderHeader({ refresh: vi.fn(), language: 'ja' }); - expect(screen.getByRole('button', { name: '更新' })).toBeTruthy(); - }); - - it('still reads English under an en session', () => { - renderHeader({ refresh: vi.fn(), language: 'en' }); - - expect(screen.getByRole('button', { name: 'Refresh' })).toBeTruthy(); + await openMore(); + expect(screen.getByRole('menuitem', { name: '更新' })).toBeTruthy(); }); }); -describe('page:header — clicking the ⟳ (objectui#3460)', () => { - it("calls the host's refresh exactly once per click", () => { - const refresh = vi.fn(); - renderHeader({ refresh }); - - fireEvent.click(refreshButton()!); - expect(refresh).toHaveBeenCalledTimes(1); - - fireEvent.click(refreshButton()!); - expect(refresh).toHaveBeenCalledTimes(2); - }); - - it('spins for the whole feedback floor, then stops', () => { - const refresh = vi.fn(); - renderHeader({ refresh }); - expect(spinIcon()).not.toContain('animate-spin'); - - vi.useFakeTimers(); - fireEvent.click(refreshButton()!); - expect(spinIcon()).toContain('animate-spin'); - - // Still spinning just short of the floor… - act(() => { vi.advanceTimersByTime(SPIN_MS - 50); }); - expect(spinIcon()).toContain('animate-spin'); - - // …and stopped once it elapses. - act(() => { vi.advanceTimersByTime(60); }); - expect(spinIcon()).not.toContain('animate-spin'); - }); - - it('restarts the floor on a second click instead of ending early', () => { +describe('page:header — choosing Refresh (objectui#3460)', () => { + it("calls the host's refresh exactly once per choice", async () => { const refresh = vi.fn(); renderHeader({ refresh }); - vi.useFakeTimers(); - fireEvent.click(refreshButton()!); - act(() => { vi.advanceTimersByTime(SPIN_MS - 100); }); - fireEvent.click(refreshButton()!); - // The first click's timer would have fired here; the second click's floor - // must still be running. - act(() => { vi.advanceTimersByTime(150); }); - expect(spinIcon()).toContain('animate-spin'); - - act(() => { vi.advanceTimersByTime(SPIN_MS); }); - expect(spinIcon()).not.toContain('animate-spin'); + await openMore(); + fireEvent.click(refreshItem()!); + await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); }); }); diff --git a/ui/packages/components/src/renderers/layout/containers.tsx b/ui/packages/components/src/renderers/layout/containers.tsx index 8166c9f3b8f..db290daf4ac 100644 --- a/ui/packages/components/src/renderers/layout/containers.tsx +++ b/ui/packages/components/src/renderers/layout/containers.tsx @@ -99,17 +99,6 @@ const PAGE_CONTAINER_INPUTS: ComponentInput[] = [ }, ]; -/** - * How long the header's manual-refresh icon keeps spinning after a click - * (objectui#3460). - * - * The refresh itself is fire-and-forget — it publishes on the #2269 - * invalidation bus and every mounted reader refetches in place, so there is no - * completion signal to spin until. Against a warm backend the whole round trip - * can finish inside one frame, which would render the click as "nothing - * happened". This floor makes the click visibly land. - */ -const MANUAL_REFRESH_SPIN_MS = 650; /** * Copy for the `page:tabs` count badge (objectstack#5506). @@ -1737,6 +1726,18 @@ export interface PageHeaderActionsProps { mobileMaxVisible?: unknown; /** Drawn in place of the action row when no action survives the gates. */ emptySlot?: React.ReactNode; + /** + * The host's manual refresh (`RecordContext.refresh`), drawn as the first + * item of the `⋯` menu (objectstack#27055, the v3 header: 「1 主 + 2 次 + ⋯ + * (刷新收进 ⋯)」). With a refresh and no action, the row is the `⋯` alone. + */ + refresh?: PageHeaderRefreshItem; +} + +/** The refresh entry of the header's `⋯` menu — see {@link PageHeaderActionsProps.refresh}. */ +export interface PageHeaderRefreshItem { + label: string; + onSelect: () => void; } /** @@ -1759,6 +1760,7 @@ export const PageHeaderActions: React.FC = ({ maxVisible: rawMaxVisible, mobileMaxVisible: rawMobileMaxVisible, emptySlot = null, + refresh, }) => { const ctx = useRecordContext(); // Record-level inline-edit session (objectui#2572 item 4): while a shared @@ -2204,7 +2206,7 @@ export const PageHeaderActions: React.FC = ({ }, [record, execute]); const renderHeaderActions = () => { - if (headerActions.length === 0) return null; + if (headerActions.length === 0 && !refresh) return null; // The label is already resolved — the `headerActions` memo above ran every // action through the shared localizer (`{ns}.objects.{objectName} // ._actions.{name}.label`, authored literal as fallback), together with the @@ -2332,7 +2334,7 @@ export const PageHeaderActions: React.FC = ({ // The first destructive entry gets a separator above it, unless it opens // the menu (a separator under nothing sets nothing apart). const firstDestructiveIdx = overflowActions.findIndex(isDestructiveAction); - const useOverflow = overflowActions.length > 0; + const useOverflow = overflowActions.length > 0 || !!refresh; // Resolve a `disabled` predicate against the record — a boolean OR an // expression (`'record.status == …'` or the `{ dialect, source }` // envelope). Without this a CEL `disabled` silently did nothing (only @@ -2462,6 +2464,16 @@ export const PageHeaderActions: React.FC = ({ + {refresh && ( + <> + {/* Not `preventDefault`: a refresh closes the menu. */} + refresh.onSelect()} className="gap-2" data-page-refresh=""> + + {refresh.label} + + {overflowActions.length > 0 && } + + )} {overflowActions.map((action, idx) => { const label = resolveLabel(action); const disabled = isActionDisabled(action); @@ -2535,30 +2547,20 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { // Console-supplied chrome copy (objectstack#5407): the ⟳ button's label. // The action row's copy is read by `PageHeaderActions` above. const tt = useSafeTranslate(); - // ── Manual refresh (objectui#3460) ──────────────────────────────────────── - // Rendered as page CHROME at the far end of the header row, NOT as a header - // action: business/system actions come and go per object and record state, - // while refresh has to sit in the same place on every record page. Keeping it - // out of the action pipeline also keeps it out of that pipeline's capability - // gating and `maxVisible` overflow budget — reading a record you are already - // looking at is not a permissioned operation, and the button must never be - // the one that gets collapsed into `⋯`. + // ── Manual refresh (objectui#3460, objectstack#27055) ───────────────────── + // The first item of the header's `⋯` menu: the v3 header is 「1 主 + 2 次 + + // ⋯(刷新收进 ⋯)」, the maintainer-approved design that moved it out of the + // row it used to hold as page chrome. It stays OUT of the action pipeline — + // no capability gate, no `maxVisible` budget: reading a record you are + // already looking at is not a permissioned operation, and it never folds + // away, it IS in the menu on every record page that offers it. // // The host opts in by providing `RecordContext.refresh`; hosts that don't // (previews, embedded drawers, non-record pages) render exactly as before. const hostRefresh = ctx?.refresh; - const [manualRefreshing, setManualRefreshing] = React.useState(false); - const refreshSpinTimer = React.useRef | undefined>(undefined); - React.useEffect(() => () => clearTimeout(refreshSpinTimer.current), []); const handleManualRefresh = React.useCallback(() => { if (!hostRefresh) return; void hostRefresh(); - setManualRefreshing(true); - clearTimeout(refreshSpinTimer.current); - refreshSpinTimer.current = setTimeout( - () => setManualRefreshing(false), - MANUAL_REFRESH_SPIN_MS, - ); }, [hostRefresh]); // Spec bridge may either inline `properties.*` onto the node or preserve // the raw bag (see record:quick_actions for the same pattern). Read from @@ -2611,66 +2613,23 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { // rely on this slot. Without this rendering they would silently disappear. const rawHeaderActions = schema?.actions ?? schema?.properties?.actions; - /** - * The ⟳ button (objectui#3460), or `null` when the host provides no - * `refresh`. - * - * Styled as the `⋯` overflow trigger's twin — same `outline` variant, same - * `size="sm"`, same padding — so the whole row reads as ONE button family - * (`[Start][Expedite][Edit][⋯][⟳]`). A bare ghost icon next to a row of - * bordered pills read as detached from it. - */ - const renderRefreshButton = (): React.ReactNode => { - if (!hostRefresh) return null; - const refreshLabel = tt('common.refresh', 'Refresh'); - return ( - - - - - - {refreshLabel} - - - ); - }; - /** * The header's trailing slot: the action row (or the empty layout slot that - * stands in for it) followed by the refresh chrome. - * - * Both header layouts below funnel through this so the ⟳ keeps the same - * position whether or not the record chip is rendered — including the first - * paint of a record page, before `ctx.data` lands. When no host provides - * `refresh` the slot is returned untouched, byte for byte what it was. + * stands in for it), whose `⋯` menu leads with the host's refresh when the + * host provides one (objectstack#27055). Both header layouts below funnel + * through this, so the row is the same whether or not the record chip is + * rendered — including the first paint of a record page, before `ctx.data` + * lands. */ - const renderHeaderTail = (emptySlot: React.ReactNode): React.ReactNode => { - const actions = ( - - ); - const refreshButton = renderRefreshButton(); - if (!refreshButton) return actions; - return ( -
- {actions} - {refreshButton} -
- ); - }; + const renderHeaderTail = (emptySlot: React.ReactNode): React.ReactNode => ( + + ); // Decide whether to render the record chip. Conditions: // 1. There's a live RecordContext with data + an object schema. diff --git a/ui/packages/plugin-detail/src/DetailSection.tsx b/ui/packages/plugin-detail/src/DetailSection.tsx index 013f91bd36e..79ec5d76175 100644 --- a/ui/packages/plugin-detail/src/DetailSection.tsx +++ b/ui/packages/plugin-detail/src/DetailSection.tsx @@ -55,6 +55,7 @@ import { useSafeFieldLabel } from '@object-ui/react'; import { NON_EDITABLE_SYSTEM_FIELDS } from './systemFields'; import { InlineFieldInput } from './InlineFieldInput'; import { headerColorClass } from './headerColor'; +import { framelessSectionColumns, useDefaultRecordPage } from './defaultRecordPage'; import { hasCellValue } from './emptiness'; import { enrichDetailField, @@ -199,6 +200,10 @@ export const DetailSection: React.FC = ({ // the empty-field heuristic below reads the same answer (objectui#12717). const isPrintPage = usePrintPage() !== null; const isCollapsed = !isPrintPage && collapsedState; + // objectstack#27055 — on the synthesized default record page a section is + // drawn frameless: a title, a hairline and the field grid, no card. Paper + // keeps the card face (objectui#12732), so a print page never takes it. + const frameless = useDefaultRecordPage() && !isPrintPage; const [copiedField, setCopiedField] = React.useState(null); const [visibleCount, setVisibleCount] = React.useState(undefined); // objectstack#25883 — "Show N empty fields" is the reader's choice for the @@ -439,7 +444,12 @@ export const DetailSection: React.FC = ({ // Never render more columns than there are visible fields — the object-wide // column count (objectui#2578) can exceed a section's visible count when // empty fields are hidden; a lone field shouldn't sit at 1/N width. - const effectiveColumns = Math.min(rawColumns, Math.max(1, visibleFields.length)); + // A frameless section (objectstack#27055) takes 1–3 columns by the fields it + // shows, never the object-wide count: it shares the page row with the + // timeline and related columns. + const effectiveColumns = frameless + ? framelessSectionColumns(visibleFields.length) + : Math.min(rawColumns, Math.max(1, visibleFields.length)); const renderField = (field: DetailViewField) => { const value = data?.[field.name] ?? field.value; @@ -884,7 +894,14 @@ export const DetailSection: React.FC = ({ ) : (
= ({ ); + // objectstack#27055 — the frameless face of the default record page: the + // section's title row over a hairline, then the field grid. No card border, + // no shadow. Every titled section collapses from its title row (a native + // button carrying `aria-expanded`, as the card face's disclosure does, + // objectui#12625); an untitled section is the grid alone. + if (frameless) { + return ( +
+ {section.title && ( +
+ +
+ )} + {section.description && !isCollapsed && ( +

{section.description}

+ )} + {!isCollapsed &&
{content}
} +
+ ); + } + // Flat render: when section has no title, no border, and is not collapsible, // skip the Card chrome entirely. This is the universal case for an // auto-generated single section (no need for a "Details" wrapper around diff --git a/ui/packages/plugin-detail/src/DetailView.tsx b/ui/packages/plugin-detail/src/DetailView.tsx index 7f2ff303aaf..7456beb7d36 100644 --- a/ui/packages/plugin-detail/src/DetailView.tsx +++ b/ui/packages/plugin-detail/src/DetailView.tsx @@ -36,7 +36,8 @@ import { X, } from 'lucide-react'; import { DetailSection } from './DetailSection'; -import { EmptySectionsFold } from './EmptySectionsFold'; +import { EmptySectionsFold, EmptySectionsLine } from './EmptySectionsFold'; +import { useDefaultRecordPage } from './defaultRecordPage'; import { DetailTabs } from './DetailTabs'; import { SectionGroup } from './SectionGroup'; import { HeaderHighlight } from './HeaderHighlight'; @@ -324,6 +325,9 @@ export const DetailView: React.FC = ({ // approval band below is live, per-viewer workflow state, not document // content, so it does not print. const isPrintPage = usePrintPage() !== null; + // objectstack#27055 — the synthesized default record page folds its + // entirely empty sections into one line (`EmptySectionsLine`). + const onDefaultRecordPage = useDefaultRecordPage(); const isInlineEditing = inline?.editing ?? false; const editedValues = inline?.draft ?? EMPTY_DRAFT; const autoFocusField = inline?.autoFocusField ?? null; @@ -2081,21 +2085,31 @@ export const DetailView: React.FC = ({ {/* Sections */} {schema.sections && schema.sections.length > 0 && (
- {foldEmptySections(schema.sections.map((section, index) => ( - - { + const renderSectionAt = (section: NonNullable[number], index: number) => ( + + + + ); + return onDefaultRecordPage && !schema.sectionGroups?.length ? ( + - - )))} + ) : foldEmptySections(schema.sections!.map(renderSectionAt)); + })()}
)} diff --git a/ui/packages/plugin-detail/src/EmptySectionsFold.tsx b/ui/packages/plugin-detail/src/EmptySectionsFold.tsx index 17c2a763361..6488a760545 100644 --- a/ui/packages/plugin-detail/src/EmptySectionsFold.tsx +++ b/ui/packages/plugin-detail/src/EmptySectionsFold.tsx @@ -116,3 +116,92 @@ export const EmptySectionsFold: React.FC = ({ ); }; + +/** + * Whether a section is ENTIRELY empty and kept — a section the v3 line folds + * (objectstack#27055). Same reading as {@link emptySectionsFoldCount}: a + * section with no fields draws nothing and an all-empty `hideEmpty: true` + * section hides itself, so neither is folded into the line. + */ +export function isFoldableEmptySection( + section: DetailViewSection, + data: Record | null | undefined, +): boolean { + try { + const fields = Array.isArray(section?.fields) ? section.fields : null; + if (!fields || fields.length === 0) return false; + if (section.hideEmpty === true) return false; + return !fields.some((field) => hasCellValue(data?.[field?.name] ?? field?.value)); + } catch { + return false; + } +} + +export interface EmptySectionsLineProps { + /** The sections the body draws, in order. */ + sections: DetailViewSection[]; + /** The record as the sections read it (record plus the inline-edit draft). */ + data: Record | null | undefined; + isEditing: boolean; + /** Draw one section (its error boundary included) at its index. */ + renderSection: (section: DetailViewSection, index: number) => React.ReactNode; +} + +/** + * The record page v3 fold of empty sections (objectstack#27055, Target 2): + * every section that is ENTIRELY empty leaves its place and the body ends in + * ONE quiet line naming them — 「地址 · 转化 · 附加信息 · 3 个空分区 · 展开」. + * Sections with a value draw in their order as before. Expanding the line + * draws every section in its own place and turns the line into a 「收起」. + * + * Drawn on the synthesized default page only (`DetailView` asks + * `useDefaultRecordPage`); elsewhere the whole-body fold + * ({@link EmptySectionsFold}) keeps its own rule. Edit mode and a print page + * draw every section, for the reasons that fold gives. + */ +export const EmptySectionsLine: React.FC = ({ + sections, + data, + isEditing, + renderSection, +}) => { + const { t } = useDetailTranslation(); + const isPrintPage = usePrintPage() !== null; + const [revealed, setRevealed] = React.useState(false); + const folded = isEditing || isPrintPage ? [] : sections.map((section) => isFoldableEmptySection(section, data)); + const foldedCount = folded.filter(Boolean).length; + if (foldedCount === 0) return <>{sections.map(renderSection)}; + + const names = sections + .filter((section, index) => folded[index]) + .map((section) => (typeof section.title === 'string' ? section.title.trim() : '')) + .filter((name) => name !== ''); + const line = ( +
+ {!revealed && names.length > 0 && {names.join(' · ')}} + {!revealed && names.length > 0 && ·} + {t('detail.emptySections', { count: foldedCount, defaultValue: '{{count}} empty sections' })} + · + +
+ ); + return ( + <> + {sections.map((section, index) => (revealed || !folded[index] ? renderSection(section, index) : null))} + {line} + + ); +}; diff --git a/ui/packages/plugin-detail/src/HeaderHighlight.tsx b/ui/packages/plugin-detail/src/HeaderHighlight.tsx index 51341861d68..595c7313835 100644 --- a/ui/packages/plugin-detail/src/HeaderHighlight.tsx +++ b/ui/packages/plugin-detail/src/HeaderHighlight.tsx @@ -32,6 +32,7 @@ import { import { NON_EDITABLE_SYSTEM_FIELDS } from './systemFields'; import { useDetailTranslation } from './useDetailTranslation'; import { hasCellValue } from './emptiness'; +import { useDefaultRecordPage } from './defaultRecordPage'; export interface HeaderHighlightProps { fields: HighlightField[]; @@ -74,6 +75,10 @@ export const HeaderHighlight: React.FC = ({ // The host predicate scope a declared deadline's `settledWhen` is judged in // (objectui#11815), against the staged record below. const predicateScope = usePredicateScope(); + // objectstack#27055 — on the synthesized default record page the strip is + // an equal-width grid (up to six highlights, 「要点 6 项等宽」): every chip + // takes one cell, two to a row on a narrow strip, three, then all six. + const gridFace = useDefaultRecordPage(); // The STAGED record: the saved record this strip receives, overlaid with the // edit session's draft by the same spread `DetailView` gives the details body @@ -118,7 +123,8 @@ export const HeaderHighlight: React.FC = ({ aria-label={t('detail.highlightsLabel')} >
= ({ return (
= ({ // The BCP-47 tag the hover tooltip's absolute date formats with // (objectui#9786). Read before the early return so hook order never varies. const displayLocale = useDisplayLocale(); + // objectstack#27055 — on the synthesized default record page the line also + // names the record's owner and its id (the v3 meta line: created · updated + // · owner · id), at the foot of the details column. + const v3MetaLine = useDefaultRecordPage(); if (!data) return null; const createdAt = toDate(data[AUDIT_FIELDS.createdAt]); @@ -234,7 +245,10 @@ export const RecordMetaFooter: React.FC = ({ const hasUpdated = !!(updatedAt || updatedBy) && !(updateIsSameAsCreate && (sameUser || !updatedBy)); - if (!hasCreated && !hasUpdated) return null; + const owner = v3MetaLine ? actorOrNone(data[OWNER_FIELD]) : undefined; + const recordId = v3MetaLine && hasCellValue(data.id) ? String(data.id) : undefined; + + if (!hasCreated && !hasUpdated && !owner && !recordId) return null; return (
= ({ locale={displayLocale} /> )} + {owner && ( + + {t('detail.owner', { defaultValue: 'Owner' })} + + + )} + {recordId && ( + + {recordId} + + )}
); }; diff --git a/ui/packages/plugin-detail/src/RecordPageColumns.tsx b/ui/packages/plugin-detail/src/RecordPageColumns.tsx index c1fb1d73d53..a609bb685d4 100644 --- a/ui/packages/plugin-detail/src/RecordPageColumns.tsx +++ b/ui/packages/plugin-detail/src/RecordPageColumns.tsx @@ -17,6 +17,7 @@ import { } from './synth/recordPageColumns'; import { useDetailTranslation } from './useDetailTranslation'; import { RelatedListCompactContext } from './relatedListCompact'; +import { DefaultRecordPageContext } from './defaultRecordPage'; export interface RecordPageColumnsProps { /** The column tier to draw; the tabbed tier is the page itself, drawn by its host. */ @@ -24,10 +25,9 @@ export interface RecordPageColumnsProps { /** The synthesized page, partitioned (`buildDefaultPageColumns`). */ columns: DefaultPageColumns; /** - * The record's provenance footer, which the host draws once per page: under - * the related column in the three-column tier (under the details column - * when the page has no related column), under the columns in the - * two-column tier (where the related column is a rail that may be closed). + * The record's provenance line (created / updated / owner / id), which the + * host draws once per page: at the foot of the details column, in every + * column tier (objectstack#27055, the v3 meta line). */ footer?: React.ReactNode; /** @@ -43,14 +43,14 @@ const ALL_COLUMNS_ON: RecordPageColumnSwitches = { timeline: true, related: true /** * Grid templates by which columns have content. Literal strings, so - * Tailwind's scanner emits each one. Details and timeline share the width - * (the timeline a little wider, it carries the composer); the related column - * is a fixed rail. + * Tailwind's scanner emits each one. The v3 widths (objectstack#27055, + * 「栏宽 1.25fr : 1fr : 320px」): details the widest, the timeline after it, + * the related column a fixed 320 px rail. */ const THREE_COLUMN_TEMPLATE = { - all: 'grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)_20rem]', + all: 'grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)_20rem]', noTimeline: 'grid-cols-[minmax(0,1fr)_20rem]', - noRelated: 'grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)]', + noRelated: 'grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)]', detailsOnly: 'grid-cols-1', } as const; @@ -125,16 +125,12 @@ export const RecordPageColumns: React.FC = ({ const relatedLabel = t('detail.related'); const foldedTimelineLabel = t('detail.timelineFolded'); - // The provenance footer closes the related column where it is resident; a - // three-column page without one closes its details column with it instead, - // and a two-column page draws it under the columns. - const footerUnderRelated = residentRelated; - const footerUnderDetails = !residentRelated && tier === 'three-column'; - const footerUnderColumns = !footerUnderRelated && !footerUnderDetails; + // The provenance line closes the details column in every tier + // (objectstack#27055): it describes the record the details show. const detailsColumn = (
{drawNodes(columns.details)} - {footerUnderDetails && footer} + {footer}
); const timelineColumn = residentTimeline ? ( @@ -209,7 +205,6 @@ export const RecordPageColumns: React.FC = ({ {residentRelated && ( )}
@@ -233,20 +228,21 @@ export const RecordPageColumns: React.FC = ({ )}
- {footerUnderColumns && footer} ); return ( -
-
- {drawNodes(columns.header)} - {body} + +
+
+ {drawNodes(columns.header)} + {body} +
-
+ ); }; diff --git a/ui/packages/plugin-detail/src/__tests__/recordPageV3Face-27055.test.tsx b/ui/packages/plugin-detail/src/__tests__/recordPageV3Face-27055.test.tsx new file mode 100644 index 00000000000..c9e3927de74 --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/recordPageV3Face-27055.test.tsx @@ -0,0 +1,191 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27055, Targets 1 and 2 — the record page v3 face of the + * synthesized default page, drawn when `DefaultRecordPageContext` is on: + * + * - a detail section is frameless: a title row over a hairline, then the + * field grid, no card; 1–3 columns by the fields it shows; collapsible + * from its title; + * - entirely empty sections leave their place and fold into ONE line naming + * them (「地址 · 转化 · 附加信息 · 3 个空分区 · 展开」), which expands them + * back in place; + * - the highlight strip is an equal-width grid; + * - the provenance line names the owner and the record id. + * + * Every assertion has its control: the same block outside the context keeps + * the face it had (the card, the whole-body fold, the flex strip, no owner). + */ + +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { PrintPageProvider } from '@object-ui/react'; +import type { DetailViewSchema, DetailViewSection } from '@object-ui/types'; +import { DetailSection } from '../DetailSection'; +import { DetailView } from '../DetailView'; +import { HeaderHighlight } from '../HeaderHighlight'; +import { RecordMetaFooter } from '../RecordMetaFooter'; +import { DefaultRecordPageContext, framelessSectionColumns } from '../defaultRecordPage'; +import { isFoldableEmptySection } from '../EmptySectionsFold'; + +afterEach(cleanup); + +const onDefaultPage = (node: React.ReactNode) => ( + {node} +); + +const field = (name: string) => ({ name, label: name.replace(/_/g, ' ') }); +const section = (title: string, names: string[], extra: Partial = {}): DetailViewSection => ({ + title, + fields: names.map(field), + ...extra, +}); + +const gridOf = (container: HTMLElement) => + container.querySelector('[data-detail-section-face="frameless"] .grid') as HTMLElement | null; + +describe('framelessSectionColumns — 1–3 columns by the fields a section shows', () => { + it('one field one column, two to five two, six and more three', () => { + expect([0, 1, 2, 5, 6, 12].map(framelessSectionColumns)).toEqual([1, 1, 2, 2, 3, 3]); + }); +}); + +describe('DetailSection — the frameless face (objectstack#27055)', () => { + const identity = section('Identity', ['first_name', 'last_name', 'email', 'phone', 'title']); + const data = { first_name: 'Mira', last_name: 'Costa', email: 'm@x.io', phone: '1', title: 'Dir' }; + + it('draws a title row over a hairline and the grid — no card', () => { + const { container } = render(onDefaultPage()); + const root = container.querySelector('[data-detail-section-face="frameless"]'); + expect(root).toBeTruthy(); + expect(root!.tagName).toBe('SECTION'); + // No card chrome: the card face's root is a bordered, shadowed box. + expect(container.querySelector('.shadow-sm, .rounded-xl')).toBeNull(); + const title = screen.getByRole('button', { name: 'Identity' }); + expect(title.parentElement!.className).toContain('border-b'); + }); + + it('lays five fields in two columns and six in three', () => { + const five = render(onDefaultPage()); + expect(gridOf(five.container)!.className).toContain('@sm:grid-cols-2'); + expect(gridOf(five.container)!.className).not.toContain('@lg:grid-cols-3'); + cleanup(); + const six = section('Company', ['company', 'job', 'industry', 'site', 'revenue', 'staff']); + const sixData = { company: 'A', job: 'B', industry: 'C', site: 'D', revenue: 1, staff: 2 }; + const { container } = render(onDefaultPage()); + expect(gridOf(container)!.className).toContain('@lg:grid-cols-3'); + }); + + it('collapses from its title row', () => { + const { container } = render(onDefaultPage()); + const title = screen.getByRole('button', { name: 'Identity' }); + expect(title.getAttribute('aria-expanded')).toBe('true'); + fireEvent.click(title); + expect(title.getAttribute('aria-expanded')).toBe('false'); + expect(gridOf(container)).toBeNull(); + }); + + it('CONTROL — outside the default page the card face draws', () => { + const { container } = render(); + expect(container.querySelector('[data-detail-section-face="frameless"]')).toBeNull(); + }); + + it('CONTROL — a print page keeps the card face', () => { + const { container } = render( + onDefaultPage( + + + , + ), + ); + expect(container.querySelector('[data-detail-section-face="frameless"]')).toBeNull(); + }); +}); + +describe('EmptySectionsLine — entirely empty sections fold into one line (objectstack#27055)', () => { + const sections: DetailViewSection[] = [ + section('Basic', ['name', 'email']), + section('Address', ['street', 'city'], { hideEmpty: false }), + section('Conversion', ['converted_at'], { hideEmpty: false }), + section('Extra', ['notes'], { hideEmpty: false }), + ]; + const schema: DetailViewSchema = { type: 'detail-view', objectName: 'lead', showHeader: false, sections } as any; + const data = { id: 'L-1', name: 'Mira', email: 'm@x.io' }; + + it('names the empty sections, counts them, and draws only the sections with a value', () => { + const { container } = render(onDefaultPage()); + const line = container.querySelector('[data-empty-sections-line]') as HTMLElement; + expect(line).toBeTruthy(); + expect(line.getAttribute('data-empty-sections-count')).toBe('3'); + expect(line.textContent).toContain('Address · Conversion · Extra'); + expect(screen.getByRole('button', { name: 'Basic' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Address' })).toBeNull(); + }); + + it('expands them back in place, and collapses again', () => { + const { container } = render(onDefaultPage()); + const toggle = container.querySelector('[data-empty-sections-line] button') as HTMLButtonElement; + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + fireEvent.click(toggle); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + const titles = Array.from(container.querySelectorAll('[data-detail-section-face="frameless"] button[aria-expanded]')).map( + (b) => b.textContent, + ); + expect(titles).toEqual(['Basic', 'Address', 'Conversion', 'Extra']); + fireEvent.click(toggle); + expect(screen.queryByRole('button', { name: 'Address' })).toBeNull(); + }); + + it('draws every section while editing (the fields are the inputs)', () => { + expect(isFoldableEmptySection(sections[1], data)).toBe(true); + expect(isFoldableEmptySection(sections[0], data)).toBe(false); + // A hideEmpty section hides itself — never named in the line. + expect(isFoldableEmptySection(section('Gone', ['x'], { hideEmpty: true }), data)).toBe(false); + }); + + it('CONTROL — outside the default page no line draws (the whole-body fold keeps its rule)', () => { + const { container } = render(); + expect(container.querySelector('[data-empty-sections-line]')).toBeNull(); + }); +}); + +describe('HeaderHighlight — the equal-width grid (objectstack#27055)', () => { + const fields = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({ name, label: name.toUpperCase() })); + const data = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 }; + + it('draws the strip as a grid of equal cells on the default page', () => { + const { container } = render(onDefaultPage()); + const grid = container.querySelector('[data-highlights-face="grid"]') as HTMLElement; + expect(grid).toBeTruthy(); + expect(grid.className).toContain('@3xl:grid-cols-6'); + expect(grid.children).toHaveLength(6); + }); + + it('CONTROL — elsewhere it keeps the wrapping row', () => { + const { container } = render(); + expect(container.querySelector('[data-highlights-face="grid"]')).toBeNull(); + }); +}); + +describe('RecordMetaFooter — the v3 meta line names the owner and the id (objectstack#27055)', () => { + const data = { id: 'L-1001', created_at: '2026-10-16T00:00:00Z', owner_id: 'Dev Admin' }; + + it('adds the owner and the record id on the default page', () => { + const { container } = render(onDefaultPage()); + expect(container.querySelector('[data-record-meta-owner]')).toBeTruthy(); + expect(container.querySelector('[data-record-meta-id]')!.textContent).toBe('L-1001'); + }); + + it('CONTROL — elsewhere the line is created / updated only', () => { + const { container } = render(); + expect(container.querySelector('[data-record-meta-owner]')).toBeNull(); + expect(container.querySelector('[data-record-meta-id]')).toBeNull(); + }); +}); diff --git a/ui/packages/plugin-detail/src/defaultRecordPage.ts b/ui/packages/plugin-detail/src/defaultRecordPage.ts new file mode 100644 index 00000000000..3fc2a819ac5 --- /dev/null +++ b/ui/packages/plugin-detail/src/defaultRecordPage.ts @@ -0,0 +1,43 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as React from 'react'; + +/** + * `true` while the synthesized default record page draws (objectstack#27055): + * the page `buildDefaultPageSchema` writes from the object's roles, in every + * tier — the column tiers (`RecordPageColumns` sets it) and the tab strip (the + * console's record view sets it around the synthesized page). + * + * The record page v3 face reads it: detail sections drawn frameless (a title, + * a hairline and the field grid, no card), entirely empty sections folded into + * one line, the highlight strip as an equal-width grid, and the provenance + * line naming the owner and the record id. A block drawn anywhere else — an + * authored page, a dashboard, a standalone `DetailView` — keeps its own face, + * because no metadata key says "v3": the face is the default page's, never an + * author's choice (ADR-0085). + */ +export const DefaultRecordPageContext = React.createContext(false); + +/** Whether the block is drawn on the synthesized default record page. */ +export function useDefaultRecordPage(): boolean { + return React.useContext(DefaultRecordPageContext); +} + +/** + * How many columns a frameless detail section lays its fields in, by the + * number of fields it shows (objectstack#27055, the v3 design: 「每段按字段数 + * 1–3 列」): one field takes one column, two to five take two, six and more + * take three. Never more than three — the details column is the page's widest + * but shares the row with the timeline and related columns. + */ +export function framelessSectionColumns(visibleFieldCount: number): 1 | 2 | 3 { + if (visibleFieldCount <= 1) return 1; + if (visibleFieldCount <= 5) return 2; + return 3; +} diff --git a/ui/packages/plugin-detail/src/index.tsx b/ui/packages/plugin-detail/src/index.tsx index 21796eb75a8..b5de08b1241 100644 --- a/ui/packages/plugin-detail/src/index.tsx +++ b/ui/packages/plugin-detail/src/index.tsx @@ -217,6 +217,9 @@ export { useRecordPageTier } from './useRecordPageTier'; export type { UseRecordPageTierOptions } from './useRecordPageTier'; export { RecordPageColumns } from './RecordPageColumns'; export type { RecordPageColumnsProps } from './RecordPageColumns'; +// objectstack#27055 — the record page v3 face: a host drawing the +// synthesized default page in its tabbed tier marks it, as the column tiers do. +export { DefaultRecordPageContext, useDefaultRecordPage } from './defaultRecordPage'; /** * The registry face of `detail-view` — objectui#5378 item 1. diff --git a/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts b/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts index 1680869382e..c0fe9c348a7 100644 --- a/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts +++ b/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts @@ -46,7 +46,7 @@ export interface ObjectDefLike { stageField?: string | false; stages?: Array<{ value: any; label: string }>; /** Semantic role (ADR-0085): the object's most important fields — - * drives the highlight strip (first 4). */ + * drives the highlight strip (first {@link RECORD_HIGHLIGHTS_MAX}). */ highlightFields?: string[]; /** * ACCEPTED BUT NEVER READ — do not "tidy" this away (objectui#7287). @@ -468,6 +468,14 @@ function membersOfGatedFieldGroups(def: ObjectDefLike): Set { return out; } +/** + * How many highlights the record header shows (objectstack#27055, the v3 + * header: 「要点 6 项等宽」 — the same six Studio's highlight editor offers). + * A declared `highlightFields` list longer than this is cut here, and so is + * the derived one. + */ +export const RECORD_HIGHLIGHTS_MAX = 6; + /** * Pick up to N "highlight" fields by name. Skips the status field * (already shown in `record:path`) and obvious junk like id / created_at. @@ -477,7 +485,7 @@ function membersOfGatedFieldGroups(def: ObjectDefLike): Set { export function deriveHighlightFields( def: ObjectDefLike | undefined, statusField: string | null, - max = 4, + max = RECORD_HIGHLIGHTS_MAX, ): string[] { if (!def) return []; // Semantic role first (ADR-0085): top-level `highlightFields`. (The @@ -681,8 +689,9 @@ export function buildDefaultHighlights( ): any[] { const statusField = options.statusField ?? detectStatusField(def); const stages = options.stages ?? (statusField ? deriveStages(def, statusField) : null); - const highlightFields = - options.highlightFields ?? deriveHighlightFields(def, statusField); + const highlightFields = ( + options.highlightFields ?? deriveHighlightFields(def, statusField) + ).slice(0, RECORD_HIGHLIGHTS_MAX); const out: any[] = []; if (highlightFields.length > 0) { out.push(componentNode('record:highlights', { fields: highlightFields })); From 1aa01918c202300b035238a97e68bcd8561fb312 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:30:39 +0000 Subject: [PATCH 03/15] wip(plugin-detail,app-shell): compact related cards and the reader's arrangement (drag, collapse, hide, merge rules) Claude-Session: https://claude.ai/code/session_01Q4EjjN14dHBs3CJce73zNm Co-authored-by: Claude --- .../src/utils/workspacePreference.ts | 23 ++ .../app-shell/src/views/RecordDetailView.tsx | 13 +- .../plugin-detail/src/RecordPageColumns.tsx | 30 +- .../plugin-detail/src/RelatedCardMenu.tsx | 84 +++++ .../plugin-detail/src/RelatedCardStack.tsx | 339 ++++++++++++++++++ ui/packages/plugin-detail/src/RelatedList.tsx | 305 ++++++++++++---- .../RecordPageColumns.switches-27052.test.tsx | 5 +- .../src/__tests__/RecordPageColumns.test.tsx | 10 +- .../__tests__/RelatedCardStack-27055.test.tsx | 151 ++++++++ .../RelatedList.compactColumn-26678.test.tsx | 68 +++- .../src/__tests__/headerColor.test.ts | 4 +- .../__tests__/recordPageV3Face-27055.test.tsx | 8 +- ui/packages/plugin-detail/src/index.tsx | 19 + .../plugin-detail/src/relatedListCompact.ts | 47 ++- .../__tests__/buildDefaultPageSchema.test.ts | 3 +- .../relatedArrangement-27055.test.ts | 181 ++++++++++ .../src/synth/relatedArrangement.ts | 332 +++++++++++++++++ 17 files changed, 1515 insertions(+), 107 deletions(-) create mode 100644 ui/packages/plugin-detail/src/RelatedCardMenu.tsx create mode 100644 ui/packages/plugin-detail/src/RelatedCardStack.tsx create mode 100644 ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx create mode 100644 ui/packages/plugin-detail/src/synth/__tests__/relatedArrangement-27055.test.ts create mode 100644 ui/packages/plugin-detail/src/synth/relatedArrangement.ts diff --git a/ui/packages/app-shell/src/utils/workspacePreference.ts b/ui/packages/app-shell/src/utils/workspacePreference.ts index 94f65bc3a2e..1f2ce59fb9d 100644 --- a/ui/packages/app-shell/src/utils/workspacePreference.ts +++ b/ui/packages/app-shell/src/utils/workspacePreference.ts @@ -7,6 +7,11 @@ */ import { useCallback, useSyncExternalStore } from 'react'; +import { + relatedArrangementFromRecord, + relatedArrangementToRecord, + type RelatedArrangement, +} from '@object-ui/plugin-detail'; /** * The console's per-reader workspace preferences — how a reader likes the @@ -151,6 +156,24 @@ export const RECORD_RELATED_COLUMN_PREFERENCE = defineWorkspacePreference({ + name: 'record.related.arrangement', + codec: versionedPreferenceCodec({ + version: 1, + toRecord: relatedArrangementToRecord, + fromRecord: relatedArrangementFromRecord, + }), +}); + /** The storage surface a read takes — `localStorage`, or a test double. */ export type WorkspacePreferenceReadStorage = Pick; diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index 4b2745c770b..eea66d72a2b 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -10,7 +10,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'; -import { activityRowToFeedItem, DefaultRecordPageContext, InlineEditSaveBar, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, recordPageColumnDefaults, resolveTitleField, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail'; +import { activityRowToFeedItem, DefaultRecordPageContext, EMPTY_RELATED_ARRANGEMENT, InlineEditSaveBar, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, recordPageColumnDefaults, resolveTitleField, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail'; import { Empty, EmptyTitle, EmptyDescription, Button, PageHeaderActions, PanelToggles, type PanelToggleItem } from '@object-ui/components'; import { useAuth, createAuthenticatedFetch } from '@object-ui/auth'; import { usePermissions } from '@object-ui/permissions'; @@ -72,6 +72,7 @@ import { useFavorites } from '../hooks/useFavorites.js'; import { RECORD_RELATED_COLUMN_PREFERENCE, RECORD_TIMELINE_COLUMN_PREFERENCE, + RECORD_RELATED_ARRANGEMENT_PREFERENCE, objectPreferenceScope, useWorkspacePreference, } from '../utils/workspacePreference.js'; @@ -936,6 +937,14 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri columnDefaults.related ? 'on' : 'off', ); const columnSwitches = { timeline: timelineSwitch === 'on', related: relatedSwitch === 'on' }; + // objectstack#27055 — the reader's arrangement of the related cards (order, + // collapsed, hidden), per object, in the same store as the switches. Laid + // over the default order by `RelatedCardStack`; never metadata. + const [relatedArrangement, setRelatedArrangement] = useWorkspacePreference( + RECORD_RELATED_ARRANGEMENT_PREFERENCE, + columnScope, + EMPTY_RELATED_ARRANGEMENT, + ); // Which switches the toolbar draws — the columns the page has content for — // set at render below and read by the shortcuts, so a shortcut never flips // a switch the reader cannot see. @@ -4194,6 +4203,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri tier={recordPageTier} columns={pageColumns} switches={columnSwitches} + relatedArrangement={relatedArrangement} + onRelatedArrangementChange={setRelatedArrangement} footer={
)} } - { if (!open) setDeleteTarget(null); }} - > - - - - {t('detail.deleteRowTitle', { defaultValue: 'Delete record' })} - - - {/* objectui#13333 — the sentence names the row by its title, the - ONE ADR-0079 resolver over this list's object schema (a - grant's `nameField` formula names the grant and its user). */} - {deleteTarget !== null && - t('detail.deleteRowConfirmation', { - name: getRecordDisplayName(objectSchema, deleteTarget), - })} - - - - - {t('detail.cancel', { defaultValue: 'Cancel' })} - - - {t('detail.delete', { defaultValue: 'Delete' })} - - - - - {addError && ( -
- {addError} -
- )} - {/* Same gate as the Add button above — `pickerObject` (i.e. - `add?.picker?.object`, computed once near the picker-schema fetch) is - what the dialog needs, so requiring it here removes the last - render-path bare read of `add.picker` rather than optional-chaining - it: off-spec `add` still does nothing at all, so no second dialect - appears (AGENTS.md #0.1). #3838. */} - {add && pickerObject && dataSource && addGranted && ( - setPickerOpen(o)} - multiple - dataSource={dataSource as any} - objectName={pickerObject} - title={add.label || t('detail.add', { defaultValue: 'Add' })} - displayField={pickerDisplayField} - columns={pickerColumns} - cellRenderer={getCellRenderer} - fieldsMeta={pickerSchema?.fields} - // The author's candidate restriction, handed over VERBATIM (#3831). - // `baseFilter` — never `lookupFilters`, which renders its entries as - // filter-bar rows the user can edit, i.e. demotes the restriction to a - // suggestion. The picker lowers the rule array through the repo's - // single filter sink, so no conversion belongs on this side. - baseFilter={add.picker.filter} - onSelect={() => {}} - onSelectRecords={(records: any[]) => { void handleAddRecords(records); }} - /> - )} + {trailingDialogs} ); }; diff --git a/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.switches-27052.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.switches-27052.test.tsx index 013b711d1fe..6c7ffd62f83 100644 --- a/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.switches-27052.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.switches-27052.test.tsx @@ -73,9 +73,10 @@ describe('RecordPageColumns — the column switches (objectstack#27052)', () => expect(folded).not.toBeNull(); expect(folded.getAttribute('aria-label')).toBe('Activity'); expect(nodesIn(folded)).toEqual(['record:discussion', 'record:history']); - // Lists first, the folded card after them, the footer last. + // Lists first, the folded card after them; the meta line closes the + // details column (objectstack#27055). expect(nodesIn(related)).toEqual(['record:related_list', 'record:related_list', 'record:discussion', 'record:history']); - expect(related.lastElementChild?.getAttribute('data-testid')).toBe('meta-footer'); + expect(column(container, 'details')!.lastElementChild?.getAttribute('data-testid')).toBe('meta-footer'); // The related lists stay siblings of one parent (one empty-fold stack). const lists = related.querySelectorAll('[data-node="record:related_list"]'); expect(lists[0].parentElement).toBe(lists[1].parentElement); diff --git a/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.test.tsx index e583d445662..28c6d8e7132 100644 --- a/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.test.tsx @@ -59,10 +59,10 @@ describe('RecordPageColumns — three columns (objectstack#26678)', () => { expect(nodesIn(column(container, 'details'))).toEqual(['record:details']); expect(nodesIn(column(container, 'timeline'))).toEqual(['record:discussion', 'record:history']); expect(nodesIn(column(container, 'related'))).toEqual(['record:related_list', 'record:related_list']); - // The meta footer sits under the related lists. + // The meta line closes the details column (objectstack#27055). const related = column(container, 'related')!; - expect(within(related).getByTestId('meta-footer')).toBeTruthy(); - expect(related.lastElementChild?.getAttribute('data-testid')).toBe('meta-footer'); + expect(within(related).queryByTestId('meta-footer')).toBeNull(); + expect(column(container, 'details')!.lastElementChild?.getAttribute('data-testid')).toBe('meta-footer'); // The related lists are siblings of one parent, so they form one empty-fold stack. const lists = related.querySelectorAll('[data-node="record:related_list"]'); expect(lists[0].parentElement).toBe(lists[1].parentElement); @@ -91,8 +91,8 @@ describe('RecordPageColumns — two columns and the related rail (objectstack#26 expect(nodesIn(column(container, 'timeline'))).toEqual(['record:discussion', 'record:history']); expect(column(container, 'related')).toBeNull(); expect(container.querySelectorAll('[data-node="record:related_list"]')).toHaveLength(0); - // The footer stays on the page while the rail is closed. - expect(screen.getByTestId('meta-footer')).toBeTruthy(); + // The footer closes the details column while the rail is closed. + expect(within(column(container, 'details')!).getByTestId('meta-footer')).toBeTruthy(); const toggle = screen.getByRole('button', { name: /Related/ }); expect(toggle.getAttribute('aria-expanded')).toBe('false'); diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx new file mode 100644 index 00000000000..bd71709a8d5 --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx @@ -0,0 +1,151 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27055, Targets 7 and 10 — the related column's cards in the + * reader's arrangement, through `RecordPageColumns`. The related-list block + * is stubbed (it draws its relationship id and the chrome it was handed), so + * what is measured is the stack: the order the store says, a collapsed card + * drawn without the list (no rows read), the keyboard moves of the `⋯` menu + * written back through the host's setter, the hidden line and the + * changed-default hint. The merge rules themselves are pinned in + * `synth/__tests__/relatedArrangement-27055.test.ts`. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; +import { RelatedCardChromeContext } from '../relatedListCompact'; + +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + SchemaRenderer: ({ schema }: { schema: any }) => , +})); + +function StubNode({ schema }: { schema: any }) { + const chrome = React.useContext(RelatedCardChromeContext); + const p = schema?.properties ?? {}; + return ( +
+ {p.objectName ?? schema?.type} + {chrome?.onMoveUp && } + {chrome?.onMoveDown && } + {chrome && } + {chrome && } + {chrome?.fresh && new} +
+ ); +} + +import { RecordPageColumns } from '../RecordPageColumns'; +import { buildDefaultPageColumns } from '../synth/recordPageColumns'; +import { buildDefaultPageSchema } from '../synth/buildDefaultPageSchema'; +import { editRelatedArrangement, type RelatedArrangement } from '../synth/relatedArrangement'; + +const RELATED = [ + { objectName: 'task', relationshipField: 'lead_id', title: 'Tasks' }, + { objectName: 'note', relationshipField: 'parent_id', title: 'Notes' }, + { objectName: 'call', relationshipField: 'lead_id', title: 'Calls' }, +]; +const DEFAULT = ['task.lead_id', 'note.parent_id', 'call.lead_id']; +const columnsOf = (related = RELATED) => + buildDefaultPageColumns(buildDefaultPageSchema({ name: 'lead', fields: {} }, { related, hideDiscussion: true }))!; + +const cardOrder = (container: HTMLElement) => + Array.from(container.querySelectorAll('[data-record-column="related"] [data-related-card-id]')).map((el) => + el.getAttribute('data-related-card-id'), + ); + +/** A host with a real store: the arrangement round-trips through state. */ +function Host({ initial, onWrite, related }: { initial?: RelatedArrangement; onWrite?: (v: RelatedArrangement) => void; related?: typeof RELATED }) { + const [arrangement, setArrangement] = React.useState(initial); + return ( + { + onWrite?.(next); + setArrangement(next); + }} + /> + ); +} + +afterEach(cleanup); + +describe('RelatedCardStack — the reader arranges the related cards (objectstack#27055)', () => { + it('draws the default order for a reader who never chose', () => { + const { container } = render(); + expect(cardOrder(container)).toEqual(DEFAULT); + }); + + it("draws the stored reader's order", () => { + const dragged = editRelatedArrangement(DEFAULT, undefined, { kind: 'move', id: 'call.lead_id', to: 0 }, Date.now()); + const { container } = render(); + expect(cardOrder(container)).toEqual(['call.lead_id', 'task.lead_id', 'note.parent_id']); + }); + + it('上移 / 下移 write the new order through the host', () => { + const onWrite = vi.fn(); + const { container } = render(); + fireEvent.click(screen.getByText('down task')); + expect(cardOrder(container)).toEqual(['note.parent_id', 'task.lead_id', 'call.lead_id']); + expect(onWrite.mock.calls[0][0].order).toEqual(['note.parent_id', 'task.lead_id', 'call.lead_id']); + // The first card offers no 上移, the last no 下移. + expect(screen.queryByText('up note')).toBeNull(); + expect(screen.queryByText('down call')).toBeNull(); + }); + + it('a collapsed card is drawn without the list — it reads no rows', () => { + const { container } = render(); + fireEvent.click(screen.getByText('collapse note')); + const collapsed = container.querySelector('[data-related-card-collapsed]'); + expect(collapsed?.getAttribute('data-related-card-id')).toBe('note.parent_id'); + // The list block for `note` is not mounted at all. + expect(container.querySelector('[data-node="record:related_list"][data-related-card-id="note.parent_id"]')).toBeNull(); + // Expanding mounts it again. + fireEvent.click(collapsed!.querySelector('button[aria-expanded="false"]')!); + expect(container.querySelector('[data-node="record:related_list"][data-related-card-id="note.parent_id"]')).not.toBeNull(); + }); + + it('a hidden card leaves the column; the last line brings them back', () => { + const { container } = render(); + fireEvent.click(screen.getByText('hide task')); + expect(cardOrder(container)).toEqual(['note.parent_id', 'call.lead_id']); + const line = container.querySelector('[data-related-hidden-line]')!; + expect(line).toBeTruthy(); + fireEvent.click(line.querySelector('button')!); + expect(cardOrder(container)).toEqual(DEFAULT); + expect(container.querySelector('[data-related-hidden-line]')).toBeNull(); + }); + + it('says when the default changed under a dragged order, and restores it', () => { + const dragged = editRelatedArrangement(DEFAULT, undefined, { kind: 'move', id: 'call.lead_id', to: 0 }, Date.now()); + const reordered = [RELATED[1], RELATED[0], RELATED[2]]; + const { container } = render(); + expect(cardOrder(container)).toEqual(['call.lead_id', 'task.lead_id', 'note.parent_id']); + const hint = container.querySelector('[data-related-default-changed]')!; + expect(hint).toBeTruthy(); + fireEvent.click(hint.querySelector('button')!); + expect(cardOrder(container)).toEqual(['note.parent_id', 'task.lead_id', 'call.lead_id']); + expect(container.querySelector('[data-related-default-changed]')).toBeNull(); + }); + + it('badges a relationship new since the drag, and stamps it as seen once', async () => { + const dragged = editRelatedArrangement(DEFAULT.slice(0, 2), undefined, { kind: 'move', id: 'note.parent_id', to: 0 }, Date.now()); + const onWrite = vi.fn(); + const { container } = render(); + expect(cardOrder(container)).toEqual(['note.parent_id', 'task.lead_id', 'call.lead_id']); + expect(container.querySelector('[data-related-card-id="call.lead_id"] [data-fresh]')).not.toBeNull(); + await waitFor(() => expect(onWrite).toHaveBeenCalledTimes(1)); + expect(Object.keys(onWrite.mock.calls[0][0].seen)).toEqual(['call.lead_id']); + // Still badged after the stamp (48 h from it). + expect(container.querySelector('[data-related-card-id="call.lead_id"] [data-fresh]')).not.toBeNull(); + }); +}); diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedList.compactColumn-26678.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedList.compactColumn-26678.test.tsx index cf31bd61ce2..aecf8d79365 100644 --- a/ui/packages/plugin-detail/src/__tests__/RelatedList.compactColumn-26678.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RelatedList.compactColumn-26678.test.tsx @@ -7,18 +7,19 @@ */ /** - * objectstack#26678 — a related list in the record page's related column - * (`RelatedListCompactContext`, set by `RecordPageColumns`) draws as a card - * that names its first rows: the identity column alone, since a second column - * does not fit beside it in a ~20rem rail. Anywhere else the list keeps every - * column. Pinned through the REAL `data-table`'s rendered headers. + * objectstack#26678 / objectstack#27055 — a related list in the record page's + * related column (`RelatedListCompactContext`, set by `RecordPageColumns`) + * draws as the v3 compact card: title + count + + in one row, NO table + * header, up to three rows of the record's name and one status / date chip, + * no 「查看全部」 footer. Anywhere else the list keeps every column, pinned + * through the REAL `data-table`'s rendered headers. */ import { describe, it, expect, vi, beforeAll } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; import { RelatedList } from '../RelatedList'; -import { RelatedListCompactContext } from '../relatedListCompact'; +import { RelatedCardChromeContext, RelatedListCompactContext, type RelatedCardChrome } from '../relatedListCompact'; // Desktop, pinned rather than inherited: below 768 px a table list renders as a gallery. beforeAll(() => { @@ -27,18 +28,27 @@ beforeAll(() => { const fields = { name: { type: 'text', label: 'Name' }, - status: { type: 'text', label: 'Status' }, + status: { + type: 'select', + label: 'Status', + options: [ + { value: 'open', label: 'Open' }, + { value: 'done', label: 'Done' }, + ], + }, }; const rows = [ { id: 't1', name: 'Call Ada', status: 'open' }, { id: 't2', name: 'Send deck', status: 'done' }, + { id: 't3', name: 'Book demo', status: 'open' }, + { id: 't4', name: 'Fourth task', status: 'open' }, ]; const columns = [ { field: 'name', label: 'Name' }, { field: 'status', label: 'Status' }, ]; -function renderList(compact: boolean) { +function renderList(compact: boolean, extra: { chrome?: RelatedCardChrome; onViewAll?: () => void } = {}) { const list = ( rows), getObjectSchema: vi.fn(async () => ({ name: 'task', fields })) } as any} /> ); - return render(compact ? {list} : list); + const withChrome = extra.chrome ? ( + {list} + ) : list; + return render(compact ? {withChrome} : withChrome); } const headers = () => @@ -64,13 +78,39 @@ describe('RelatedList in the related column (objectstack#26678)', () => { expect(screen.getByText('open')).toBeInTheDocument(); }); - it('names its rows by the identity column alone inside it', async () => { + it('draws the compact card inside it: no table header, three named rows, one chip each', async () => { renderList(true); await waitFor(() => expect(screen.getByText('Call Ada')).toBeInTheDocument()); - expect(headers()).toEqual(['Name']); - expect(screen.queryByText('open')).not.toBeInTheDocument(); - // Still the card: its title and its count. + expect(document.querySelector('thead')).toBeNull(); + const cardRows = Array.from(document.querySelectorAll('[data-related-card-row]')); + expect(cardRows.map((row) => row.firstElementChild?.textContent)).toEqual(['Call Ada', 'Send deck', 'Book demo']); + expect(screen.queryByText('Fourth task')).not.toBeInTheDocument(); + // ONE chip per row: the status, by its option label. + expect(cardRows.map((row) => row.querySelector('[data-related-card-chip]')?.textContent)).toEqual(['Open', 'Done', 'Open']); + // Title and count in the one header row. expect(screen.getByText('Tasks')).toBeInTheDocument(); - expect(screen.getByLabelText('2 records')).toBeInTheDocument(); + expect(document.querySelector('[data-related-card-count]')?.textContent).toBe('4'); + }); + + it('draws no 「查看全部」 footer, even with a View All host hook (one path: ⤢)', async () => { + renderList(true, { onViewAll: vi.fn() }); + await waitFor(() => expect(screen.getByText('Call Ada')).toBeInTheDocument()); + expect(screen.queryByText(/View all/i)).not.toBeInTheDocument(); + }); + + it('offers ⤢ and the ⋯ menu only with the arranged stack\'s chrome, and ⤢ enlarges', async () => { + renderList(true); + await waitFor(() => expect(screen.getByText('Call Ada')).toBeInTheDocument()); + expect(document.querySelector('[data-related-card-enlarge]')).toBeNull(); + expect(document.querySelector('[data-related-card-menu]')).toBeNull(); + document.body.innerHTML = ''; + const onEnlarge = vi.fn(); + renderList(true, { chrome: { id: 'task.lead_id', fresh: true, onCollapse: vi.fn(), onHide: vi.fn(), onEnlarge } }); + await waitFor(() => expect(screen.getByText('Call Ada')).toBeInTheDocument()); + (document.querySelector('[data-related-card-enlarge]') as HTMLButtonElement).click(); + (document.querySelector('[data-related-card-count]') as HTMLButtonElement).click(); + expect(onEnlarge).toHaveBeenCalledTimes(2); + expect(document.querySelector('[data-related-card-menu]')).not.toBeNull(); + expect(document.querySelector('[data-related-card-new]')).not.toBeNull(); }); }); diff --git a/ui/packages/plugin-detail/src/__tests__/headerColor.test.ts b/ui/packages/plugin-detail/src/__tests__/headerColor.test.ts index 1c6ee63886b..3127f03f63e 100644 --- a/ui/packages/plugin-detail/src/__tests__/headerColor.test.ts +++ b/ui/packages/plugin-detail/src/__tests__/headerColor.test.ts @@ -134,7 +134,9 @@ describe('headerColor — (1) the scanner can extract every class it can emit', } // …and the call sites do go through the resolver, so the check above is // not passing because `headerColor` stopped being read at all. - expect(callSiteSource.match(/headerColorClass\(section\.headerColor\)/g) ?? []).toHaveLength(2); + // Three faces read it: the card face (open and collapsible) and the + // record page v3 frameless face (objectstack#27055). + expect(callSiteSource.match(/headerColorClass\(section\.headerColor\)/g) ?? []).toHaveLength(3); }); }); diff --git a/ui/packages/plugin-detail/src/__tests__/recordPageV3Face-27055.test.tsx b/ui/packages/plugin-detail/src/__tests__/recordPageV3Face-27055.test.tsx index c9e3927de74..ce118397b72 100644 --- a/ui/packages/plugin-detail/src/__tests__/recordPageV3Face-27055.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/recordPageV3Face-27055.test.tsx @@ -116,11 +116,11 @@ describe('EmptySectionsLine — entirely empty sections fold into one line (obje section('Conversion', ['converted_at'], { hideEmpty: false }), section('Extra', ['notes'], { hideEmpty: false }), ]; - const schema: DetailViewSchema = { type: 'detail-view', objectName: 'lead', showHeader: false, sections } as any; const data = { id: 'L-1', name: 'Mira', email: 'm@x.io' }; + const schema: DetailViewSchema = { type: 'detail-view', data, sections } as any; it('names the empty sections, counts them, and draws only the sections with a value', () => { - const { container } = render(onDefaultPage()); + const { container } = render(onDefaultPage()); const line = container.querySelector('[data-empty-sections-line]') as HTMLElement; expect(line).toBeTruthy(); expect(line.getAttribute('data-empty-sections-count')).toBe('3'); @@ -130,7 +130,7 @@ describe('EmptySectionsLine — entirely empty sections fold into one line (obje }); it('expands them back in place, and collapses again', () => { - const { container } = render(onDefaultPage()); + const { container } = render(onDefaultPage()); const toggle = container.querySelector('[data-empty-sections-line] button') as HTMLButtonElement; expect(toggle.getAttribute('aria-expanded')).toBe('false'); fireEvent.click(toggle); @@ -151,7 +151,7 @@ describe('EmptySectionsLine — entirely empty sections fold into one line (obje }); it('CONTROL — outside the default page no line draws (the whole-body fold keeps its rule)', () => { - const { container } = render(); + const { container } = render(); expect(container.querySelector('[data-empty-sections-line]')).toBeNull(); }); }); diff --git a/ui/packages/plugin-detail/src/index.tsx b/ui/packages/plugin-detail/src/index.tsx index b5de08b1241..62f62c9dae6 100644 --- a/ui/packages/plugin-detail/src/index.tsx +++ b/ui/packages/plugin-detail/src/index.tsx @@ -220,6 +220,25 @@ export type { RecordPageColumnsProps } from './RecordPageColumns'; // objectstack#27055 — the record page v3 face: a host drawing the // synthesized default page in its tabbed tier marks it, as the column tiers do. export { DefaultRecordPageContext, useDefaultRecordPage } from './defaultRecordPage'; +// objectstack#27055 — the reader's arrangement of the related cards: the pure +// merge rule and the stored record's codec halves, for the host's +// workspace-preference store. +export { + EMPTY_RELATED_ARRANGEMENT, + RELATED_CARD_NEW_BADGE_MS, + editRelatedArrangement, + relatedArrangementFromRecord, + relatedArrangementToRecord, + relatedCardId, + relatedDefaultOrderHash, + resolveRelatedArrangement, +} from './synth/relatedArrangement'; +export type { + RelatedArrangement, + RelatedArrangementEdit, + RelatedCardId, + ResolvedRelatedArrangement, +} from './synth/relatedArrangement'; /** * The registry face of `detail-view` — objectui#5378 item 1. diff --git a/ui/packages/plugin-detail/src/relatedListCompact.ts b/ui/packages/plugin-detail/src/relatedListCompact.ts index fbd32a932b7..89802190645 100644 --- a/ui/packages/plugin-detail/src/relatedListCompact.ts +++ b/ui/packages/plugin-detail/src/relatedListCompact.ts @@ -10,14 +10,51 @@ import * as React from 'react'; /** * `true` inside the record page's related column (objectstack#26678): a - * fixed rail about 20rem wide, where a related list draws as a card — title, - * count, its first rows, View All, New — instead of a table cut off at the - * column's edge. `RelatedList` then keeps its first column (the row's - * identity: no second column fits beside it) and lets its header wrap so the - * title is never squeezed out by the actions. + * fixed rail 320 px wide, where a related list draws as a compact card — + * title + count + + in one row, no table header, up to three rows of name + + * one status / date chip, ⤢ to enlarge (objectstack#27055, Target 4) — + * instead of a table cut off at the column's edge. * * Set by `RecordPageColumns` only, so a related list anywhere else — the * Related tab, an authored page — draws exactly as before. Package-internal: * not exported from the barrel. */ export const RelatedListCompactContext = React.createContext(false); + +/** How many rows a compact related card names. */ +export const COMPACT_RELATED_ROWS = 3; + +/** + * The reader's controls over one related card (objectstack#27055, Targets 7, + * 9 and 10), handed to the card by the arranged stack that draws it. Absent + * outside that stack: the card then has no menu, no drag and no enlarge. + */ +export interface RelatedCardChrome { + /** The relationship id, `CHILD_OBJECT.FK_FIELD`. */ + id: string; + /** New since the reader's last drag — the 「新」 badge. */ + fresh: boolean; + /** Collapse the card to its title + count. */ + onCollapse: () => void; + /** Move one place up / down in the column; absent at the edge. */ + onMoveUp?: () => void; + onMoveDown?: () => void; + /** Hide the card (「隐藏」). */ + onHide: () => void; + /** Enlarge the list in place (⤢, the count chip) — `?related=object.field`. */ + onEnlarge?: () => void; + /** Spread onto the title row: the card's drag handle (desktop only). */ + dragHandleProps?: React.HTMLAttributes & { draggable?: boolean }; +} + +/** The chrome of the card being drawn, or `null` outside an arranged stack. */ +export const RelatedCardChromeContext = React.createContext(null); + +/** + * The drop line of a card being dragged over (objectstack#27055): the stack + * marks the card the dragged one would land before with + * `data-related-drop="before"` (the last card `after`), and the card draws a + * primary line on that edge. Literal classes, so Tailwind's scanner emits them. + */ +export const RELATED_CARD_DROP_LINE = + 'data-[related-drop=before]:shadow-[0_-3px_0_0_var(--color-primary)] data-[related-drop=after]:shadow-[0_3px_0_0_var(--color-primary)]'; diff --git a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts index 064d6046fd9..0c17019048b 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts @@ -172,7 +172,8 @@ describe('deriveHighlightFields', () => { expect(fields).not.toContain('status'); expect(fields).not.toContain('created_at'); expect(fields).toContain('owner_id'); - expect(fields.length).toBeLessThanOrEqual(4); + // Six since objectstack#27055 (「要点 6 项等宽」). + expect(fields.length).toBeLessThanOrEqual(6); }); it('falls back to any field order when preferred names absent, minus the H1 field', () => { diff --git a/ui/packages/plugin-detail/src/synth/__tests__/relatedArrangement-27055.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/relatedArrangement-27055.test.ts new file mode 100644 index 00000000000..14d4577ec84 --- /dev/null +++ b/ui/packages/plugin-detail/src/synth/__tests__/relatedArrangement-27055.test.ts @@ -0,0 +1,181 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27055, Targets 7 and 10 — the merge rules of the reader's + * related-card arrangement, one `describe` per rule as the maintainer stated + * them on the card (2026-10-28, on 「如果让用户自己定义排序,那和默认顺序冲突怎么办? + * 开发了新的子表之后怎么办」 and 「有需要隐藏某个子表的场景吗?怎么处理」). + */ + +import { describe, expect, it } from 'vitest'; +import { + RELATED_CARD_NEW_BADGE_MS, + editRelatedArrangement, + relatedArrangementFromRecord, + relatedArrangementToRecord, + relatedCardId, + relatedDefaultOrderHash, + resolveRelatedArrangement, + type RelatedArrangement, +} from '../relatedArrangement'; + +const NOW = Date.UTC(2026, 9, 28, 12); +const A = 'task.lead_id'; +const B = 'note.parent_id'; +const C = 'call.lead_id'; +const D = 'campaign_member.lead_id'; +const DEFAULT = [A, B, C]; + +/** The reader dragged C to the top of the default A B C. */ +const dragged = (): RelatedArrangement => + editRelatedArrangement(DEFAULT, undefined, { kind: 'move', id: C, to: 0 }, NOW); + +describe('ids are object.field', () => { + it('names the child object and its FK field', () => { + expect(relatedCardId('task', 'lead_id')).toBe(A); + }); +}); + +describe('rule 1 — a reader who never dragged sees the default order', () => { + it('no stored value', () => { + expect(resolveRelatedArrangement(DEFAULT, undefined, NOW).order).toEqual(DEFAULT); + }); + + it('a collapse or a hide alone does not freeze the order', () => { + const collapsedOnly = editRelatedArrangement(DEFAULT, undefined, { kind: 'collapse', id: B, collapsed: true }, NOW); + expect(collapsedOnly.order).toEqual([]); + // The author then reorders the default: the collapse-only reader follows it. + expect(resolveRelatedArrangement([C, A, B], collapsedOnly, NOW).order).toEqual([C, A, B]); + expect(resolveRelatedArrangement([C, A, B], collapsedOnly, NOW).collapsed.has(B)).toBe(true); + }); +}); + +describe("rule 2 — dragged cards keep the reader's order; a later default change never reorders them", () => { + it('the drag is the order', () => { + expect(resolveRelatedArrangement(DEFAULT, dragged(), NOW).order).toEqual([C, A, B]); + }); + + it('the author reorders the default — the reader keeps theirs', () => { + expect(resolveRelatedArrangement([B, A, C], dragged(), NOW).order).toEqual([C, A, B]); + }); + + it('「恢复默认顺序」 restores the default', () => { + const restored = editRelatedArrangement([B, A, C], dragged(), { kind: 'restore-default-order' }, NOW); + expect(restored.order).toEqual([]); + expect(resolveRelatedArrangement([B, A, C], restored, NOW).order).toEqual([B, A, C]); + }); + + it('上移 / 下移 move one place in the shown order', () => { + const up = editRelatedArrangement(DEFAULT, undefined, { kind: 'move-up', id: B }, NOW); + expect(resolveRelatedArrangement(DEFAULT, up, NOW).order).toEqual([B, A, C]); + const down = editRelatedArrangement(DEFAULT, up, { kind: 'move-down', id: B }, NOW); + expect(resolveRelatedArrangement(DEFAULT, down, NOW).order).toEqual([A, B, C]); + // At the edge a move is a no-op on the order. + const top = editRelatedArrangement(DEFAULT, undefined, { kind: 'move-up', id: A }, NOW); + expect(resolveRelatedArrangement(DEFAULT, top, NOW).order).toEqual(DEFAULT); + }); +}); + +describe('rule 3 — a new relationship: after its nearest placed default predecessor, 「新」 for 48 h, default collapsed state', () => { + it('inserted after the nearest card preceding it in the default order', () => { + // Default grows to A B D C; the reader dragged C first. D's nearest + // predecessor in the default that the reader's order holds is B. + const resolved = resolveRelatedArrangement([A, B, D, C], dragged(), NOW); + expect(resolved.order).toEqual([C, A, B, D]); + }); + + it('first in the default → first', () => { + expect(resolveRelatedArrangement([D, A, B, C], dragged(), NOW).order).toEqual([D, C, A, B]); + }); + + it('carries the badge until it has been seen for 48 h', () => { + const fresh = resolveRelatedArrangement([A, B, C, D], dragged(), NOW); + expect(fresh.fresh.has(D)).toBe(true); + expect(fresh.unseen).toEqual([D]); + const stamped = editRelatedArrangement([A, B, C, D], dragged(), { kind: 'mark-seen', ids: [D], at: NOW }, NOW); + expect(resolveRelatedArrangement([A, B, C, D], stamped, NOW + RELATED_CARD_NEW_BADGE_MS - 1).fresh.has(D)).toBe(true); + expect(resolveRelatedArrangement([A, B, C, D], stamped, NOW + RELATED_CARD_NEW_BADGE_MS).fresh.has(D)).toBe(false); + // Stamping does not reorder anything. + expect(stamped.order).toEqual(dragged().order); + }); + + it('a reader who never dragged gets no badge — every card is the default', () => { + expect(resolveRelatedArrangement([A, B, C, D], undefined, NOW).fresh.size).toBe(0); + }); + + it('takes the default collapsed state', () => { + const resolved = resolveRelatedArrangement([A, B, C, D], dragged(), NOW, new Set([D])); + expect(resolved.collapsed.has(D)).toBe(true); + expect(resolveRelatedArrangement([A, B, C, D], dragged(), NOW).collapsed.has(D)).toBe(false); + }); +}); + +describe('rule 4 — a relationship that no longer exists is dropped on the next write', () => { + it('drops it from order, collapsed and hidden', () => { + let stored = dragged(); + stored = editRelatedArrangement(DEFAULT, stored, { kind: 'collapse', id: B, collapsed: true }, NOW); + stored = editRelatedArrangement(DEFAULT, stored, { kind: 'hide', id: A }, NOW); + // B is gone from the default; the next write drops it everywhere. + const next = editRelatedArrangement([A, C], stored, { kind: 'collapse', id: C, collapsed: true }, NOW); + expect(next.order).toEqual([C, A]); + expect(next.collapsed).toEqual([C]); + expect(next.hidden).toEqual([A]); + }); + + it('reading alone already leaves it out of the column', () => { + expect(resolveRelatedArrangement([A, C], dragged(), NOW).order).toEqual([C, A]); + }); +}); + +describe("rule 5 — basedOn names the default the reader's order was laid over", () => { + it('a changed default raises the hint; restoring clears it', () => { + expect(resolveRelatedArrangement(DEFAULT, dragged(), NOW).defaultChanged).toBe(false); + expect(resolveRelatedArrangement([B, A, C], dragged(), NOW).defaultChanged).toBe(true); + const restored = editRelatedArrangement([B, A, C], dragged(), { kind: 'restore-default-order' }, NOW); + expect(resolveRelatedArrangement([B, A, C], restored, NOW).defaultChanged).toBe(false); + }); + + it('the hash is stable and order-sensitive', () => { + expect(relatedDefaultOrderHash(DEFAULT)).toBe(relatedDefaultOrderHash([...DEFAULT])); + expect(relatedDefaultOrderHash(DEFAULT)).not.toBe(relatedDefaultOrderHash([B, A, C])); + expect(relatedDefaultOrderHash(DEFAULT)).toMatch(/^[0-9a-f]{8}$/); + }); +}); + +describe('rule 6 — hidden cards leave the column and come back together', () => { + it('hide, then 「显示」', () => { + const hidden = editRelatedArrangement(DEFAULT, undefined, { kind: 'hide', id: B }, NOW); + const resolved = resolveRelatedArrangement(DEFAULT, hidden, NOW); + expect(resolved.order).toEqual([A, C]); + expect(resolved.hidden).toEqual([B]); + const shown = editRelatedArrangement(DEFAULT, hidden, { kind: 'show-hidden' }, NOW); + expect(resolveRelatedArrangement(DEFAULT, shown, NOW).order).toEqual(DEFAULT); + }); + + it('a hidden card keeps its place when others move around it', () => { + let stored = editRelatedArrangement(DEFAULT, undefined, { kind: 'hide', id: B }, NOW); + stored = editRelatedArrangement(DEFAULT, stored, { kind: 'move', id: C, to: 0 }, NOW); + expect(stored.order).toEqual([C, A, B]); + expect(resolveRelatedArrangement(DEFAULT, stored, NOW).order).toEqual([C, A]); + }); +}); + +describe('the stored record', () => { + it('round-trips, and refuses a malformed record', () => { + const value = editRelatedArrangement([A, B, C, D], dragged(), { kind: 'mark-seen', ids: [D], at: NOW }, NOW); + expect(relatedArrangementFromRecord(relatedArrangementToRecord(value))).toEqual(value); + expect(relatedArrangementFromRecord({ order: [1], collapsed: [], hidden: [], basedOn: 'x' })).toBeUndefined(); + expect(relatedArrangementFromRecord({ order: [], collapsed: [], hidden: [] })).toBeUndefined(); + expect(relatedArrangementFromRecord({ order: [], collapsed: [], hidden: [], basedOn: 'x', seen: { a: 'now' } })).toBeUndefined(); + }); + + it('carries no `seen` key when nothing was seen', () => { + expect('seen' in relatedArrangementToRecord(dragged())).toBe(false); + }); +}); diff --git a/ui/packages/plugin-detail/src/synth/relatedArrangement.ts b/ui/packages/plugin-detail/src/synth/relatedArrangement.ts new file mode 100644 index 00000000000..555d8b0db5e --- /dev/null +++ b/ui/packages/plugin-detail/src/synth/relatedArrangement.ts @@ -0,0 +1,332 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * The reader's arrangement of the record page's related cards + * (objectstack#27055, Targets 7 and 10). + * + * Related cards are the reader's to arrange: reorder (drag, or 上移 / 下移), + * collapse to title + count, hide. What a reader chose is kept per user × + * object in the console's workspace-preference store, never metadata; this + * module is the pure rule that lays the author's default order and the + * reader's choice over each other, so the column, the tabbed tier's 关联 tab + * and the tests read one answer. + * + * ## The stored value is a DIFF against the default order + * + * `{ order, collapsed, hidden, basedOn }`, relationship ids being + * `object.field` (the child object and its FK field): + * + * - `order` — the order of the reader's last drag; EMPTY while the reader + * never dragged, which is "follow the default" (a collapse or a hide alone + * does not freeze the order); + * - `collapsed` / `hidden` — the cards the reader collapsed / hid; + * - `basedOn` — the hash of the default order the reader's `order` was laid + * over ({@link relatedDefaultOrderHash}). + * + * ## The merge rules (the maintainer's, on the card, each pinned by a test) + * + * 1. A reader who never dragged sees the default order. + * 2. Dragged cards keep the reader's relative order; a later change to the + * author's default never reorders them — only 「恢复默认顺序」 does. + * 3. A relationship new since the reader last dragged is inserted after the + * nearest card that precedes it in the default order and exists in the + * reader's order (first in default → first), carries a 「新」 badge for + * 48 h, and takes the default collapsed state. + * 4. A relationship that no longer exists is dropped from `order` (and from + * `collapsed` / `hidden`) on the next write. + * 5. When `basedOn` differs from the current default's hash, the column + * says so: 「默认顺序已更新 · 恢复默认」. + * 6. Hidden cards leave the column; its last line 「已隐藏 N 张 · 显示」 + * brings them back. + * + * Rule 3's 48 h needs a clock the four keys cannot carry, so the value has + * one more member, `seen` — when this reader first saw each new relationship + * (epoch ms). It is written once per new relationship, by the host, and + * dropped with the relationship. + * + * ## The default order (#27069 fence) + * + * The default order is what the HOST passes in: today the derived order + * (`deriveRelatedLists` — `relatedList: 'primary'` first, then registration / + * declaration order). ⛔ The object's authored `relatedLists` order is not + * read here or anywhere on this page yet: it waits for the spec key + * (objectstack#27069). When that key lands, the host computes the default + * order from it at its one read point (`RecordDetailView`, beside the + * `deriveRelatedLists` call) and this module is unchanged. + */ + +/** A relationship id: `CHILD_OBJECT.FK_FIELD`. */ +export type RelatedCardId = string; + +/** The reader's stored arrangement — see the module header. */ +export interface RelatedArrangement { + order: RelatedCardId[]; + collapsed: RelatedCardId[]; + hidden: RelatedCardId[]; + basedOn: string; + /** When the reader first saw each relationship new since their last drag (epoch ms). */ + seen?: Record; +} + +/** + * The arrangement of a reader who chose nothing: the default order, every + * card open and shown. Equivalent to no stored value in every rule — the + * host's fallback while the store holds no choice. + */ +export const EMPTY_RELATED_ARRANGEMENT: RelatedArrangement = Object.freeze({ + order: [], + collapsed: [], + hidden: [], + basedOn: '', +}) as RelatedArrangement; + +/** How long a new relationship carries its 「新」 badge. */ +export const RELATED_CARD_NEW_BADGE_MS = 48 * 60 * 60 * 1000; + +/** The id of the relationship a related list draws: `CHILD_OBJECT.FK_FIELD`. */ +export function relatedCardId(objectName: string, relationshipField: string): RelatedCardId { + return `${objectName}.${relationshipField}`; +} + +/** + * A stable hash of a default order (FNV-1a over the ids, `|`-joined, as 8 hex + * digits). Equal orders hash equal; any reorder, addition or removal changes + * it (in all but a hash collision, which only hides one hint). + */ +export function relatedDefaultOrderHash(defaultOrder: readonly RelatedCardId[]): string { + let hash = 0x811c9dc5; + const text = defaultOrder.join('|'); + for (let i = 0; i < text.length; i += 1) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, '0'); +} + +/** The arrangement as the column draws it. */ +export interface ResolvedRelatedArrangement { + /** The shown cards, in display order (hidden cards excluded). */ + order: RelatedCardId[]; + /** Every existing card in display order, hidden ones in their place. */ + fullOrder: RelatedCardId[]; + collapsed: ReadonlySet; + hidden: RelatedCardId[]; + /** Cards that carry the 「新」 badge (rule 3). */ + fresh: ReadonlySet; + /** Relationships new since the reader's last drag that have no `seen` stamp yet. */ + unseen: RelatedCardId[]; + /** The reader's order was laid over an older default (rule 5). */ + defaultChanged: boolean; + /** The reader has dragged (their order is not the default's). */ + dragged: boolean; +} + +/** + * Lay the reader's stored arrangement over the default order (rules 1–6). + * + * `defaultCollapsed` is the default collapsed state of each card (a card the + * page draws collapsed until the reader opens it); every card is open by + * default when it is omitted. + */ +export function resolveRelatedArrangement( + defaultOrder: readonly RelatedCardId[], + stored: RelatedArrangement | undefined, + now: number, + defaultCollapsed: ReadonlySet = new Set(), +): ResolvedRelatedArrangement { + const exists = new Set(defaultOrder); + const dragged = !!stored && stored.order.length > 0; + let fullOrder: RelatedCardId[]; + const newSinceDrag: RelatedCardId[] = []; + if (!dragged) { + // Rule 1. + fullOrder = [...defaultOrder]; + } else { + // Rules 2 and 4: the reader's order, minus what no longer exists. + fullOrder = stored!.order.filter((id, index, all) => exists.has(id) && all.indexOf(id) === index); + const placed = new Set(fullOrder); + // Rule 3: each new relationship after its nearest default predecessor + // that is placed, in default order so consecutive new ones keep theirs. + defaultOrder.forEach((id, index) => { + if (placed.has(id)) return; + newSinceDrag.push(id); + let at = 0; + for (let back = index - 1; back >= 0; back -= 1) { + const before = fullOrder.indexOf(defaultOrder[back]); + if (before >= 0) { + at = before + 1; + break; + } + } + fullOrder.splice(at, 0, id); + placed.add(id); + }); + } + const hiddenSet = new Set((stored?.hidden ?? []).filter((id) => exists.has(id))); + const collapsed = new Set(); + for (const id of fullOrder) { + // A new relationship takes the default collapsed state (rule 3); every + // other card the state the reader's last write recorded for it. + const fromDefault = !stored || newSinceDrag.includes(id); + if (fromDefault ? defaultCollapsed.has(id) : stored!.collapsed.includes(id)) collapsed.add(id); + } + // Rule 3's badge runs on the reader's clock, not on the order: a + // relationship new since the last drag is fresh until stamped, and a stamped + // one stays fresh for 48 h from its stamp — a later drag, which takes it + // into the order, does not cut the 48 h short. + const seen = stored?.seen ?? {}; + const fresh = new Set(); + const unseen: RelatedCardId[] = []; + for (const id of newSinceDrag) { + if (typeof seen[id] !== 'number') { + unseen.push(id); + fresh.add(id); + } + } + for (const [id, at] of Object.entries(seen)) { + if (exists.has(id) && now - at < RELATED_CARD_NEW_BADGE_MS) fresh.add(id); + } + return { + order: fullOrder.filter((id) => !hiddenSet.has(id)), + fullOrder, + collapsed, + hidden: fullOrder.filter((id) => hiddenSet.has(id)), + fresh, + unseen, + defaultChanged: dragged && stored!.basedOn !== relatedDefaultOrderHash(defaultOrder), + dragged, + }; +} + +/** What a reader did to the arrangement. */ +export type RelatedArrangementEdit = + | { kind: 'move'; id: RelatedCardId; to: number } + | { kind: 'move-up'; id: RelatedCardId } + | { kind: 'move-down'; id: RelatedCardId } + | { kind: 'collapse'; id: RelatedCardId; collapsed: boolean } + | { kind: 'hide'; id: RelatedCardId } + | { kind: 'show-hidden' } + | { kind: 'restore-default-order' } + | { kind: 'mark-seen'; ids: RelatedCardId[]; at: number }; + +/** + * The stored value after `edit`, written against the CURRENT default order. + * Every write keeps only existing relationships (rule 4). + * + * `move` places `id` at index `to` of the SHOWN order (hidden cards keep + * their place relative to the shown ones). A move writes the full order and + * stamps `basedOn` with the current default's hash. + */ +export function editRelatedArrangement( + defaultOrder: readonly RelatedCardId[], + stored: RelatedArrangement | undefined, + edit: RelatedArrangementEdit, + now: number, + defaultCollapsed: ReadonlySet = new Set(), +): RelatedArrangement { + const resolved = resolveRelatedArrangement(defaultOrder, stored, now, defaultCollapsed); + const exists = new Set(defaultOrder); + const hash = relatedDefaultOrderHash(defaultOrder); + const keepSeen = (seen: Record | undefined) => { + const out: Record = {}; + for (const [id, at] of Object.entries(seen ?? {})) if (exists.has(id)) out[id] = at; + return out; + }; + // Only a MOVE writes the full order; every other write keeps the reader's + // last-drag order as it was (minus what no longer exists, rule 4), so a + // relationship new since that drag stays new — placed by rule 3, badged — + // until the reader drags again. + const base: RelatedArrangement = { + order: resolved.dragged ? stored!.order.filter((id, index, all) => exists.has(id) && all.indexOf(id) === index) : [], + collapsed: resolved.fullOrder.filter((id) => resolved.collapsed.has(id)), + hidden: [...resolved.hidden], + basedOn: resolved.dragged ? stored!.basedOn : hash, + seen: keepSeen(stored?.seen), + }; + const finish = (next: RelatedArrangement): RelatedArrangement => { + if (next.seen && Object.keys(next.seen).length === 0) delete next.seen; + return next; + }; + const moveTo = (id: RelatedCardId, shownIndex: number): RelatedArrangement => { + const shown = resolved.order.filter((x) => x !== id); + const clamped = Math.max(0, Math.min(shownIndex, shown.length)); + shown.splice(clamped, 0, id); + // Re-insert the hidden cards where they stood relative to the shown ones. + const full: RelatedCardId[] = [...shown]; + resolved.fullOrder.forEach((hiddenId, index) => { + if (!resolved.hidden.includes(hiddenId)) return; + const before = resolved.fullOrder.slice(0, index).reverse().find((x) => full.includes(x) && x !== hiddenId); + full.splice(before ? full.indexOf(before) + 1 : 0, 0, hiddenId); + }); + return finish({ ...base, order: full, basedOn: hash }); + }; + switch (edit.kind) { + case 'move': + if (!exists.has(edit.id)) return finish(base); + return moveTo(edit.id, edit.to); + case 'move-up': + case 'move-down': { + const at = resolved.order.indexOf(edit.id); + if (at < 0) return finish(base); + return moveTo(edit.id, edit.kind === 'move-up' ? at - 1 : at + 1); + } + case 'collapse': { + const collapsed = base.collapsed.filter((id) => id !== edit.id); + if (edit.collapsed && exists.has(edit.id)) collapsed.push(edit.id); + return finish({ ...base, collapsed: resolved.fullOrder.filter((id) => collapsed.includes(id)) }); + } + case 'hide': + if (!exists.has(edit.id) || base.hidden.includes(edit.id)) return finish(base); + return finish({ ...base, hidden: resolved.fullOrder.filter((id) => id === edit.id || base.hidden.includes(id)) }); + case 'show-hidden': + return finish({ ...base, hidden: [] }); + case 'restore-default-order': + return finish({ ...base, order: [], basedOn: hash, seen: {} }); + case 'mark-seen': { + const seen = { ...(base.seen ?? {}) }; + for (const id of edit.ids) if (exists.has(id) && typeof seen[id] !== 'number') seen[id] = edit.at; + return finish({ ...base, seen }); + } + default: + return finish(base); + } +} + +/** + * Read a stored record into an arrangement, or `undefined` for one this + * module does not read (the codec's `fromRecord`). Strict: every list is an + * array of strings, `basedOn` a string, `seen` a string → finite-number map. + */ +export function relatedArrangementFromRecord(record: Record): RelatedArrangement | undefined { + const ids = (value: unknown): string[] | undefined => + Array.isArray(value) && value.every((v) => typeof v === 'string') ? (value as string[]) : undefined; + const order = ids(record.order); + const collapsed = ids(record.collapsed); + const hidden = ids(record.hidden); + if (!order || !collapsed || !hidden || typeof record.basedOn !== 'string') return undefined; + let seen: Record | undefined; + if (record.seen !== undefined) { + if (!record.seen || typeof record.seen !== 'object' || Array.isArray(record.seen)) return undefined; + seen = {}; + for (const [id, at] of Object.entries(record.seen as Record)) { + if (typeof at !== 'number' || !Number.isFinite(at)) return undefined; + seen[id] = at; + } + } + return { order, collapsed, hidden, basedOn: record.basedOn, ...(seen ? { seen } : {}) }; +} + +/** The stored record of an arrangement (the codec's `toRecord`). */ +export function relatedArrangementToRecord(value: RelatedArrangement): Record { + return { + order: value.order, + collapsed: value.collapsed, + hidden: value.hidden, + basedOn: value.basedOn, + ...(value.seen && Object.keys(value.seen).length > 0 ? { seen: value.seen } : {}), + }; +} From 402a990f786e7efb151acbaae7c7ea25ef73ed1a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:35:12 +0000 Subject: [PATCH 04/15] wip(plugin-detail,app-shell): enlarge a related card in place, addressed by ?related=object.field Claude-Session: https://claude.ai/code/session_01Q4EjjN14dHBs3CJce73zNm Co-authored-by: Claude --- ui/packages/app-shell/src/urlParams.ts | 8 ++ .../app-shell/src/views/RecordDetailView.tsx | 32 +++++++- .../plugin-detail/src/RecordPageColumns.tsx | 77 ++++++++++++++++++- .../__tests__/RelatedCardStack-27055.test.tsx | 51 +++++++++++- .../RelatedList.compactColumn-26678.test.tsx | 2 +- 5 files changed, 162 insertions(+), 8 deletions(-) diff --git a/ui/packages/app-shell/src/urlParams.ts b/ui/packages/app-shell/src/urlParams.ts index 38da8fd8baa..d56957d4134 100644 --- a/ui/packages/app-shell/src/urlParams.ts +++ b/ui/packages/app-shell/src/urlParams.ts @@ -97,6 +97,13 @@ export const RECORD_FORM_LINK_PARAM = 'formLink'; /** Active record-detail tab (objectui#2257; stable semantic values). */ export const RECORD_DETAIL_TAB_PARAM = 'tab'; +/** + * The record page's enlarged related list, `object.field` — the child object + * and its FK field (objectstack#27055): the list drawn as a full-width table + * over the page body. Read by `RecordDetailView` in the column tiers. + */ +export const RECORD_RELATED_ENLARGE_PARAM = 'related'; + /** Command palette overlay (ADR-0054 Phase 1; alias `cmdk`). */ export const COMMAND_PALETTE_PARAM = 'palette'; @@ -138,6 +145,7 @@ export const RESERVED_URL_PARAMS: readonly string[] = [ RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM, RECORD_DETAIL_TAB_PARAM, + RECORD_RELATED_ENLARGE_PARAM, COMMAND_PALETTE_PARAM, KEYBOARD_SHORTCUTS_PARAM, RECORD_TRAIL_PARAM, diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index eea66d72a2b..a2fbe147b10 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -35,7 +35,10 @@ import { FlowRefusalNotice, type FlowRefusalState } from './FlowRefusalNotice.js import { RelatedRecordActionsBridge } from './RelatedRecordActionsBridge.js'; import { RecordDrawerReturnLink, RecordPageDrawerHost } from './recordDrawerStack.js'; import { withPageTabsUrlSync } from '../utils/pageTabsUrlSync.js'; -import { RECORD_DETAIL_TAB_PARAM, RECORD_TRAIL_PARAM, decodeRecordTrail, buildRecordTrailHref } from '../urlParams.js'; +import { RECORD_DETAIL_TAB_PARAM, RECORD_RELATED_ENLARGE_PARAM, RECORD_TRAIL_PARAM, decodeRecordTrail, buildRecordTrailHref } from '../urlParams.js'; + +/** History-state flag: this view pushed the `?related=` entry, so closing pops it. */ +const RELATED_ENLARGE_PUSHED = '__relatedEnlargePushed'; import { resolveActionParams } from '@object-ui/fields'; import { createConsoleServerActionHandler } from '../utils/consoleServerAction.js'; import { withNewTabUrlFastPath } from '../utils/newTabUrlFastPath.js'; @@ -701,6 +704,30 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // renders the "← All " back link — switching tabs must keep it. setTabSearchParams(sp, { replace: true, state: location.state }); }, [setTabSearchParams, location.state]); + // ── The enlarged related list, `?related=object.field` (objectstack#27055, + // Target 9) ── One path to a related list's every record and column: a + // card's ⤢ (or its count) opens the list as a full-width table over the + // page body, addressed by the URL so it survives a reload and a shared link. + // Opening PUSHES an entry (the browser's Back returns to the columns); + // closing — Esc or ‹ — pops it when this view pushed it, and otherwise + // replaces the URL without the parameter. + const enlargedRelatedParam = tabSearchParams.get(RECORD_RELATED_ENLARGE_PARAM) ?? undefined; + const openEnlargedRelated = useCallback((id: string) => { + const sp = new URLSearchParams(window.location.search); + if (sp.get(RECORD_RELATED_ENLARGE_PARAM) === id) return; + sp.set(RECORD_RELATED_ENLARGE_PARAM, id); + const state = location.state && typeof location.state === 'object' ? location.state : {}; + setTabSearchParams(sp, { state: { ...(state as object), [RELATED_ENLARGE_PUSHED]: true } }); + }, [setTabSearchParams, location.state]); + const closeEnlargedRelated = useCallback(() => { + if ((location.state as Record | null)?.[RELATED_ENLARGE_PUSHED]) { + navigate(-1); + return; + } + const sp = new URLSearchParams(window.location.search); + sp.delete(RECORD_RELATED_ENLARGE_PARAM); + setTabSearchParams(sp, { replace: true, state: location.state }); + }, [setTabSearchParams, location.state, navigate]); // Inline "← back to parent" link shown above the record body. Prefers an // explicit history-state `from` (legacy, in-session only), then falls back // to the last ancestor in the `?from=` URL trail — which, unlike history @@ -4205,6 +4232,9 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri switches={columnSwitches} relatedArrangement={relatedArrangement} onRelatedArrangementChange={setRelatedArrangement} + enlargedRelated={enlargedRelatedParam} + onEnlargeRelated={openEnlargedRelated} + onCloseEnlargedRelated={closeEnlargedRelated} footer={ void; /** Enlarge a related card's list in place (Target 9, the host's `?related=`). */ onEnlargeRelated?: (id: RelatedCardId) => void; + /** + * The enlarged related list (`object.field`, the host's `?related=`): drawn + * as a full-width table over the page body — the header stays, the columns + * step aside — until Esc or ‹ (`onCloseEnlargedRelated`). An id naming no + * related list of the page draws the columns. + */ + enlargedRelated?: RelatedCardId; + onCloseEnlargedRelated?: () => void; className?: string; } +/** Rows per page of an enlarged related list whose block names no `limit`. */ +const ENLARGED_PAGE_SIZE = 25; + +/** The relationship id of a related-list node, or `null`. */ +function relatedNodeId(node: any): RelatedCardId | null { + const p = node && typeof node === 'object' ? { ...node, ...(node.properties ?? {}) } : null; + if (!p || p.type !== 'record:related_list') return null; + if (typeof p.objectName !== 'string' || typeof p.relationshipField !== 'string') return null; + return relatedCardId(p.objectName, p.relationshipField); +} + +/** + * The enlarged related list (objectstack#27055, Target 9 — the ONE path to a + * related list's every record): the full `RelatedList` face the old Related + * tab drew — every column, paging, sort, inline edit — at the page's full + * width, under the record header. ‹ and Esc return to the columns. A dialog + * open over the page keeps its own Esc. + */ +const EnlargedRelatedList: React.FC<{ node: any; onClose?: () => void }> = ({ node, onClose }) => { + const { t } = useDetailTranslation(); + const p = { ...node, ...(node.properties ?? {}) }; + const title = typeof p.title === 'string' && p.title ? p.title : p.objectName; + React.useEffect(() => { + if (!onClose) return undefined; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + if (document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"]')) return; + onClose(); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [onClose]); + const authoredLimit = typeof p.limit === 'number' && p.limit > 0; + const enlarged = authoredLimit + ? node + : { ...node, properties: { ...(node.properties ?? {}), limit: ENLARGED_PAGE_SIZE } }; + return ( +
+
+ +

{title}

+
+ +
+ ); +}; + const ALL_COLUMNS_ON: RecordPageColumnSwitches = { timeline: true, related: true }; /** @@ -117,6 +183,8 @@ export const RecordPageColumns: React.FC = ({ relatedArrangement, onRelatedArrangementChange, onEnlargeRelated, + enlargedRelated, + onCloseEnlargedRelated, className, }) => { const { t } = useDetailTranslation(); @@ -124,6 +192,9 @@ export const RecordPageColumns: React.FC = ({ const toggleRef = React.useRef(null); const [railOpen, setRailOpen] = React.useState(false); const arrangement = recordPageArrangementFor(tier, switches); + const enlargedNode = enlargedRelated + ? columns.related.find((node) => relatedNodeId(node) === enlargedRelated) + : undefined; const hasTimeline = columns.timeline.length > 0; const hasRelatedLists = columns.related.length > 0; // objectstack#27052 — a timeline the reader switched off is not a column: @@ -260,7 +331,7 @@ export const RecordPageColumns: React.FC = ({ >
{drawNodes(columns.header)} - {body} + {enlargedNode ? : body}
diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx index bd71709a8d5..c836c1ead29 100644 --- a/ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx @@ -20,7 +20,7 @@ import * as React from 'react'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; -import { RelatedCardChromeContext } from '../relatedListCompact'; +import { RelatedCardChromeContext, RelatedListCompactContext } from '../relatedListCompact'; vi.mock('@object-ui/react', async (importOriginal) => ({ ...(await importOriginal>()), @@ -29,9 +29,11 @@ vi.mock('@object-ui/react', async (importOriginal) => ({ function StubNode({ schema }: { schema: any }) { const chrome = React.useContext(RelatedCardChromeContext); + const compact = React.useContext(RelatedListCompactContext); const p = schema?.properties ?? {}; return ( -
+
+ {chrome?.onEnlarge && } {p.objectName ?? schema?.type} {chrome?.onMoveUp && } {chrome?.onMoveDown && } @@ -141,7 +143,8 @@ describe('RelatedCardStack — the reader arranges the related cards (objectstac const dragged = editRelatedArrangement(DEFAULT.slice(0, 2), undefined, { kind: 'move', id: 'note.parent_id', to: 0 }, Date.now()); const onWrite = vi.fn(); const { container } = render(); - expect(cardOrder(container)).toEqual(['note.parent_id', 'task.lead_id', 'call.lead_id']); + // Rule 3: after its nearest default predecessor the reader's order holds (note). + expect(cardOrder(container)).toEqual(['note.parent_id', 'call.lead_id', 'task.lead_id']); expect(container.querySelector('[data-related-card-id="call.lead_id"] [data-fresh]')).not.toBeNull(); await waitFor(() => expect(onWrite).toHaveBeenCalledTimes(1)); expect(Object.keys(onWrite.mock.calls[0][0].seen)).toEqual(['call.lead_id']); @@ -149,3 +152,45 @@ describe('RelatedCardStack — the reader arranges the related cards (objectstac expect(container.querySelector('[data-related-card-id="call.lead_id"] [data-fresh]')).not.toBeNull(); }); }); + +describe('the enlarged related list — ⤢ and ?related= (objectstack#27055, Target 9)', () => { + it('⤢ asks the host to enlarge that relationship', () => { + const onEnlarge = vi.fn(); + render(); + fireEvent.click(screen.getByText('enlarge note')); + expect(onEnlarge).toHaveBeenCalledWith('note.parent_id'); + }); + + it('draws the full list over the page body: the header stays, the columns step aside', () => { + const { container } = render( + , + ); + const enlarged = container.querySelector('[data-related-enlarged="note.parent_id"]')!; + expect(enlarged).toBeTruthy(); + expect(container.querySelector('[data-record-columns]')).toBeNull(); + // The FULL face, not the compact card — and paged. + const list = enlarged.querySelector('[data-node="record:related_list"]')!; + expect(list.getAttribute('data-compact')).toBe('no'); + expect(list.getAttribute('data-limit')).toBe('25'); + // The header nodes are still drawn above it. + expect(container.querySelector('[data-node="page:header"]')).not.toBeNull(); + }); + + it('‹ and Esc return to the columns', () => { + const onClose = vi.fn(); + const { container } = render( + , + ); + fireEvent.click(container.querySelector('[data-related-enlarged-back]')!); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it('an id naming no related list draws the columns', () => { + const { container } = render( + , + ); + expect(container.querySelector('[data-related-enlarged]')).toBeNull(); + expect(container.querySelector('[data-record-columns]')).not.toBeNull(); + }); +}); diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedList.compactColumn-26678.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedList.compactColumn-26678.test.tsx index aecf8d79365..f575fcbcc26 100644 --- a/ui/packages/plugin-detail/src/__tests__/RelatedList.compactColumn-26678.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RelatedList.compactColumn-26678.test.tsx @@ -75,7 +75,7 @@ describe('RelatedList in the related column (objectstack#26678)', () => { renderList(false); await waitFor(() => expect(screen.getByText('Call Ada')).toBeInTheDocument()); expect(headers()).toEqual(['Name', 'Status']); - expect(screen.getByText('open')).toBeInTheDocument(); + expect(screen.getAllByText('Open').length).toBeGreaterThan(0); }); it('draws the compact card inside it: no table header, three named rows, one chip each', async () => { From f5b98abeb5b5b744a5d3ad98b5494766041d3090 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:40:35 +0000 Subject: [PATCH 05/15] wip(plugin-detail,app-shell): body tables in the details column after the first field section Claude-Session: https://claude.ai/code/session_01Q4EjjN14dHBs3CJce73zNm Co-authored-by: Claude --- .../app-shell/src/views/RecordDetailView.tsx | 39 +++++- .../plugin-detail/src/RecordPageColumns.tsx | 11 +- ui/packages/plugin-detail/src/RelatedList.tsx | 94 +++++++++++++- .../RelatedList.bodyTable-27055.test.tsx | 94 ++++++++++++++ ui/packages/plugin-detail/src/index.tsx | 2 + .../plugin-detail/src/relatedListCompact.ts | 13 ++ ...DefaultPageSchema.bodyTables-27055.test.ts | 103 +++++++++++++++ ...tPageSchema.relatedListFilter-4664.test.ts | 8 +- .../__tests__/buildDefaultPageSchema.test.ts | 14 +- .../synth/__tests__/recordPageColumns.test.ts | 11 +- .../src/synth/buildDefaultPageSchema.ts | 121 ++++++++++++------ .../src/synth/recordPageColumns.ts | 36 ++++++ 12 files changed, 478 insertions(+), 68 deletions(-) create mode 100644 ui/packages/plugin-detail/src/__tests__/RelatedList.bodyTable-27055.test.tsx create mode 100644 ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.bodyTables-27055.test.ts diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index a2fbe147b10..2f9c4491bb7 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -10,7 +10,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'; -import { activityRowToFeedItem, DefaultRecordPageContext, EMPTY_RELATED_ARRANGEMENT, InlineEditSaveBar, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, recordPageColumnDefaults, resolveTitleField, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail'; +import { activityRowToFeedItem, bodyTableIdsOf, DefaultRecordPageContext, EMPTY_RELATED_ARRANGEMENT, InlineEditSaveBar, RecordBodyTablesContext, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, recordPageColumnDefaults, resolveTitleField, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail'; import { Empty, EmptyTitle, EmptyDescription, Button, PageHeaderActions, PanelToggles, type PanelToggleItem } from '@object-ui/components'; import { useAuth, createAuthenticatedFetch } from '@object-ui/auth'; import { usePermissions } from '@object-ui/permissions'; @@ -3323,7 +3323,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri sections?: Array>; highlightFields?: string[]; headerActions?: ActionDef[]; - related?: Array<{ title?: string; objectName: string; relationshipField: string; columns?: any[]; icon?: string; isPrimary?: boolean }>; + related?: Array<{ title?: string; objectName: string; relationshipField: string; columns?: any[]; icon?: string; isPrimary?: boolean; isBody?: boolean }>; history?: { entries: any[]; loading: boolean; unknownUserText?: string }; approvals?: { count?: number; node?: Record }; }; @@ -3400,8 +3400,26 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // its tab is shown (nothing is preloaded here), and header/row // affordances (+ New / View All / row navigation) are wired // downstream by `RelatedRecordActionsBridge`. - const related = childRelations.map(({ childObject, childLabel, referenceField, title: titleOverride, columns: columnsOverride, isPrimary, sort: inheritedSort, filter: declaredFilter }) => { + // The DEFAULT order of the related lists (objectstack#27055): the derived + // order with `relatedList: 'primary'` lists first, each group keeping + // `deriveRelatedLists`' order (owned before lookup, then declaration). + // ⛔ FENCE (objectstack#27069): the object's authored `relatedLists` + // order is not read — the spec key is not declared yet. When it lands, + // THIS is the one read point: order by it, then by this derived order. + const orderedRelations = [ + ...childRelations.filter((relation) => relation.isPrimary), + ...childRelations.filter((relation) => !relation.isPrimary), + ]; + const related = orderedRelations.map(({ childObject, childLabel, referenceField, title: titleOverride, columns: columnsOverride, isPrimary, sort: inheritedSort, filter: declaredFilter }) => { const childObjectDef = objects.find((o: any) => o.name === childObject); + // objectstack#27055, Target 8 — the record's own BODY: a `master_detail` + // FK declaring `inlineEdit` (the child is edited as part of the parent), + // or a list the author marks `relatedList: 'primary'`. The two keys the + // spec already has; no key of its own. + const fkDef = (childObjectDef as any)?.fields?.[referenceField]; + const isBody = + isPrimary || + (fkDef?.type === 'master_detail' && (fkDef.inlineEdit === true || fkDef.inlineEdit === 'grid' || fkDef.inlineEdit === 'form')); // A `relatedListTitle` on the relationship wins; else fall back to the // localized child-object PLURAL label — the section lists the child's // records, so it is named as that object's list page is (objectui#11733). @@ -3441,9 +3459,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // the node is a different fact from "the author said nothing". ...(declaredFilter ? { filter: declaredFilter } : {}), ...(childObjectDef?.icon ? { icon: childObjectDef.icon } : {}), - // `relatedList: 'primary'` prominence flag (ADR-0085) — the list is - // promoted to its own tab by `buildDefaultTabs`. + // `relatedList: 'primary'` prominence flag (ADR-0085), and the body + // verdict above: a body list is a table in the Details tab. ...(isPrimary ? { isPrimary: true } : {}), + ...(isBody ? { isBody: true } : {}), }; }); @@ -3904,6 +3923,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // included). `null` in the tabbed tier, and for a page whose strip the // partition cannot read, which then keeps its tabs. const pageColumns = pageInColumnTier ? buildDefaultPageColumns(renderedPage) : null; + // objectstack#27055, Target 8 — the related lists the synthesized page + // places in its Details tab are the record's body tables (the tabbed tier; + // the column tiers read the same off their details column). + // A plain computation: this point is below the view's early returns, where + // no hook may run. + const bodyTableIds = assignedPage || namedPageBlocked ? new Set() : bodyTableIdsOf(renderedPage); // objectstack#27052 — the panel toggles on the crumb row: one per column the // page has content for, drawn only where the columns are (never in the @@ -4249,7 +4274,9 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // the empty-sections line, the highlight grid); an authored // page keeps its own face. - + + + )} diff --git a/ui/packages/plugin-detail/src/RecordPageColumns.tsx b/ui/packages/plugin-detail/src/RecordPageColumns.tsx index 62a9bbe2896..fd098b204e2 100644 --- a/ui/packages/plugin-detail/src/RecordPageColumns.tsx +++ b/ui/packages/plugin-detail/src/RecordPageColumns.tsx @@ -12,11 +12,12 @@ import { SchemaRenderer, toRenderableSchema } from '@object-ui/react'; import { ChevronLeft, PanelRightClose, PanelRightOpen } from 'lucide-react'; import { recordPageArrangementFor, + relatedListIdsIn, type DefaultPageColumns, type RecordPageColumnSwitches, } from './synth/recordPageColumns'; import { useDetailTranslation } from './useDetailTranslation'; -import { RelatedListCompactContext } from './relatedListCompact'; +import { RecordBodyTablesContext, RelatedEnlargeContext, RelatedListCompactContext } from './relatedListCompact'; import { DefaultRecordPageContext } from './defaultRecordPage'; import { RelatedCardStack } from './RelatedCardStack'; import { relatedCardId, type RelatedArrangement, type RelatedCardId } from './synth/relatedArrangement'; @@ -192,9 +193,11 @@ export const RecordPageColumns: React.FC = ({ const toggleRef = React.useRef(null); const [railOpen, setRailOpen] = React.useState(false); const arrangement = recordPageArrangementFor(tier, switches); + // Body tables (in the details column) enlarge too (Target 8 names ⤢ on them). const enlargedNode = enlargedRelated - ? columns.related.find((node) => relatedNodeId(node) === enlargedRelated) + ? [...columns.related, ...columns.details].find((node) => relatedNodeId(node) === enlargedRelated) : undefined; + const bodyTableIds = React.useMemo(() => new Set(relatedListIdsIn(columns.details)), [columns.details]); const hasTimeline = columns.timeline.length > 0; const hasRelatedLists = columns.related.length > 0; // objectstack#27052 — a timeline the reader switched off is not a column: @@ -324,6 +327,8 @@ export const RecordPageColumns: React.FC = ({ return ( + +
= ({ {enlargedNode ? : body}
+
+
); }; diff --git a/ui/packages/plugin-detail/src/RelatedList.tsx b/ui/packages/plugin-detail/src/RelatedList.tsx index 1348190f5aa..29237bea232 100644 --- a/ui/packages/plugin-detail/src/RelatedList.tsx +++ b/ui/packages/plugin-detail/src/RelatedList.tsx @@ -77,7 +77,15 @@ import { useSafeFieldLabel } from '@object-ui/react'; import { usePermissions } from '@object-ui/permissions'; import { useDetailTranslation } from './useDetailTranslation'; import { isMaskedDetailFieldType } from './fieldEnrichment'; -import { COMPACT_RELATED_ROWS, RELATED_CARD_DROP_LINE, RelatedCardChromeContext, RelatedListCompactContext } from './relatedListCompact'; +import { + COMPACT_RELATED_ROWS, + RELATED_CARD_DROP_LINE, + RecordBodyTablesContext, + RelatedCardChromeContext, + RelatedEnlargeContext, + RelatedListCompactContext, +} from './relatedListCompact'; +import { relatedCardId } from './synth/relatedArrangement'; import { RelatedCardMenu } from './RelatedCardMenu'; import { detectStatusField } from '@object-ui/types'; @@ -631,6 +639,61 @@ const RelatedListLoadFailed: React.FC<{ error: unknown; onRetry: () => void }> = * seam itself — `@object-ui/core`'s `parent-scope` — so one place answers it. */ +/** Field types a body table totals (objectstack#27055, Target 8). */ +const TOTALLED_FIELD_TYPES = new Set(['currency', 'number', 'integer', 'decimal']); + +/** + * The totals line of a body table: the sum of each numeric column over the + * rows on show (the page), labelled as the page's — never as the whole + * list's, which a windowed list does not hold. Currency sums keep the field's + * declared currency. Nothing is drawn without a numeric column. + */ +const BodyTableTotals: React.FC<{ rows: readonly any[]; columns: any[] | undefined; objectSchema: any }> = ({ + rows, + columns, + objectSchema, +}) => { + const { t } = useDetailTranslation(); + const fields: Record = (objectSchema?.fields as any) ?? {}; + const totals: Array<{ key: string; label: string; text: string }> = []; + for (const col of columns ?? []) { + const key = col?.accessorKey || columnIdentity(col); + const def = key ? fields[key] : undefined; + if (!key || !def || !TOTALLED_FIELD_TYPES.has(def.type)) continue; + let sum = 0; + let counted = 0; + for (const row of rows) { + const n = typeof row?.[key] === 'number' ? row[key] : Number(row?.[key]); + if (row?.[key] === null || row?.[key] === undefined || row?.[key] === '' || !Number.isFinite(n)) continue; + sum += n; + counted += 1; + } + if (counted === 0) continue; + const currency = def.type === 'currency' ? def.currencyConfig?.defaultCurrency ?? def.currency : undefined; + let text: string; + try { + text = currency + ? new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(sum) + : new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(sum); + } catch { + text = String(sum); + } + totals.push({ key, label: col.header || col.label || def.label || key, text }); + } + if (totals.length === 0) return null; + return ( +
+ {t('detail.bodyTableTotals', { defaultValue: 'Totals (this page)' })} + {totals.map((total) => ( + + {total.label} + {total.text} + + ))} +
+ ); +}; + export const RelatedList: React.FC = ({ title, type, @@ -2357,6 +2420,13 @@ export const RelatedList: React.FC = ({ // enlarge), handed down by the arranged stack that draws it; `null` // anywhere else. const cardChrome = React.useContext(RelatedCardChromeContext); + // objectstack#27055, Target 8 — a body table of the default record page: + // collapsible, ⤢ to enlarge, a totals line, no 「查看全部」. + const bodyTableIds = React.useContext(RecordBodyTablesContext); + const enlargeRelated = React.useContext(RelatedEnlargeContext); + const ownRelationshipId = objectName && referenceField ? relatedCardId(objectName, referenceField) : null; + const bodyTable = !compact && !!ownRelationshipId && bodyTableIds.has(ownRelationshipId); + const isCollapsible = collapsible || bodyTable; // The row click hands the host the rows on show beside the clicked one // (objectstack#26679), so its drawer can step through them. @@ -2451,8 +2521,8 @@ export const RelatedList: React.FC = ({ } }, [type, paginatedData, tableColumns, effectiveColumns, schema, effectivePageSize, hasRowActions, hasCustomRowActions, rowActions, onRowAction, onRowEdit, onRowDelete, handleDeleteRow, handleRowClick, isMobile, compact, api, objectSchema, activeSort, handleTableSort]); - const headerClassName = collapsible ? 'cursor-pointer select-none' : undefined; - const handleHeaderClick = collapsible ? () => setCollapsed((c) => !c) : undefined; + const headerClassName = isCollapsible ? 'cursor-pointer select-none' : undefined; + const handleHeaderClick = isCollapsible ? () => setCollapsed((c) => !c) : undefined; const SectionIcon = resolveIconComponent(icon); // Badge shows the COLLECTION size: the server total in windowed mode (only @@ -2741,7 +2811,7 @@ export const RelatedList: React.FC = ({ >
- {collapsible && ( + {isCollapsible && ( collapsed ? () : () @@ -2814,6 +2884,18 @@ export const RelatedList: React.FC = ({ {add.label || t('detail.add', { defaultValue: 'Add' })} )} + {bodyTable && enlargeRelated && ownRelationshipId && ( + + )} {onNew && (
{modalElement} + diff --git a/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx b/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx index 640d05a05c7..27e45548418 100644 --- a/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx +++ b/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx @@ -40,6 +40,12 @@ import { useDisplayLocale } from '@object-ui/i18n'; import { useRecordContext, useSafeFieldLabel } from '@object-ui/react'; import { useDetailTranslation } from './useDetailTranslation'; import { foldFeedItems, type ActivityTimelineEntry } from './renderers/recordActivityFeed'; +import { + TIMELINE_STREAM_FILTERS, + filterTimelineStream, + isSystemStreamEntry, + type TimelineStreamFilter, +} from './recordTimelineStream'; /** * Which slice of the record feed the timeline shows. @@ -123,6 +129,19 @@ export interface RecordActivityTimelineProps { activityDenied?: boolean; /** The same, for the feed's comment read. */ commentsDenied?: boolean; + /** + * objectstack#27055, Target 3 — `stream` is the record page v3 timeline: + * the filter chips 全部 / 活动 / 讨论 / 变更 on the title row instead of + * the dropdown, system entries (field changes, system events) as one + * compact muted line each, and 「查看全部 N 条 →」 at the foot. `panel` + * (the default) is the block as it has always been. + */ + variant?: 'panel' | 'stream'; + /** The stream's chip (controlled); `all` when absent. */ + streamFilter?: TimelineStreamFilter; + onStreamFilterChange?: (filter: TimelineStreamFilter) => void; + /** How many entries the whole stream holds, for its 「查看全部 N 条 →」. */ + totalCount?: number; className?: string; } @@ -285,8 +304,13 @@ export const RecordActivityTimeline: React.FC = ({ emptyLabel, activityDenied = false, commentsDenied = false, + variant = 'panel', + streamFilter = 'all', + onStreamFilterChange, + totalCount, className, }) => { + const isStream = variant === 'stream'; const { t } = useDetailTranslation(); // The BCP-47 tag the absolute-date tail formats with (objectui#9786). const displayLocale = useDisplayLocale(); @@ -335,8 +359,8 @@ export const RecordActivityTimeline: React.FC = ({ const showSubscription = config?.showSubscriptionToggle ?? false; const filtered = React.useMemo( - () => filterItems(items, activeFilter), - [items, activeFilter], + () => (isStream ? filterTimelineStream(items, streamFilter) : filterItems(items, activeFilter)), + [items, activeFilter, isStream, streamFilter], ); // Group replies by parentId. @@ -372,7 +396,13 @@ export const RecordActivityTimeline: React.FC = ({ // Runs of consecutive updates by one actor, folded into one row each // (objectstack#25959). Folded at render from the rows the reader sees; the // feed itself keeps every entry. - const timelineEntries = React.useMemo(() => foldFeedItems(rootItems), [rootItems]); + // The stream draws every entry as its own line (objectstack#27055): a + // system entry is already one compact line, so there is nothing to fold. + const timelineEntries = React.useMemo( + (): ActivityTimelineEntry[] => + isStream ? (rootItems.map((item) => ({ kind: 'item', item })) as ActivityTimelineEntry[]) : foldFeedItems(rootItems), + [rootItems, isStream], + ); // Which folded rows the reader has expanded, by the fold's id. const [expandedFolds, setExpandedFolds] = React.useState>(() => new Set()); const toggleFold = React.useCallback((id: string) => { @@ -465,7 +495,41 @@ export const RecordActivityTimeline: React.FC = ({ // One feed item's row. A folded run renders each of its entries with this // same function when expanded (objectstack#25959). + // objectstack#27055 — a system entry of the stream: ONE muted line, the + // change itself, then who and when. 「状态 新建 → 已联系 · Dev Admin · 昨天」. + const renderSystemLine = (item: FeedItem): React.ReactNode => { + const Icon = FEED_TYPE_ICONS[item.type] || Zap; + const changes = (item.fieldChanges ?? []).map((change) => { + const label = change.fieldLabel || labelOfField(change.field); + const from = change.oldDisplayValue ?? (change.oldValue == null ? '' : String(change.oldValue)); + const to = change.newDisplayValue ?? (change.newValue == null ? '' : String(change.newValue)); + return `${label} ${from || '—'} → ${to || '—'}`; + }); + const what = + changes.length > 0 + ? changes.join('; ') + : item.body && item.body.trim() + ? item.body + : item.changedFields && item.changedFields.length > 0 + ? t('detail.foldedUpdates', { + fields: item.changedFields.map(labelOfField).join(t('validation.formInvalidJoiner')), + }) + : t('detail.activityUpdated'); + return ( +
+ + + +

+ {what} + {` · ${item.actor} · ${formatTimestamp(item.createdAt, t, displayLocale)}`} +

+
+ ); + }; + const renderItem = (item: FeedItem): React.ReactNode => { + if (isStream && isSystemStreamEntry(item)) return renderSystemLine(item); const Icon = FEED_TYPE_ICONS[item.type] || Zap; const colorClass = FEED_TYPE_COLORS[item.type] || 'bg-gray-100 text-gray-600'; @@ -706,6 +770,30 @@ export const RecordActivityTimeline: React.FC = ({
+ {isStream && ( +
+ {TIMELINE_STREAM_FILTERS.map((chip) => ( + + ))} +
+ )} {showSubscription && subscription && ( = ({
- {/* Filter dropdown */} - {showFilter && ( + {/* Filter dropdown — the stream has its chips instead */} + {showFilter && !isStream && (