diff --git a/.changeset/27524-record-page-activity-i18n.md b/.changeset/27524-record-page-activity-i18n.md new file mode 100644 index 00000000000..2069270117b --- /dev/null +++ b/.changeset/27524-record-page-activity-i18n.md @@ -0,0 +1,18 @@ +--- +'@object-ui/i18n': patch +--- + +feat(i18n)!: 「动态」 (Activity) names the record page's stream in all ten packs; the timeline column's two keys leave + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +The record page's stream is called 「动态」 in zh and "Activity" in en, in the record page's panel, its tab and the right-column switch's labels (record page v4). + +- `detail.timeline` changes value in all ten packs: 动态 · Activity · アクティビティ · 활동 · Aktivität · Activité · Actividad · Atividade · Активность · النشاط. +- `detail.relatedColumn` now names the right column (「右栏」, "Right column"), and `console.shortcuts.toggleRelated` shows or hides it. +- `detail.timelineComposer` (the 动态 panel's one-line composer, 「写一条备注… @ 提到同事」) arrives in all ten packs. + +**BREAKING** (`@object-ui/i18n`, TypeScript only): the exported `TranslationKeys` (`typeof en`) loses `detail.timelineFolded` and `console.shortcuts.toggleTimeline`, which named the retired timeline column and its shortcut. Both leave every pack. A `t()` call naming one still compiles, since its key is a `string`, and reads the key back. Nothing replaces them: the column is gone. diff --git a/.changeset/27524-record-page-tiers-activity-panel.md b/.changeset/27524-record-page-tiers-activity-panel.md new file mode 100644 index 00000000000..e43555c7184 --- /dev/null +++ b/.changeset/27524-record-page-tiers-activity-panel.md @@ -0,0 +1,40 @@ +--- +'@object-ui/plugin-detail': patch +'@object-ui/components': patch +'@object-ui/app-shell': patch +--- + +feat(plugin-detail)!: one record page laid out by its own width — details beside a right column from 760 px, one column below; 动态 is the right column's first panel; ⊟ shows or hides the right column + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +The synthesized record page (record page v4) is laid out by one rule, the width of the container it occupies. The page, the split pane, the overlay drawer and the maximized pane all follow it. + +- **At 760 px and wider**, the details sit beside a right column 320 px wide. **At 1120 px and wider** the right column is 380 px, a details section takes up to three field columns (two otherwise), and the 动态 panel shows five entries (three otherwise). +- **Below 760 px** (the split pane at 720 px, a phone) the page is ONE column: the details, then the right column's panels in their order. +- The three-column tier, the tabbed tier and the 「相关 N」 rail are gone, and so are the resident timeline column, its ⊞ switch and the per-object default that decided it. +- **动态** (formerly the timeline) is the first panel of the right column. It has the same header as a related panel: 「动态」 and the stream's count, and the header row opens the 动态 tab. Its body is a one-line composer (pressed, it opens the full composer, focused), the newest entries one line each, and 「查看全部 N 条」 when the stream holds more. The 动态 tab (`?related=timeline`) is the full stream: the 全部 · 活动 · 讨论 · 变更 chips, threaded replies, and "Load more" for the next page. +- **⊟** (「右栏」, `⌘⇧,`) is one icon at the end of the record header's action row, drawn only where the tier has a right column. Off, the details take the row. It keeps the reader's earlier related-column choice: the stored preference `record.columns.related` is unchanged. The record header's `PanelToggles` (`@object-ui/components`) now draws icon-only toggles named by their label. +- The 动态 panel is on screen at open in every tier, so a synthesized page reads its feed and its audit history at open. An authored page with tabs still waits for the tab that holds them. +- **Tab labels** (`page:tabs`, `@object-ui/components`). A synthesized page that keeps its tab strip (one the layout cannot partition) labels its stream tab `Activity stream`, which the strip reads as 「动态」. An authored `Timeline` tab, such as the served approval page's history, is no longer read as the record stream: it keeps the pack's own word for a timeline (「时间线」, タイムライン, and so on). + +**BREAKING** (`@object-ui/plugin-detail`, TypeScript only). The only importer is the console's own record view. `RecordPageTier` is now `'stack' | 'two-column' | 'wide'`: there is no tabbed tier, and the widest tier is called `wide`. `recordPageTierFor(width)` takes no second argument. `useRecordPageTier` answers `null` only while the page is not laid out (disabled, or not yet measured). `RecordPageColumnsProps.tier` takes every `RecordPageTier`. A caller still naming a retired symbol gets a compile error that names it. + +What leaves, and what to write instead: + +- FROM `RECORD_PAGE_THREE_COLUMN_MIN_WIDTH` TO `RECORD_PAGE_WIDE_MIN_WIDTH`, the same 1120 px floor under its new name. +- FROM `recordPageArrangementFor` TO `recordPageTierHasRightColumn(tier)` and the reader's ⊟: every laid-out page is the details and, where the tier has room, the right column. +- FROM `recordPageColumnDefaults` TO no call: every object's page opens with its right column, and ⊟ defaults to on. +- FROM `recordTimelineDefaultReason` TO no call, for the same reason: no object decides a timeline column any more. +- FROM `RecordPageColumnSwitches` TO a `boolean`, the right column's switch. +- FROM `RecordPageArrangement` TO the `RecordPageTier` itself, read through `recordPageTierHasRightColumn`. +- FROM `RecordPageObjectLike` TO no type: no helper takes an object definition any more. +- FROM `RecordTimelineDefaultReason` TO no type, with `recordTimelineDefaultReason`. +- FROM `DefaultPageColumns.relatedCount` TO the length of `columns.related`; the rail it labelled is gone. +- FROM `UseRecordPageTierOptions.embedded` TO no option: an embedded page follows the same width rule. +- FROM `RecordPageColumnsProps.switches` TO `RecordPageColumnsProps.rightColumn`, one `boolean`. + +`RECORD_PAGE_WIDE_MIN_WIDTH`, `recordPageTierHasRightColumn` and `RECORD_PAGE_TIMELINE_PANEL` (the 动态 tab's `?related=` id) are new. diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index c9eb19d418f..0ff71d0733d 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -180,33 +180,33 @@ replacement. ### The synthesized default A record page needs no authoring. With no page assigned, the Console synthesizes one from -the object: a header, the highlights strip and a tab strip of **Details**, **Timeline** and -**Related**, plus **Approvals** when the record has approval requests. A relationship whose -child field declares `relatedList: 'primary'` is part of the record's body: its list is a -full-width table inside **Details**, after the first field section, with no tab of its own. -The other related lists are cards in the shared **Related** tab, which an object with -`enable: { files: true }` closes with its attachments. **Timeline** is one stream: the -discussion composer on top, then the discussion, the activity and, with -`enable: { trackHistory: true }`, the record's field changes, filtered by All / Activity / -Discussion / Changes; there is no separate History tab. Moving a child table into the -record's body is therefore a one-word change on the relationship field, not a -page-authoring task, and `relatedList: false` leaves a child off the page. - -The Console lays the page out by the width of the surface it occupies (the drawer, the -split pane or the full page), never by the viewport. From 1280 px the tabs become three -columns: details, the timeline stream and the -related lists as cards with Approvals, Attachments and the created/updated line. From -1024 px the related column folds into a rail behind a **Related (N)** toggle. Below that, and -always in a drawer or split pane, the tab strip is drawn. `?tab=` selects a tab in the strip. - -In the column tiers the related column loads in steps, and the details column never waits for -it. The page asks every card's count in one request set, sent beside the record read, and the -cards show placeholders until those counts land. A card counted at 0 is its title row alone -and reads no rows. A card above 0 reads its first three rows once it scrolls into view, asking -only for the record's name field and one status or date field. When the timeline column is -switched off, its **Activity** card in the related column stays closed, and reads nothing, until -it is opened. In a drawer, the record that **↑** / **↓** would show next has its related counts -read ahead. +the object: a header, the highlights strip, the record's **details**, and a right column of +panels: **动态** (Activity) first, then the related lists, **Approvals** when the record has +approval requests, and the attachments of an object with `enable: { files: true }`. A +relationship whose child field declares `relatedList: 'primary'` is part of the record's body: +its list is a full-width table in the details, after the first field section, with no panel of +its own. **动态** is one stream: the discussion, the activity and, with +`enable: { trackHistory: true }`, the record's field changes; there is no separate History tab. +Its panel shows a one-line composer and the newest entries, and its header opens the whole +stream (`?related=timeline`), filtered by All / Activity / Discussion / Changes. A related +panel's header opens that list's whole table the same way (`?related=.`). +Moving a child table into the record's body is therefore a one-word change on the +relationship field, not a page-authoring task, and `relatedList: false` leaves a child off the +page. + +The Console lays the page out by the width of the surface it occupies (the full page, the split +pane or the drawer), never by the viewport, and by one rule. From 760 px the details sit beside +the right column, 320 px wide; from 1120 px the right column is 380 px and a details section +takes up to three field columns. Below 760 px (the split pane, a phone) the page is one column: +the details, then the right column's panels in their order. The **⊟** switch at the end of the +record header's actions (`⌘⇧,`) hides the right column for the reader, per object. + +The related panels load in steps, and the details never wait for them. The page asks every +panel's count in one request set, sent beside the record read, and the panels show +placeholders until those counts land. A panel counted at 0 reads no rows, and two or more of +them fold into one line. A panel above 0 reads its first three rows once it scrolls into view, +asking only for the record's name field and one status or date field. In a drawer, the record +that **↑** / **↓** would show next has its related counts read ahead. ### Slotted pages @@ -219,12 +219,12 @@ synthesizer fills in the rest. A slotted page authors `slots`, not `regions`: | `actions` | The action bar (`record:quick_actions`) | | `alerts` | The alert banners above the body | | `highlights` | The highlights strip | -| `details` | The body of the Details tab; the other tabs stay synthesized | +| `details` | The record's details; the rest of the page stays synthesized | | `discussion` | The discussion panel | Each slot takes one component or an array of them, and replaces its part whole: there is no -deep merge. There is no `tabs` slot: the tab strip is composed from the object's fields and -relationship flags. To change which related lists appear or their order, set `relatedList` / +deep merge. There is no `tabs` slot: the page's panels are composed from the object's fields +and relationship flags. To change which related lists appear or their order, set `relatedList` / `relatedList: 'primary'` on the child's reference field; a page that needs its own strip is `kind: 'full'`, drawn as authored. `slots.tabs`, `slots.related` and `slots.relatedLists` are refused by name, and `os migrate meta --from 17` drops a stored `slots.tabs`. diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index 00504de0656..dc34c83ab98 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -213,7 +213,7 @@ "capture the create request/response (expect POST /api/v1/data/showcase_account). The payload also carries the select defaults of fields the step leaves untouched — sales_region:'amer' (its AMER option is default:true, examples/app-showcase/src/data/objects/account.object.ts) — so expect that key in the body you diff (QA run #24556)", "re-read server-side: GET /api/v1/data/showcase_account?$filter=[[\"name\",\"=\",\"os-qa-\"]] — field-by-field vs the submitted payload", "open the record detail; edit exactly one field (annual_revenue); submit it (an edit form's button reads 'Update'); capture the PATCH /api/v1/data/showcase_account/; re-read the full row via GET /api/v1/data/showcase_account/", - "read the record's field-change history on the record page's timeline stream, under its Changes chip (变更). Record page v3 draws no tab named History at any width (#27055). Where the stream sits depends on the record view's width (ui/packages/plugin-detail/src/synth/recordPageColumns.ts#recordPageTierFor), the window minus the console sidebar: at 760 px and wider it is resident (the Timeline column, or, while the reader's timeline switch is off, the Activity card at the foot of the related column); narrower, it is the Timeline tab. A 1440×900 window is a column tier whatever the sidebar does. Screenshot first, then read the change lines", + "read the record's field-change history on the record page's timeline stream, under its Changes chip (变更). Record page v3 draws no tab named History at any width (#27055). The stream's whole face, with its chips, is the 动态 (Activity) tab: open it from the header of the 动态 panel, the first panel of the record page's right column (below the details in one column under 760 px, ui/packages/plugin-detail/src/synth/recordPageColumns.ts#recordPageTierFor), or by ?related=timeline (#27524). The panel itself shows only the newest lines, with no chips. Screenshot first, then read the change lines", "clone the record: POST /api/v1/data/showcase_account//clone (optionally with {overrides:{name:'os-qa--clone'}}); capture the 201 result (new id ≠ source, sourceId, record); re-read the clone via GET /api/v1/data/showcase_account/", "RLS probe on clone: as a persona WITHOUT read on the source row (a permission-zoo persona, or a non-owner under owner-RLS), POST the same clone route and capture the refusal — expect 404 RECORD_NOT_FOUND, never a silent duplicate (record blocked(fixture) if no non-reading persona is bound on this boot)", "delete via the row/detail action; confirm the dialog; re-read via the filtered GET. ⚠️ If clause 7's recipe gave the member an own-row invoice pointing at THIS account, the delete answers 409 DELETE_RESTRICTED: the invoice's account lookup is required, and the engine turns a required set_null into restrict (packages/objectql/src/engine.ts). Point that invoice at a seeded account instead (the recipe's step 5 says so)", @@ -3725,7 +3725,7 @@ "steps": [ "open a record; run the packaged undoable action (fixtures.requires[0]) on it; confirm the success toast shows an Undo button. An inline edit offers none by design", "click Undo (or press Ctrl+Z); capture the resulting update; re-read the record over the API and confirm the prior value is restored", - "read the record's field-change history on the record page's timeline stream, under its Changes chip (变更). Record page v3 draws no tab named History at any width (#27055). Where the stream sits depends on the record view's width (ui/packages/plugin-detail/src/synth/recordPageColumns.ts#recordPageTierFor), the window minus the console sidebar: at 760 px and wider it is resident (the Timeline column, or, while the reader's timeline switch is off, the Activity card at the foot of the related column); narrower, it is the Timeline tab. A 1440×900 window is a column tier whatever the sidebar does. Also read sys_audit_log for the record over the API, and confirm a revert entry was added (a forward write, not a silent rollback)", + "read the record's field-change history on the record page's timeline stream, under its Changes chip (变更). Record page v3 draws no tab named History at any width (#27055). The stream's whole face, with its chips, is the 动态 (Activity) tab: open it from the header of the 动态 panel, the first panel of the record page's right column (below the details in one column under 760 px, ui/packages/plugin-detail/src/synth/recordPageColumns.ts#recordPageTierFor), or by ?related=timeline (#27524). The panel itself shows only the newest lines, with no chips. Also read sys_audit_log for the record over the API, and confirm a revert entry was added (a forward write, not a silent rollback)", "press Ctrl+Shift+Z (redo); confirm the edited value is restored", "CONCURRENT-CLOBBER probe: in session A make the undoable edit; in session B change the SAME field and save; back in A, click Undo", "observe and FLAG whether A's undo carries an OCC guard or silently overwrites B's value" @@ -5171,7 +5171,7 @@ "boot the showcase on its own port and DB; sign in as admin; open a seeded account record", "edit the TRACKED select `industry` (e.g. Technology → Finance); save; read the record's sys_activity rows over the API and open the timeline", "edit an UNTRACKED field (e.g. website) alone; save; re-read sys_activity for the record", - "record the network log from page load, open the account record and select its timeline stream's Changes chip (变更). Record page v3 has no History tab at any width (#27055). Where the stream sits depends on the record view's width (ui/packages/plugin-detail/src/synth/recordPageColumns.ts#recordPageTierFor), which is the window minus the console sidebar. At 760 px and wider it is resident: the Timeline column, or, while the reader's timeline switch is off, the Activity card at the foot of the related column. The switch is off by default for an object that is not worked, and showcase_account is not one (ui/packages/plugin-detail/src/synth/recordPageColumns.ts#recordTimelineDefaultReason answers null). Narrower, it is the Timeline tab. Measured on this item's re-point: a 1440×900 window gave 1184 px (sidebar expanded) and a 1000×800 window 952 px (sidebar as its icon rail), both columns with no tab strip; only a 700×800 window drew the tabs (Details / Related / Timeline). Then open a seeded task's detail and read the network log again", + "record the network log from page load, open the account record and select its timeline stream's Changes chip (变更). Record page v3 has no History tab at any width (#27055). The chips are on the stream's whole face, the 动态 (Activity) tab: open it from the header of the 动态 panel, the first panel of the record page's right column at every width (below the details in one column under 760 px, ui/packages/plugin-detail/src/synth/recordPageColumns.ts#recordPageTierFor), or by ?related=timeline (#27524). No width draws a tab strip on the synthesized page. Then open a seeded task's detail and read the network log again", "GET /api/v1/auth/me/permissions and compare apiOperations for showcase_account vs showcase_task" ], "acceptance": [ @@ -5190,7 +5190,7 @@ { "clause": "the record page's sys_audit_log field-change history gates on OBJECT-level enable.trackHistory, both ways: the account detail (flag true) reads sys_audit_log and its timeline stream's Changes chip lists the tracked edits as old → new lines with display values; the task detail (no enable block — per-field flags alone do not summon it) makes NO sys_audit_log read", "oracle": "network", - "verify": "the network log after settle: one sys_audit_log read for the account record (at page load in a column tier; in the tabbed tier when the Timeline tab opens, or at load under ?tab=timeline) and none for the task; screenshot the account's Changes chip showing the industry edit. Gating ruled at ui/packages/app-shell/src/views/RecordDetailView.tsx (historyEnabled: enable.trackHistory explicitly true, sys_audit_log registered, the viewed object not sys_audit_log itself; and the read serves only the synthesized page's stream, never an assigned page); the read's rows reach the stream through RecordFieldChangesContext as field_change items (ui/packages/plugin-detail/src/recordTimelineStream.ts#auditEntriesToFeedItems). The task half is over-determined (knownGaps): record which gate the verdict rests on. ⛔ An empty Changes chip is never an absence oracle: the chip also lists the stream's sys_activity created/updated rows (ACTIVITY_TYPE_TO_FEED_TYPE maps both to field_change). Measured on a seeded account, the chip showed a sys_activity 'Updated Account …' row beside the audit read. ⛔ Nor is a History tab: record page v3 removed it at every width (#27055; buildDefaultPageSchema.test.ts asserts 'no Activity or History tab'), so looking for one fails a working product (QA run #27438)", + "verify": "the network log after settle: one sys_audit_log read for the account record (at page load, at every width: the 动态 panel is on screen at open since #27524) and none for the task; screenshot the account's Changes chip showing the industry edit. Gating ruled at ui/packages/app-shell/src/views/RecordDetailView.tsx (historyEnabled: enable.trackHistory explicitly true, sys_audit_log registered, the viewed object not sys_audit_log itself; and the read serves only the synthesized page's stream, never an assigned page); the read's rows reach the stream through RecordFieldChangesContext as field_change items (ui/packages/plugin-detail/src/recordTimelineStream.ts#auditEntriesToFeedItems). The task half is over-determined (knownGaps): record which gate the verdict rests on. ⛔ An empty Changes chip is never an absence oracle: the chip also lists the stream's sys_activity created/updated rows (ACTIVITY_TYPE_TO_FEED_TYPE maps both to field_change). Measured on a seeded account, the chip showed a sys_activity 'Updated Account …' row beside the audit read. ⛔ Nor is a History tab: record page v3 removed it at every width (#27055; buildDefaultPageSchema.test.ts asserts 'no Activity or History tab'), so looking for one fails a working product (QA run #27438)", "evidence": "the two network logs + the account's Changes screenshot" }, { diff --git a/examples/app-showcase/e2e/detail-shapes.spec.ts b/examples/app-showcase/e2e/detail-shapes.spec.ts index 2961a092371..ffd8dc2fc10 100644 --- a/examples/app-showcase/e2e/detail-shapes.spec.ts +++ b/examples/app-showcase/e2e/detail-shapes.spec.ts @@ -13,10 +13,11 @@ import { test, expect } from '@playwright/test'; * - stageField:false → NO `record:path` stepper despite a `status` field * named to bait the heuristic * - related-heavy → related lists surface by prominence (ADR-0085), in the - * tabbed tier as record page v3 draws it (objectstack#27055, - * Target 8): a `relatedList: 'primary'` list is a body - * table in the Details tab, the rest sit behind the - * Related tab and self-fetch when it is shown + * one-column stack tier (objectstack#27524): a + * `relatedList: 'primary'` list is a body table in the + * details (objectstack#27055, Target 8), the rest are + * panels of the right column below them and read their + * rows when they come into view * * Runs against the console the backend serves at /_console (baseURL from * playwright.config.ts; storageState carries the admin session). The @@ -157,27 +158,28 @@ test('stageField:false: status renders as a plain field, no stepper', async ({ p expect(errors, 'uncaught page errors on stageField:false detail').toEqual([]); }); -test('ungrouped + related-heavy: flat details and related-list tabs on Contoso', async ({ page }) => { +test('ungrouped + related-heavy: flat details and the related panels on Contoso', async ({ page }) => { // The synthesized record page lays itself out by the width of the view it - // occupies (objectstack#26678): the tab strip this case opens exists only - // below the two-column floor, a record view narrower than - // `RECORD_PAGE_TWO_COLUMN_MIN_WIDTH` (760 px, `@object-ui/plugin-detail`). - // The case stays in that tabbed tier on purpose: the column tiers are what - // every other page of this smoke draws at the Desktop Chrome viewport, and - // the tab strip is also what the drawer and split pane draw at any width. + // occupies, by one rule (objectstack#26678, objectstack#27524): details | + // the right column from `RECORD_PAGE_TWO_COLUMN_MIN_WIDTH` (760 px, + // `@object-ui/plugin-detail`), and ONE column below it — the details, then + // the right column's panels in their order. No tier draws a tab strip. The + // case stays in that stack tier on purpose: the column tiers are what every + // other page of this smoke draws at the Desktop Chrome viewport, and the + // stack is also what the split pane (720 px) and a phone draw. // // The window is derived, not picked. Measured on this harness (a fresh // browser context, so no stored sidebar cookie): from a 768 px window // (`MOBILE_BREAKPOINT`, the desktop shell) to 1000 px the console sidebar // came up as its 48 px icon rail, and the record view was the window minus - // that rail — two columns at 808 px (760 of view), tabs at 800 px (752). - // So the desktop tabbed tier is a window of 768 to 807 px, and an expanded + // that rail — two columns at 808 px (760 of view), one at 800 px (752). + // So the desktop stack tier is a window of 768 to 807 px, and an expanded // sidebar (256 px) only narrows the view further. 780 sits inside it with // headroom on both sides: 732 px of view against the 760 floor, 12 px above // the mobile breakpoint. ⛔ Not 1200 px: since objectstack#27473 moved the - // floor to a 760 px view, 1200 draws columns and no tablist. ⛔ Not below - // 768 px: that is the mobile shell, where the off-canvas sidebar gives the - // view the whole window (760 px measured as two columns). + // floor to a 760 px view, 1200 draws two columns. ⛔ Not below 768 px: that + // is the mobile shell, where the off-canvas sidebar gives the view the + // whole window (760 px measured as two columns). await page.setViewportSize({ width: 780, height: 800 }); const errors = await openRecord(page, recordUrl('showcase_account', contosoId)); @@ -185,28 +187,31 @@ test('ungrouped + related-heavy: flat details and related-list tabs on Contoso', // record:path renders a desktop + a mobile variant — assert the visible one. await expect(page.locator(PATH_STEPPER).first()).toBeVisible(); + // One column and no tab strip: the details, then the right column. + await expect(page.locator('[data-record-page-tier]')).toHaveAttribute('data-record-page-tier', 'stack'); + await expect(page.locator('[data-record-columns]')).toHaveAttribute('data-record-columns-shown', 'details'); + await expect(page.getByRole('tab', { name: /Details|Related|Invoices|Projects/i })).toHaveCount(0); + // Related lists surface by prominence (ADR-0085), as record page v3 draws // them (objectstack#27055, Target 8): Invoices and Projects are // `relatedList: 'primary'` (CORE relationships), so each is a body table in - // the Details tab, with its rows read at open; the rest of Contoso's lists - // (Contacts and the Field Zoo lookups) sit behind the Related tab. - const detailsTab = page.getByRole('tab', { name: /Details/i }); - const relatedTab = page.getByRole('tab', { name: /^Related/ }); - await expect(detailsTab).toBeVisible(); - await expect(detailsTab).toHaveAttribute('aria-selected', 'true'); - await expect(relatedTab).toBeVisible(); - await expect(page.getByRole('tab', { name: /Invoices|Projects/i })).toHaveCount(0); - const detailsPanel = page.getByRole('tabpanel', { name: /Details/i }); - const invoicesTable = detailsPanel + // the details, with its rows read at open; the rest of Contoso's lists + // (Contacts and the Field Zoo lookups) are panels of the right column, + // after its first panel, 动态 (objectstack#27524, Target 5). + const details = page.locator('[data-record-column="details"]'); + const invoicesTable = details .getByRole('table') .filter({ has: page.getByRole('columnheader', { name: 'Invoice Number' }) }); - const projectsTable = detailsPanel + const projectsTable = details .getByRole('table') .filter({ has: page.getByRole('columnheader', { name: 'Project Name' }) }); await expect(invoicesTable.getByRole('cell', { name: 'INV-1002', exact: true })).toBeVisible(); await expect(projectsTable.getByRole('cell', { name: 'Data Platform', exact: true })).toBeVisible(); + const rightColumn = page.locator('[data-record-column="right"]'); + await expect(rightColumn.locator(':scope > *').first()).toHaveAttribute('data-record-timeline-panel', ''); + await expect(rightColumn.getByRole('region', { name: /^(Invoices|Projects)$/ })).toHaveCount(0); - // Ungrouped shape: the Details body is flat — no fieldGroup section cards. + // Ungrouped shape: the details body is flat — no fieldGroup section cards. // (Contoso declares no fieldGroups; spot-check a body field label.) await expect(page.getByText('Basics', { exact: true })).toHaveCount(0); @@ -220,13 +225,11 @@ test('ungrouped + related-heavy: flat details and related-list tabs on Contoso', expect(footerText, 'footer dangles "Created by" without an actor').not.toMatch(/Created by/); expect(footerText).toMatch(/Created/); - // The non-primary lists self-fetch lazily when their tab is shown: the - // Related tab's Contacts list reads Contoso's seeded contacts. - await relatedTab.click(); - await expect(relatedTab).toHaveAttribute('aria-selected', 'true'); - const relatedPanel = page.getByRole('tabpanel', { name: /^Related/ }); - await expect(relatedPanel).toBeVisible(); - await expect(relatedPanel.getByRole('cell', { name: 'Carl Fox', exact: true })).toBeVisible(); + // A panel reads its rows once it is in view: below the details here, so + // the Contacts panel reads Contoso's seeded contacts when scrolled to. + const contactsPanel = rightColumn.getByRole('region', { name: 'Contacts', exact: true }); + await contactsPanel.scrollIntoViewIfNeeded(); + await expect(contactsPanel.locator('[data-related-card-row]').filter({ hasText: 'Carl Fox' })).toBeVisible(); expect(errors, 'uncaught page errors on related-heavy detail').toEqual([]); }); diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx index 3c0f092bcc0..a90beab9cc8 100644 --- a/ui/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx +++ b/ui/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx @@ -241,14 +241,14 @@ const metadata = { const EN: I18nConfig = { defaultLanguage: 'en', detectBrowserLanguage: false }; -function openRequestPage() { +function openRequestPage(search = '') { const ds = routeApprovalRecordReads(host as unknown as DataSource, { fetch: transport, baseUrl: 'http://test.local/api/v1', }); return render( - + { - openRequestPage(); - const related = await screen.findByRole('tab', { name: /Related/ }, { timeout: 8000 }); - fireEvent.mouseDown(related); - fireEvent.click(related); + // The actions list's whole face (`?related=`): the slotted page is laid + // out since objectstack#27524, so the list is a right-column panel and no + // longer a Related tab to open. + openRequestPage(`?related=${APPROVAL_ACTION_OBJECT}.request_id`); expect(await screen.findByText('Budget checked', undefined, { timeout: 8000 })).toBeTruthy(); expect(wire.some((w) => w.path === `/api/v1/approvals/requests/${REQ}/actions`)).toBe(true); expect(host.find.mock.calls.some(([resource]) => resource === APPROVAL_ACTION_OBJECT)).toBe(false); diff --git a/ui/e2e/live/record-history-display.spec.ts b/ui/e2e/live/record-history-display.spec.ts index d3554117577..d9cfdca8b8b 100644 --- a/ui/e2e/live/record-history-display.spec.ts +++ b/ui/e2e/live/record-history-display.spec.ts @@ -9,8 +9,10 @@ import { test, expect, type APIRequestContext } from '@playwright/test'; * audit payloads: ISO datetimes ("2026-08-04T12:00:00.000Z"), raw lookup id * arrays, raw select values, and "Unknown user" for every entry. Record page * v3 (objectstack#27055) removed the History tab at every width: the same - * enriched audit entries are now the field_change lines of the ONE timeline - * stream, under its Changes (变更) chip. + * enriched audit entries are now the field_change lines of the ONE 动态 + * (Activity) stream, under its Changes (变更) chip — in the 动态 tab since + * objectstack#27524 (the right column's 动态 panel shows the newest lines, + * no chips). * * Targets `showcase_account`, the showcase's `enable.trackHistory` object — * the console reads `sys_audit_log` only for an object with that flag. Its @@ -42,29 +44,12 @@ async function seededUsers(request: APIRequestContext): Promise<{ actor: SysUser /** * The synthesized record page lays itself out by the width of the view it - * occupies (objectstack#26678): a column tier at `RECORD_PAGE_TWO_COLUMN_MIN_WIDTH` - * (`@object-ui/plugin-detail`) and wider, the tab strip below it. - * - * A 1440 px window is a column tier whatever the console sidebar does: 1184 px - * of record view with the sidebar expanded (objectstack#27241's three-column - * window), more with it collapsed. ⛔ Not a window near 1000 px: whether that - * is the tabbed tier depends on the sidebar's state. Measured on the showcase - * at 1000×800 the sidebar came up as its icon rail and the page drew two - * columns with no tab strip at all, so a spec that waited for a tab there - * timed out. - * - * In a column tier the stream is NOT resident by default for this object. The - * reader's Timeline switch (the header's Panels control) defaults to off for - * an object that is not worked (`recordTimelineDefaultReason`: showcase_account - * declares no `stageField`, no lifecycle option `kind` and no `enable.files`), - * and a switched-off timeline folds into the Activity (动态) card at the related - * column's foot — closed, its blocks not mounted until the reader opens it - * (`FoldedTimelineCard`, objectstack#27052). Measured under the nightly harness - * (a built Console on a fresh database, so no stored switch): at 1440×900 the - * page drew three columns, the Panels control read Timeline unpressed, and the - * Activity card was folded, so a spec that waited for the Changes chip at open - * timed out. The spec opens that card the way a reader does, and asserts each - * step: the switch is off, the card starts closed, the click opens it. + * occupies, by one rule (objectstack#26678, objectstack#27524): details | the + * right column from `RECORD_PAGE_TWO_COLUMN_MIN_WIDTH` (`@object-ui/plugin-detail`), + * one column below it, no tab strip at any width. The whole stream, chips and + * all, is the 动态 tab (`?related=timeline`) at every width, so the spec opens + * it by the URL and needs no switch. A 1440 px window keeps the record header + * and the face at the width the QA report read. */ test.use({ viewport: { width: 1440, height: 900 } }); @@ -92,22 +77,11 @@ test('the timeline stream\'s Changes chip shows display values (localized dates, expect(res.ok()).toBeTruthy(); } - // 2) Open the synthesized record page, open the folded Activity card that - // holds the stream (see the viewport note above), and select the + // 2) Open the synthesized record page on its 动态 tab and select the // stream's Changes chip — the field changes alone (the audit entries, // plus the activity stream's own `created` row). - await page.goto(`/apps/showcase_app/showcase_account/record/${id}`); - const timelineSwitch = page.locator('[data-panel-toggles] [data-panel-toggle="timeline"]'); - // The page-open wait: the same 15 s budget the spec gives its first element. - await expect(timelineSwitch, 'the Timeline switch is off for an object that is not worked').toHaveAttribute('aria-pressed', 'false', { timeout: 15_000 }); - const activityCard = page.getByRole('region', { name: /^(Activity|动态)$/ }); - // The toggle is the card heading's button: once open, the stream inside the - // card has an "Activity" filter chip of its own. - const activityToggle = activityCard.getByRole('heading', { name: /^(Activity|动态)$/ }).getByRole('button'); - await expect(activityToggle, 'the switched-off timeline is folded into a closed Activity card').toHaveAttribute('aria-expanded', 'false'); - await activityToggle.click(); - await expect(activityToggle).toHaveAttribute('aria-expanded', 'true'); - const stream = activityCard.getByRole('region', { name: /^(Timeline|时间线)$/ }); + await page.goto(`/apps/showcase_app/showcase_account/record/${id}?related=timeline`); + const stream = page.locator('[data-related-enlarged="timeline"]'); const changesChip = stream.locator('[data-stream-filter="changes"]'); await changesChip.waitFor({ state: 'visible', timeout: 15_000 }); await changesChip.click(); diff --git a/ui/packages/app-shell/src/utils/workspacePreference.hook.test.tsx b/ui/packages/app-shell/src/utils/workspacePreference.hook.test.tsx index 5eb467e11ee..e039460151c 100644 --- a/ui/packages/app-shell/src/utils/workspacePreference.hook.test.tsx +++ b/ui/packages/app-shell/src/utils/workspacePreference.hook.test.tsx @@ -18,7 +18,7 @@ import * as React from 'react'; import { describe, it, expect, afterEach, vi } from 'vitest'; import { render, screen, cleanup, act, fireEvent } from '@testing-library/react'; import { - RECORD_TIMELINE_COLUMN_PREFERENCE, + RECORD_RELATED_COLUMN_PREFERENCE, objectPreferenceScope, useWorkspacePreference, workspacePreferenceStorageKey, @@ -28,10 +28,10 @@ import { const LEAD = objectPreferenceScope('crm_lead'); const PRODUCT = objectPreferenceScope('crm_product'); -const LEAD_KEY = workspacePreferenceStorageKey(RECORD_TIMELINE_COLUMN_PREFERENCE, LEAD); +const LEAD_KEY = workspacePreferenceStorageKey(RECORD_RELATED_COLUMN_PREFERENCE, LEAD); function Probe({ id, scope, fallback }: { id: string; scope: WorkspacePreferenceScope; fallback: PreferenceSwitch }) { - const [value, setValue] = useWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, scope, fallback); + const [value, setValue] = useWorkspacePreference(RECORD_RELATED_COLUMN_PREFERENCE, scope, fallback); return (