Skip to content

Commit bf74607

Browse files
feat(plugin-detail)!: a tab row under the record header; an open panel face is the active tab (#28394)
domain:ui#2 · os-tesla · session_01L9qibzGCEznLfiQm2T5icF <!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoFyFEfkhs8RW6bDg2qQg8D2)_ Part of #27524 Clause-②: yes (narrowing) Metadata-protocol: none Skills-docs: none — the narrowing retires a ui component export and an i18n key, not an author-writable metadata surface; `git grep` finds neither `ArrangedRelatedTab` nor `detail.relatedEnlargedBack` under `skills/`, `content/` or `docs/`. ## 维护者速读 - 改了什么:记录页头部底边多了一行常驻标签:「概览」、动态,再按读者的排列顺序列出每个关联面板,各带条数。能放几个由这一行自己的宽度算(窗口、侧栏、分屏拖宽都会重算),放不下的收进「+N ▾」,隐藏面板排在下拉末尾;当前标签永远在行上。放大视图就是当前标签:整宽列表画在标签行下面,头部不动,地址仍是 `?related=对象.字段`(动态是 `?related=timeline`),「概览」或 Esc 返回。 - 为什么改:#27524 的 Target 8、9(画布 60 定稿),四个串行 PR 中的第三个;顶栏在 PR ④。 - 风险与回滚:只动 ui 包(plugin-detail、app-shell、十个语言包)和文档。开页时现在给所有关联面板取条数,包括隐藏的和 ⊟ 关掉时的(标签要显示条数),同一个计数缓存去重。退役了 `ArrangedRelatedTab` 导出和 `detail.relatedEnlargedBack` 一个键,只有控制台自己用过。回滚即 revert 本 PR。 - 先看:`ui/packages/plugin-detail/src/RecordTabRow.tsx` 与 `synth/recordTabRow.ts`(标签行和放几个的规则);`RecordPageColumns.tsx`(标签从哪来、当前标签面);app-shell `RecordDetailView.tsx` 的 `openEnlargedRelated`(标签间切换替换历史)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-detail/src/synth/__tests__/recordTabRow-27524.test.ts packages/plugin-detail/src/__tests__/RecordTabRow-27524.test.tsx packages/plugin-detail/src/__tests__/RecordPageColumns.tabRow-27524.test.tsx packages/app-shell/src/views/RecordDetailView.recordTabRowUrl-27524.test.tsx`,预期 4 个文件、26 个测试全绿。 - 需要你做的:无,席位落地。 ## What changes Record page v4, Target items 8 and 9, PR ③ of the four serial PRs for #27524. It is cut from `main` after PR ② (#28116, `3d2da4fd1a`) and leaves `main` coherent on its own. The top bar (Target 7) is PR ④. | Target item | Where | |---|---| | 1, 2, 3, 10, 11, 12 | PR ① (merged) | | 4, 5, 6 | PR ② #28116 (merged) | | 8 the tab row | **this PR** | | 9 enlarged = the active tab | **this PR** | | 7 the top bar (and ⊟ moving to it) | PR ④ | **8. The tab row** (`RecordTabRow.tsx`, `synth/recordTabRow.ts`, `RecordPageColumns.tsx`). A row at the bottom edge of the record header, on every face the record is drawn in: the page, the split pane, the overlay drawer, the maximized pane and a phone. - The tabs are 「概览」, then 动态, then one tab per related panel in `record.related.arrangement` order (`resolveRelatedArrangement`, the order the right column draws). Each tab carries its count: 动态 the stream's size, a related tab its list's count. - The counts are one set. The page asks `useRelatedCounts` once for every panel, hidden ones included, and hands the same counts to the right column (`RelatedCardStack` takes a new optional `counts` prop and then asks for none itself). 动态's count is the stream's own, reported by the `record:activity` block through `RecordTimelinePanel`'s new `count` / `onCount` props in the peek face, and through `RecordTimelineCountContext` on the 动态 tab while its chip is 全部. - How many tabs show is computed from the row's own width. A `ResizeObserver` on the row re-measures on every resize (window, sidebar, a pane dragged wider), against a hidden measuring copy of every tab and of the widest 「+N ▾」. The rule is the pure `layoutRecordTabRow`: - every tab fits and no panel is hidden: all of them, no dropdown; - otherwise the budget is the row less the trigger and one gap. 「概览」 and the active tab are reserved, then tabs are taken in order until the first that does not fit; - 「概览」 and at least one more tab always show; - the active tab is always on the row. When it would have folded it takes the last slot, and the tab it displaced folds. - 「+N ▾」 is a dropdown listing the folded tabs with their counts, then a 「已隐藏」 heading and the hidden panels' tabs. Choosing one opens it, and it becomes the active tab. - The row is a `tablist` named 「记录页签」 and a single tab stop: ← → Home End move between the shown tabs, Enter or Space opens one. - A body table opened in place (its ⤢ in the details) gets a tab of its own while it is open. - No row is drawn where the view gives no way to open a face (`onEnlargeRelated` absent), or where there is nothing but 「概览」. **9. The enlarged face is the active tab** (`RecordPageColumns.tsx`, app-shell `RecordDetailView.tsx`). - Choosing a tab, a panel's header or 「查看全部 N 条」 draws that panel's whole face below the tab row at the page's full width. The header stays as it is: no compaction, no modal, no drill-down, no path in the top bar. - The face is a `role="tabpanel"`. The ‹ back line and its own `h2` are gone, because the tab names it. Esc (window capture, as in PR ②) and 「概览」 return to the page. - The URL stays `?related=object.field`, `?related=timeline` for 动态. Leaving 「概览」 pushes one history entry. Moving from tab to tab now replaces that entry, keeping its state, so one Back, Esc or 「概览」 always returns to the page. A deep link to a tab opens it as the active tab, and 「概览」 then replaces the address, since there is nothing to pop. - In the pane the face fills the pane body under the same row (no change to `recordDrawerStack.tsx`; the pane draws the same view). **Retired.** - `ArrangedRelatedTab` (plugin-detail entry): it laid the reader's arrangement over the 关联 tab of the tabbed tier that PR ② retired, and only the console's record view imported it. Its full-face chrome path in `RelatedList` (`arrangedFull`) and its scope branch in the `record:related-list` renderer go with it. A page still drawn as its strip is drawn as written. - `detail.relatedEnlargedBack` leaves all ten packs. **Arrives in ten packs:** `detail.recordTabs`, `detail.recordTabOverview` (概览 · Overview · 概要 · 개요 · Übersicht · Aperçu · Resumen · Visão geral · Обзор · نظرة عامة), `detail.recordTabsMore` (with each pack's plural forms) and `detail.recordTabsHidden`. **Declared.** `Clause-②: yes (narrowing)` (the claim said no; measured on the diff, the retired export and the retired key narrow two published surfaces). Two changesets: - `27524-record-page-tab-row.md` (plugin-detail, app-shell): `ArrangedRelatedTab` under `not-required (runtime-export-retired …)` with its FROM → TO line. - `27524-record-page-tab-row-i18n.md` (i18n): `TranslationKeys` loses one key, under `not-required (runtime-interface-only …#TranslationKeys)`, as PR ① and ② did. **Reads at open.** A record open now counts every related panel, the hidden ones and those behind ⊟ off included (before, only the panels on screen), because their tabs show the counts. The requests are the same `RelatedCountStore` count requests, deduplicated with the panels' own. Nothing else a record open fetches changes. (Seat 2's #27627 / #28321 touches the data adapter; this PR does not.) **File surface beyond the order.** `RelatedList.tsx` (the dead `arrangedFull` path), `renderers/record-related-list.tsx` (the retired scope branch), `index.tsx` (the export), `recordTimelineStream.ts` and `renderers/record-activity.tsx` (动态's count, for its tab), `RelatedCardStack.tsx` and `RecordTimelinePanel.tsx` (shared counts), the plugin-detail README and `content/docs/ui/pages.mdx`. `navigation-overlay.tsx` (#28294), the top bar (④) and #27545's split / overlay / maximize mechanics are untouched. ## Tests New: - `plugin-detail/src/synth/__tests__/recordTabRow-27524.test.ts` (6): the layout rule at a wide, a medium and a narrow width; the active tab displacing the last tab, fitting beside them, or keeping its place; 「概览」 and the active tab at the narrowest; hidden panels forcing the dropdown and taking the trigger's width. - `plugin-detail/src/__tests__/RecordTabRow-27524.test.tsx` (7), with given widths and a stub `ResizeObserver`: the shown count follows the width and is recomputed on resize; the active tab is always on the row; the count chips; 「+N ▾」 lists the folded tabs with counts, then the hidden ones, and choosing one selects it; a hidden panel makes the dropdown on a wide row; clicking; the keyboard (one tab stop, ← → Home End). - `plugin-detail/src/__tests__/RecordPageColumns.tabRow-27524.test.tsx` (10), the real `RecordPageColumns`: the tabs in arrangement order with hidden panels in the dropdown; counts for every panel from one request each (hidden ones included) and 动态's from the stream; selecting a tab and 「概览」; no row without a way to open; the stack tier; the enlarged face keeps the header, is a `tabpanel` and draws no back line or title; the 动态 face; a body table's tab; Esc. - `app-shell/src/views/RecordDetailView.recordTabRowUrl-27524.test.tsx` (3), a real `RecordDetailView` on the browser's own history: a tab pushes one entry and tab to tab replaces it (three tabs visited, one entry), 「概览」 returns; Esc and the browser's Back return in one step at 720; a deep link opens its tab and 「概览」 replaces the address without growing history. Rewritten to the new anatomy: - `RecordDetailView.recordPageTiers-26678` (6): the page's only tablist is its tab row on every tier and embedded (`overview, timeline, showcase_task.project, showcase_note.project`), 「概览」 active; `?related=timeline` makes 动态 the active tab. - `RelatedCardStack-27055`: the enlarged face's back-button case reads 「概览」 and Esc; the `ArrangedRelatedTab` cases leave with the component. - `RecordPageColumns.enlargedEscapeInPane-27524`: Esc is pressed on the face, which no longer draws a back button to focus. - `RelatedPanel.header-27524`: the case for the arranged full face (the retired tabbed tier's 关联 tab) leaves with that face. <details><summary>Ablations (each through <code>node scripts/ablation-replace.mjs</code>, restore proven blob == HEAD)</summary> - A1, the active tab's reservation removed from `layoutRecordTabRow` (`chosen.add(active)`): `Tests 2 failed | 11 passed (13)`, the rule's and the drawn row's "active tab always shown" cases. - A2, hidden panels dropped from the page's count targets: `Tests 1 failed | 9 passed (10)`, "each panel tab carries its count — the hidden ones counted too". - A3, `openEnlargedRelated` pushing on every tab change (the replace branch removed): `Tests 2 failed | 1 passed (3)`, the one-entry history case and the Esc / Back case. </details> ## Acceptance notes - Target 8: the row is on every face (page, pane, drawer, phone), 「概览」 then 动态 then the panels in arrangement order with counts; the count of shown tabs comes from the row's width with a `ResizeObserver`; the rest in 「+N ▾」 with counts; the active tab always shown, in the last slot when it would fold; 「概览」 plus at least one more always; hidden panels at the dropdown's end. - Target 9: the enlarged state is the active tab, the full-width list under the row; `?related=object.field` / `?related=timeline`; Esc or 「概览」 returns; not a modal, not a drill-down, no top-bar path, the header not compacted; in the pane it fills the pane body. - Screenshots on `examples/app-crm`, not HotCRM, as in ① and ②. Ten shots `*.png` in `/mnt/project-files/ui-seat-2/r2026-10-31/out/27524-pr3-shots/` (project files, not a repository branch), zh, through the console's dev server on this branch: - `page-1440` and `page-1200`: six tabs, no dropdown (概览 · 动态 · 活动列表 2 · 联系人列表 1 · 线索列表 1 · 商机列表 4). - `page-390`: 概览 · 动态 · 活动列表 · 联系人列表, then 「+2」; `page-390-more-open`: the dropdown lists 线索列表 1 and 商机列表 4; `page-390-folded-active`: 商机列表 chosen from it takes the last slot (联系人列表 folds, still 「+2」) and its list draws below. - `page-1440-enlarged` (联系人列表 active), `page-1200-enlarged` (商机列表 active, every column), `page-1440-timeline` (`?related=timeline`, the full stream with its chips). - `pane-720` and `pane-720-enlarged`: the split pane beside the list (row 623 px wide), all six tabs; switching tabs replaced the URL, Esc returned to 「概览」 with the pane still open, and Back then closed the pane. - Proxy preview review against the v4 boards at `a7825499aa` (RecordPageV4Enlarged, RecordDrawerV4Enlarged, and the rows on RecordPageV4Panels / TwoCol / RecordDrawerV4Peek): - Matches: the row's place (under the highlights, above the body), height 36 px, 13 px labels, the active tab's primary underline, the 10.5 px count chip, 「+N ▾」 at the row's end, no title or back line on the enlarged face, the header unchanged above it. - Differs, noted and not changed here: the enlarged list keeps `RelatedList`'s own card header (title, count, 新建) and its 查看全部 foot, where the board draws a bare toolbar (filter, 全部 / 我的 / 逾期, 新建, 导出) over a table filling the rest of the height. That toolbar is not something the related list has today. - On a deep link to another tab (`page-1440-enlarged`), 动态's tab has no count until the stream is drawn once: its count is the stream's own size, which only the stream reads. - The 动态 count reads 0 in the shots because app-crm's Acme Corp has no activity; the chip is drawn for a known 0. ## Known gaps - The enlarged related list keeps `RelatedList`'s own card header (title, count, 新建) and its 查看全部 foot. The v4 board (RecordPageV4Enlarged / RecordDrawerV4Enlarged at `a7825499aa`) draws a bare toolbar there (filter, 全部 / 我的 / 逾期, 新建, 导出) over a table that fills the rest of the height. The related list has no such toolbar today; this PR does not add one. - On a deep link to another tab, 动态's tab has no count until the stream is drawn once (its count is the stream's own size). ## Verification <details><summary>Tests, type-check and the derived gates on 572d6b1</summary> Head `572d6b18fb`, change set vs the merge base `a878ff6c26`: 36 paths, 1,816 (+1,478 / −338) changed lines. - Under the verify lock: `pnpm --dir ui exec vitest run packages/plugin-detail packages/i18n` plus 226 test files across `ui/` that name a module, test id or key this PR changes (`RecordDetailView`, `RecordPageColumns`, `RelatedCardStack`, `ArrangedRelatedTab`, `relatedEnlargedBack`, `data-related-enlarged`, `RECORD_RELATED_ENLARGE`, `record-tab`; derived by `git grep`) → `Test Files 672 passed | 1 skipped (673)`, `Tests 6395 passed | 31 skipped (6426)`, VERDICT command-exit 0 (on `7a0555e2`; `572d6b18fb` only adds `objectName` to one pin's record context, and that file then ran `Tests 10 passed (10)`). A first broad run found the six `recordPageTiers-26678` cases (they asserted no tablist) and the retired full-face case in `RelatedPanel.header-27524`, both rewritten above. - `type-check` (`tsc --noEmit` + `tsc -p tsconfig.test.json`) of `@object-ui/i18n`, `@object-ui/plugin-detail` and `@object-ui/app-shell`, under the lock: each exit 0 on `572d6b18fb`. The first plugin-detail run was exit 2 on the new pin's missing `objectName` (TS2741), fixed in `572d6b18fb`. - eslint on the 31 added or modified `ts`/`tsx` files under `ui/`: 0 errors. `pnpm --dir ui run check:i18n-drift`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` → 66 commands, the same list on `c1734bc8` and on `572d6b18fb`. Each was run on `572d6b18fb` with its exit code captured before any pipe (the three heavy ones under the lock); all 66 are `:: exit 0`, `node scripts/check-sdui-manifest.mjs` among them. `--ran` reconcile: `66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN`. - Dogfood (the title has `!`), once on `572d6b18fb` under the lock: `pnpm --filter @objectstack/dogfood test` → VERDICT command-exit 0, `Test Files 361 passed | 3 skipped (364)`, `Tests 2917 passed | 28 skipped (2945)`. - CI on `572d6b18fb`: all 59 check-runs concluded success, or skipped by path filter or opt-in; `Test UI` (16/16), `Type Check UI`, `Lint UI`, `Live E2E UI` and `Build & E2E UI` among them. - `main` has moved 8 commits past the merge base; `git merge-tree --write-tree origin/main HEAD` is clean, and no merge was pushed. - Not run locally, CI's own: the whole `ui/` suite (Test UI; `app-shell` alone is 1,379 test files), the other type-check lanes, the repo-wide scans. ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs :: exit 0 node scripts/check-docs-section-name.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/check-section-landing-index.mjs :: exit 0 node scripts/check-section-landing-index.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0 pnpm --filter @objectstack/spec run check:docs :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-anchors :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:doc-frontmatter :: exit 0 pnpm check:docs-audit-scope :: exit 0 pnpm check:docs-redirects :: exit 0 pnpm check:docs-single-h1 :: exit 0 pnpm check:docs-spec-enumerations :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:published-readme-links :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:react-page-adapter-contract :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:role-word :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vendor-version-stamps :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm --dir ui run check:i18n-drift :: exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibzGCEznLfiQm2T5icF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a9f1452 commit bf74607

36 files changed

Lines changed: 1478 additions & 338 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/i18n': patch
3+
---
4+
5+
feat(i18n)!: the record page's tab row in all ten packs; the enlarged face's Back label leaves
6+
7+
Clause-②: yes (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (runtime-interface-only ui/packages/i18n/src/locales/en.ts#TranslationKeys) the key type of the console's own translation catalogue: no Zod schema, no spec declaration and no stored metadata reads it, so the compiler is the channel that reaches every caller -->
11+
12+
The record page's tab row (record page v4) arrives in all ten packs:
13+
14+
- `detail.recordTabOverview`: the row's first tab, 「概览」 · Overview · 概要 · 개요 · Übersicht · Aperçu · Resumen · Visão geral · Обзор · نظرة عامة.
15+
- `detail.recordTabs`: the row's accessible name.
16+
- `detail.recordTabsMore`, with its plural forms: the 「+N ▾」 trigger's accessible name.
17+
- `detail.recordTabsHidden`: the dropdown's heading over the hidden panels' tabs.
18+
19+
**BREAKING** (`@object-ui/i18n`, TypeScript only). The exported `TranslationKeys` (`typeof en`) loses `detail.relatedEnlargedBack`, the ‹ back line of the enlarged face that the active tab replaces. The key leaves every pack. A `t()` call that names it still compiles, because its key is a `string`, and reads the key back. Nothing replaces it: 「概览」 and Esc return.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
'@object-ui/app-shell': patch
4+
---
5+
6+
feat(plugin-detail)!: a tab row closes the record header — 概览, 动态, then each related panel with its count, as many as the row's width fits; an open panel face is the active tab
7+
8+
Clause-②: yes (narrowing)
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (runtime-export-retired @object-ui/plugin-detail#ArrangedRelatedTab) the retired operand is a component in a published ui package's entry that only the console's own record view imported, for the tabbed tier the record page no longer has; no packages/spec schema, no registered node type, no ADR-0087 conversion surface and no stored shape names it, so there is nothing for `objectstack migrate meta` to rewrite -->
12+
13+
The synthesized record page (record page v4) gets a persistent tab row at the bottom edge of its record header, on every face: the full page, the split pane, the overlay drawer, the maximized pane and a phone.
14+
15+
- **The tabs.** 「概览」 (Overview) first, then 「动态」 (Activity), then one tab per related panel in the reader's arrangement (`record.related.arrangement`), each with its count. 「动态」 shows the stream's size and a related tab its list's count. The counts come from the same request set the right column's panels read, so no relationship is counted twice.
16+
- **Width.** The row shows as many tabs as fit its own width, measured on every resize (the window, the sidebar collapsing, a pane dragged wider). The rest fold into 「+N ▾」, which lists them with their counts, and the hidden panels' tabs come at its end. 「概览」 and at least one more tab are always on the row. So is the active tab: when it would fold, it takes the row's last slot instead.
17+
- **The open face is the active tab.** Choosing a tab, a panel's header or 「查看全部 N 条」 draws that panel's whole face below the tab row, at the page's full width: the related list with every column, paging, sort and inline edit, or the 动态 stream with its chips. The top of the page and the record header stay as they are. The URL keeps `?related=<object>.<field>` and `?related=timeline`. 「概览」 or Esc returns. The face no longer draws a ‹ back line or a title of its own, because the tab names it. Moving from one tab to another replaces the history entry, so one Back, Esc or 「概览」 always returns to the page. Inside the pane the face fills the pane body, and maximizing widens it in place.
18+
- A body table that is open in place (its ⤢ in the details) takes a tab of its own while it is open.
19+
- **Reads.** The page now counts every related panel, including the hidden ones and those behind a closed ⊟, because their tabs show the counts.
20+
- A synthesized page whose tab strip cannot be laid out is drawn as written. Its 关联 tab no longer reorders its lists to the reader's arrangement, which the right column and the tab row now carry.
21+
22+
**BREAKING** (`@object-ui/plugin-detail`, TypeScript only). The only importer was the console's own record view. `ArrangedRelatedTab` leaves the package entry. It laid the reader's arrangement over the 关联 tab of the tabbed tier, which the record page no longer has. FROM `ArrangedRelatedTab` TO no wrapper: draw the page with `RecordPageColumns`, whose right column and tab row read the arrangement, or draw an unlaid page as written. A caller still naming it gets a compile error that names it.

‎content/docs/ui/pages.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,10 @@ its own. **动态** is one stream: the discussion, the activity and, with
190190
Its panel shows a one-line composer and the newest entries, and its header opens the whole
191191
stream (`?related=timeline`), filtered by All / Activity / Discussion / Changes. A related
192192
panel's header opens that list's whole table the same way (`?related=<object>.<field>`).
193+
Each of those faces is also a tab: a row of tabs closes the record header, **Overview** first,
194+
then **动态**, then one tab per related panel in the reader's order, each with its count. The
195+
row shows as many tabs as its width fits and folds the rest into a **+N** menu; the open tab
196+
is always on the row, and **Overview** or Esc returns to the page.
193197
Moving a child table into the record's body is therefore a one-word change on the
194198
relationship field, not a page-authoring task, and `relatedList: false` leaves a child off the
195199
page.

‎ui/packages/app-shell/src/views/RecordDetailView.recordPageTiers-26678.test.tsx‎

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@
2121
* three columns.
2222
* - ≥ 760 px: details | the right column at 320 px, the details grid at two.
2323
* - < 760 px (the split pane at 720, a phone at 390): ONE column, the details
24-
* then the right column's panels in their order. No tab strip, no rail.
24+
* then the right column's panels in their order. No rail, and no tab
25+
* strip but the record's own tab row under the header (Target 8), the
26+
* same on every tier: 「概览」, 动态, then the related panels.
2527
* - The embedded record (drawer / split pane) follows the same rule.
2628
* - The right column opens with the 动态 panel, so History is read at open in
27-
* every tier. `?tab=` addresses no strip; `?related=timeline` is the 动态
28-
* tab, the full stream.
29+
* every tier. `?tab=` addresses no strip (「概览」 stays the active tab);
30+
* `?related=timeline` is the 动态 tab, the full stream.
2931
*/
3032

3133
import * as React from 'react';
@@ -218,6 +220,24 @@ const rightPanels = (container: HTMLElement) =>
218220
(el) => (el.hasAttribute('data-record-timeline-panel') ? 'timeline' : el.getAttribute('data-related-card-id')),
219221
);
220222

223+
/**
224+
* The page's only tablist is its tab row (objectstack#27524, Target 8): the
225+
* same tabs on every tier, the active one as `active`.
226+
*/
227+
const expectTabRowOnly = (active = 'overview') => {
228+
const lists = screen.getAllByRole('tablist');
229+
expect(lists).toHaveLength(1);
230+
expect(lists[0].closest('[data-record-tab-row]')).not.toBeNull();
231+
const tabs = Array.from(lists[0].querySelectorAll('[role="tab"]'));
232+
expect(tabs.map((tab) => tab.getAttribute('data-record-tab'))).toEqual([
233+
'overview',
234+
'timeline',
235+
'showcase_task.project',
236+
'showcase_note.project',
237+
]);
238+
expect(lists[0].querySelector('[aria-selected="true"]')?.getAttribute('data-record-tab')).toBe(active);
239+
};
240+
221241
describe('RecordDetailView — one record page, laid out by its width (objectstack#26678, objectstack#27524)', () => {
222242
it('1440 px: details beside the right column at 380 px, the details grid at three columns; 动态 leads; History is read at open', async () => {
223243
const { container } = renderPage({ width: 1440 });
@@ -226,7 +246,7 @@ describe('RecordDetailView — one record page, laid out by its width (objectsta
226246
expect(shown(container)).toBe('details right');
227247
expect(columnsGrid(container)).toContain('23.75rem');
228248
expect(detailsGrid(container)).toContain('@lg:grid-cols-3');
229-
expect(screen.queryByRole('tablist')).toBeNull();
249+
expectTabRowOnly();
230250
expect(rightPanels(container)).toEqual(['timeline', 'showcase_task.project', 'showcase_note.project']);
231251
// No resident timeline column and no rail: the two columns are all.
232252
expect(column(container, 'timeline')).toBeNull();
@@ -253,19 +273,19 @@ describe('RecordDetailView — one record page, laid out by its width (objectsta
253273
// The same panels as the wide tier — no rail to open first.
254274
expect(rightPanels(container)).toEqual(['timeline', 'showcase_task.project', 'showcase_note.project']);
255275
expect(screen.queryByTestId('record-related-rail-toggle')).toBeNull();
256-
expect(screen.queryByRole('tablist')).toBeNull();
276+
expectTabRowOnly();
257277
});
258278

259279
it.each([
260280
['the split pane', 720],
261281
['a phone', 390],
262-
])('%s (%i px): one column — the details, then the 动态 panel and the related panels; no tabs; History is read at open', async (_name, width) => {
282+
])('%s (%i px): one column — the details, then the 动态 panel and the related panels; the tab row; History is read at open', async (_name, width) => {
263283
const { container } = renderPage({ width });
264284
await screen.findAllByText('Tasks', undefined, { timeout: 5000 });
265285
expect(tierOf(container)).toBe('stack');
266286
expect(shown(container)).toBe('details');
267287
expect(columnsGrid(container)).toContain('grid-cols-1');
268-
expect(screen.queryByRole('tablist')).toBeNull();
288+
expectTabRowOnly();
269289
// The panels follow the details, in the right column's order.
270290
const details = column(container, 'details')!;
271291
const right = column(container, 'right')!;
@@ -284,14 +304,14 @@ describe('RecordDetailView — one record page, laid out by its width (objectsta
284304
const { container } = renderPage({ width: 720, embedded: true });
285305
await screen.findAllByText('Tasks', undefined, { timeout: 5000 });
286306
expect(tierOf(container)).toBe('stack');
287-
expect(screen.queryByRole('tablist')).toBeNull();
307+
expectTabRowOnly();
288308
});
289309

290310
it('`?tab=` addresses no strip on a laid-out page; `?related=timeline` is the 动态 tab: the full stream with its chips', async () => {
291311
const tabbed = renderPage({ width: 720, search: '?tab=related' });
292312
await screen.findAllByText('Tasks', undefined, { timeout: 5000 });
293313
expect(tierOf(tabbed.container)).toBe('stack');
294-
expect(screen.queryByRole('tablist')).toBeNull();
314+
expectTabRowOnly();
295315
expect(column(tabbed.container, 'details')).not.toBeNull();
296316
tabbed.unmount();
297317

@@ -307,6 +327,7 @@ describe('RecordDetailView — one record page, laid out by its width (objectsta
307327
expect(face.getAttribute('aria-label')).toBe('Activity');
308328
expect(face.querySelector('[data-stream-filters]')).not.toBeNull();
309329
expect(face.querySelector('[data-record-timeline-peek]')).toBeNull();
330+
expectTabRowOnly('timeline');
310331
// The face takes the page: no columns under it.
311332
expect(container.querySelector('[data-record-columns]')).toBeNull();
312333
await waitFor(() => expect(auditReads()).toBeGreaterThan(0));

0 commit comments

Comments
 (0)