From c3c1cc1eb398b268259dd457c0a74c057f988ad0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 08:14:56 +0000 Subject: [PATCH 1/5] feat(plugin-detail): record page tab row under the header; the open panel face is the active tab Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzGCEznLfiQm2T5icF --- .../app-shell/src/views/RecordDetailView.tsx | 44 ++- ui/packages/i18n/src/locales/ar.ts | 11 +- ui/packages/i18n/src/locales/de.ts | 7 +- ui/packages/i18n/src/locales/en.ts | 7 +- ui/packages/i18n/src/locales/es.ts | 8 +- ui/packages/i18n/src/locales/fr.ts | 8 +- ui/packages/i18n/src/locales/ja.ts | 7 +- ui/packages/i18n/src/locales/ko.ts | 7 +- ui/packages/i18n/src/locales/pt.ts | 8 +- ui/packages/i18n/src/locales/ru.ts | 9 +- ui/packages/i18n/src/locales/zh.ts | 7 +- .../plugin-detail/src/ArrangedRelatedTab.tsx | 119 -------- .../plugin-detail/src/RecordPageColumns.tsx | 156 ++++++++--- .../plugin-detail/src/RecordTabRow.tsx | 265 ++++++++++++++++++ .../plugin-detail/src/RecordTimelinePanel.tsx | 23 +- .../plugin-detail/src/RelatedCardStack.tsx | 22 +- ui/packages/plugin-detail/src/RelatedList.tsx | 6 - ...olumns.enlargedEscapeInPane-27524.test.tsx | 2 +- .../__tests__/RelatedCardStack-27055.test.tsx | 60 +--- ui/packages/plugin-detail/src/index.tsx | 2 - .../plugin-detail/src/recordTimelineStream.ts | 8 + .../src/renderers/record-activity.tsx | 17 +- .../src/renderers/record-related-list.tsx | 43 +-- .../plugin-detail/src/synth/recordTabRow.ts | 84 ++++++ .../src/synth/relatedArrangement.ts | 2 +- .../src/useRelatedArrangement.ts | 5 +- 26 files changed, 631 insertions(+), 306 deletions(-) delete mode 100644 ui/packages/plugin-detail/src/ArrangedRelatedTab.tsx create mode 100644 ui/packages/plugin-detail/src/RecordTabRow.tsx create mode 100644 ui/packages/plugin-detail/src/synth/recordTabRow.ts diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index 2ccf25dd2fd..0ee296969aa 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -10,7 +10,7 @@ import { useState, useEffect, useLayoutEffect, useCallback, useContext, useMemo, useRef } from 'react'; import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'; -import { activityRowToFeedItem, ArrangedRelatedTab, auditEntriesToFeedItems, bodyTableIdsOf, claimQueuedRecord, RecordFieldChangesContext, DefaultRecordPageContext, EMPTY_RELATED_ARRANGEMENT, InlineEditSaveBar, RecordBodyTablesContext, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, recordPageTierHasRightColumn, recordSitsInQueue, resolveTitleField, useQueueClaimOffered, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail'; +import { activityRowToFeedItem, auditEntriesToFeedItems, bodyTableIdsOf, claimQueuedRecord, RecordFieldChangesContext, DefaultRecordPageContext, EMPTY_RELATED_ARRANGEMENT, InlineEditSaveBar, RecordBodyTablesContext, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, recordPageTierHasRightColumn, recordSitsInQueue, resolveTitleField, useQueueClaimOffered, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail'; import { Empty, EmptyTitle, EmptyDescription, Button, PageHeaderActions, PageHeaderTrailingContext, PanelToggles, type PanelToggleItem } from '@object-ui/components'; import { useAuth, createAuthenticatedFetch } from '@object-ui/auth'; import { usePermissions } from '@object-ui/permissions'; @@ -720,18 +720,25 @@ 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. + // ── The active tab, `?related=object.field` (objectstack#27055, Target 9; + // objectstack#27524, Targets 8 and 9) ── One path to a related list's every + // record and column: a tab of the record's tab row (or a panel's header) + // opens the list as a full-width table under the header, addressed by the + // URL so it survives a reload and a shared link. Leaving 「概览」 PUSHES an + // entry (the browser's Back returns to the page); moving between tabs + // REPLACES it, so Back, Esc and 「概览」 all return to the page in one step. + // Closing — Esc or 「概览」 — pops the entry 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; + const open = sp.get(RECORD_RELATED_ENLARGE_PARAM); + if (open === id) return; sp.set(RECORD_RELATED_ENLARGE_PARAM, id); + if (open) { + setTabSearchParams(sp, { replace: true, state: location.state }); + return; + } const state = location.state && typeof location.state === 'object' ? location.state : {}; setTabSearchParams(sp, { state: { ...(state as object), [RELATED_ENLARGE_PUSHED]: true } }); }, [setTabSearchParams, location.state]); @@ -4510,21 +4517,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // 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) => ( - - )} - - )} + {/* A page drawn as its strip is drawn as written: the + reader's arrangement belongs to the right column and + the tab row (objectstack#27524). */} + )} diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index a0e4bed19ba..73e64611c3f 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -1549,7 +1549,16 @@ const ar = { relatedHiddenCount_many: "المخفية: {{count}}", relatedHiddenCount_other: "المخفية: {{count}}", relatedShowHidden: "إظهار", - relatedEnlargedBack: "رجوع", + recordTabs: "علامات تبويب السجل", + recordTabOverview: "نظرة عامة", + recordTabsMore: "علامات تبويب أخرى: {{count}}", + recordTabsMore_zero: "علامات تبويب أخرى: {{count}}", + recordTabsMore_one: "علامات تبويب أخرى: {{count}}", + recordTabsMore_two: "علامات تبويب أخرى: {{count}}", + recordTabsMore_few: "علامات تبويب أخرى: {{count}}", + recordTabsMore_many: "علامات تبويب أخرى: {{count}}", + recordTabsMore_other: "علامات تبويب أخرى: {{count}}", + recordTabsHidden: "مخفية", bodyTableTotals: "المجاميع (هذه الصفحة)", streamAll: "الكل", streamActivity: "النشاط", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 246d6a95e43..8b571dd654a 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -1375,7 +1375,12 @@ const de = { relatedHiddenCount_one: "{{count}} ausgeblendet", relatedHiddenCount_other: "{{count}} ausgeblendet", relatedShowHidden: "Anzeigen", - relatedEnlargedBack: "Zurück", + recordTabs: "Datensatz-Tabs", + recordTabOverview: "Übersicht", + recordTabsMore: "{{count}} weitere Tabs", + recordTabsMore_one: "{{count}} weiterer Tab", + recordTabsMore_other: "{{count}} weitere Tabs", + recordTabsHidden: "Ausgeblendet", bodyTableTotals: "Summen (diese Seite)", streamAll: "Alle", streamActivity: "Aktivität", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 0ced9c95101..56599b300cd 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1564,7 +1564,12 @@ const en = { relatedHiddenCount_one: '{{count}} hidden', relatedHiddenCount_other: '{{count}} hidden', relatedShowHidden: 'Show', - relatedEnlargedBack: 'Back', + recordTabs: 'Record tabs', + recordTabOverview: 'Overview', + recordTabsMore: '{{count}} more tabs', + recordTabsMore_one: '{{count}} more tab', + recordTabsMore_other: '{{count}} more tabs', + recordTabsHidden: 'Hidden', bodyTableTotals: 'Totals (this page)', streamAll: 'All', streamActivity: 'Activity', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 2807a984a8b..0cbb17538ac 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -1420,7 +1420,13 @@ const es = { relatedHiddenCount_many: "{{count}} ocultas", relatedHiddenCount_other: "{{count}} ocultas", relatedShowHidden: "Mostrar", - relatedEnlargedBack: "Volver", + recordTabs: "Pestañas del registro", + recordTabOverview: "Resumen", + recordTabsMore: "{{count}} pestañas más", + recordTabsMore_one: "{{count}} pestaña más", + recordTabsMore_many: "{{count}} pestañas más", + recordTabsMore_other: "{{count}} pestañas más", + recordTabsHidden: "Ocultas", bodyTableTotals: "Totales (esta página)", streamAll: "Todo", streamActivity: "Actividad", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 8bec754d9e7..2c41f92c9c4 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -1417,7 +1417,13 @@ const fr = { relatedHiddenCount_many: "{{count}} masquées", relatedHiddenCount_other: "{{count}} masquées", relatedShowHidden: "Afficher", - relatedEnlargedBack: "Retour", + recordTabs: "Onglets de l'enregistrement", + recordTabOverview: "Aperçu", + recordTabsMore: "{{count}} onglets de plus", + recordTabsMore_one: "{{count}} onglet de plus", + recordTabsMore_many: "{{count}} onglets de plus", + recordTabsMore_other: "{{count}} onglets de plus", + recordTabsHidden: "Masqués", bodyTableTotals: "Totaux (cette page)", streamAll: "Tout", streamActivity: "Activité", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 72a3cf3b765..9e000ae26df 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1209,7 +1209,12 @@ const ja = { relatedHiddenCount_one: "{{count}} 件非表示", relatedHiddenCount_other: "{{count}} 件非表示", relatedShowHidden: "表示", - relatedEnlargedBack: "戻る", + recordTabs: "レコードのタブ", + recordTabOverview: "概要", + recordTabsMore: "他 {{count}} 個のタブ", + recordTabsMore_one: "他 {{count}} 個のタブ", + recordTabsMore_other: "他 {{count}} 個のタブ", + recordTabsHidden: "非表示", bodyTableTotals: "合計(このページ)", streamAll: "すべて", streamActivity: "アクティビティ", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 07470827138..7823263eb0b 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1375,7 +1375,12 @@ const ko = { relatedHiddenCount_one: "{{count}}개 숨김", relatedHiddenCount_other: "{{count}}개 숨김", relatedShowHidden: "표시", - relatedEnlargedBack: "뒤로", + recordTabs: "레코드 탭", + recordTabOverview: "개요", + recordTabsMore: "탭 {{count}}개 더", + recordTabsMore_one: "탭 {{count}}개 더", + recordTabsMore_other: "탭 {{count}}개 더", + recordTabsHidden: "숨김", bodyTableTotals: "합계(이 페이지)", streamAll: "전체", streamActivity: "활동", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index dde4cbf8d7d..7ad2d1f9a05 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -1417,7 +1417,13 @@ const pt = { relatedHiddenCount_many: "{{count}} ocultas", relatedHiddenCount_other: "{{count}} ocultas", relatedShowHidden: "Mostrar", - relatedEnlargedBack: "Voltar", + recordTabs: "Abas do registro", + recordTabOverview: "Visão geral", + recordTabsMore: "{{count}} abas a mais", + recordTabsMore_one: "{{count}} aba a mais", + recordTabsMore_many: "{{count}} abas a mais", + recordTabsMore_other: "{{count}} abas a mais", + recordTabsHidden: "Ocultas", bodyTableTotals: "Totais (esta página)", streamAll: "Tudo", streamActivity: "Atividade", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 75355b27376..bd61d8a7864 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1286,7 +1286,14 @@ const ru = { relatedHiddenCount_many: "Скрыто: {{count}}", relatedHiddenCount_other: "Скрыто: {{count}}", relatedShowHidden: "Показать", - relatedEnlargedBack: "Назад", + recordTabs: "Вкладки записи", + recordTabOverview: "Обзор", + recordTabsMore: "Ещё вкладок: {{count}}", + recordTabsMore_one: "Ещё вкладок: {{count}}", + recordTabsMore_few: "Ещё вкладок: {{count}}", + recordTabsMore_many: "Ещё вкладок: {{count}}", + recordTabsMore_other: "Ещё вкладок: {{count}}", + recordTabsHidden: "Скрытые", bodyTableTotals: "Итоги (эта страница)", streamAll: "Все", streamActivity: "Активность", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index a110de05872..19efba4df3f 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1296,7 +1296,12 @@ const zh = { relatedHiddenCount_one: '已隐藏 {{count}} 张', relatedHiddenCount_other: '已隐藏 {{count}} 张', relatedShowHidden: '显示', - relatedEnlargedBack: '返回', + recordTabs: '记录页签', + recordTabOverview: '概览', + recordTabsMore: '另外 {{count}} 个页签', + recordTabsMore_one: '另外 {{count}} 个页签', + recordTabsMore_other: '另外 {{count}} 个页签', + recordTabsHidden: '已隐藏', bodyTableTotals: '本页合计', streamAll: '全部', streamActivity: '活动', diff --git a/ui/packages/plugin-detail/src/ArrangedRelatedTab.tsx b/ui/packages/plugin-detail/src/ArrangedRelatedTab.tsx deleted file mode 100644 index b1101e29d25..00000000000 --- a/ui/packages/plugin-detail/src/ArrangedRelatedTab.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * 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 … apply in the tabbed tier's - * 关联 tab」). Read by the `record:related_list` renderer: every list gets - * its chrome (the ⠿ handle's ⌥↑ / ⌥↓, the ⋯ menu's 隐藏), and the tab's LAST - * list draws the column's two lines after itself — the changed-default hint - * and the foot line (「已隐藏 N 张 · 显示」 · 「恢复默认顺序」). - */ -export interface RelatedArrangementScope { - chromeOf: (id: RelatedCardId) => RelatedCardChrome | null; - /** The list that draws the tab's closing lines. */ - lastId: RelatedCardId | null; - hiddenCount: number; - showHidden: () => void; - defaultChanged: boolean; - restoreDefault: () => void; - /** The reader moved or hid a card: the foot offers 「恢复默认顺序」 (objectstack#27241). */ - rearranged: boolean; - restoreDefaultArrangement: () => 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 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; - }, - 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' }), - rearranged: resolved.rearranged, - restoreDefaultArrangement: () => edit({ kind: 'restore-default-arrangement' }), - }; - return ( - {children(arrangedPage)} - ); -}; diff --git a/ui/packages/plugin-detail/src/RecordPageColumns.tsx b/ui/packages/plugin-detail/src/RecordPageColumns.tsx index afc4b156d8e..d5ea5323b58 100644 --- a/ui/packages/plugin-detail/src/RecordPageColumns.tsx +++ b/ui/packages/plugin-detail/src/RecordPageColumns.tsx @@ -7,10 +7,9 @@ */ import * as React from 'react'; -import { Button, cn } from '@object-ui/components'; +import { cn } from '@object-ui/components'; import type { FieldContainerLike } from '@object-ui/core'; -import { SchemaRenderer, toRenderableSchema } from '@object-ui/react'; -import { ChevronLeft } from 'lucide-react'; +import { SchemaRenderer, toRenderableSchema, useRecordContext } from '@object-ui/react'; import { RECORD_PAGE_TIMELINE_PANEL, relatedListIdsIn, @@ -22,7 +21,15 @@ import { RecordBodyTablesContext, RelatedEnlargeContext, RelatedListCompactConte import { DefaultRecordPageContext, DetailsColumnCapContext } from './defaultRecordPage'; import { RelatedCardStack } from './RelatedCardStack'; import { RecordTimelinePanel } from './RecordTimelinePanel'; -import { relatedCardId, type RelatedArrangement, type RelatedCardId } from './synth/relatedArrangement'; +import { RECORD_TAB_OVERVIEW, RecordTabRow, type RecordTab } from './RecordTabRow'; +import { RecordTimelineCountContext } from './recordTimelineStream'; +import { + relatedCardId, + resolveRelatedArrangement, + type RelatedArrangement, + type RelatedCardId, +} from './synth/relatedArrangement'; +import { relatedCountTargetOf, useRelatedCounts, type RelatedCountTarget } from './useRelatedCounts'; export interface RecordPageColumnsProps { /** The tier to draw (`recordPageTierFor` of the container's width). */ @@ -52,14 +59,17 @@ export interface RecordPageColumnsProps { onRelatedArrangementChange?: (next: RelatedArrangement) => void; /** * Open a panel's whole face in place — a related list's `object.field`, or - * `timeline` for the 动态 stream — the host's `?related=`. + * `timeline` for the 动态 stream — the host's `?related=`. It is what a + * tab of the tab row does (objectstack#27524, Target 8); absent, the page + * draws no tab row. */ onEnlargeRelated?: (id: RelatedCardId) => void; /** - * The open face (the host's `?related=`): a related list's full table, or - * the 动态 stream in full (`timeline`), drawn at the page's full width - * under the record header until Esc or ‹ (`onCloseEnlargedRelated`). An id - * naming nothing on the page draws the page. + * The active tab (the host's `?related=`, objectstack#27524, Target 9): a + * related list's full table, or the 动态 stream in full (`timeline`), drawn + * at the page's full width under the record header and its tab row, until + * Esc or 「概览」 (`onCloseEnlargedRelated`). An id naming nothing on the + * page draws the page. */ enlargedRelated?: RelatedCardId; onCloseEnlargedRelated?: () => void; @@ -103,10 +113,12 @@ function isTypingTarget(target: EventTarget | null): target is Element { } /** - * A panel's whole face, open in place (objectstack#27055, Target 9): under the - * record header, at the page's full width, with ‹ to return. Esc returns too; - * a dialog or menu open over the page keeps its own Esc, and so does a field - * the reader types in. + * A panel's whole face, open in place — the active tab (objectstack#27524, + * Target 9): under the record header and its tab row, at the page's full + * width. The tab names it, so it draws no title of its own; 「概览」 returns, + * and so does Esc — a dialog or menu open over the page keeps its own Esc, + * and so does a field the reader types in. Not a modal and not a level down: + * the header above it stays as it is. */ const EnlargedFace: React.FC<{ id: string; title: string; onClose?: () => void; children: React.ReactNode }> = ({ id, @@ -114,7 +126,6 @@ const EnlargedFace: React.FC<{ id: string; title: string; onClose?: () => void; onClose, children, }) => { - const { t } = useDetailTranslation(); const faceRef = React.useRef(null); React.useEffect(() => { if (!onClose) return undefined; @@ -141,22 +152,14 @@ const EnlargedFace: React.FC<{ id: string; title: string; onClose?: () => void; return () => window.removeEventListener('keydown', onKeyDown, true); }, [onClose]); return ( -
-
- -

{title}

-
+
{children}
); @@ -248,6 +251,7 @@ export const RecordPageColumns: React.FC = ({ className, }) => { const { t } = useDetailTranslation(); + const ctx = useRecordContext(); const bodyTableIds = React.useMemo(() => new Set(relatedListIdsIn(columns.details)), [columns.details]); const hasTimeline = columns.timeline.length > 0; const hasPanels = hasTimeline || columns.related.length > 0; @@ -259,22 +263,78 @@ export const RecordPageColumns: React.FC = ({ [onEnlargeRelated], ); - // The open face: the 动态 stream in full, or a related list — body tables - // (in the details) enlarge too (Target 8 names ⤢ on them). + // ── The reader's arrangement, read once for the panels and the tab row ── + // A host without a preference store keeps the reader's choices here, for + // this view, so the stack and the row read the same one. + const [localArrangement, setLocalArrangement] = React.useState(undefined); + const arrangement = onRelatedArrangementChange ? relatedArrangement : localArrangement; + const onArrangementChange = onRelatedArrangementChange ?? setLocalArrangement; + const panelNodes = new Map(); + for (const node of columns.related) { + const id = relatedNodeId(node); + if (id && !panelNodes.has(id)) panelNodes.set(id, node); + } + const [now] = React.useState(() => Date.now()); + const resolved = resolveRelatedArrangement(Array.from(panelNodes.keys()), arrangement, now); + + // ── The panels' counts, one set for the panels and their tabs ── + // The hidden panels are counted too: their tabs sit in the row's dropdown + // with their counts (Target 8). + const countTargets: RelatedCountTarget[] = []; + for (const id of [...resolved.order, ...resolved.hidden]) { + const target = relatedCountTargetOf(panelNodes.get(id)); + if (target) countTargets.push(target); + } + const counts = useRelatedCounts(countTargets, { + parentId: ctx?.recordId, + dataSource: ctx?.dataSource, + fieldsOf: relatedFieldsOf, + }); + const countOf = (id: RelatedCardId): number | null => { + const state = counts.stateOf(id); + return state.kind === 'counted' ? state.count : null; + }; + + // ── The 动态 stream's size, for its panel and its tab ── + // Held per record, so a pane stepping to the next record never shows the + // last one's number; kept while neither face that reports it is drawn. + const recordKey = ctx?.recordId === undefined || ctx?.recordId === null ? '' : String(ctx.recordId); + const [timelineCount, setTimelineCount] = React.useState<{ record: string; count: number | null }>({ + record: recordKey, + count: null, + }); + const recordKeyRef = React.useRef(recordKey); + recordKeyRef.current = recordKey; + const reportTimelineCount = React.useCallback((count: number | null) => { + const record = recordKeyRef.current; + setTimelineCount((prev) => (prev.record === record && prev.count === count ? prev : { record, count })); + }, []); + const streamCount = timelineCount.record === recordKey ? timelineCount.count : null; + + // ── The active tab: the open face ── + // The 动态 stream in full, or a related list — body tables (in the details) + // open in place too (their ⤢), and take a tab of their own while open. let enlarged: React.ReactNode = null; + let activeTab = RECORD_TAB_OVERVIEW; + let openBodyTable: RecordTab | null = null; if (enlargedRelated === RECORD_PAGE_TIMELINE_PANEL && hasTimeline) { + activeTab = RECORD_PAGE_TIMELINE_PANEL; enlarged = ( - {drawNodes(columns.timeline)} + + {drawNodes(columns.timeline)} + ); } else if (enlargedRelated) { const node = [...columns.related, ...columns.details].find((n) => relatedNodeId(n) === enlargedRelated); if (node) { + activeTab = enlargedRelated; + if (!panelNodes.has(enlargedRelated)) openBodyTable = { id: enlargedRelated, label: relatedNodeTitle(node) }; enlarged = ( {enlargedRelatedList(node)} @@ -283,6 +343,28 @@ export const RecordPageColumns: React.FC = ({ } } + // ── The tab row (Target 8): 概览, 动态, the panels in the reader's order ── + const panelTab = (id: RelatedCardId): RecordTab => ({ + id, + label: relatedNodeTitle(panelNodes.get(id)), + count: countOf(id), + }); + const tabs: RecordTab[] = [{ id: RECORD_TAB_OVERVIEW, label: t('detail.recordTabOverview', { defaultValue: 'Overview' }) }]; + if (hasTimeline) { + tabs.push({ id: RECORD_PAGE_TIMELINE_PANEL, label: t('detail.timeline', { defaultValue: 'Activity' }), count: streamCount }); + } + for (const id of resolved.order) tabs.push(panelTab(id)); + if (openBodyTable) tabs.push(openBodyTable); + const hiddenTabs = resolved.hidden.map(panelTab); + const selectTab = (id: string) => { + if (id === RECORD_TAB_OVERVIEW) onCloseEnlargedRelated?.(); + else onEnlargeRelated?.(id); + }; + const tabRow = + onEnlargeRelated && tabs.length + hiddenTabs.length > 1 ? ( + + ) : null; + // The provenance line closes the details column in every tier // (objectstack#27055): it describes the record the details show. const detailsColumn = ( @@ -302,16 +384,19 @@ export const RecordPageColumns: React.FC = ({ nodes={columns.timeline} entries={wide ? 5 : 3} onViewAll={onEnlargeRelated ? viewAllTimeline : undefined} + count={streamCount} + onCount={reportTimelineCount} /> )} {/* The related panels, in the reader's arrangement (objectstack#27055). */} @@ -331,6 +416,7 @@ export const RecordPageColumns: React.FC = ({ >
{drawNodes(columns.header)} + {tabRow} {enlarged ?? (