diff --git a/.changeset/27524-record-page-tab-row-i18n.md b/.changeset/27524-record-page-tab-row-i18n.md new file mode 100644 index 00000000000..353fcc2aaff --- /dev/null +++ b/.changeset/27524-record-page-tab-row-i18n.md @@ -0,0 +1,19 @@ +--- +'@object-ui/i18n': patch +--- + +feat(i18n)!: the record page's tab row in all ten packs; the enlarged face's Back label leaves + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +The record page's tab row (record page v4) arrives in all ten packs: + +- `detail.recordTabOverview`: the row's first tab, 「概览」 · Overview · 概要 · 개요 · Übersicht · Aperçu · Resumen · Visão geral · Обзор · نظرة عامة. +- `detail.recordTabs`: the row's accessible name. +- `detail.recordTabsMore`, with its plural forms: the 「+N ▾」 trigger's accessible name. +- `detail.recordTabsHidden`: the dropdown's heading over the hidden panels' tabs. + +**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. diff --git a/.changeset/27524-record-page-tab-row.md b/.changeset/27524-record-page-tab-row.md new file mode 100644 index 00000000000..7e17d2cbe53 --- /dev/null +++ b/.changeset/27524-record-page-tab-row.md @@ -0,0 +1,22 @@ +--- +'@object-ui/plugin-detail': patch +'@object-ui/app-shell': patch +--- + +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 + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +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. + +- **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. +- **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. +- **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=.` 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. +- A body table that is open in place (its ⤢ in the details) takes a tab of its own while it is open. +- **Reads.** The page now counts every related panel, including the hidden ones and those behind a closed ⊟, because their tabs show the counts. +- 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. + +**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. diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index 0ff71d0733d..e7860c18ba1 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -190,6 +190,10 @@ its own. **动态** is one stream: the discussion, the activity and, with 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=.`). +Each of those faces is also a tab: a row of tabs closes the record header, **Overview** first, +then **动态**, then one tab per related panel in the reader's order, each with its count. The +row shows as many tabs as its width fits and folds the rest into a **+N** menu; the open tab +is always on the row, and **Overview** or Esc returns to the page. 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. diff --git a/ui/packages/app-shell/src/views/RecordDetailView.recordPageTiers-26678.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordPageTiers-26678.test.tsx index 0ebe4fdd74d..b9d29e89ecb 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.recordPageTiers-26678.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.recordPageTiers-26678.test.tsx @@ -21,11 +21,13 @@ * three columns. * - ≥ 760 px: details | the right column at 320 px, the details grid at two. * - < 760 px (the split pane at 720, a phone at 390): ONE column, the details - * then the right column's panels in their order. No tab strip, no rail. + * then the right column's panels in their order. No rail, and no tab + * strip but the record's own tab row under the header (Target 8), the + * same on every tier: 「概览」, 动态, then the related panels. * - The embedded record (drawer / split pane) follows the same rule. * - The right column opens with the 动态 panel, so History is read at open in - * every tier. `?tab=` addresses no strip; `?related=timeline` is the 动态 - * tab, the full stream. + * every tier. `?tab=` addresses no strip (「概览」 stays the active tab); + * `?related=timeline` is the 动态 tab, the full stream. */ import * as React from 'react'; @@ -218,6 +220,24 @@ const rightPanels = (container: HTMLElement) => (el) => (el.hasAttribute('data-record-timeline-panel') ? 'timeline' : el.getAttribute('data-related-card-id')), ); +/** + * The page's only tablist is its tab row (objectstack#27524, Target 8): the + * same tabs on every tier, the active one as `active`. + */ +const expectTabRowOnly = (active = 'overview') => { + const lists = screen.getAllByRole('tablist'); + expect(lists).toHaveLength(1); + expect(lists[0].closest('[data-record-tab-row]')).not.toBeNull(); + const tabs = Array.from(lists[0].querySelectorAll('[role="tab"]')); + expect(tabs.map((tab) => tab.getAttribute('data-record-tab'))).toEqual([ + 'overview', + 'timeline', + 'showcase_task.project', + 'showcase_note.project', + ]); + expect(lists[0].querySelector('[aria-selected="true"]')?.getAttribute('data-record-tab')).toBe(active); +}; + describe('RecordDetailView — one record page, laid out by its width (objectstack#26678, objectstack#27524)', () => { it('1440 px: details beside the right column at 380 px, the details grid at three columns; 动态 leads; History is read at open', async () => { const { container } = renderPage({ width: 1440 }); @@ -226,7 +246,7 @@ describe('RecordDetailView — one record page, laid out by its width (objectsta expect(shown(container)).toBe('details right'); expect(columnsGrid(container)).toContain('23.75rem'); expect(detailsGrid(container)).toContain('@lg:grid-cols-3'); - expect(screen.queryByRole('tablist')).toBeNull(); + expectTabRowOnly(); expect(rightPanels(container)).toEqual(['timeline', 'showcase_task.project', 'showcase_note.project']); // No resident timeline column and no rail: the two columns are all. expect(column(container, 'timeline')).toBeNull(); @@ -253,19 +273,19 @@ describe('RecordDetailView — one record page, laid out by its width (objectsta // The same panels as the wide tier — no rail to open first. expect(rightPanels(container)).toEqual(['timeline', 'showcase_task.project', 'showcase_note.project']); expect(screen.queryByTestId('record-related-rail-toggle')).toBeNull(); - expect(screen.queryByRole('tablist')).toBeNull(); + expectTabRowOnly(); }); it.each([ ['the split pane', 720], ['a phone', 390], - ])('%s (%i px): one column — the details, then the 动态 panel and the related panels; no tabs; History is read at open', async (_name, width) => { + ])('%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) => { const { container } = renderPage({ width }); await screen.findAllByText('Tasks', undefined, { timeout: 5000 }); expect(tierOf(container)).toBe('stack'); expect(shown(container)).toBe('details'); expect(columnsGrid(container)).toContain('grid-cols-1'); - expect(screen.queryByRole('tablist')).toBeNull(); + expectTabRowOnly(); // The panels follow the details, in the right column's order. const details = column(container, 'details')!; const right = column(container, 'right')!; @@ -284,14 +304,14 @@ describe('RecordDetailView — one record page, laid out by its width (objectsta const { container } = renderPage({ width: 720, embedded: true }); await screen.findAllByText('Tasks', undefined, { timeout: 5000 }); expect(tierOf(container)).toBe('stack'); - expect(screen.queryByRole('tablist')).toBeNull(); + expectTabRowOnly(); }); it('`?tab=` addresses no strip on a laid-out page; `?related=timeline` is the 动态 tab: the full stream with its chips', async () => { const tabbed = renderPage({ width: 720, search: '?tab=related' }); await screen.findAllByText('Tasks', undefined, { timeout: 5000 }); expect(tierOf(tabbed.container)).toBe('stack'); - expect(screen.queryByRole('tablist')).toBeNull(); + expectTabRowOnly(); expect(column(tabbed.container, 'details')).not.toBeNull(); tabbed.unmount(); @@ -307,6 +327,7 @@ describe('RecordDetailView — one record page, laid out by its width (objectsta expect(face.getAttribute('aria-label')).toBe('Activity'); expect(face.querySelector('[data-stream-filters]')).not.toBeNull(); expect(face.querySelector('[data-record-timeline-peek]')).toBeNull(); + expectTabRowOnly('timeline'); // The face takes the page: no columns under it. expect(container.querySelector('[data-record-columns]')).toBeNull(); await waitFor(() => expect(auditReads()).toBeGreaterThan(0)); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.recordTabRowUrl-27524.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordTabRowUrl-27524.test.tsx new file mode 100644 index 00000000000..1a972badb6d --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordDetailView.recordTabRowUrl-27524.test.tsx @@ -0,0 +1,265 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27524, Targets 8 and 9 — the record's tab row drives the + * address, through a real `RecordDetailView` mount and the browser's own + * history: + * + * - leaving 「概览」 for a tab PUSHES one entry, `?related=object.field` + * (`?related=timeline` for 动态); + * - moving from tab to tab REPLACES that entry, so the history grows by one + * however many tabs the reader visits; + * - 「概览」, Esc and the browser's Back each return to the page in one step, + * and the header with its tab row stays drawn on every face. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor, fireEvent, act } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import { MetadataCtx } from '@object-ui/react'; +import { RelatedCountStore } from '@object-ui/components'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }), +})); + +// Orthogonal chrome — stubbed so the only asynchrony here is the page's own. +vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); +vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null })); +vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('./FlowRunner', () => ({ FlowRunner: () => null })); +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); + +import { RecordDetailView } from './RecordDetailView'; + +const PARENT = 'showcase_project'; +const RECORD_ID = 'p-1'; + +const BASE_FIELDS = { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, +}; + +/** + * The project's own text fields, each with a value: more than the header's + * highlights take, so the details section holds enough of them (more than + * five) for the wide tier's three-column grid. + */ +const PROJECT_TEXT = ['code', 'region', 'sponsor', 'phase', 'cost_center', 'tier', 'site', 'channel', 'segment', 'program', 'quarter', 'team']; + +const OBJECTS = [ + { + name: PARENT, + label: 'Project', + pluralLabel: 'Projects', + managedBy: 'platform', + // History is on for this object, so the synthesized page carries it. + enable: { trackHistory: true }, + // A worked object — its status options declare lifecycle kinds. Since + // objectstack#27524 that no longer changes the page: every object's page + // has the same two columns. + fields: { + ...BASE_FIELDS, + ...Object.fromEntries(PROJECT_TEXT.map((name) => [name, { type: 'text', label: name }])), + status: { + type: 'select', + label: 'Status', + options: [ + { value: 'active', label: 'Active', kind: 'open' }, + { value: 'done', label: 'Done', kind: 'won' }, + ], + }, + }, + }, + { + name: 'showcase_task', + label: 'Task', + pluralLabel: 'Tasks', + managedBy: 'platform', + fields: { ...BASE_FIELDS, project: { type: 'master_detail', reference: PARENT, label: 'Project' } }, + }, + { + name: 'showcase_note', + label: 'Note', + pluralLabel: 'Notes', + managedBy: 'platform', + fields: { ...BASE_FIELDS, project: { type: 'lookup', reference: PARENT, label: 'Project' } }, + }, + { name: 'sys_audit_log', label: 'Audit log', managedBy: 'platform', fields: BASE_FIELDS }, +]; + +/** The width this view's root measures; every other element measures as the environment does. */ +let rootWidth = 0; +let dataSource: { find: ReturnType } & Record; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [], total: 0 })), + create: vi.fn(async (_o: string, row: unknown) => row), + findOne: vi.fn(async (_o: string, recordId: string) => ({ + id: recordId, + name: `Project ${recordId}`, + ...Object.fromEntries(PROJECT_TEXT.map((name) => [name, `${name} value`])), + status: 'active', + })), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + }; +} + +function renderPage({ width, embedded = false, search = '' }: { width: number; embedded?: boolean; search?: string }) { + rootWidth = width; + // The real browser history: the view reads the address bar it writes. + window.history.replaceState(null, '', `/app/demo/${PARENT}/${RECORD_ID}${search}`); + dataSource = makeDataSource(); + const metadata = { + objects: OBJECTS, + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], + } as never; + return render( + + + {}} + objectNameOverride={PARENT} + recordIdOverride={RECORD_ID} + embedded={embedded} + /> + + , + ); +} + +const tierOf = (container: HTMLElement) => + container.querySelector('[data-record-page-tier]')?.getAttribute('data-record-page-tier') ?? null; +const column = (container: HTMLElement, name: string) => + container.querySelector(`[data-record-column="${name}"]`) as HTMLElement | null; +const auditReads = () => dataSource.find.mock.calls.filter((call) => call[0] === 'sys_audit_log').length; + +beforeEach(() => { + cleanup(); + RelatedCountStore._reset(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); + const measure = HTMLElement.prototype.getBoundingClientRect; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (!this.hasAttribute('data-record-page-root')) return measure.call(this); + return { x: 0, y: 0, top: 0, left: 0, right: rootWidth, bottom: 800, width: rootWidth, height: 800, toJSON: () => ({}) } as DOMRect; + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const search = () => new URLSearchParams(window.location.search).get('related'); +const tab = (id: string) => document.querySelector(`[role="tab"][data-record-tab="${id}"]`) as HTMLElement; +const activeTab = () => document.querySelector('[role="tab"][aria-selected="true"]')?.getAttribute('data-record-tab') ?? null; +const face = () => document.querySelector('[data-related-enlarged]'); + +describe('RecordDetailView — the tab row drives the address (objectstack#27524, Targets 8 and 9)', () => { + it('a tab pushes one entry, the next tab replaces it, 「概览」 returns in one step', async () => { + renderPage({ width: 1440 }); + await screen.findAllByText('Tasks', undefined, { timeout: 5000 }); + const start = window.history.length; + expect(activeTab()).toBe('overview'); + + fireEvent.click(tab('showcase_task.project')); + await waitFor(() => expect(face()?.getAttribute('data-related-enlarged')).toBe('showcase_task.project')); + expect(search()).toBe('showcase_task.project'); + expect(window.history.length).toBe(start + 1); + expect(activeTab()).toBe('showcase_task.project'); + // The header is drawn once, above the row, on the enlarged face too. + expect(document.querySelectorAll('h1')).toHaveLength(1); + + fireEvent.click(tab('timeline')); + await waitFor(() => expect(face()?.getAttribute('data-related-enlarged')).toBe('timeline')); + expect(search()).toBe('timeline'); + fireEvent.click(tab('showcase_note.project')); + await waitFor(() => expect(face()?.getAttribute('data-related-enlarged')).toBe('showcase_note.project')); + expect(search()).toBe('showcase_note.project'); + // Three tabs visited, one entry. + expect(window.history.length).toBe(start + 1); + + fireEvent.click(tab('overview')); + await waitFor(() => expect(face()).toBeNull()); + await waitFor(() => expect(search()).toBeNull()); + expect(activeTab()).toBe('overview'); + expect(document.querySelector('[data-record-columns]')).not.toBeNull(); + }); + + it('Esc on a tab returns to 「概览」, and so does the browser\'s Back', async () => { + renderPage({ width: 720 }); + await screen.findAllByText('Tasks', undefined, { timeout: 5000 }); + fireEvent.click(tab('showcase_task.project')); + fireEvent.click(await waitFor(() => tab('showcase_note.project'))); + await waitFor(() => expect(search()).toBe('showcase_note.project')); + fireEvent.keyDown(face()!, { key: 'Escape' }); + await waitFor(() => expect(search()).toBeNull()); + await waitFor(() => expect(face()).toBeNull()); + + fireEvent.click(tab('timeline')); + await waitFor(() => expect(search()).toBe('timeline')); + act(() => window.history.back()); + await waitFor(() => expect(search()).toBeNull()); + await waitFor(() => expect(face()).toBeNull()); + expect(activeTab()).toBe('overview'); + }); + + it('a link to a tab opens it as the active tab; 「概览」 then replaces the address, with nothing to pop', async () => { + renderPage({ width: 1440, search: '?related=showcase_task.project' }); + await waitFor(() => expect(face()?.getAttribute('data-related-enlarged')).toBe('showcase_task.project'), { timeout: 5000 }); + expect(activeTab()).toBe('showcase_task.project'); + const start = window.history.length; + fireEvent.click(tab('overview')); + await waitFor(() => expect(search()).toBeNull()); + expect(window.history.length).toBe(start); + expect(window.location.pathname).toBe(`/app/demo/${PARENT}/${RECORD_ID}`); + }); +}); 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/README.md b/ui/packages/plugin-detail/README.md index a18e2128a19..39d226187ee 100644 --- a/ui/packages/plugin-detail/README.md +++ b/ui/packages/plugin-detail/README.md @@ -817,6 +817,17 @@ then each related list in the reader's arrangement, then Approvals and Attachments. The reader's ⊟ (`rightColumn`) takes the right column out of the two-column tiers; there is no tabbed tier. +A tab row closes the record header in every tier (`RecordTabRow`): 「概览」 +(Overview), then 动态, then one tab per related panel in the reader's +arrangement, each with its count. A tab is its panel's whole face: choosing it +draws the full list (every column, paging, sort, inline edit) or the full +stream below the row, with the header unchanged, and the URL carries it +(`?related=.`, `?related=timeline`); 「概览」 or Esc returns. The +row shows as many tabs as its own width fits, measured on every resize, and +folds the rest into 「+N ▾」, which lists them with their counts and ends with +the hidden panels' tabs. 「概览」, at least one more tab and the active tab are +always on the row. + The related panels load counts first and rows on demand (`RelatedCardStack`). Every panel's count is asked in one request set, and each panel is a placeholder until the set has answered. A panel counted at 0 reads no rows. A 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 ?? (