diff --git a/.changeset/27055-record-page-v3-layout.md b/.changeset/27055-record-page-v3-layout.md new file mode 100644 index 00000000000..880f9b101f6 --- /dev/null +++ b/.changeset/27055-record-page-v3-layout.md @@ -0,0 +1,28 @@ +--- +'@object-ui/plugin-detail': patch +'@object-ui/components': patch +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(plugin-detail,app-shell,components): the record page v3 — frameless details, one timeline, compact related cards the reader arranges, body tables, enlarge in place + +Clause-②: no +Metadata-protocol: none + +The synthesized default record page (an object with no assigned page) takes the v3 layout. Nothing an author writes changes: the page is still drawn from the object's semantic roles (ADR-0085), and every change below is the renderer's. + +What changes for a reader: + +- **Header.** One filled primary action, two outline actions, the rest in the `⋯` menu. The header's Refresh moved into that menu as its first item. Up to six highlights, drawn as an equal-width grid on the default page. +- **Details.** Sections are frameless: a title over a hairline, then the fields in one to three columns by how many fields the section shows. Every titled section collapses from its title. Sections with no value at all fold into one line at the foot, such as 「Address · Conversion · Extra · 3 empty sections · Expand」. The created / updated line closes the details column and also names the owner and the record id. +- **Timeline.** One stream: comments, `sys_activity` rows and field changes (`trackHistory`), newest first, with the composer on top and the chips All / Activity / Discussion / Changes. Field changes and system events are one muted line each. 「View all N →」 shows the rest. The separate History tab, the Activity tab and the discussion footer are gone. With `enable.feeds: false` the stream keeps activity and changes and offers no composer. +- **Related cards.** In the related column each related list is a compact card: title, count and + in one row, no table header, up to three rows of the record's name and one status or date chip. The column is 320 px, beside details and the timeline at 1.25fr : 1fr. 「查看全部」 is gone from the card. +- **The reader arranges the cards.** On desktop the title row drags a card, with a drop line. The card's `⋯` menu has Move up, Move down, Collapse and Hide. A collapsed card shows its title and count and reads no rows. The order, collapsed and hidden state are kept per user and object in the console's workspace preferences, the same store as the column switches, and apply in the tabbed tier's Related tab too. A reader who never dragged sees the default order. A later change to the default does not reorder a dragged column; the column then says 「Default order updated · Restore default」. A relationship added since the reader's last drag lands after its nearest default neighbour and carries a 「New」 badge for 48 hours. Hidden cards are listed at the column's foot (「N hidden · Show」). +- **Enlarge in place.** A card's ⤢ or its count opens the list as a full-width table over the page body, with every column, paging, sort and inline edit. The address is `?related=object.field`; Esc or ‹ returns to the columns. +- **Body tables.** A related list that is the record's own body is drawn as a table inside the details column, right after the first field section, instead of a side card. A body list is a `master_detail` FK declaring `inlineEdit`, or a list marked `relatedList: 'primary'`. A body table opens on ten rows and has a totals line for its numeric columns over the rows on the page. +- **Tabs.** The drawer and narrow widths show Details / Timeline / Related / (Approvals). The Timeline is second for a worked object and last otherwise. Attachments close the Related tab. + +**BREAKING** (`@object-ui/plugin-detail`, TypeScript only): `BuildPageOptions` no longer accepts `hideHighlights`, `hidePath`, `showReferenceRail`, `hideReferenceRail`, `hideRelatedTab`, `relatedLayout`, `recordChrome`, `showActivity`, `history`, or the `slots.rightRail` slot. No console caller passed any of them. A caller still passing one gets a compile error naming the key, and the page it draws is the same with the key or without it. The `record:reference_rail` renderer stays for authored pages. `buildDefaultHeader` takes `{ actions }` only, and `buildDefaultHighlights` drops its two hide flags. + + diff --git a/content/docs/protocol/objectql/security.mdx b/content/docs/protocol/objectql/security.mdx index f274a0b49c7..0f841f792a0 100644 --- a/content/docs/protocol/objectql/security.mdx +++ b/content/docs/protocol/objectql/security.mdx @@ -601,7 +601,7 @@ Set `deterministicEncryption: true` to allow equality queries on the ciphertext, Object- and field-level history is opt-in metadata, not a free-form `enable.audit` block. -- **Object history tab** — set `enable.trackHistory: true` on the object (the `ObjectCapabilities` block in `object.zod.ts`) to surface the record History tab. +- **Object history** — set `enable.trackHistory: true` on the object (the `ObjectCapabilities` block in `object.zod.ts`) to surface the record's field changes. On the default record page they join the one Timeline stream (its Changes filter); an assigned page shows them where it places a `record:history` block. - **Per-field history** — set `trackHistory: true` on an individual field to render that field's value changes on the record activity timeline. (The former per-field `auditTrail` flag was pruned in 2026-06 — it had no runtime consumer.) - **RLS audit** — removed (2026-07, ADR-0056 D8): the `RLSAuditConfigSchema`/`RLSAuditEventSchema` shapes were never emitted or read by the runtime RLS path and were deleted from the spec. Enforcement decisions surface today through the security plugin's fail-closed warnings and the standard audit log (`plugin-audit`), not a dedicated RLS event stream. diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.recordPage.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.recordPage.test.tsx index 6ec0b773364..d172e608ebd 100644 --- a/ui/apps/console/src/services/approvalRequestsDataSource.recordPage.test.tsx +++ b/ui/apps/console/src/services/approvalRequestsDataSource.recordPage.test.tsx @@ -93,7 +93,7 @@ const host = { const EN: I18nConfig = { defaultLanguage: 'en', detectBrowserLanguage: false }; -function openRequestPage() { +function openRequestPage(search = '') { const ds = createApprovalRequestsDataSource({ host: host as unknown as DataSource, scope: { kind: 'awaiting_me', approverIds: ['u1'] }, @@ -107,7 +107,7 @@ function openRequestPage() { } as never; render( - + { answer = 'ok'; - openRequestPage(); + // Opened on the Timeline tab: the synthesized page's feed is a tab since + // #27055 (no footer), and its reads are the side reads this pins. + openRequestPage('?tab=timeline'); expect((await screen.findAllByText('invoice_approval', undefined, { timeout: 5000 })).length).toBeGreaterThan(0); await waitFor(() => expect(screen.queryByTestId('record-load-failed')).toBeNull()); expect(new Set(getReads)).toEqual(new Set([`/api/v1/approvals/requests/${REQ}`])); diff --git a/ui/content/docs/guide/slotted-pages.md b/ui/content/docs/guide/slotted-pages.md index 3f164335d1e..a325915eb3f 100644 --- a/ui/content/docs/guide/slotted-pages.md +++ b/ui/content/docs/guide/slotted-pages.md @@ -130,8 +130,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/e2e/record-header-title-width.spec.ts b/ui/e2e/record-header-title-width.spec.ts index 811c706311c..ada86e7b375 100644 --- a/ui/e2e/record-header-title-width.spec.ts +++ b/ui/e2e/record-header-title-width.spec.ts @@ -574,8 +574,10 @@ const PAGE_HEADER = { 'sm:flex-row', ]), titleColumn: classBefore(containersSrc, CONTAINERS_FILE, '/, ['min-w-0']), chipRow: classBefore(chipSrc, CHIP_FILE, '{icon && (', ['flex-wrap']), // #1 after the anchor is the icon's own span; #2 is the h1, #3 the object label. @@ -583,7 +585,7 @@ const PAGE_HEADER = { objectLabel: classAt(chipSrc, CHIP_FILE, '{icon && (', 3, ['shrink-0']), }; -/** The mobile tail: one inline action, the `⋯` trigger and the `⟳` chrome. */ +/** The mobile tail: one inline action (`mobileMaxVisible` 1) and the `⋯` trigger, which carries refresh. */ const PAGE_HEADER_TAIL_BOX = 'h-9 w-12 shrink-0'; const pageHeaderFixture = (title: string) => @@ -598,7 +600,7 @@ const pageHeaderFixture = (title: string) => `

${title}

Case`, ), ), - ) + box(PAGE_HEADER.tail, Array.from({ length: 3 }, () => box(PAGE_HEADER_TAIL_BOX)).join('')), + ) + box(PAGE_HEADER.tail, Array.from({ length: 2 }, () => box(PAGE_HEADER_TAIL_BOX)).join('')), ); const PAGE_HEADER_CANDIDATES = [...Object.values(PAGE_HEADER), PAGE_HEADER_TAIL_BOX] 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/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.activityActorId-12067.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.activityActorId-12067.test.tsx index ed8507a0443..06bbc08431c 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.activityActorId-12067.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.activityActorId-12067.test.tsx @@ -149,7 +149,7 @@ describe('the record page feed names the actor of an id-only activity row (objec it('reads `sys_activity` with `actor_id` expanded and shows the user, not "System"', async () => { const dataSource = makeDataSource(); render( - + + >) => render( - + - + { renderHistory(locale); - const label = await screen.findByText('Close date', { selector: 'li > span' }); - const text = (label.closest('li')?.textContent ?? '').replace(/\s+/g, ' ').trim(); + let text = ''; + await waitFor(() => { + const line = Array.from(document.querySelectorAll('[data-stream-change]')).find((el) => + (el.textContent ?? '').startsWith('Close date'), + ); + expect(line).toBeTruthy(); + text = (line!.textContent ?? '').replace(/\s+/g, ' ').trim(); + }); cleanup(); return text; } const expected = (locale: string) => - `Close date: ${new Date(`${FROM_DATE}T00:00:00Z`).toLocaleDateString(locale)} → ${new Date(`${TO_DATE}T00:00:00Z`).toLocaleDateString(locale)}`; + `Close date ${new Date(`${FROM_DATE}T00:00:00Z`).toLocaleDateString(locale)} → ${new Date(`${TO_DATE}T00:00:00Z`).toLocaleDateString(locale)}`; beforeEach(() => { // Unrelated chrome on this view reaches for the platform API; answering it @@ -187,13 +198,13 @@ afterEach(() => { describe('RecordDetailView — the audit history dates follow the display locale (objectui#10442)', () => { it('formats the date diff as de-CH under an English UI with a de-CH display locale', async () => { const text = await diffLine('de-CH'); - expect(text, `got: ${text}`).toBe('Close date: 3.3.2020 → 4.3.2020'); + expect(text, `got: ${text}`).toBe('Close date 3.3.2020 → 4.3.2020'); expect(text).toBe(expected('de-CH')); }); it('control: formats the date diff as en-US under an en-US display locale', async () => { const text = await diffLine('en-US'); - expect(text, `got: ${text}`).toBe('Close date: 3/3/2020 → 3/4/2020'); + expect(text, `got: ${text}`).toBe('Close date 3/3/2020 → 3/4/2020'); expect(text).toBe(expected('en-US')); }); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.discussionExplicitComposition-7298.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.discussionExplicitComposition-7298.test.tsx index cad7e0a717e..153d32ff62d 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.discussionExplicitComposition-7298.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.discussionExplicitComposition-7298.test.tsx @@ -185,9 +185,9 @@ function makeMetadata(objects: any[], pages: any[]) { } as any; } -function renderDetail(opts: { objects: any[]; pages: any[]; comments?: Array> }) { +function renderDetail(opts: { objects: any[]; pages: any[]; comments?: Array>; search?: string }) { return render( - + { - // No authored page ⇒ `buildDefaultPageSchema`, which composes - // `record:discussion` itself. The ruling removes the host's append, not the - // default page's panel, so the out-of-the-box record page is unchanged. - renderDetail({ objects: OBJECTS_FEEDS_ON, pages: [], comments: ONE_COMMENT }); + it('the SYNTHESIZED default page still shows the discussion — its Timeline stream declares it', async () => { + // No authored page ⇒ `buildDefaultPageSchema`, which composes the timeline + // stream itself (objectstack#27055: discussion, activity and changes in the + // Timeline tab's one `record:activity`). The ruling removes the host's + // append, not the default page's discussion. + renderDetail({ objects: OBJECTS_FEEDS_ON, pages: [], comments: ONE_COMMENT, search: '?tab=timeline' }); await settled(); await waitFor(() => expect(count(COMMENT_MARKER)).toBeGreaterThan(0)); - expect(count(PANEL_HEADING)).toBeGreaterThan(0); + expect(composerCount()).toBeGreaterThan(0); }); }); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.discussionFallbackPipeline-8983.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.discussionFallbackPipeline-8983.test.tsx index b18b0f414d3..1e7cbcfde09 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.discussionFallbackPipeline-8983.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.discussionFallbackPipeline-8983.test.tsx @@ -241,8 +241,10 @@ function makeMetadata(pages: any[]) { } function renderDetail(fixture: Fixture, pages: any[]) { + // The synthesized page's feed is its Timeline tab (objectstack#27055). + const search = pages.length === 0 ? '?tab=timeline' : ''; return render( - + screen.queryAllByText(m).length > 0); } -const loadMoreCount = () => screen.queryAllByRole('button', { name: LOAD_MORE }).length; +const loadMoreCount = () => + screen.queryAllByRole('button', { name: LOAD_MORE }).length + + // The stream's paging affordance (objectstack#27055): 「查看全部 N 条 →」. + document.querySelectorAll('[data-stream-view-all]').length; /** Wait until BOTH feed reads have settled, so an absent row is an answer. */ async function settled() { @@ -349,10 +354,13 @@ describe('both composition routes render the same feed (objectui#8983)', () => { const authored = await viaAuthoredBlock(fixture); expect(authored.rows).toEqual(expected); + // The synthesized route is the Timeline stream (objectstack#27055), whose + // node DECLARES `showCompleted: true` — a stream shows what happened. It + // still runs the one pipeline: the completed row is there because the + // declared config says so, and the paging case below holds the window. cleanup(); const synthesized = await viaSynthesizedBlock(fixture); - expect(synthesized.rows).toEqual(expected); - expect(authored.rows).toEqual(synthesized.rows); + expect(synthesized.rows).toEqual([...expected, TASK_MARKER]); // PRE-FIX CONTROL — the same feed, the shape the retired fallback had. cleanup(); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.favoritesEntry-12427.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.favoritesEntry-12427.test.tsx index fd922f41e81..2af9e55502c 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.favoritesEntry-12427.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.favoritesEntry-12427.test.tsx @@ -165,11 +165,13 @@ afterEach(() => { }); describe('record page: the favorites entry is in the overflow, ahead of Share (objectui#12427)', () => { - it('the overflow leads with Add to favorites, then Share', async () => { + it('the overflow leads with Add to favorites, then Share — after the Refresh that heads it', async () => { await mountRecordPage(); const items = await openOverflow(); - expect(items.indexOf('Add to favorites')).toBe(0); - expect(items.indexOf('Share')).toBe(1); + // objectstack#27055: the host's Refresh is the ⋯'s first item. + expect(items.indexOf('Refresh')).toBe(0); + expect(items.indexOf('Add to favorites')).toBe(1); + expect(items.indexOf('Share')).toBe(2); }); it('the record header draws no star, so the overflow entry is the one favorites control', async () => { @@ -202,9 +204,9 @@ describe('record page: the favorites entry is in the overflow, ahead of Share (o ]); // The header re-derives its system actions on the next render, so the - // overflow's first entry now offers the way back. + // overflow's first action entry (after Refresh) now offers the way back. await waitFor(() => - expect((screen.getAllByRole('menuitem')[0].textContent ?? '').trim()).toBe('Remove from favorites'), + expect((screen.getAllByRole('menuitem')[1].textContent ?? '').trim()).toBe('Remove from favorites'), ); }); }); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.feedLoading.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.feedLoading.test.tsx index 8dfff6b8256..a93b4b85b07 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.feedLoading.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.feedLoading.test.tsx @@ -84,7 +84,11 @@ vi.mock('./MetadataInspector', () => ({ import { RecordDetailView } from './RecordDetailView'; -const EMPTY_COMMENTS = 'No comments yet'; +// The record page's feed is the Timeline tab's one stream (objectstack#27055); +// its empty copy is the stream's. +const EMPTY_COMMENTS = 'No activity recorded'; +/** An AUTHORED `record:discussion` keeps its own panel and its own empty copy. */ +const AUTHORED_EMPTY_COMMENTS = 'No comments yet'; const OBJECT_NAME = 'crm_account'; const RECORD_ID = 'rec-alpha'; @@ -158,7 +162,7 @@ function makeMetadata(pages: any[]) { function renderDetail(dataSource: any, pages: any[] = []) { return render( - + { @@ -358,7 +362,7 @@ describe('RecordDetailView — an AUTHORED discussion node is on the same chain renderDetail(dataSource, [AUTHORED_PAGE_WITH_DISCUSSION]); - expect(await screen.findByText(EMPTY_COMMENTS)).toBeTruthy(); + expect(await screen.findByText(AUTHORED_EMPTY_COMMENTS)).toBeTruthy(); expect(screen.queryByTestId('activity-loading')).toBeNull(); }); @@ -370,7 +374,7 @@ describe('RecordDetailView — an AUTHORED discussion node is on the same chain renderDetail(dataSource, [AUTHORED_PAGE_WITH_DISCUSSION]); - expect(await screen.findByText(EMPTY_COMMENTS)).toBeTruthy(); + expect(await screen.findByText(AUTHORED_EMPTY_COMMENTS)).toBeTruthy(); expect(screen.queryByTestId('activity-loading')).toBeNull(); }); }); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.feedRecordScope.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.feedRecordScope.test.tsx index 5c4d723be2b..2c63dbd7b71 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.feedRecordScope.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.feedRecordScope.test.tsx @@ -75,7 +75,8 @@ vi.mock('./MetadataInspector', () => ({ import { RecordDetailView } from './RecordDetailView'; -const EMPTY_COMMENTS = DETAIL_DEFAULT_TRANSLATIONS['detail.noCommentsYet']; +// The feed is the Timeline tab's one stream (objectstack#27055); its empty copy is the stream's. +const EMPTY_COMMENTS = DETAIL_DEFAULT_TRANSLATIONS['detail.noActivity']; const COMMENT_PLACEHOLDER = DETAIL_DEFAULT_TRANSLATIONS['detail.leaveCommentPlaceholder']; const SUBMIT_COMMENT = DETAIL_DEFAULT_TRANSLATIONS['detail.submitComment']; const OBJECT_NAME = 'crm_account'; @@ -215,7 +216,7 @@ function makeMetadata(pages: any[]) { */ function tree(dataSource: any, recordId: string, pages: any[] = []) { return ( - + { + // These mount an AUTHORED `record:discussion`, whose empty copy is the + // discussion's own — not the synthesized page's timeline stream. + const EMPTY_COMMENTS = DETAIL_DEFAULT_TRANSLATIONS['detail.noCommentsYet']; /** Post a comment through the real composer on the authored panel. */ async function postComment(text: string) { const box = await screen.findByPlaceholderText(COMMENT_PLACEHOLDER); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.feedRefusal-11195.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.feedRefusal-11195.test.tsx index a2888e53604..a9e05ecb7eb 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.feedRefusal-11195.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.feedRefusal-11195.test.tsx @@ -66,7 +66,9 @@ vi.mock('./MetadataInspector', () => ({ import { RecordDetailView } from './RecordDetailView'; -const EMPTY_COMMENTS = 'No comments yet'; +// The record page's feed is the Timeline tab's one stream (objectstack#27055); +// its empty copy is the stream's. +const EMPTY_COMMENTS = 'No activity recorded'; const ACTIVITY_DENIED = "You don't have permission to view activity on this record."; const COMMENTS_DENIED = "You don't have permission to view comments on this record."; const OBJECT_NAME = 'crm_account'; @@ -127,7 +129,7 @@ function makeMetadata() { function renderDetail(dataSource: any) { return render( - + { await mountRecordPage(FULL_ROW); const menu = await openOverflow(); - expect(menuRows(menu)).toEqual(['Add to favorites', 'Share', '---', 'Delete']); + // The host's Refresh leads the ⋯, set apart (objectstack#27055). + expect(menuRows(menu)).toEqual(['Refresh', '---', 'Add to favorites', 'Share', '---', 'Delete']); }); it('with three app actions in the header, Edit stays a labelled button after them, opens the edit session, and nothing is icon-only', async () => { @@ -232,6 +233,7 @@ describe('record header: iconed system entries, Delete set apart in the ⋯, Edi // The ⋯ then holds only the menu-pinned system entries. const menu = await openOverflow(); - expect(menuRows(menu)).toEqual(['Add to favorites', 'Share', '---', 'Delete']); + // The host's Refresh leads the ⋯, set apart (objectstack#27055). + expect(menuRows(menu)).toEqual(['Refresh', '---', 'Add to favorites', 'Share', '---', 'Delete']); }); }); 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.recordOpenWaves-13010.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx index 07434dc0992..18476c35700 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx @@ -619,18 +619,27 @@ describe('the timeline, comments, attachments and user list wait for their panel expect(screen.getByText('signed-contract.pdf')).toBeTruthy(); }); - it('CONTROL: the synthesized page draws its discussion footer at open, so it still reads the feed and the user list at open', async () => { + // The synthesized page has no discussion footer any more (#27055): its one + // timeline stream is a tab in the tabbed tier, so it reads like any other tab. + it('the synthesized page opened on its Details tab reads neither the feed nor the user list', async () => { const { held } = await openRecordPage({ page: { ...RECORD_PAGE, isDefault: false, object: 'os_13010_other' }, observer: FoldObserver }); const table = waveTable(held); + expect(screen.getByRole('tab', { name: /^Timeline/ }), table).toBeTruthy(); + for (const isRead of [isActivityRead, isCommentRead, isUserListRead]) expect(held.filter(isRead), table).toEqual([]); + }); + + it('CONTROL: the synthesized page opened on its Timeline tab reads the feed and the user list at open', async () => { + const { held } = await openRecordPage({ page: { ...RECORD_PAGE, isDefault: false, object: 'os_13010_other' }, observer: FoldObserver, search: '?tab=timeline' }); + const table = waveTable(held); for (const isRead of [isActivityRead, isCommentRead, isUserListRead]) { expect(held.filter(isRead).map((h) => h.wave), table).toEqual([1]); } }); }); -// ── objectui#13010 part 5: the History tab's audit read waits for its tab ── +// ── objectui#13010 part 5: the audit read waits for the Timeline tab (#27055 merged History into it) ── -/** Registered, so the view offers a History tab for a `trackHistory` object. */ +/** Registered, so the view merges field changes into the timeline of a `trackHistory` object. */ const auditLogDef = { name: 'sys_audit_log', label: 'Audit Log', @@ -653,7 +662,7 @@ const isAuditUserLookup = (h: Held) => isPanelRead('sys_user')(h) && h.path.incl const openHistory = (options: OpenOptions = {}) => openRecordPage({ page: SYNTH, object: historyObject, extraObjects: [auditLogDef], observer: FoldObserver, ...options }); -describe('the History tab reads the audit log when it is shown (objectui#13010 part 5)', () => { +describe('the Timeline tab reads the audit log when it is shown (objectui#13010 part 5)', () => { beforeEach(() => { offscreen.length = 0; }); @@ -661,22 +670,24 @@ describe('the History tab reads the audit log when it is shown (objectui#13010 p // Wave table measured on this file, the synthesized page of a `trackHistory` // object. Before this part: `sys_audit_log top=50` in wave 1 beside the // record read and its `sys_user` name lookup in wave 2, with the History tab - // closed. After it: neither until the tab opens. + // closed. After it: neither until the tab opens. Since #27055 the audit rows + // are the field changes of the one Timeline stream, so that tab gates them. it('a trackHistory record opened on its Details tab reads neither the audit log nor its user names', async () => { const { held } = await openHistory(); const table = waveTable(held); expect(held.filter(isRecordRead).map((h) => h.wave), table).toEqual([1]); // The tab is offered, so the silence is not a missing tab. - expect(screen.getByRole('tab', { name: /^History/ }), table).toBeTruthy(); + expect(screen.getByRole('tab', { name: /^Timeline/ }), table).toBeTruthy(); + expect(screen.queryByRole('tab', { name: /^History/ }), table).toBeNull(); expect(held.filter(isAuditRead), table).toEqual([]); expect(held.filter(isAuditUserLookup), table).toEqual([]); expect(screen.queryByText('Grace'), table).toBeNull(); }); - it('opening the History tab reads the audit log and its user names once each, and draws the rows', async () => { + it('opening the Timeline tab reads the audit log and its user names once each, and draws the rows', async () => { const { held, state } = await openHistory(); const openedWith = held.length; - await openTab('History'); + await openTab('Timeline'); await runWaves(held, state); const table = waveTable(held); const audit = held.filter(isAuditRead); @@ -688,8 +699,8 @@ describe('the History tab reads the audit log when it is shown (objectui#13010 p expect(screen.getByText(/negotiation/)).toBeTruthy(); }); - it('CONTROL: a trackHistory record opened with ?tab=history reads the audit log at open and draws the rows', async () => { - const { held } = await openHistory({ search: '?tab=history' }); + it('CONTROL: a trackHistory record opened with ?tab=timeline reads the audit log at open and draws the rows', async () => { + const { held } = await openHistory({ search: '?tab=timeline' }); const table = waveTable(held); expect(held.filter(isAuditRead).map((h) => h.wave), table).toEqual([1]); expect(held.filter(isAuditUserLookup).map((h) => h.wave), table).toEqual([2]); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.replyCountOnRead-12144.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.replyCountOnRead-12144.test.tsx index 271703ff489..4c2007fe16f 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.replyCountOnRead-12144.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.replyCountOnRead-12144.test.tsx @@ -172,7 +172,7 @@ interface MountOptions { async function mount(comments: Record[], options: MountOptions = {}) { const dataSource = makeDataSource(comments, options.activities); const page = ( - + = new Set([ ]); /** * objectui#13010 part 5 — the block that draws this view's `sys_audit_log` - * read: the synthesized page's History tab hands it the rows as `entries`. + * read: on the synthesized page, the timeline stream (objectstack#27055), a + * `record:activity` the field changes reach through `RecordFieldChangesContext`. */ -const HISTORY_CONSUMER_TYPES: ReadonlySet = new Set(['record:history']); +const HISTORY_CONSUMER_TYPES: ReadonlySet = new Set(['record:activity']); /** Where a page tree nests nodes: the keys `pageSchemaIntrospect` walks. */ const PAGE_NODE_KEYS = ['children', 'items', 'body', 'components', 'regions'] as const; const PAGE_NODE_PROPERTY_KEYS = ['children', 'items'] as const; @@ -700,6 +705,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 @@ -748,7 +777,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // A record-action flow launch that ended `refused` without pausing (objectui#9973). const [flowRefusal, setFlowRefusal] = useState({ open: false }); const [historyEntries, setHistoryEntries] = useState(null); - const [historyLoading, setHistoryLoading] = useState(false); + const [, setHistoryLoading] = useState(false); const [recordTitle, setRecordTitle] = useState(); const objectDef = objects.find((o: any) => o.name === objectName); // objectui#13385 (maintainer ruling, option B) — a record page inside the @@ -936,6 +965,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. @@ -2271,25 +2308,19 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri return () => observer.disconnect(); }, [attachmentsFallbackEl, attachmentsFallbackShown]); - // ── The audit read waits for the History tab (objectui#13010 part 5) ─── + // ── The audit read waits for the timeline (objectui#13010 part 5) ─────── // // The `sys_audit_log` read below, and the `sys_user` name lookup behind it, - // fill only the `record:history` node of the History tab the synthesized - // page composes; an assigned page is rendered as authored, and its - // `record:history` reads its own source. So, like the feed above, the read - // goes out when that node is on screen, judged by `pageShowsNodeOnOpen` on - // the page this view synthesizes (the History tab is added only in the - // rebuild below, so the judgment synthesizes it here with no rows): at open - // when `?tab=` names the History tab, else on the tab switch that mounts it. - // Latched for the view's life for the feed's reason. - const historyPage = useMemo(() => { - if (!historyEnabled || assignedPage || namedPageBlocked || !objectDef) return null; - return buildDefaultPageSchema(objectDef, { - ...(assignedSlots ? { slots: assignedSlots } : {}), - history: { entries: [] }, - }); - }, [historyEnabled, assignedPage, namedPageBlocked, objectDef, assignedSlots]); - // In a column tier History is a resident block of the timeline column, so + // fill only the field changes of the timeline stream the synthesized page + // composes (objectstack#27055: one stream, no History tab); an assigned page + // is rendered as authored, and its `record:history` reads its own source. + // So, like the feed above, the read goes out when the stream is on screen, + // judged by `pageShowsNodeOnOpen` on the page this view synthesizes: at open + // when the page opens on it (the column tiers, or `?tab=timeline`), else on + // the tab switch that mounts it. Latched for the view's life for the feed's + // reason. + const historyPage = historyEnabled && !assignedPage && !namedPageBlocked && objectDef ? synthesizedPage : null; + // In a column tier the stream is a resident block of the timeline column, so // its read goes out at open (objectstack#26678). const historyConsumerOnScreen = useMemo( () => @@ -2301,9 +2332,6 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri const [historyConsumerShown, setHistoryConsumerShown] = useState(false); if (historyConsumerOnScreen && !historyConsumerShown) setHistoryConsumerShown(true); const historyWanted = historyConsumerOnScreen || historyConsumerShown; - // The read is issued from an effect, after the commit that mounts the tab; - // that commit shows the tab's loading state, as the read's own would. - const historyPending = historyWanted && !!dataSource && !!pureRecordId; useEffect(() => { if (!dataSource || !pureRecordId || !objectDef || !historyEnabled) { @@ -3326,8 +3354,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 }>; - history?: { entries: any[]; loading: boolean; unknownUserText?: string }; + related?: Array<{ title?: string; objectName: string; relationshipField: string; columns?: any[]; icon?: string; isPrimary?: boolean; isBody?: boolean }>; approvals?: { count?: number; node?: Record }; }; } @@ -3403,8 +3430,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). @@ -3444,9 +3489,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 } : {}), }; }); @@ -3457,13 +3503,6 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri highlightFields: highlightFields.length > 0 ? highlightFields : undefined, headerActions: recordHeaderActions.length > 0 ? (recordHeaderActions as ActionDef[]) : undefined, related: related.length > 0 ? related : undefined, - ...(historyEnabled && { - history: { - entries: historyEntries ?? [], - loading: (historyLoading || historyPending) && historyEntries === null, - unknownUserText: t('detail.unknownUser', { defaultValue: 'Unknown' }), - }, - }), // Approvals tab (#3461) — only when the record actually has requests, // so approval-free records carry no dead tab. The node carries the // LIVE hook result this view already holds (the same read behind the @@ -3489,7 +3528,17 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // the panel's headline is the pending one. (The decision actions no longer // ride this list at all — objectui#3055 moved them to the declared-action // bar, which reads the pending row directly.) - }, [objectDef?.name, childRelations, t, objectPluralLabel, objects, historyEnabled, historyEntries, historyLoading, historyPending, approvals.available, approvals.pendingRequest, approvals.requests, user?.id]); + }, [objectDef?.name, childRelations, t, objectPluralLabel, objects, approvals.available, approvals.pendingRequest, approvals.requests, user?.id]); + // objectstack#27055, Target 3 — the record's field changes, as feed items of + // the timeline stream. `null` while there are none to give (no + // `trackHistory`, an authored page, the read not made or not landed). + const timelineFieldChanges = useMemo( + () => + historyEnabled && historyEntries + ? auditEntriesToFeedItems(historyEntries, { unknownUser: t('detail.unknownUser', { defaultValue: 'Unknown' }) }) + : null, + [historyEnabled, historyEntries, t], + ); if (isLoading) { return ; @@ -3876,13 +3925,11 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri highlightFields: synthParts.highlightFields, headerActions: synthParts.headerActions, 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 } : {}), }); @@ -3923,6 +3970,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 @@ -4066,8 +4119,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // handler's presence. The reaction control is the add row on the // records store (its take-back asks the remove row per click), and // the column row where the deployment stores reactions in a column. - onAddComment={feedGates.comment ? (handleAddComment as any) : undefined} - onAddReply={feedGates.comment ? (handleAddReply as any) : undefined} + // objectstack#27055: the timeline stream is a `record:activity`, + // which feeds-off does not strip, so `enable.feeds: false` withholds + // the comment writes here (the server refuses them, 403 + // FEEDS_DISABLED) and the stream shows activity and changes alone. + onAddComment={feedsEnabled && feedGates.comment ? (handleAddComment as any) : undefined} + onAddReply={feedsEnabled && feedGates.comment ? (handleAddReply as any) : undefined} // objectui#12268: the author's Edit and Delete, each under its own // grant; the panel offers them only on the rows the read marked // `authoredByViewer`. @@ -4082,6 +4139,9 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri } mentionSuggestions={mentionSuggestions} > + {/* objectstack#27055 — the field changes of the timeline stream, + for the synthesized page's `record:activity`. */} + {/* No `approval` handler in the set below (objectui#3055): the record page has no bespoke approval action TYPE any more. A decision is an ordinary `type:'api'` action declared on `sys_approval_request` and @@ -4249,6 +4309,11 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri tier={recordPageTier} columns={pageColumns} switches={columnSwitches} + relatedArrangement={relatedArrangement} + onRelatedArrangementChange={setRelatedArrangement} + enlargedRelated={enlargedRelatedParam} + onEnlargeRelated={openEnlargedRelated} + onCloseEnlargedRelated={closeEnlargedRelated} footer={ ) : 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. + + + {assignedPage || namedPageBlocked ? ( + + ) : ( + // objectstack#27055 — the 关联 tab in the reader's + // arrangement, the same one the related column reads. + + {(arranged) => ( + + )} + + )} + + )} {/* Approval visibility fallback (objectui#3461) — synthesized @@ -4305,11 +4392,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 @@ -4334,6 +4423,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri {modalElement} + diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.test.tsx index 5e4160c98d5..d9b7d571370 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/PagePreview.test.tsx @@ -123,9 +123,10 @@ describe('PagePreview — slotted record page synthesis', () => { const comps: any[] = main.components; const types = comps.map((c) => c.type); - // Omitted slots → synthesized defaults. + // Omitted slots → synthesized defaults. (The discussion is no longer a + // footer node: it rides the default strip's Timeline tab, objectstack#27055, + // and this draft authors its own strip.) expect(types).toContain('page:header'); - expect(types).toContain('record:discussion'); // Authored highlights override is applied verbatim (replaces the default // chips + chevron path). 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/__tests__/page-tabs-builtin-label-i18n-4645.test.tsx b/ui/packages/components/src/__tests__/page-tabs-builtin-label-i18n-4645.test.tsx index eba54d80241..1acfd4c562c 100644 --- a/ui/packages/components/src/__tests__/page-tabs-builtin-label-i18n-4645.test.tsx +++ b/ui/packages/components/src/__tests__/page-tabs-builtin-label-i18n-4645.test.tsx @@ -45,9 +45,14 @@ import { SchemaRenderer } from '@object-ui/react'; // object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021). import '../renderers'; -/** Exactly the tokens `buildDefaultPageSchema` writes onto its tab nodes. */ +/** + * The tokens `buildDefaultPageSchema` writes onto its tab nodes (Details, + * Timeline, Related, Approvals since objectstack#27055), plus the ones it wrote + * before, which the strip still localizes. + */ const SYNTHESIZED_TABS: Array<{ token: string; packKey: keyof typeof builtInLocales.en.detail }> = [ { token: 'Details', packKey: 'details' }, + { token: 'Timeline', packKey: 'timeline' }, { token: 'Related', packKey: 'related' }, { token: 'Attachments', packKey: 'attachments' }, { token: 'Activity', packKey: 'activity' }, @@ -138,9 +143,11 @@ describe('page:tabs — built-in record-detail labels speak the session locale ( expect(texts).toContain('詳情'); expect(texts).toContain('相關'); + expect(texts).toContain('時間線'); // The Simplified pack values, which a pack-first order would have produced. expect(texts).not.toContain('详情'); expect(texts).not.toContain('相关'); + expect(texts).not.toContain('时间线'); }); /** diff --git a/ui/packages/components/src/renderers/layout/containers.tsx b/ui/packages/components/src/renderers/layout/containers.tsx index 8166c9f3b8f..e3b40b493b7 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). @@ -221,6 +210,7 @@ const KNOWN_LABEL_DICT: Record> = { 'zh-TW': { Details: '詳情', Related: '相關', + Timeline: '時間線', Activity: '活動', History: '歷史', Notes: '備註', @@ -268,6 +258,8 @@ const KNOWN_LABEL_KEYS: Record = { Related: 'detail.related', Activity: 'detail.activity', History: 'detail.history', + // The synthesized record page's one timeline stream tab (objectstack#27055). + Timeline: 'detail.timeline', Attachments: 'detail.attachments', Approvals: 'detail.approvalsPanelTitle', Discussion: 'detail.discussion', @@ -1737,6 +1729,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 +1763,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 +2209,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 +2337,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 +2467,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 +2550,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 +2616,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/i18n/src/__tests__/residue-namespaces-3546.test.tsx b/ui/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx index 04a2ecf4b76..17c8ecad291 100644 --- a/ui/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx +++ b/ui/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx @@ -382,12 +382,14 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { } } // PageView uses it twice on the same button (title + aria-label) and - // RelatedList twice in two different affordances — the counts are pinned so a - // silent drop of one of them is visible. AppContent's two record-save toast + // RelatedList three times in three different affordances (the picker title, + // the compact card's icon-only + and the full list's Add button, + // objectstack#27055) — the counts are pinned so a silent drop of one of them + // is visible. AppContent's two record-save toast // sites share one message builder since objectstack-ai/objectstack#26172, // so its fallback label is written once. expect(sourceOf(PAGE_VIEW).split("t('common.editInStudio', { defaultValue: 'Edit in studio' })").length - 1).toBe(2); - expect(sourceOf(RELATED_LIST).split("t('detail.add', { defaultValue: 'Add' })").length - 1).toBe(2); + expect(sourceOf(RELATED_LIST).split("t('detail.add', { defaultValue: 'Add' })").length - 1).toBe(3); expect(sourceOf(APP_CONTENT).split("t('common.record', { defaultValue: 'Record' })").length - 1).toBe(1); }); diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 1189da39b5c..72e74aa7db2 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -1456,6 +1456,55 @@ const ar = { timeline: "الخط الزمني", timelineFolded: "النشاط", relatedColumn: "عمود العناصر ذات الصلة", + // objectstack#27055 — the record page v3. + emptySections: "أقسام فارغة: {{count}}", + emptySections_zero: "أقسام فارغة: {{count}}", + emptySections_one: "أقسام فارغة: {{count}}", + emptySections_two: "أقسام فارغة: {{count}}", + emptySections_few: "أقسام فارغة: {{count}}", + emptySections_many: "أقسام فارغة: {{count}}", + emptySections_other: "أقسام فارغة: {{count}}", + showEmptySections: "توسيع", + hideEmptySections: "طي", + owner: "المالك", + relatedCardMenu: "المزيد لـ {{title}}", + relatedCardEnlarge: "تكبير", + relatedCardMoveUp: "نقل لأعلى", + relatedCardMoveDown: "نقل لأسفل", + relatedCardExpand: "توسيع", + relatedCardCollapse: "طي", + relatedCardHide: "إخفاء", + relatedCardNew: "جديد", + relatedCardCount: "السجلات: {{count}} · تكبير", + relatedCardCount_zero: "السجلات: {{count}} · تكبير", + relatedCardCount_one: "السجلات: {{count}} · تكبير", + relatedCardCount_two: "السجلات: {{count}} · تكبير", + relatedCardCount_few: "السجلات: {{count}} · تكبير", + relatedCardCount_many: "السجلات: {{count}} · تكبير", + relatedCardCount_other: "السجلات: {{count}} · تكبير", + relatedDefaultChanged: "تم تحديث الترتيب الافتراضي", + relatedRestoreDefault: "استعادة الافتراضي", + relatedHiddenCount: "المخفية: {{count}}", + relatedHiddenCount_zero: "المخفية: {{count}}", + relatedHiddenCount_one: "المخفية: {{count}}", + relatedHiddenCount_two: "المخفية: {{count}}", + relatedHiddenCount_few: "المخفية: {{count}}", + relatedHiddenCount_many: "المخفية: {{count}}", + relatedHiddenCount_other: "المخفية: {{count}}", + relatedShowHidden: "إظهار", + relatedEnlargedBack: "رجوع", + bodyTableTotals: "المجاميع (هذه الصفحة)", + streamAll: "الكل", + streamActivity: "النشاط", + streamDiscussion: "المناقشة", + streamChanges: "التغييرات", + viewAllEntries: "عرض الكل ({{count}}) →", + viewAllEntries_zero: "عرض الكل ({{count}}) →", + viewAllEntries_one: "عرض الكل ({{count}}) →", + viewAllEntries_two: "عرض الكل ({{count}}) →", + viewAllEntries_few: "عرض الكل ({{count}}) →", + viewAllEntries_many: "عرض الكل ({{count}}) →", + viewAllEntries_other: "عرض الكل ({{count}}) →", panelToggles: "اللوحات", history: "السجل", historyEmpty: "لا يوجد سجل بعد", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 72cf49e1888..0556a1a16f5 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -1302,6 +1302,39 @@ const de = { timeline: "Zeitleiste", timelineFolded: "Aktivität", relatedColumn: "Verknüpfte Spalte", + // objectstack#27055 — the record page v3. + emptySections: "{{count}} leere Abschnitte", + emptySections_one: "{{count}} leerer Abschnitt", + emptySections_other: "{{count}} leere Abschnitte", + showEmptySections: "Aufklappen", + hideEmptySections: "Zuklappen", + owner: "Inhaber", + relatedCardMenu: "Mehr für {{title}}", + relatedCardEnlarge: "Vergrößern", + relatedCardMoveUp: "Nach oben", + relatedCardMoveDown: "Nach unten", + relatedCardExpand: "Aufklappen", + relatedCardCollapse: "Zuklappen", + relatedCardHide: "Ausblenden", + relatedCardNew: "Neu", + relatedCardCount: "{{count}} Datensätze – vergrößern", + relatedCardCount_one: "{{count}} Datensatz – vergrößern", + relatedCardCount_other: "{{count}} Datensätze – vergrößern", + relatedDefaultChanged: "Standardreihenfolge aktualisiert", + relatedRestoreDefault: "Standard wiederherstellen", + relatedHiddenCount: "{{count}} ausgeblendet", + relatedHiddenCount_one: "{{count}} ausgeblendet", + relatedHiddenCount_other: "{{count}} ausgeblendet", + relatedShowHidden: "Anzeigen", + relatedEnlargedBack: "Zurück", + bodyTableTotals: "Summen (diese Seite)", + streamAll: "Alle", + streamActivity: "Aktivität", + streamDiscussion: "Diskussion", + streamChanges: "Änderungen", + viewAllEntries: "Alle {{count}} anzeigen →", + viewAllEntries_one: "Alle {{count}} anzeigen →", + viewAllEntries_other: "Alle {{count}} anzeigen →", panelToggles: "Bereiche", history: "Verlauf", historyEmpty: "Noch kein Verlauf", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 7b69cf59bdf..c869e699380 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1488,6 +1488,39 @@ const en = { timeline: 'Timeline', timelineFolded: 'Activity', relatedColumn: 'Related column', + // objectstack#27055 — the record page v3. + emptySections: '{{count}} empty sections', + emptySections_one: '{{count}} empty section', + emptySections_other: '{{count}} empty sections', + showEmptySections: 'Expand', + hideEmptySections: 'Collapse', + owner: 'Owner', + relatedCardMenu: 'More for {{title}}', + relatedCardEnlarge: 'Enlarge', + relatedCardMoveUp: 'Move up', + relatedCardMoveDown: 'Move down', + relatedCardExpand: 'Expand', + relatedCardCollapse: 'Collapse', + relatedCardHide: 'Hide', + relatedCardNew: 'New', + relatedCardCount: '{{count}} records — enlarge', + relatedCardCount_one: '{{count}} record — enlarge', + relatedCardCount_other: '{{count}} records — enlarge', + relatedDefaultChanged: 'Default order updated', + relatedRestoreDefault: 'Restore default', + relatedHiddenCount: '{{count}} hidden', + relatedHiddenCount_one: '{{count}} hidden', + relatedHiddenCount_other: '{{count}} hidden', + relatedShowHidden: 'Show', + relatedEnlargedBack: 'Back', + bodyTableTotals: 'Totals (this page)', + streamAll: 'All', + streamActivity: 'Activity', + streamDiscussion: 'Discussion', + streamChanges: 'Changes', + viewAllEntries: 'View all {{count}} →', + viewAllEntries_one: 'View all {{count}} →', + viewAllEntries_other: 'View all {{count}} →', panelToggles: 'Panels', history: 'History', historyEmpty: 'No history yet', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 32b287f7f8a..441e01e2c82 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -1342,6 +1342,43 @@ const es = { timeline: "Cronología", timelineFolded: "Actividad", relatedColumn: "Columna relacionada", + // objectstack#27055 — the record page v3. + emptySections: "{{count}} secciones vacías", + emptySections_one: "{{count}} sección vacía", + emptySections_many: "{{count}} de secciones vacías", + emptySections_other: "{{count}} secciones vacías", + showEmptySections: "Desplegar", + hideEmptySections: "Contraer", + owner: "Propietario", + relatedCardMenu: "Más opciones de {{title}}", + relatedCardEnlarge: "Ampliar", + relatedCardMoveUp: "Subir", + relatedCardMoveDown: "Bajar", + relatedCardExpand: "Desplegar", + relatedCardCollapse: "Contraer", + relatedCardHide: "Ocultar", + relatedCardNew: "Nuevo", + relatedCardCount: "{{count}} registros · ampliar", + relatedCardCount_one: "{{count}} registro · ampliar", + relatedCardCount_many: "{{count}} de registros · ampliar", + relatedCardCount_other: "{{count}} registros · ampliar", + relatedDefaultChanged: "Orden predeterminado actualizado", + relatedRestoreDefault: "Restaurar predeterminado", + relatedHiddenCount: "{{count}} ocultas", + relatedHiddenCount_one: "{{count}} oculta", + relatedHiddenCount_many: "{{count}} ocultas", + relatedHiddenCount_other: "{{count}} ocultas", + relatedShowHidden: "Mostrar", + relatedEnlargedBack: "Volver", + bodyTableTotals: "Totales (esta página)", + streamAll: "Todo", + streamActivity: "Actividad", + streamDiscussion: "Discusión", + streamChanges: "Cambios", + viewAllEntries: "Ver los {{count}} →", + viewAllEntries_one: "Ver los {{count}} →", + viewAllEntries_many: "Ver los {{count}} →", + viewAllEntries_other: "Ver los {{count}} →", panelToggles: "Paneles", history: "Historial", historyEmpty: "Aún no hay historial", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index b3c1aa73c15..341407d7017 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -1339,6 +1339,43 @@ const fr = { timeline: "Chronologie", timelineFolded: "Activité", relatedColumn: "Colonne associée", + // objectstack#27055 — the record page v3. + emptySections: "{{count}} sections vides", + emptySections_one: "{{count}} section vide", + emptySections_many: "{{count}} de sections vides", + emptySections_other: "{{count}} sections vides", + showEmptySections: "Déplier", + hideEmptySections: "Replier", + owner: "Propriétaire", + relatedCardMenu: "Plus pour {{title}}", + relatedCardEnlarge: "Agrandir", + relatedCardMoveUp: "Monter", + relatedCardMoveDown: "Descendre", + relatedCardExpand: "Déplier", + relatedCardCollapse: "Replier", + relatedCardHide: "Masquer", + relatedCardNew: "Nouveau", + relatedCardCount: "{{count}} enregistrements · agrandir", + relatedCardCount_one: "{{count}} enregistrement · agrandir", + relatedCardCount_many: "{{count}} enregistrements · agrandir", + relatedCardCount_other: "{{count}} enregistrements · agrandir", + relatedDefaultChanged: "Ordre par défaut mis à jour", + relatedRestoreDefault: "Rétablir par défaut", + relatedHiddenCount: "{{count}} masquées", + relatedHiddenCount_one: "{{count}} masquée", + relatedHiddenCount_many: "{{count}} masquées", + relatedHiddenCount_other: "{{count}} masquées", + relatedShowHidden: "Afficher", + relatedEnlargedBack: "Retour", + bodyTableTotals: "Totaux (cette page)", + streamAll: "Tout", + streamActivity: "Activité", + streamDiscussion: "Discussion", + streamChanges: "Modifications", + viewAllEntries: "Tout voir ({{count}}) →", + viewAllEntries_one: "Tout voir ({{count}}) →", + viewAllEntries_many: "Tout voir ({{count}}) →", + viewAllEntries_other: "Tout voir ({{count}}) →", panelToggles: "Panneaux", history: "Historique", historyEmpty: "Aucun historique pour le moment", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index da6d99f5972..ff2624ecfae 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1145,6 +1145,39 @@ const ja = { timeline: "タイムライン", timelineFolded: "アクティビティ", relatedColumn: "関連列", + // objectstack#27055 — the record page v3. + emptySections: "空のセクション {{count}} 件", + emptySections_one: "空のセクション {{count}} 件", + emptySections_other: "空のセクション {{count}} 件", + showEmptySections: "展開", + hideEmptySections: "折りたたむ", + owner: "所有者", + relatedCardMenu: "{{title}} のその他の操作", + relatedCardEnlarge: "拡大", + relatedCardMoveUp: "上へ移動", + relatedCardMoveDown: "下へ移動", + relatedCardExpand: "展開", + relatedCardCollapse: "折りたたむ", + relatedCardHide: "非表示", + relatedCardNew: "新規", + relatedCardCount: "{{count}} 件 · 拡大", + relatedCardCount_one: "{{count}} 件 · 拡大", + relatedCardCount_other: "{{count}} 件 · 拡大", + relatedDefaultChanged: "既定の順序が更新されました", + relatedRestoreDefault: "既定に戻す", + relatedHiddenCount: "{{count}} 件非表示", + relatedHiddenCount_one: "{{count}} 件非表示", + relatedHiddenCount_other: "{{count}} 件非表示", + relatedShowHidden: "表示", + relatedEnlargedBack: "戻る", + bodyTableTotals: "合計(このページ)", + streamAll: "すべて", + streamActivity: "アクティビティ", + streamDiscussion: "ディスカッション", + streamChanges: "変更", + viewAllEntries: "すべて表示({{count}} 件)→", + viewAllEntries_one: "すべて表示({{count}} 件)→", + viewAllEntries_other: "すべて表示({{count}} 件)→", panelToggles: "パネル", editRow: "編集", deleteRow: "削除", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 42e45fa452d..c90661e4a9e 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1302,6 +1302,39 @@ const ko = { timeline: "타임라인", timelineFolded: "활동", relatedColumn: "관련 열", + // objectstack#27055 — the record page v3. + emptySections: "빈 섹션 {{count}}개", + emptySections_one: "빈 섹션 {{count}}개", + emptySections_other: "빈 섹션 {{count}}개", + showEmptySections: "펼치기", + hideEmptySections: "접기", + owner: "소유자", + relatedCardMenu: "{{title}} 추가 작업", + relatedCardEnlarge: "확대", + relatedCardMoveUp: "위로 이동", + relatedCardMoveDown: "아래로 이동", + relatedCardExpand: "펼치기", + relatedCardCollapse: "접기", + relatedCardHide: "숨기기", + relatedCardNew: "새 항목", + relatedCardCount: "{{count}}개 레코드 · 확대", + relatedCardCount_one: "{{count}}개 레코드 · 확대", + relatedCardCount_other: "{{count}}개 레코드 · 확대", + relatedDefaultChanged: "기본 순서가 업데이트됨", + relatedRestoreDefault: "기본값 복원", + relatedHiddenCount: "{{count}}개 숨김", + relatedHiddenCount_one: "{{count}}개 숨김", + relatedHiddenCount_other: "{{count}}개 숨김", + relatedShowHidden: "표시", + relatedEnlargedBack: "뒤로", + bodyTableTotals: "합계(이 페이지)", + streamAll: "전체", + streamActivity: "활동", + streamDiscussion: "토론", + streamChanges: "변경", + viewAllEntries: "{{count}}개 모두 보기 →", + viewAllEntries_one: "{{count}}개 모두 보기 →", + viewAllEntries_other: "{{count}}개 모두 보기 →", panelToggles: "패널", history: "기록", historyEmpty: "아직 기록이 없습니다", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 59f9849f786..a222134e854 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -1339,6 +1339,43 @@ const pt = { timeline: "Linha do tempo", timelineFolded: "Atividade", relatedColumn: "Coluna relacionada", + // objectstack#27055 — the record page v3. + emptySections: "{{count}} seções vazias", + emptySections_one: "{{count}} seção vazia", + emptySections_many: "{{count}} de seções vazias", + emptySections_other: "{{count}} seções vazias", + showEmptySections: "Expandir", + hideEmptySections: "Recolher", + owner: "Proprietário", + relatedCardMenu: "Mais para {{title}}", + relatedCardEnlarge: "Ampliar", + relatedCardMoveUp: "Mover para cima", + relatedCardMoveDown: "Mover para baixo", + relatedCardExpand: "Expandir", + relatedCardCollapse: "Recolher", + relatedCardHide: "Ocultar", + relatedCardNew: "Novo", + relatedCardCount: "{{count}} registros · ampliar", + relatedCardCount_one: "{{count}} registro · ampliar", + relatedCardCount_many: "{{count}} de registros · ampliar", + relatedCardCount_other: "{{count}} registros · ampliar", + relatedDefaultChanged: "Ordem padrão atualizada", + relatedRestoreDefault: "Restaurar padrão", + relatedHiddenCount: "{{count}} ocultas", + relatedHiddenCount_one: "{{count}} oculta", + relatedHiddenCount_many: "{{count}} ocultas", + relatedHiddenCount_other: "{{count}} ocultas", + relatedShowHidden: "Mostrar", + relatedEnlargedBack: "Voltar", + bodyTableTotals: "Totais (esta página)", + streamAll: "Tudo", + streamActivity: "Atividade", + streamDiscussion: "Discussão", + streamChanges: "Alterações", + viewAllEntries: "Ver todos ({{count}}) →", + viewAllEntries_one: "Ver todos ({{count}}) →", + viewAllEntries_many: "Ver todos ({{count}}) →", + viewAllEntries_other: "Ver todos ({{count}}) →", panelToggles: "Painéis", history: "Histórico", historyEmpty: "Nenhum histórico ainda", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 7d0cbd480a8..bd42f6fd34b 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1212,6 +1212,47 @@ const ru = { timeline: "Хронология", timelineFolded: "Активность", relatedColumn: "Связанная колонка", + // objectstack#27055 — the record page v3. + emptySections: "{{count}} пустых разделов", + emptySections_one: "{{count}} пустой раздел", + emptySections_few: "{{count}} пустых раздела", + emptySections_many: "{{count}} пустых разделов", + emptySections_other: "{{count}} пустого раздела", + showEmptySections: "Развернуть", + hideEmptySections: "Свернуть", + owner: "Владелец", + relatedCardMenu: "Ещё для «{{title}}»", + relatedCardEnlarge: "Увеличить", + relatedCardMoveUp: "Переместить вверх", + relatedCardMoveDown: "Переместить вниз", + relatedCardExpand: "Развернуть", + relatedCardCollapse: "Свернуть", + relatedCardHide: "Скрыть", + relatedCardNew: "Новое", + relatedCardCount: "{{count}} записей · увеличить", + relatedCardCount_one: "{{count}} запись · увеличить", + relatedCardCount_few: "{{count}} записи · увеличить", + relatedCardCount_many: "{{count}} записей · увеличить", + relatedCardCount_other: "{{count}} записи · увеличить", + relatedDefaultChanged: "Порядок по умолчанию обновлён", + relatedRestoreDefault: "Восстановить по умолчанию", + relatedHiddenCount: "Скрыто: {{count}}", + relatedHiddenCount_one: "Скрыто: {{count}}", + relatedHiddenCount_few: "Скрыто: {{count}}", + relatedHiddenCount_many: "Скрыто: {{count}}", + relatedHiddenCount_other: "Скрыто: {{count}}", + relatedShowHidden: "Показать", + relatedEnlargedBack: "Назад", + bodyTableTotals: "Итоги (эта страница)", + streamAll: "Все", + streamActivity: "Активность", + streamDiscussion: "Обсуждение", + streamChanges: "Изменения", + viewAllEntries: "Показать все ({{count}}) →", + viewAllEntries_one: "Показать все ({{count}}) →", + viewAllEntries_few: "Показать все ({{count}}) →", + viewAllEntries_many: "Показать все ({{count}}) →", + viewAllEntries_other: "Показать все ({{count}}) →", panelToggles: "Панели", editRow: "Редактировать", deleteRow: "Удалить", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 9b93706d751..a32bc433169 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1224,6 +1224,39 @@ const zh = { timeline: '时间线', timelineFolded: '动态', relatedColumn: '关联', + // objectstack#27055 — the record page v3. + emptySections: '{{count}} 个空分区', + emptySections_one: '{{count}} 个空分区', + emptySections_other: '{{count}} 个空分区', + showEmptySections: '展开', + hideEmptySections: '收起', + owner: '负责人', + relatedCardMenu: '{{title}}的更多操作', + relatedCardEnlarge: '放大', + relatedCardMoveUp: '上移', + relatedCardMoveDown: '下移', + relatedCardExpand: '展开', + relatedCardCollapse: '折叠', + relatedCardHide: '隐藏', + relatedCardNew: '新', + relatedCardCount: '{{count}} 条记录 · 放大', + relatedCardCount_one: '{{count}} 条记录 · 放大', + relatedCardCount_other: '{{count}} 条记录 · 放大', + relatedDefaultChanged: '默认顺序已更新', + relatedRestoreDefault: '恢复默认', + relatedHiddenCount: '已隐藏 {{count}} 张', + relatedHiddenCount_one: '已隐藏 {{count}} 张', + relatedHiddenCount_other: '已隐藏 {{count}} 张', + relatedShowHidden: '显示', + relatedEnlargedBack: '返回', + bodyTableTotals: '本页合计', + streamAll: '全部', + streamActivity: '活动', + streamDiscussion: '讨论', + streamChanges: '变更', + viewAllEntries: '查看全部 {{count}} 条 →', + viewAllEntries_one: '查看全部 {{count}} 条 →', + viewAllEntries_other: '查看全部 {{count}} 条 →', panelToggles: '面板', history: '历史', historyEmpty: '暂无历史记录', diff --git a/ui/packages/plugin-detail/README.md b/ui/packages/plugin-detail/README.md index 0ab34795cee..88e4eff508f 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/ArrangedRelatedTab.tsx b/ui/packages/plugin-detail/src/ArrangedRelatedTab.tsx new file mode 100644 index 00000000000..3f72c27b55c --- /dev/null +++ b/ui/packages/plugin-detail/src/ArrangedRelatedTab.tsx @@ -0,0 +1,118 @@ +/** + * 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'; +import type { RelatedCardChrome } from './relatedListCompact'; +import { relatedListIdsIn } from './synth/recordPageColumns'; +import type { RelatedArrangement, RelatedCardId } from './synth/relatedArrangement'; +import { useRelatedArrangement } from './useRelatedArrangement'; + +/** + * The reader's arrangement as the tabbed tier's 关联 tab reads it + * (objectstack#27055, Target 7: 「The same order and collapsed state apply in + * the tabbed tier's 关联 tab」). Read by the `record:related_list` renderer: a + * collapsed list draws as its title + count (no rows read), every other list + * gets its chrome (the ⋯ menu), and the tab's LAST list draws the column's + * two lines after itself — the changed-default hint and 「已隐藏 N 张 · 显示」. + */ +export interface RelatedArrangementScope { + chromeOf: (id: RelatedCardId) => RelatedCardChrome | null; + isCollapsed: (id: RelatedCardId) => boolean; + expand: (id: RelatedCardId) => void; + /** The list that draws the tab's closing lines. */ + lastId: RelatedCardId | null; + hiddenCount: number; + showHidden: () => void; + defaultChanged: boolean; + restoreDefault: () => void; +} + +export const RelatedArrangementScopeContext = React.createContext(null); + +type Node = Record; +const asNode = (value: unknown): Node | null => + value && typeof value === 'object' && !Array.isArray(value) ? (value as Node) : null; + +/** The `related` tab item of a synthesized page's strip, with its path. */ +function findRelatedTab(page: unknown): { regionIndex: number; componentIndex: number; itemIndex: number; item: Node } | null { + const regions = Array.isArray(asNode(page)?.regions) ? (asNode(page)!.regions as unknown[]) : []; + for (let r = 0; r < regions.length; r += 1) { + const components = asNode(regions[r])?.components; + if (!Array.isArray(components)) continue; + for (let c = 0; c < components.length; c += 1) { + const tabs = asNode(components[c]); + if (tabs?.type !== 'page:tabs' || !Array.isArray(tabs.properties?.items)) continue; + const items = tabs.properties.items as unknown[]; + for (let i = 0; i < items.length; i += 1) { + const item = asNode(items[i]); + if (item?.value === 'related') return { regionIndex: r, componentIndex: c, itemIndex: i, item }; + } + } + } + return null; +} + +const relatedListIdOf = (node: unknown): RelatedCardId | null => relatedListIdsIn([node])[0] ?? null; + +/** + * Lays the reader's arrangement over a synthesized page's 关联 tab + * (objectstack#27055): its lists in the reader's order, hidden lists left out, + * any other child (the attachments) after them; and the scope the + * `record:related_list` renderer reads for the collapsed state, the chrome and + * the closing lines. A page with no 关联 tab is handed through unchanged. + */ +export const ArrangedRelatedTab: React.FC<{ + page: unknown; + arrangement?: RelatedArrangement; + onArrangementChange?: (next: RelatedArrangement) => void; + children: (page: unknown) => React.ReactNode; +}> = ({ page, arrangement, onArrangementChange, children }) => { + const found = findRelatedTab(page); + const tabChildren: unknown[] = Array.isArray(found?.item.children) ? (found!.item.children as unknown[]) : []; + const lists = tabChildren.filter((node) => relatedListIdOf(node) !== null); + const others = tabChildren.filter((node) => relatedListIdOf(node) === null); + const defaultOrder = lists.map((node) => relatedListIdOf(node)!); + const { resolved, edit, chromeOf } = useRelatedArrangement({ defaultOrder, arrangement, onArrangementChange }); + + const arrangedPage = React.useMemo(() => { + if (!found) return page; + const byId = new Map(lists.map((node) => [relatedListIdOf(node)!, node])); + const nextChildren = [...resolved.order.map((id) => byId.get(id)!), ...others]; + const source = page as Node; + const regions = [...source.regions]; + const region = { ...regions[found.regionIndex] }; + const components = [...region.components]; + const tabs = { ...components[found.componentIndex] }; + const items = [...tabs.properties.items]; + items[found.itemIndex] = { ...found.item, children: nextChildren }; + tabs.properties = { ...tabs.properties, items }; + components[found.componentIndex] = tabs; + region.components = components; + regions[found.regionIndex] = region; + return { ...source, regions }; + // The arranged order is the input that moves; the page is the other. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [page, resolved.order.join('|')]); + + const scope: RelatedArrangementScope = { + chromeOf: (id) => { + const index = resolved.order.indexOf(id); + return index >= 0 ? chromeOf(id, index) : null; + }, + isCollapsed: (id) => resolved.collapsed.has(id), + expand: (id) => edit({ kind: 'collapse', id, collapsed: false }), + lastId: resolved.order.length > 0 ? resolved.order[resolved.order.length - 1] : null, + hiddenCount: resolved.hidden.length, + showHidden: () => edit({ kind: 'show-hidden' }), + defaultChanged: resolved.defaultChanged, + restoreDefault: () => edit({ kind: 'restore-default-order' }), + }; + return ( + {children(arrangedPage)} + ); +}; diff --git a/ui/packages/plugin-detail/src/DetailSection.tsx b/ui/packages/plugin-detail/src/DetailSection.tsx index 013f91bd36e..0c85ac6e032 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. A collapsible section collapses from its title row (a native + // button carrying `aria-expanded`, as the card face's disclosure does, + // objectui#12625); a section its group declares `collapse: 'none'` keeps a + // plain heading — the author's declaration holds on every face. An untitled + // section is the grid alone. + if (frameless) { + const titleRow = 'flex min-h-9 w-full items-center justify-between gap-2 py-1.5 text-left text-[13px] font-semibold text-foreground'; + return ( +
+ {section.title && !section.collapsible && ( +
+

+ + {section.icon && } + {section.title} + +

+
+ )} + {section.title && section.collapsible && ( +
+ +
+ )} + {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 d8a0c7f0fea..ac5e86b0e55 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; @@ -2093,21 +2097,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..56894ffc028 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 (
void; + /** How many entries the whole stream holds, for its 「查看全部 N 条 →」. */ + totalCount?: number; className?: string; } +/** The stream chips' copy (objectstack#27055): 全部 / 活动 / 讨论 / 变更. */ +const STREAM_FILTER_KEYS: Record = { + all: { key: 'detail.streamAll', fallback: 'All' }, + activity: { key: 'detail.streamActivity', fallback: 'Activity' }, + discussion: { key: 'detail.streamDiscussion', fallback: 'Discussion' }, + changes: { key: 'detail.streamChanges', fallback: 'Changes' }, +}; + // Total over `FeedItemType`, so a feed kind the spec adds cannot reach the // timeline without an icon and a colour (objectstack#4115): the six entries // below `call` were missing for as long as the local union was a 7-member @@ -285,8 +312,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 +367,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 +404,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 +503,44 @@ 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'; @@ -697,7 +772,9 @@ export const RecordActivityTimeline: React.FC = ({ aria-label={title} >
-
+ {/* Wraps: in a 320 px foot card the stream's chips go under the title + rather than past the card's edge (objectstack#27055). */} +

{title} @@ -706,6 +783,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 && (