From 2b8b4e05ea7de8257ff206ce8444be33f4eba43b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 05:32:12 +0000 Subject: [PATCH 1/9] =?UTF-8?q?feat(plugin-detail):=20one=20record=20page?= =?UTF-8?q?=20laid=20out=20by=20its=20own=20width;=20=E5=8A=A8=E6=80=81=20?= =?UTF-8?q?is=20the=20right=20column's=20first=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tiers by the container's width: at 760 px and up, details beside a right column (320 px; 380 px with a 3-column details grid at 1120 px and up); below 760 px, one column. The three-column, tabbed and rail tiers, the resident timeline column and its ⊞ switch retire. 动态 is the first panel of the right column: a one-line composer, the newest 3 entries (5 when wide) and 「查看全部 N 条 →」 to the full stream. ⊟ shows or hides the right column. Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt Co-authored-by: Claude --- .../utils/workspacePreference.hook.test.tsx | 6 +- .../src/utils/workspacePreference.test.ts | 35 +- .../src/utils/workspacePreference.ts | 22 +- .../app-shell/src/views/RecordDetailView.tsx | 211 +++++----- .../src/custom/panel-toggles.test.tsx | 17 +- .../components/src/custom/panel-toggles.tsx | 27 +- ui/packages/i18n/src/locales/ar.ts | 12 +- ui/packages/i18n/src/locales/de.ts | 12 +- ui/packages/i18n/src/locales/en.ts | 12 +- ui/packages/i18n/src/locales/es.ts | 12 +- ui/packages/i18n/src/locales/fr.ts | 12 +- ui/packages/i18n/src/locales/ja.ts | 12 +- ui/packages/i18n/src/locales/ko.ts | 12 +- ui/packages/i18n/src/locales/pt.ts | 12 +- ui/packages/i18n/src/locales/ru.ts | 12 +- ui/packages/i18n/src/locales/zh.ts | 12 +- ui/packages/plugin-detail/README.md | 71 ++-- .../plugin-detail/src/DetailSection.tsx | 10 +- .../src/RecordActivityTimeline.tsx | 197 +++++++--- .../plugin-detail/src/RecordPageColumns.tsx | 360 +++++++----------- .../plugin-detail/src/RecordTimelinePanel.tsx | 88 +++++ .../src/RichTextCommentInput.tsx | 9 + .../RecordPageColumns.switches-27052.test.tsx | 118 ++---- .../src/__tests__/RecordPageColumns.test.tsx | 127 +++--- .../RecordTimelinePanel-27524.test.tsx | 160 ++++++++ .../__tests__/RelatedCardStack-27055.test.tsx | 16 +- .../recordTimelineStream-27055.test.tsx | 19 +- .../plugin-detail/src/defaultRecordPage.ts | 20 +- ui/packages/plugin-detail/src/index.tsx | 15 +- .../plugin-detail/src/recordTimelineStream.ts | 21 +- .../src/renderers/record-activity.tsx | 65 +++- ...DefaultPageSchema.bodyTables-27055.test.ts | 15 +- ...ildDefaultPageSchema.strictPayload.test.ts | 10 +- .../__tests__/buildDefaultPageSchema.test.ts | 41 +- .../recordPageColumns.switches-27052.test.ts | 128 ++----- .../synth/__tests__/recordPageColumns.test.ts | 70 ++-- .../src/synth/buildDefaultPageSchema.ts | 23 +- .../src/synth/recordPageColumns.ts | 259 ++++--------- .../plugin-detail/src/useDetailTranslation.ts | 8 +- .../plugin-detail/src/useRecordPageTier.ts | 30 +- 40 files changed, 1209 insertions(+), 1109 deletions(-) create mode 100644 ui/packages/plugin-detail/src/RecordTimelinePanel.tsx create mode 100644 ui/packages/plugin-detail/src/__tests__/RecordTimelinePanel-27524.test.tsx diff --git a/ui/packages/app-shell/src/utils/workspacePreference.hook.test.tsx b/ui/packages/app-shell/src/utils/workspacePreference.hook.test.tsx index 5eb467e11ee..e039460151c 100644 --- a/ui/packages/app-shell/src/utils/workspacePreference.hook.test.tsx +++ b/ui/packages/app-shell/src/utils/workspacePreference.hook.test.tsx @@ -18,7 +18,7 @@ import * as React from 'react'; import { describe, it, expect, afterEach, vi } from 'vitest'; import { render, screen, cleanup, act, fireEvent } from '@testing-library/react'; import { - RECORD_TIMELINE_COLUMN_PREFERENCE, + RECORD_RELATED_COLUMN_PREFERENCE, objectPreferenceScope, useWorkspacePreference, workspacePreferenceStorageKey, @@ -28,10 +28,10 @@ import { const LEAD = objectPreferenceScope('crm_lead'); const PRODUCT = objectPreferenceScope('crm_product'); -const LEAD_KEY = workspacePreferenceStorageKey(RECORD_TIMELINE_COLUMN_PREFERENCE, LEAD); +const LEAD_KEY = workspacePreferenceStorageKey(RECORD_RELATED_COLUMN_PREFERENCE, LEAD); function Probe({ id, scope, fallback }: { id: string; scope: WorkspacePreferenceScope; fallback: PreferenceSwitch }) { - const [value, setValue] = useWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, scope, fallback); + const [value, setValue] = useWorkspacePreference(RECORD_RELATED_COLUMN_PREFERENCE, scope, fallback); return ( + )} + + )} + {filtered.length > 0 && deniedNotice &&
{deniedNotice}
} + {loading && filtered.length === 0 ? ( +
+ + {t('detail.loading')} +
+ ) : (activityDenied || commentsDenied) && filtered.length === 0 ? ( +
{deniedNotice}
+ ) : filtered.length === 0 ? ( +
+ {emptyLabel ?? t('detail.noActivity')} +
+ ) : ( + rootItems.map(peekLine) + )} + {onViewAll && total > rootItems.length && ( + + )} + + ); + } + return (
= ({ )} {/* Comment Input — rich text with @mention + bold/italic/list/code + preview */} - {showCommentInput && ( - 0} - extraSlot={ - onUploadAttachments ? ( - - ) : pendingAttachments.length > 0 ? ( - - ) : undefined - } - /> - )} + {showCommentInput && composer} {/* A refusal with rows on screen (objectui#11195): the comments were readable and the activity was not, or the other way round. @@ -942,17 +1035,15 @@ export const RecordActivityTimeline: React.FC = ({ size="sm" onClick={handleLoadMore} disabled={isLoadingMore} - aria-label={isStream ? undefined : t('detail.loadMore')} - data-stream-view-all={isStream ? '' : undefined} + aria-label={t('detail.loadMore')} + data-stream-load-more={isStream ? '' : undefined} > {isLoadingMore ? ( - ) : isStream ? null : ( + ) : ( )} - {isStream - ? t('detail.viewAllEntries', { count: totalCount ?? filtered.length, defaultValue: 'View all {{count}} →' }) - : t('detail.loadMore')} + {t('detail.loadMore')} )} diff --git a/ui/packages/plugin-detail/src/RecordPageColumns.tsx b/ui/packages/plugin-detail/src/RecordPageColumns.tsx index 8fd7715c476..9ef292d98d1 100644 --- a/ui/packages/plugin-detail/src/RecordPageColumns.tsx +++ b/ui/packages/plugin-detail/src/RecordPageColumns.tsx @@ -7,63 +7,66 @@ */ import * as React from 'react'; -import { Badge, Button, cn } from '@object-ui/components'; +import { Button, cn } from '@object-ui/components'; import type { FieldContainerLike } from '@object-ui/core'; import { SchemaRenderer, toRenderableSchema } from '@object-ui/react'; -import { ChevronLeft, ChevronRight, PanelRightClose, PanelRightOpen } from 'lucide-react'; +import { ChevronLeft } from 'lucide-react'; import { - recordPageArrangementFor, + RECORD_PAGE_TIMELINE_PANEL, relatedListIdsIn, type DefaultPageColumns, - type RecordPageColumnSwitches, + type RecordPageTier, } from './synth/recordPageColumns'; import { useDetailTranslation } from './useDetailTranslation'; import { RecordBodyTablesContext, RelatedEnlargeContext, RelatedListCompactContext } from './relatedListCompact'; -import { DefaultRecordPageContext } from './defaultRecordPage'; +import { DefaultRecordPageContext, DetailsColumnCapContext } from './defaultRecordPage'; import { RelatedCardStack } from './RelatedCardStack'; +import { RecordTimelinePanel } from './RecordTimelinePanel'; import { relatedCardId, type RelatedArrangement, type RelatedCardId } from './synth/relatedArrangement'; export interface RecordPageColumnsProps { - /** The column tier to draw; the tabbed tier is the page itself, drawn by its host. */ - tier: 'two-column' | 'three-column'; + /** The tier to draw (`recordPageTierFor` of the container's width). */ + tier: RecordPageTier; /** The synthesized page, partitioned (`buildDefaultPageColumns`). */ columns: DefaultPageColumns; /** * The record's provenance line (created / updated / owner / id), which the * host draws once per page: at the foot of the details column, in every - * column tier (objectstack#27055, the v3 meta line). + * tier (objectstack#27055, the v3 meta line). */ footer?: React.ReactNode; /** - * The reader's column switches (objectstack#27052): a column that is off is - * not drawn — the timeline folds into the related column's 「动态」 card; the - * related column is gone, rail and all (objectstack#27241). A related column - * that is on but has no room is the rail (`recordPageArrangementFor`). - * Absent: both on. + * The reader's right-column switch ⊟ (objectstack#27052; objectstack#27524, + * Target 4): off, a tier with a right column draws the details alone, the + * full width. The stack has no right column — its panels follow the + * details — so it does not read the switch. Absent: on. */ - switches?: RecordPageColumnSwitches; + rightColumn?: boolean; /** - * The reader's arrangement of the related cards (objectstack#27055, - * Targets 7 and 10) — order, collapsed, hidden — from the host's workspace - * preference store, and the setter that keeps a new one. Absent: the - * reader's choices hold for this view only. + * The reader's arrangement of the related panels (objectstack#27055, + * Targets 7 and 10) — order, hidden — from the host's workspace preference + * store, and the setter that keeps a new one. Absent: the reader's choices + * hold for this view only. */ relatedArrangement?: RelatedArrangement; onRelatedArrangementChange?: (next: RelatedArrangement) => void; - /** Enlarge a related card's list in place (Target 9, the host's `?related=`). */ + /** + * Open a panel's whole face in place — a related list's `object.field`, or + * `timeline` for the 动态 stream — the host's `?related=`. + */ onEnlargeRelated?: (id: RelatedCardId) => void; /** - * The enlarged related list (`object.field`, the host's `?related=`): drawn - * as a full-width table over the page body — the header stays, the columns - * step aside — until Esc or ‹ (`onCloseEnlargedRelated`). An id naming no - * related list of the page draws the columns. + * 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. */ enlargedRelated?: RelatedCardId; onCloseEnlargedRelated?: () => void; /** * The child objects' field definitions the host holds (its metadata), for - * the related cards' up-front counts (objectstack#27056). Absent: the counts - * read each child's definition first. + * the related panels' up-front counts (objectstack#27056). Absent: the + * counts read each child's definition first. */ relatedFieldsOf?: (objectName: string) => FieldContainerLike; className?: string; @@ -81,16 +84,17 @@ function relatedNodeId(node: any): RelatedCardId | null { } /** - * The enlarged related list (objectstack#27055, Target 9 — the ONE path to a - * related list's every record): the full `RelatedList` face the old Related - * tab drew — every column, paging, sort, inline edit — at the page's full - * width, under the record header. ‹ and Esc return to the columns. A dialog - * open over the page keeps its own Esc. + * 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. */ -const EnlargedRelatedList: React.FC<{ node: any; onClose?: () => void }> = ({ node, onClose }) => { +const EnlargedFace: React.FC<{ id: string; title: string; onClose?: () => void; children: React.ReactNode }> = ({ + id, + title, + onClose, + children, +}) => { const { t } = useDetailTranslation(); - const p = { ...node, ...(node.properties ?? {}) }; - const title = typeof p.title === 'string' && p.title ? p.title : p.objectName; React.useEffect(() => { if (!onClose) return undefined; const onKeyDown = (event: KeyboardEvent) => { @@ -101,12 +105,8 @@ const EnlargedRelatedList: React.FC<{ node: any; onClose?: () => void }> = ({ no document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); }, [onClose]); - const authoredLimit = typeof p.limit === 'number' && p.limit > 0; - const enlarged = authoredLimit - ? node - : { ...node, properties: { ...(node.properties ?? {}), limit: ENLARGED_PAGE_SIZE } }; return ( -
+

{title}

- + {children}
); }; -const ALL_COLUMNS_ON: RecordPageColumnSwitches = { timeline: true, related: true }; - /** - * The timeline folded into the related column (objectstack#27052): the - * 「动态」 card under the lists. Closed until the reader opens it, and its - * blocks are not mounted while closed, so the feed reads nothing before it is - * asked for (objectstack#27056, Target 2). + * The enlarged related list (Target 9, the ONE path to a related list's + * every record): the full `RelatedList` face — every column, paging, sort, + * inline edit. */ -const FoldedTimelineCard: React.FC<{ nodes: any[]; label: string }> = ({ nodes, label }) => { - const [open, setOpen] = React.useState(false); - const bodyId = React.useId(); - return ( -
-

- -

- {open && ( -
- {drawNodes(nodes)} -
- )} -
- ); -}; +function enlargedRelatedList(node: any): React.ReactNode { + const p = { ...node, ...(node.properties ?? {}) }; + const authoredLimit = typeof p.limit === 'number' && p.limit > 0; + const enlarged = authoredLimit + ? node + : { ...node, properties: { ...(node.properties ?? {}), limit: ENLARGED_PAGE_SIZE } }; + return ; +} + +/** The title an enlarged related list's face names it by. */ +function relatedNodeTitle(node: any): string { + const p = { ...node, ...(node.properties ?? {}) }; + return typeof p.title === 'string' && p.title ? p.title : p.objectName; +} /** - * Grid templates by which columns have content. Literal strings, so - * Tailwind's scanner emits each one. The v3 widths (objectstack#27055, - * 「栏宽 1.25fr : 1fr : 320px」): details the widest, the timeline after it, - * the related column a fixed 320 px rail. + * Grid templates. Literal strings, so Tailwind's scanner emits each one: + * details | the right column at 320 px, or 380 px in the wide tier + * (objectstack#27524, Target 6). */ -const THREE_COLUMN_TEMPLATE = { - all: 'grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)_20rem]', - noTimeline: 'grid-cols-[minmax(0,1fr)_20rem]', - noRelated: 'grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)]', - detailsOnly: 'grid-cols-1', +const GRID_TEMPLATE = { + twoColumn: 'grid-cols-[minmax(0,1fr)_20rem]', + wide: 'grid-cols-[minmax(0,1fr)_23.75rem]', + oneColumn: 'grid-cols-1', } as const; /** Each node of a column, drawn as a sibling of the others: a related-list @@ -188,7 +167,6 @@ function drawNodes(nodes: any[]): React.ReactNode { )); } - /** * A column stacks its blocks 16 px apart. A related list folded into its * stack's empty line stays MOUNTED with its content `hidden` @@ -199,35 +177,33 @@ function drawNodes(nodes: any[]): React.ReactNode { const COLUMN_STACK = 'flex min-w-0 flex-col gap-4 [&>*:has(>[hidden]:only-child)]:hidden'; /** - * The synthesized default record page in its column tiers (objectstack#26678): - * one record header, then details | timeline | related as resident columns. - * - * The frame mirrors the record page frame `PageRenderer` draws for the tabbed - * tier (its padding and background, the header nodes 16 px apart), so the - * header does not move when the page changes tier. The nodes are the page's - * own — the same `page:header`, `record:details`, `record:discussion` and - * `record:related_list` nodes the tab strip draws — so every tier shows the - * same header and the same sections. + * The synthesized default record page, laid out by the width it has + * (objectstack#26678; one rule since objectstack#27524, Target 6): one record + * header, then the details and the right column — the 动态 panel first, then + * the related panels in the reader's arrangement. * - * Two-column tier: details | timeline, and the related column folded into a - * rail opened from a toggle above the columns. The rail opens over the - * timeline rather than pushing a third column in, which is the point of the - * tier: three columns do not fit. Closed, its lists are not mounted, so they - * read nothing until opened. Whether it is open is this view's ephemeral - * state. + * - `two-column` / `wide`: details | the right column (320 / 380 px). The + * wide tier lets a details section take three field columns (two + * otherwise, `DetailsColumnCapContext`) and the 动态 panel five entries + * (three otherwise). The reader's ⊟ (`rightColumn`) takes the right + * column out; the details then take the row. + * - `stack`: ONE column — the details, then the right column's panels in + * their order. No tabs, no rail: the stack is what a narrow container + * (the split pane, a phone) draws. * - * The reader's switches (objectstack#27052) take a column out: with the - * timeline off the related column is resident in its place and the - * timeline's blocks are its foot card (「动态」); with the related column off - * there is no related column and no rail toggle (objectstack#27241: the - * switch decides whether the column exists, the rail toggle only opens one - * the switch allows). `data-record-columns-shown` names the resident columns. + * The frame mirrors the record page frame `PageRenderer` draws (its padding + * and background, the header nodes 16 px apart), so the header sits where an + * authored page's would. The nodes are the page's own — the same + * `page:header`, `record:details`, `record:activity` and + * `record:related_list` nodes its tab strip holds — so every tier shows the + * same header and the same sections. `data-record-columns-shown` names the + * columns drawn side by side. */ export const RecordPageColumns: React.FC = ({ tier, columns, footer, - switches = ALL_COLUMNS_ON, + rightColumn = true, relatedArrangement, onRelatedArrangementChange, onEnlargeRelated, @@ -237,26 +213,40 @@ export const RecordPageColumns: React.FC = ({ className, }) => { const { t } = useDetailTranslation(); - const railId = React.useId(); - const toggleRef = React.useRef(null); - const [railOpen, setRailOpen] = React.useState(false); - const arrangement = recordPageArrangementFor(tier, switches); - // Body tables (in the details column) enlarge too (Target 8 names ⤢ on them). - const enlargedNode = enlargedRelated - ? [...columns.related, ...columns.details].find((node) => relatedNodeId(node) === enlargedRelated) - : undefined; const bodyTableIds = React.useMemo(() => new Set(relatedListIdsIn(columns.details)), [columns.details]); const hasTimeline = columns.timeline.length > 0; - const hasRelatedLists = columns.related.length > 0; - // objectstack#27052 — a timeline the reader switched off is not a column: - // its blocks are the related column's foot card, under the lists. - const foldTimeline = arrangement.timelineFolded && hasTimeline; - const hasRelated = hasRelatedLists || foldTimeline; - const residentTimeline = hasTimeline && arrangement.columns.includes('timeline'); - const residentRelated = hasRelated && arrangement.columns.includes('related'); - const relatedInRail = hasRelated && arrangement.relatedRail; - const relatedLabel = t('detail.related'); - const foldedTimelineLabel = t('detail.timelineFolded'); + const hasPanels = hasTimeline || columns.related.length > 0; + const wide = tier === 'wide'; + const besideDetails = tier !== 'stack'; + const drawsPanels = hasPanels && (!besideDetails || rightColumn); + const viewAllTimeline = React.useCallback( + () => onEnlargeRelated?.(RECORD_PAGE_TIMELINE_PANEL), + [onEnlargeRelated], + ); + + // The open face: the 动态 stream in full, or a related list — body tables + // (in the details) enlarge too (Target 8 names ⤢ on them). + let enlarged: React.ReactNode = null; + if (enlargedRelated === RECORD_PAGE_TIMELINE_PANEL && hasTimeline) { + enlarged = ( + + {drawNodes(columns.timeline)} + + ); + } else if (enlargedRelated) { + const node = [...columns.related, ...columns.details].find((n) => relatedNodeId(n) === enlargedRelated); + if (node) { + enlarged = ( + + {enlargedRelatedList(node)} + + ); + } + } // The provenance line closes the details column in every tier // (objectstack#27055): it describes the record the details show. @@ -266,15 +256,20 @@ export const RecordPageColumns: React.FC = ({ {footer} ); - const timelineColumn = residentTimeline ? ( -
- {drawNodes(columns.timeline)} -
- ) : null; - const relatedContent = ( - <> - {/* The related column's lists draw as compact cards the reader - arranges (objectstack#27055). */} + const rightPanels = drawsPanels ? ( + + ) : null; + const sideBySide = besideDetails && drawsPanels; + const template = sideBySide ? (wide ? GRID_TEMPLATE.wide : GRID_TEMPLATE.twoColumn) : GRID_TEMPLATE.oneColumn; return ( +
= ({ >
{drawNodes(columns.header)} - {enlargedNode ? : body} + {enlarged ?? ( +
+ {detailsColumn} + {rightPanels} +
+ )}
+
diff --git a/ui/packages/plugin-detail/src/RecordTimelinePanel.tsx b/ui/packages/plugin-detail/src/RecordTimelinePanel.tsx new file mode 100644 index 00000000000..98d3e7df036 --- /dev/null +++ b/ui/packages/plugin-detail/src/RecordTimelinePanel.tsx @@ -0,0 +1,88 @@ +/** + * 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 { ChevronRight } from 'lucide-react'; +import { SchemaRenderer, toRenderableSchema } from '@object-ui/react'; +import { RecordTimelinePanelContext, type RecordTimelinePanel as RecordTimelinePanelValue } from './recordTimelineStream'; +import { useDetailTranslation } from './useDetailTranslation'; + +export interface RecordTimelinePanelProps { + /** The page's 动态 blocks (`DefaultPageColumns.timeline`). */ + nodes: any[]; + /** How many of the newest entries the panel shows: 3, or 5 when wide. */ + entries: number; + /** Open the 动态 tab — the host's `?related=timeline`. Absent: no way out. */ + onViewAll?: () => void; +} + +/** + * The 动态 panel (objectstack#27524, Target 5): the FIRST panel of the record + * page's right column, in the same shape as the related panels after it — a + * header of 「动态」 and the stream's count, the header row opening the 动态 + * tab (「查看全部 N 条」) — and, as its body, the page's 动态 blocks in their + * peek face (`RecordTimelinePanelContext`): the one-line composer, the newest + * `entries`, 「查看全部 N 条 →」. The full stream, with its chips and paging, + * is the 动态 tab; the panel never grows into a column. + * + * Not arrangeable: it leads the column whatever the related panels' order. + */ +export const RecordTimelinePanel: React.FC = ({ nodes, entries, onViewAll }) => { + const { t } = useDetailTranslation(); + const [count, setCount] = React.useState(null); + const panel = React.useMemo( + () => ({ entries, onViewAll, onCount: setCount }), + [entries, onViewAll], + ); + const title = t('detail.timeline', { defaultValue: 'Activity' }); + const countChip = count !== null && ( + + {count} + + ); + return ( +
+
+ {onViewAll ? ( + // The row is the target, as on a related panel: the button's ::after + // stretches over the whole header. + + ) : ( + <> + {title} + {countChip} + + )} +
+ + {nodes.map((node, index) => ( + + ))} + +
+ ); +}; diff --git a/ui/packages/plugin-detail/src/RichTextCommentInput.tsx b/ui/packages/plugin-detail/src/RichTextCommentInput.tsx index 3d49ea05697..575d1e33ce9 100644 --- a/ui/packages/plugin-detail/src/RichTextCommentInput.tsx +++ b/ui/packages/plugin-detail/src/RichTextCommentInput.tsx @@ -41,6 +41,9 @@ export interface RichTextCommentInputProps { /** When true, the Send button is enabled even if the text value is empty. * Hosts pass this when attachments-only submits should be allowed. */ canSubmitEmpty?: boolean; + /** Focus the editor when it mounts: the 动态 panel opens this composer + * from its one-line stand-in, and the reader's press lands in it. */ + autoFocus?: boolean; } /** Render minimal markdown to HTML for preview. */ @@ -84,6 +87,7 @@ export const RichTextCommentInput: React.FC = ({ disabled = false, extraSlot, canSubmitEmpty = false, + autoFocus = false, }) => { const { t } = useDetailTranslation(); const [isPreview, setIsPreview] = React.useState(false); @@ -91,6 +95,11 @@ export const RichTextCommentInput: React.FC = ({ const [mentionQuery, setMentionQuery] = React.useState(''); const [mentionIndex, setMentionIndex] = React.useState(0); const textareaRef = React.useRef(null); + React.useEffect(() => { + if (autoFocus) textareaRef.current?.focus(); + // On mount only: a later re-render must not pull focus back. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const filteredMentions = React.useMemo(() => { if (!mentionQuery) return mentionSuggestions; diff --git a/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.switches-27052.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.switches-27052.test.tsx index 97645de873c..f44774ca82f 100644 --- a/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.switches-27052.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.switches-27052.test.tsx @@ -7,14 +7,18 @@ */ /** - * objectstack#27052 — `RecordPageColumns` with the reader's column switches. - * The blocks are stubbed (each node draws its type), so what is measured is - * where each node lands when a column is switched off. + * objectstack#27052 → objectstack#27524, Target 4 — `RecordPageColumns` with + * the reader's ONE switch, ⊟ (the right column). The timeline switch, its + * folded 「动态」 card and the related rail retired: 动态 is the right column's + * first panel, so ⊟ takes the 动态 panel and the related panels out + * together, and the details take the row. The stack has no right column, so + * the switch does not reach it. The blocks are stubbed (each node draws its + * type), so what is measured is where each node lands. */ import * as React from 'react'; import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, screen, cleanup, fireEvent, within } from '@testing-library/react'; +import { render, cleanup } from '@testing-library/react'; vi.mock('@object-ui/react', async (importOriginal) => ({ ...(await importOriginal>()), @@ -47,90 +51,34 @@ const shown = (container: HTMLElement) => afterEach(() => cleanup()); -describe('RecordPageColumns — the column switches (objectstack#27052)', () => { - it('both on draws what it drew before', () => { - const { container } = render( - , - ); - expect(shown(container)).toBe('details timeline related'); - expect(container.querySelector('[data-record-folded-timeline]')).toBeNull(); +describe('RecordPageColumns — the right-column switch ⊟ (objectstack#27052, objectstack#27524)', () => { + it('absent or on: details | right column, 动态 first', () => { + for (const rightColumn of [undefined, true]) { + const { container, unmount } = render(); + expect(shown(container)).toBe('details right'); + expect(nodesIn(column(container, 'right'))).toEqual(['record:activity', 'record:related_list', 'record:related_list']); + unmount(); + } }); - it('timeline off, three columns: details | related, the timeline folded into its 「动态」 card under the lists', () => { - const { container } = render( - meta

} - />, - ); - expect(shown(container)).toBe('details related'); - expect(column(container, 'timeline')).toBeNull(); - const related = column(container, 'related')!; - const folded = related.querySelector('[data-record-folded-timeline]')!; - expect(folded).not.toBeNull(); - expect(folded.getAttribute('aria-label')).toBe('Activity'); - // Closed until opened, and its blocks are not mounted while closed, so the - // feed reads nothing before the reader asks for it (objectstack#27056). - const toggle = folded.querySelector('[data-record-folded-timeline-toggle]') as HTMLButtonElement; - expect(toggle.getAttribute('aria-expanded')).toBe('false'); - expect(nodesIn(folded)).toEqual([]); - fireEvent.click(toggle); - expect(toggle.getAttribute('aria-expanded')).toBe('true'); - expect(nodesIn(folded)).toEqual(['record:activity']); - // Lists first, the folded card after them; the meta line closes the - // details column (objectstack#27055). - expect(nodesIn(related)).toEqual(['record:related_list', 'record:related_list', 'record:activity']); - expect(column(container, 'details')!.lastElementChild?.getAttribute('data-testid')).toBe('meta-footer'); - // The related lists stay siblings of one parent (one empty-fold stack). - const lists = related.querySelectorAll('[data-node="record:related_list"]'); - expect(lists[0].parentElement).toBe(lists[1].parentElement); - }); - - it('timeline off, two columns: details | related resident, no rail toggle', () => { - const { container } = render( - , - ); - expect(shown(container)).toBe('details related'); - expect(screen.queryByTestId('record-related-rail-toggle')).toBeNull(); - expect(within(column(container, 'related')!).getAllByText('task')).toHaveLength(1); - }); - - it('related off, three columns: details | timeline, no related column and no rail toggle (objectstack#27241)', () => { - const { container } = render( - , - ); - expect(shown(container)).toBe('details timeline'); - expect(column(container, 'related')).toBeNull(); - expect(screen.queryByTestId('record-related-rail-toggle')).toBeNull(); - }); - - it('related off, two columns: no rail toggle — the switch decides whether the column exists (objectstack#27241)', () => { - const { container } = render( - , - ); - expect(shown(container)).toBe('details timeline'); - expect(screen.queryByTestId('record-related-rail-toggle')).toBeNull(); - expect(column(container, 'related')).toBeNull(); - }); - - it('related on, two columns: the rail toggle is there and opens the related column (objectstack#27241)', () => { - const { container } = render( - , - ); - expect(shown(container)).toBe('details timeline'); - expect(column(container, 'related')).toBeNull(); - fireEvent.click(screen.getByTestId('record-related-rail-toggle')); - expect(nodesIn(column(container, 'related'))).toEqual(['record:related_list', 'record:related_list']); - }); + for (const tier of ['wide', 'two-column'] as const) { + it(`off, ${tier}: the details alone take the row — no right column, no 动态 panel, no lists, no rail`, () => { + const { container } = render( + meta

} />, + ); + expect(shown(container)).toBe('details'); + expect(column(container, 'right')).toBeNull(); + expect(container.querySelector('[data-record-timeline-panel]')).toBeNull(); + expect(container.querySelectorAll('[data-node="record:related_list"]')).toHaveLength(0); + expect(container.querySelector('[data-testid="record-related-rail-toggle"]')).toBeNull(); + expect(container.querySelector('[data-record-folded-timeline]')).toBeNull(); + expect(column(container, 'details')!.lastElementChild?.getAttribute('data-testid')).toBe('meta-footer'); + }); + } - it('both off: details alone, no rail', () => { - const { container } = render( - , - ); + it('the stack does not read it: its panels follow the details whatever the switch says', () => { + const { container } = render(); expect(shown(container)).toBe('details'); - expect(screen.queryByTestId('record-related-rail-toggle')).toBeNull(); - expect(container.querySelector('[data-record-folded-timeline]')).toBeNull(); + expect(nodesIn(column(container, 'right'))).toEqual(['record:activity', 'record:related_list', 'record:related_list']); }); }); diff --git a/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.test.tsx index e1a4179fab3..10a9b0a74aa 100644 --- a/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.test.tsx @@ -7,23 +7,30 @@ */ /** - * objectstack#26678 — `RecordPageColumns`, the column tiers of the synthesized - * record page. The blocks themselves are stubbed (each node draws its type), - * so what is measured here is the layout alone: where each node lands, that - * the header is drawn once, that no tab strip exists, and the two-column - * tier's related rail. + * objectstack#26678 → objectstack#27524, Target 6 — `RecordPageColumns`, the + * synthesized record page laid out by one rule. The blocks themselves are + * stubbed (each node draws its type, and reports the details column cap it + * is drawn under), so what is measured here is the layout alone: where each + * node lands, that the header is drawn once, that no tab strip and no rail + * exist, the 动态 panel leading the right column, and each tier's measures. */ import * as React from 'react'; import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, screen, cleanup, fireEvent, within } from '@testing-library/react'; +import { render, screen, cleanup, within } from '@testing-library/react'; -vi.mock('@object-ui/react', async (importOriginal) => ({ - ...(await importOriginal>()), - SchemaRenderer: ({ schema }: { schema: any }) => ( -
{schema?.properties?.objectName ?? schema?.type}
- ), -})); +vi.mock('@object-ui/react', async (importOriginal) => { + const { DetailsColumnCapContext } = await import('../defaultRecordPage'); + const Stub = ({ schema }: { schema: any }) => ( +
+ {schema?.properties?.objectName ?? schema?.type} +
+ ); + return { + ...(await importOriginal>()), + SchemaRenderer: Stub, + }; +}); import { RecordPageColumns } from '../RecordPageColumns'; import { buildDefaultPageColumns } from '../synth/recordPageColumns'; @@ -44,80 +51,76 @@ const column = (container: HTMLElement, name: string) => container.querySelector(`[data-record-column="${name}"]`) as HTMLElement | null; const nodesIn = (el: HTMLElement | null) => Array.from(el?.querySelectorAll('[data-node]') ?? []).map((n) => n.getAttribute('data-node')); +const grid = (container: HTMLElement) => container.querySelector('[data-record-columns]') as HTMLElement; afterEach(() => cleanup()); -describe('RecordPageColumns — three columns (objectstack#26678)', () => { - it('draws one header, then details | timeline | related, and no tab strip', () => { +describe('RecordPageColumns — details | the right column (objectstack#27524, Target 6)', () => { + it('wide (≥ 1120): one header, details | right column at 380 px, 动态 first, no tab strip', () => { const { container } = render( - meta

} />, + meta

} />, ); - expect(container.querySelector('[data-record-page-tier="three-column"]')).not.toBeNull(); + expect(container.querySelector('[data-record-page-tier="wide"]')).not.toBeNull(); expect(screen.queryByRole('tablist')).toBeNull(); expect(container.querySelectorAll('[data-node="page:header"]')).toHaveLength(1); + expect(grid(container).getAttribute('data-record-columns-shown')).toBe('details right'); + expect(grid(container).className).toContain('grid-cols-[minmax(0,1fr)_23.75rem]'); expect(nodesIn(column(container, 'details'))).toEqual(['record:details']); - expect(nodesIn(column(container, 'timeline'))).toEqual(['record:activity']); - expect(nodesIn(column(container, 'related'))).toEqual(['record:related_list', 'record:related_list']); + // The 动态 panel leads the right column; the related panels follow it. + const right = column(container, 'right')!; + expect(right.firstElementChild?.hasAttribute('data-record-timeline-panel')).toBe(true); + expect(nodesIn(right)).toEqual(['record:activity', 'record:related_list', 'record:related_list']); + // No timeline column exists any more (「动态是面板,不是栏」). + expect(column(container, 'timeline')).toBeNull(); // The meta line closes the details column (objectstack#27055). - const related = column(container, 'related')!; - expect(within(related).queryByTestId('meta-footer')).toBeNull(); + expect(within(right).queryByTestId('meta-footer')).toBeNull(); expect(column(container, 'details')!.lastElementChild?.getAttribute('data-testid')).toBe('meta-footer'); // The related lists are siblings of one parent, so they form one empty-fold stack. - const lists = related.querySelectorAll('[data-node="record:related_list"]'); + const lists = right.querySelectorAll('[data-node="record:related_list"]'); expect(lists[0].parentElement).toBe(lists[1].parentElement); + // Wide: a details section may take three field columns. + expect(column(container, 'details')!.querySelector('[data-node="record:details"]')!.getAttribute('data-cap')).toBe('3'); }); - it('collapses a column with nothing in it; the footer then closes the details column', () => { + it('two-column (760 – 1119): the right column at 320 px, its lists resident — no rail, no toggle', () => { + const { container } = render(); + expect(grid(container).getAttribute('data-record-columns-shown')).toBe('details right'); + expect(grid(container).className).toContain('grid-cols-[minmax(0,1fr)_20rem]'); + expect(nodesIn(column(container, 'right'))).toEqual(['record:activity', 'record:related_list', 'record:related_list']); + expect(screen.queryByRole('button', { name: /Related/ })).toBeNull(); + expect(container.querySelector('[data-testid="record-related-rail-toggle"]')).toBeNull(); + // Beside the 320 px column a details section takes two field columns at most. + expect(column(container, 'details')!.querySelector('[data-node="record:details"]')!.getAttribute('data-cap')).toBe('2'); + }); + + it('a page with nothing for the right column draws the details alone; the footer closes them', () => { const bare = buildDefaultPageColumns(buildDefaultPageSchema({ name: 'thing', fields: {} }, { hideDiscussion: true }))!; const { container } = render( - meta

} />, + meta

} />, ); expect(column(container, 'details')).not.toBeNull(); - expect(column(container, 'timeline')).toBeNull(); - expect(column(container, 'related')).toBeNull(); + expect(column(container, 'right')).toBeNull(); + expect(grid(container).getAttribute('data-record-columns-shown')).toBe('details'); expect(within(column(container, 'details')!).getByTestId('meta-footer')).toBeTruthy(); }); }); -describe('RecordPageColumns — two columns and the related rail (objectstack#26678)', () => { - it('draws details | timeline with the rail closed and its lists unmounted', () => { +describe('RecordPageColumns — the stack (< 760, objectstack#27524, Target 6)', () => { + it('ONE column: the details and their footer, then the right column’s panels in their order — no tabs', () => { const { container } = render( - meta

} />, + meta

} />, ); - expect(container.querySelector('[data-record-page-tier="two-column"]')).not.toBeNull(); + expect(container.querySelector('[data-record-page-tier="stack"]')).not.toBeNull(); expect(screen.queryByRole('tablist')).toBeNull(); - expect(nodesIn(column(container, 'details'))).toEqual(['record:details']); - expect(nodesIn(column(container, 'timeline'))).toEqual(['record:activity']); - expect(column(container, 'related')).toBeNull(); - expect(container.querySelectorAll('[data-node="record:related_list"]')).toHaveLength(0); - // The footer closes the details column while the rail is closed. - expect(within(column(container, 'details')!).getByTestId('meta-footer')).toBeTruthy(); - - const toggle = screen.getByRole('button', { name: /Related/ }); - expect(toggle.getAttribute('aria-expanded')).toBe('false'); - // The toggle counts the related collections. - expect(toggle.textContent).toContain('2'); - }); - - it('opens the rail from the toggle, and Escape closes it back to the toggle', () => { - const { container } = render(); - const toggle = screen.getByRole('button', { name: /Related/ }); - fireEvent.click(toggle); - expect(toggle.getAttribute('aria-expanded')).toBe('true'); - const rail = column(container, 'related')!; - expect(rail).not.toBeNull(); - expect(rail.id).toBe(toggle.getAttribute('aria-controls')); - expect(nodesIn(rail)).toEqual(['record:related_list', 'record:related_list']); - - fireEvent.keyDown(rail, { key: 'Escape' }); - expect(column(container, 'related')).toBeNull(); - expect(toggle.getAttribute('aria-expanded')).toBe('false'); - expect(document.activeElement).toBe(toggle); - }); - - it('draws no toggle when the related column is empty', () => { - const bare = buildDefaultPageColumns(buildDefaultPageSchema({ name: 'thing', fields: {} }))!; - render(); - expect(screen.queryByRole('button', { name: /Related/ })).toBeNull(); + expect(grid(container).getAttribute('data-record-columns-shown')).toBe('details'); + expect(grid(container).className).toContain('grid-cols-1'); + // Details first, the panels after them, in one flow. + const [details, right] = Array.from(grid(container).children) as HTMLElement[]; + expect(details.getAttribute('data-record-column')).toBe('details'); + expect(right.getAttribute('data-record-column')).toBe('right'); + expect(details.lastElementChild?.getAttribute('data-testid')).toBe('meta-footer'); + expect(nodesIn(right)).toEqual(['record:activity', 'record:related_list', 'record:related_list']); + // The stack's details are the whole width: the section's own container decides. + expect(details.querySelector('[data-node="record:details"]')!.getAttribute('data-cap')).toBe('3'); }); }); diff --git a/ui/packages/plugin-detail/src/__tests__/RecordTimelinePanel-27524.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordTimelinePanel-27524.test.tsx new file mode 100644 index 00000000000..714a18e8664 --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/RecordTimelinePanel-27524.test.tsx @@ -0,0 +1,160 @@ +/** + * 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, Target 5 — 动态 is a panel: the first panel of the + * record page's right column, the same shape as the related panels (「动态」 + * and the stream's count; the header row opens the 动态 tab), holding a + * one-line composer, the newest three entries (five when wide) and + * 「查看全部 N 条 →」 to the 动态 tab — no chips, no column. Drawn here with + * the real `record:activity` block on the default record page. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import { DiscussionContextProvider, SchemaRenderer } from '@object-ui/react'; +import type { FeedItem } from '@object-ui/types'; +import { RecordTimelinePanel } from '../RecordTimelinePanel'; +import { DefaultRecordPageContext } from '../defaultRecordPage'; +import { RecordFieldChangesContext, auditEntriesToFeedItems } from '../recordTimelineStream'; +import { buildDefaultTimeline } from '../synth/buildDefaultPageSchema'; +import '../index'; + +afterEach(cleanup); + +const notes: FeedItem[] = Array.from({ length: 6 }, (_, i) => ({ + id: `c${i}`, + type: 'comment', + actor: 'Ada', + body: `Note ${i}`, + createdAt: new Date(Date.UTC(2026, 9, 1, 0, i)).toISOString(), +})); +// A reply to the newest note: it sits under its comment in the tab, never as a peek line. +const reply: FeedItem = { id: 'r1', type: 'comment', actor: 'Bo', body: 'A reply', parentId: 'c5', createdAt: new Date(Date.UTC(2026, 9, 1, 0, 30)).toISOString() }; +const audit = auditEntriesToFeedItems( + [{ id: 1, created_at: new Date(Date.UTC(2026, 9, 1, 0, 20)).toISOString(), user_name: 'Dev', changes: [{ field: 'status', label: 'Status', from: 'New', to: 'Contacted' }] }], + { unknownUser: 'Unknown' }, +); + +function mount({ + entries = 3, + items = [...notes, reply], + changes = audit, + loading = false, + onViewAll = vi.fn(), + onAddComment, +}: { + entries?: number; + items?: FeedItem[]; + changes?: FeedItem[] | null; + loading?: boolean; + onViewAll?: () => void; + onAddComment?: (text: string) => Promise; +} = {}) { + const view = render( + + + + + + + , + ); + return { ...view, onViewAll }; +} + +const peekLines = (container: HTMLElement) => + Array.from(container.querySelectorAll('[data-record-timeline-peek-row]')).map((row) => row.textContent ?? ''); + +describe('the 动态 panel (objectstack#27524, Target 5)', () => { + it('is a panel: 「动态」 (Activity) and the whole stream\'s count; the header row opens the 动态 tab', async () => { + const { container, onViewAll } = mount(); + const panel = container.querySelector('[data-record-timeline-panel]') as HTMLElement; + expect(panel.getAttribute('aria-label')).toBe('Activity'); + // Six notes + the reply + the audit change. + await waitFor(() => expect(panel.querySelector('[data-record-timeline-count]')?.textContent).toBe('8')); + const open = panel.querySelector('[data-record-timeline-panel-open]') as HTMLButtonElement; + expect(open.getAttribute('title')).toContain('8'); + fireEvent.click(open); + expect(onViewAll).toHaveBeenCalledTimes(1); + }); + + it('peeks: the newest three roots as one line each, no chips, no load-more — 「查看全部 N 条 →」 opens the tab', () => { + const { container, onViewAll } = mount(); + // Newest first: the change (00:20), Note 5 (00:05), Note 4 — the reply (00:30) is not a root. + const lines = peekLines(container); + expect(lines).toHaveLength(3); + expect(lines[0]).toContain('Status New → Contacted'); + expect(lines[1]).toContain('Note 5'); + expect(lines[2]).toContain('Note 4'); + expect(lines.join(' ')).not.toContain('A reply'); + expect(container.querySelector('[data-stream-filters]')).toBeNull(); + expect(container.querySelector('[data-stream-load-more]')).toBeNull(); + const viewAll = container.querySelector('[data-record-timeline-view-all]') as HTMLButtonElement; + expect(viewAll.textContent).toContain('8'); + fireEvent.click(viewAll); + expect(onViewAll).toHaveBeenCalledTimes(1); + }); + + it('wide: five entries', () => { + const { container } = mount({ entries: 5 }); + expect(peekLines(container)).toHaveLength(5); + }); + + it('no 「查看全部」 when the stream holds no more than it draws', () => { + const { container } = mount({ items: notes.slice(0, 1) }); + // One note + the audit change: two lines, all of it. + expect(peekLines(container)).toHaveLength(2); + expect(container.querySelector('[data-record-timeline-view-all]')).toBeNull(); + }); + + it('no count while the feed loads: the count is unknown, not 0', () => { + const { container } = mount({ items: [], changes: null, loading: true }); + expect(container.querySelector('[data-record-timeline-count]')).toBeNull(); + expect(screen.getByTestId('activity-loading')).toBeTruthy(); + }); + + it('the composer is one line until pressed, then the full composer, focused; a sent note folds it back', async () => { + const onAddComment = vi.fn().mockResolvedValue(undefined); + const { container } = mount({ onAddComment }); + const oneLine = container.querySelector('[data-record-timeline-composer]') as HTMLButtonElement; + expect(oneLine.textContent).toBe('Write a note… @ to mention'); + expect(container.querySelector('textarea')).toBeNull(); + fireEvent.click(oneLine); + const textarea = container.querySelector('textarea') as HTMLTextAreaElement; + expect(textarea).not.toBeNull(); + expect(document.activeElement).toBe(textarea); + fireEvent.change(textarea, { target: { value: 'Called back' } }); + fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }); + await waitFor(() => expect(onAddComment).toHaveBeenCalledWith('Called back', undefined)); + await waitFor(() => expect(container.querySelector('[data-record-timeline-composer]')).not.toBeNull()); + expect(container.querySelector('textarea')).toBeNull(); + }); + + it('CONTROL — outside the panel the same block is the full stream: chips, every entry of the page, paging', () => { + const { container } = render( + + + + + + + , + ); + expect(container.querySelector('[data-stream-filters]')).not.toBeNull(); + expect(container.querySelector('[data-record-timeline-peek]')).toBeNull(); + expect(screen.getByText('Note 0')).toBeTruthy(); + }); +}); + + +/** The page's 动态 node drawn bare — as the 动态 tab draws it. */ +function RecordTimelinePanelFree() { + return ; +} diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx index 6c642ca585f..72511c96069 100644 --- a/ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx @@ -60,7 +60,7 @@ const columnsOf = (related = RELATED) => buildDefaultPageColumns(buildDefaultPageSchema({ name: 'lead', fields: {} }, { related, hideDiscussion: true }))!; const cardOrder = (container: HTMLElement) => - Array.from(container.querySelectorAll('[data-record-column="related"] [data-related-card-id]')).map((el) => + Array.from(container.querySelectorAll('[data-record-column="right"] [data-related-card-id]')).map((el) => el.getAttribute('data-related-card-id'), ); @@ -69,7 +69,7 @@ function Host({ initial, onWrite, related }: { initial?: RelatedArrangement; onW const [arrangement, setArrangement] = React.useState(initial); return ( { @@ -129,7 +129,7 @@ describe('RelatedCardStack — the reader arranges the related cards (objectstac expect(container.querySelector('[data-related-foot-line]')).toBeNull(); fireEvent.click(screen.getByText('down task')); expect(cardOrder(container)).toEqual(['note.parent_id', 'task.lead_id', 'call.lead_id']); - const foot = container.querySelector('[data-record-column="related"] [data-related-foot-line]')!; + const foot = container.querySelector('[data-record-column="right"] [data-related-foot-line]')!; expect(foot).not.toBeNull(); // Not the changed-default hint: the author's default did not change. expect(container.querySelector('[data-related-default-changed]')).toBeNull(); @@ -182,14 +182,14 @@ describe('RelatedCardStack — the reader arranges the related cards (objectstac describe('the enlarged related list — ⤢ and ?related= (objectstack#27055, Target 9)', () => { it('⤢ asks the host to enlarge that relationship', () => { const onEnlarge = vi.fn(); - render(); + render(); fireEvent.click(screen.getByText('enlarge note')); expect(onEnlarge).toHaveBeenCalledWith('note.parent_id'); }); it('draws the full list over the page body: the header stays, the columns step aside', () => { const { container } = render( - , + , ); const enlarged = container.querySelector('[data-related-enlarged="note.parent_id"]')!; expect(enlarged).toBeTruthy(); @@ -205,7 +205,7 @@ describe('the enlarged related list — ⤢ and ?related= (objectstack#27055, Ta it('‹ and Esc return to the columns', () => { const onClose = vi.fn(); const { container } = render( - , + , ); fireEvent.click(container.querySelector('[data-related-enlarged-back]')!); fireEvent.keyDown(document, { key: 'Escape' }); @@ -214,7 +214,7 @@ describe('the enlarged related list — ⤢ and ?related= (objectstack#27055, Ta it('an id naming no related list draws the columns', () => { const { container } = render( - , + , ); expect(container.querySelector('[data-related-enlarged]')).toBeNull(); expect(container.querySelector('[data-record-columns]')).not.toBeNull(); @@ -223,7 +223,7 @@ describe('the enlarged related list — ⤢ and ?related= (objectstack#27055, Ta import { ArrangedRelatedTab, RelatedArrangementScopeContext } from '../ArrangedRelatedTab'; -describe('the tabbed tier 关联 tab reads the same arrangement (objectstack#27055, Target 7)', () => { +describe('a page drawn as its strip: the 关联 tab reads the same arrangement (objectstack#27055, Target 7)', () => { const pageOf = () => buildDefaultPageSchema({ name: 'lead', fields: {}, enable: { files: true } }, { related: RELATED }); const relatedTabOf = (page: any) => page.regions[0].components.find((c: any) => c.type === 'page:tabs').properties.items.find((i: any) => i.value === 'related'); diff --git a/ui/packages/plugin-detail/src/__tests__/recordTimelineStream-27055.test.tsx b/ui/packages/plugin-detail/src/__tests__/recordTimelineStream-27055.test.tsx index d7f602d426a..6fa08ba23b0 100644 --- a/ui/packages/plugin-detail/src/__tests__/recordTimelineStream-27055.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/recordTimelineStream-27055.test.tsx @@ -10,8 +10,9 @@ * objectstack#27055, Target 3 — the timeline is ONE stream: discussion * (`sys_comment`), activity (`sys_activity`) and field changes * (`sys_audit_log`) merged newest first, filtered by the chips 全部 / 活动 / - * 讨论 / 变更, system entries drawn as one compact muted line, and - * 「查看全部 N 条 →」 at the foot. + * 讨论 / 变更, system entries drawn as one compact muted line, and paged at + * the foot (objectstack#27524: in full it is the 动态 tab; its panel's + * 「查看全部 N 条 →」 is pinned in `RecordTimelinePanel-27524.test.tsx`). */ import React from 'react'; @@ -107,21 +108,21 @@ describe('record:activity on the default record page is the stream', () => { , ); - it('merges the field changes in, pages at 20, and 「查看全部 N 条」 shows the rest', () => { + it('merges the field changes in, pages at 20, and the foot draws the next page (objectstack#27524)', () => { const { container } = mount(true); expect(container.querySelector('[data-stream-filters]')).toBeTruthy(); - const viewAll = container.querySelector('[data-stream-view-all]') as HTMLButtonElement; - expect(viewAll).toBeTruthy(); - // 25 notes + the call + the audit change. - expect(viewAll.textContent).toContain('27'); + // 25 notes + the call + the audit change: 27, twenty a page. + const more = container.querySelector('[data-stream-load-more]') as HTMLButtonElement; + expect(more).toBeTruthy(); // The page is the NEWEST twenty (newest first): the call, the change, Note 24 … Note 7. expect(screen.getByText('Note 24')).toBeTruthy(); expect(screen.getByText('Note 7')).toBeTruthy(); expect(screen.queryByText('Note 6')).toBeNull(); expect(screen.queryByText('Note 0')).toBeNull(); - fireEvent.click(viewAll); - expect(container.querySelector('[data-stream-view-all]')).toBeNull(); + fireEvent.click(more); + // The next page: the seven left, and no more to load. expect(screen.getByText('Note 0')).toBeTruthy(); + expect(container.querySelector('[data-stream-load-more]')).toBeNull(); }); it('a chip filters the whole stream, not the first page', () => { diff --git a/ui/packages/plugin-detail/src/defaultRecordPage.ts b/ui/packages/plugin-detail/src/defaultRecordPage.ts index 3fc2a819ac5..e4cd4922212 100644 --- a/ui/packages/plugin-detail/src/defaultRecordPage.ts +++ b/ui/packages/plugin-detail/src/defaultRecordPage.ts @@ -11,8 +11,8 @@ import * as React from 'react'; /** * `true` while the synthesized default record page draws (objectstack#27055): * the page `buildDefaultPageSchema` writes from the object's roles, in every - * tier — the column tiers (`RecordPageColumns` sets it) and the tab strip (the - * console's record view sets it around the synthesized page). + * tier (`RecordPageColumns` sets it) and, where its strip cannot be laid out, + * as its tab strip (the console's record view sets it around the page). * * The record page v3 face reads it: detail sections drawn frameless (a title, * a hairline and the field grid, no card), entirely empty sections folded into @@ -29,15 +29,23 @@ export function useDefaultRecordPage(): boolean { return React.useContext(DefaultRecordPageContext); } +/** + * The most columns a frameless detail section may take on the page being + * drawn (objectstack#27524, Target 6): the record page's layout sets 2 in its + * two-column tier, where the details share the row with the 320 px right + * column, and leaves 3 in the wide tier and the stack, where the section's + * own container width decides. 3 anywhere else. + */ +export const DetailsColumnCapContext = React.createContext<2 | 3>(3); + /** * How many columns a frameless detail section lays its fields in, by the * number of fields it shows (objectstack#27055, the v3 design: 「每段按字段数 * 1–3 列」): one field takes one column, two to five take two, six and more - * take three. Never more than three — the details column is the page's widest - * but shares the row with the timeline and related columns. + * take three — never more than `cap` ({@link DetailsColumnCapContext}). */ -export function framelessSectionColumns(visibleFieldCount: number): 1 | 2 | 3 { +export function framelessSectionColumns(visibleFieldCount: number, cap: 2 | 3 = 3): 1 | 2 | 3 { if (visibleFieldCount <= 1) return 1; - if (visibleFieldCount <= 5) return 2; + if (visibleFieldCount <= 5 || cap === 2) return 2; return 3; } diff --git a/ui/packages/plugin-detail/src/index.tsx b/ui/packages/plugin-detail/src/index.tsx index 38d6c8d83fd..77401b710ae 100644 --- a/ui/packages/plugin-detail/src/index.tsx +++ b/ui/packages/plugin-detail/src/index.tsx @@ -198,15 +198,14 @@ export type { ObjectDefFieldLike, BuildPageOptions, } from './synth/buildDefaultPageSchema'; -// objectstack#26678 — the synthesized record page at two widths: the tier a -// container takes, the page partitioned into its columns, and the columns. +// objectstack#26678 / objectstack#27524 — the synthesized record page laid +// out by its width: the tier a container takes, the page partitioned into its +// details and panels, and the layout. export { - RECORD_PAGE_THREE_COLUMN_MIN_WIDTH, + RECORD_PAGE_WIDE_MIN_WIDTH, RECORD_PAGE_TWO_COLUMN_MIN_WIDTH, recordPageTierFor, - recordPageArrangementFor, - recordPageColumnDefaults, - recordTimelineDefaultReason, + recordPageTierHasRightColumn, recordPageColumnOfTab, buildDefaultPageColumns, bodyTableIdsOf, @@ -217,10 +216,6 @@ export type { RecordPageTier, RecordPageColumn, DefaultPageColumns, - RecordPageColumnSwitches, - RecordPageArrangement, - RecordPageObjectLike, - RecordTimelineDefaultReason, } from './synth/recordPageColumns'; export { useRecordPageTier } from './useRecordPageTier'; export type { UseRecordPageTierOptions } from './useRecordPageTier'; diff --git a/ui/packages/plugin-detail/src/recordTimelineStream.ts b/ui/packages/plugin-detail/src/recordTimelineStream.ts index 91024c0f816..976eeae6452 100644 --- a/ui/packages/plugin-detail/src/recordTimelineStream.ts +++ b/ui/packages/plugin-detail/src/recordTimelineStream.ts @@ -5,7 +5,9 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * The record page v3 timeline: ONE stream (objectstack#27055, Target 3). + * The record page's 动态 stream: ONE stream (objectstack#27055, Target 3), + * drawn as the first panel of the right column and in full in the 动态 tab + * (objectstack#27524, Target 5). * * Three sources, one stream: the discussion (`sys_comment`, `enable.feeds`), * the activity (`sys_activity`, `enable.activities`) — both already merged by @@ -27,6 +29,23 @@ import type { FeedItem } from '@object-ui/types'; */ export const RecordFieldChangesContext = React.createContext(null); +/** + * The 动态 panel being drawn (objectstack#27524, Target 5): the first panel + * of the record page's right column. The stream drawn inside it takes its + * peek face — the one-line composer, the newest `entries` and + * 「查看全部 N 条 →」 (`onViewAll`, the 动态 tab) — and reports how many + * entries the whole stream holds (`onCount`, `null` while unknown) for the + * panel's count chip. `null` anywhere else: the stream draws in full. + */ +export interface RecordTimelinePanel { + /** How many of the newest entries the panel shows: 3, or 5 when wide. */ + entries: number; + onViewAll?: () => void; + onCount: (count: number | null) => void; +} + +export const RecordTimelinePanelContext = React.createContext(null); + /** One audit-log entry as the host enriches it (`RecordDetailView`'s history read). */ export interface AuditEntryLike { id?: string | number; diff --git a/ui/packages/plugin-detail/src/renderers/record-activity.tsx b/ui/packages/plugin-detail/src/renderers/record-activity.tsx index 2e581b598cb..093767e66dd 100644 --- a/ui/packages/plugin-detail/src/renderers/record-activity.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-activity.tsx @@ -81,6 +81,7 @@ import { useRecordAriaProps } from './recordComponentAria'; import { useDefaultRecordPage } from '../defaultRecordPage'; import { RecordFieldChangesContext, + RecordTimelinePanelContext, filterTimelineStream, mergeTimelineStream, type TimelineStreamFilter, @@ -270,13 +271,18 @@ export const RecordActivityRenderer: React.FC = ({ }, [canSelfFetch, dataSource, objectName, recordId, pageSize, systemActorLabel]); // objectstack#27055, Target 3 — on the synthesized default record page this - // block IS the timeline: ONE stream of the discussion, the activity and the - // field changes the host hands over (`RecordFieldChangesContext`), filtered - // by the chips 全部 / 活动 / 讨论 / 变更 and paged by 「查看全部 N 条 →」. + // block IS the 动态 stream: ONE stream of the discussion, the activity and + // the field changes the host hands over (`RecordFieldChangesContext`). In + // full (the 动态 tab) it is filtered by the chips 全部 / 活动 / 讨论 / 变更 + // and paged `limit` entries at a time; in the 动态 panel (objectstack#27524, + // `RecordTimelinePanelContext`) it peeks: the newest few, no chips. const asStream = useDefaultRecordPage(); + const timelinePanel = React.useContext(RecordTimelinePanelContext); + const asPeek = asStream && timelinePanel !== null; + const peekEntries = timelinePanel?.entries ?? 0; const fieldChanges = React.useContext(RecordFieldChangesContext); const [streamFilter, setStreamFilter] = React.useState('all'); - const [streamShowAll, setStreamShowAll] = React.useState(false); + const [streamPages, setStreamPages] = React.useState(1); const discussionItems = discussion?.items as FeedItem[] | undefined; const applied = React.useMemo(() => { @@ -286,15 +292,22 @@ export const RecordActivityRenderer: React.FC = ({ } // The chip filters the WHOLE stream before the page is cut, so 讨论 is // every comment, not the comments of the first page. - const stream = filterTimelineStream(mergeTimelineStream(sourceItems, fieldChanges), streamFilter); + const stream = filterTimelineStream(mergeTimelineStream(sourceItems, fieldChanges), asPeek ? 'all' : streamFilter); // Filtered only (no window): `applyFeedConfig` windows a chronological // feed from its tail, but the stream is newest first, so its window is the - // HEAD — the newest `limit` entries, not the oldest. + // HEAD — the newest entries, not the oldest. const all = applyFeedConfig(stream, { types, showCompleted, unifiedTimeline }, Number.MAX_SAFE_INTEGER, BLOCK_NAME); const total = all.items.length; - if (streamShowAll || total <= limit) return { items: all.items, hasMore: false, total }; - return { items: all.items.slice(0, limit), hasMore: true, total }; - }, [hostItems, discussionItems, fetched, types, showCompleted, unifiedTimeline, pageSize, asStream, fieldChanges, streamFilter, streamShowAll, limit]); + if (asPeek) { + // A reply sits under its comment in the tab; the peek names roots only. + const held = new Set(all.items.map((item) => item.id)); + const roots = all.items.filter((item) => !item.parentId || !held.has(item.parentId)); + return { items: roots.slice(0, peekEntries), hasMore: false, total }; + } + const shown = limit * streamPages; + if (total <= shown) return { items: all.items, hasMore: false, total }; + return { items: all.items.slice(0, shown), hasMore: true, total }; + }, [hostItems, discussionItems, fetched, types, showCompleted, unifiedTimeline, pageSize, asStream, asPeek, peekEntries, fieldChanges, streamFilter, streamPages, limit]); const items = applied.items; const hasMore = applied.hasMore || (canSelfFetch && fetchedHasMore); @@ -316,10 +329,18 @@ export const RecordActivityRenderer: React.FC = ({ React.useEffect(() => { setFilterMode(defaultFilterMode); }, [defaultFilterMode]); const handleLoadMore = React.useCallback(() => { - if (asStream) setStreamShowAll(true); + if (asStream) setStreamPages((n) => n + 1); else setExtraPages((n) => n + 1); }, [asStream]); + // The 动态 panel's count chip: the whole stream's size, unknown while the + // feed loads or a read was refused (a refused read is not "none"). + const reportCount = timelinePanel?.onCount; + const peekCount = asPeek && !loading && !activityDenied && !commentsDenied ? applied.total : null; + React.useEffect(() => { + reportCount?.(peekCount); + }, [reportCount, peekCount]); + // Write path: whatever the host's DiscussionContext provides, nothing more. // `enableMentions: false` withholds the suggestion list, which is what turns // the composer's @-autocomplete off. @@ -347,18 +368,24 @@ export const RecordActivityRenderer: React.FC = ({ onUploadAttachments={discussion?.onUploadAttachments as any} activityDenied={activityDenied} commentsDenied={commentsDenied} - {...(asStream + {...(asPeek ? { - variant: 'stream' as const, - streamFilter, - onStreamFilterChange: (next: TimelineStreamFilter) => { - setStreamFilter(next); - setStreamShowAll(false); - }, + variant: 'peek' as const, totalCount: applied.total, - titleLabel: tt('detail.timeline', 'Timeline'), + onViewAll: timelinePanel?.onViewAll, } - : {})} + : asStream + ? { + variant: 'stream' as const, + streamFilter, + onStreamFilterChange: (next: TimelineStreamFilter) => { + setStreamFilter(next); + setStreamPages(1); + }, + totalCount: applied.total, + titleLabel: tt('detail.timeline', 'Activity'), + } + : {})} /> ); diff --git a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.bodyTables-27055.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.bodyTables-27055.test.ts index 162ba50ecef..c4a7fd47e2f 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.bodyTables-27055.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.bodyTables-27055.test.ts @@ -42,6 +42,7 @@ const SCHEDULE = { objectName: 'quote_schedule', relationshipField: 'quote', tit const TASKS = { objectName: 'task', relationshipField: 'quote', title: 'Tasks' }; const items = (tabs: any) => tabs.properties.items; +const tabOf = (tabs: any, value: string) => items(tabs).find((i: any) => i.value === value); const shape = (nodes: any[]) => nodes.map((n) => n.type === 'record:details' @@ -52,14 +53,15 @@ const shape = (nodes: any[]) => describe('body tables sit in the Details tab after the first section (objectstack#27055)', () => { it('splits the details around the body tables; side lists stay in Related', () => { const tabs = buildDefaultTabs(quoteDef, { sections: SECTIONS, related: [LINES, TASKS, SCHEDULE] }); - expect(items(tabs).map((i: any) => i.value)).toEqual(['details', 'related', 'timeline']); + // 动态 is second for every object (objectstack#27524, Target 5). + expect(items(tabs).map((i: any) => i.value)).toEqual(['details', 'timeline', 'related']); expect(shape(items(tabs)[0].children)).toEqual([ 'details(Basic)', 'table(quote_line_item)', 'table(quote_schedule)', 'details(Terms,Notes)', ]); - expect(items(tabs)[1].children.map((n: any) => n.properties.objectName)).toEqual(['task']); + expect(tabOf(tabs, 'related').children.map((n: any) => n.properties.objectName)).toEqual(['task']); }); it('a body table opens on a first page of ten rows unless its list names a limit', () => { @@ -75,7 +77,7 @@ describe('body tables sit in the Details tab after the first section (objectstac expect(shape(items(asBody)[0].children)).toContain('table(task)'); const asSide = buildDefaultTabs(quoteDef, { sections: SECTIONS, related: [{ ...primary, isBody: false }] }); expect(shape(items(asSide)[0].children)).not.toContain('table(task)'); - expect(items(asSide)[1].value).toBe('related'); + expect(items(asSide)[2].value).toBe('related'); }); it('with one section the tables follow the details', () => { @@ -83,7 +85,7 @@ describe('body tables sit in the Details tab after the first section (objectstac expect(shape(items(tabs)[0].children)).toEqual(['details(Basic)', 'table(quote_line_item)']); }); - it('emits no per-list tab any more — the strip is Details / Related / Timeline', () => { + it('emits no per-list tab any more — the strip is Details / Timeline / Related', () => { const tabs = buildDefaultTabs(quoteDef, { sections: SECTIONS, related: [{ ...TASKS, isPrimary: true }, LINES] }); expect(items(tabs).some((i: any) => String(i.value).startsWith('related:'))).toBe(false); }); @@ -96,11 +98,10 @@ describe('the page knows its body tables', () => { expect([...bodyTableIdsOf(page)]).toEqual(['quote_line_item.quote']); }); - it('the column tiers draw them in the details column, side lists in the related column', () => { + it('the laid-out page draws them in the details column, side lists as the right column\'s panels', () => { const columns = buildDefaultPageColumns(page)!; expect(shape(columns.details)).toEqual(['details(Basic)', 'table(quote_line_item)', 'details(Terms,Notes)']); expect(columns.related.map((n: any) => n.properties.objectName)).toEqual(['task']); - expect(columns.relatedCount).toBe(1); }); }); @@ -159,7 +160,7 @@ describe('a field group places a body table at its position (objectstack#27069, // objectstack#27055); the renderer never second-guesses body-ness either. const tabs = buildDefaultTabs(placed('task'), { related: [LINES, TASKS] }); expect(shape(items(tabs)[0].children)).toEqual(['details(basic)', 'table(quote_line_item)', 'details(terms,notes)']); - expect(items(tabs)[1].children.map((n: any) => n.properties.objectName)).toEqual(['task']); + expect(tabOf(tabs, 'related').children.map((n: any) => n.properties.objectName)).toEqual(['task']); }); it('the host-passed sections place the same way (the `{ group }` sections RecordDetailView passes)', () => { diff --git a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.strictPayload.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.strictPayload.test.ts index f9f0c818fca..8356b46629f 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.strictPayload.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.strictPayload.test.ts @@ -229,10 +229,10 @@ describe('the accepted shape still carries the semantics (objectui#4232)', () => related: [{ objectName: 'task', relationshipField: 'lead_id', title: 'Tasks' }], }); const parsed = PageTabsProps.parse(tabs.properties) as any; - expect(parsed.items.map((i: any) => i.label)).toEqual(['Details', 'Related', 'Timeline']); - expect(parsed.items.map((i: any) => i.value)).toEqual(['details', 'related', 'timeline']); + expect(parsed.items.map((i: any) => i.label)).toEqual(['Details', 'Timeline', 'Related']); + expect(parsed.items.map((i: any) => i.value)).toEqual(['details', 'timeline', 'related']); expect(parsed.items[0].children[0].type).toBe('record:details'); - expect(parsed.items[1].children[0].properties.objectName).toBe('task'); + expect(parsed.items[2].children[0].properties.objectName).toBe('task'); }); it('the tab items survive the whole-page parse the server runs', () => { @@ -241,7 +241,7 @@ describe('the accepted shape still carries the semantics (objectui#4232)', () => }); const page = PageSchema.parse(body) as any; const tabs = page.regions[0].components.find((c: any) => c.type === 'page:tabs'); - expect(tabs.properties.items.map((i: any) => i.value)).toEqual(['details', 'related', 'timeline']); - expect(tabs.properties.items[1].children[0].properties.relationshipField).toBe('lead_id'); + expect(tabs.properties.items.map((i: any) => i.value)).toEqual(['details', 'timeline', 'related']); + expect(tabs.properties.items[2].children[0].properties.relationshipField).toBe('lead_id'); }); }); diff --git a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts index 7f66c9a3011..9ba13f27abd 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts @@ -366,14 +366,15 @@ describe('buildDefaultPageSchema', () => { ], }); const tabs = page.regions[0].components.find((c: any) => c.type === 'page:tabs'); - // Details, Related, then the Timeline (leadDef is not a worked object). + // Details, the Timeline, then Related (objectstack#27524: 动态 is + // second for every object). expect(tabItems(tabs)).toHaveLength(3); - expect(tabItems(tabs)[1].label).toBe('Related'); - expect(tabItems(tabs)[1].children).toHaveLength(2); - expect(tabItems(tabs)[1].children[0].type).toBe('record:related_list'); - expect(props(tabItems(tabs)[1].children[0]).objectName).toBe('task'); - expect(props(tabItems(tabs)[1].children[0]).relationshipField).toBe('lead_id'); - expect(props(tabItems(tabs)[1].children[0]).limit).toBe(10); + expect(tabItems(tabs)[2].label).toBe('Related'); + expect(tabItems(tabs)[2].children).toHaveLength(2); + expect(tabItems(tabs)[2].children[0].type).toBe('record:related_list'); + expect(props(tabItems(tabs)[2].children[0]).objectName).toBe('task'); + expect(props(tabItems(tabs)[2].children[0]).relationshipField).toBe('lead_id'); + expect(props(tabItems(tabs)[2].children[0]).limit).toBe(10); }); it('draws an isPrimary related list as a body table of the Details tab (objectstack#27055)', () => { @@ -396,7 +397,7 @@ describe('buildDefaultPageSchema', () => { ], }); const labels = tabItems(tabs).map((t: any) => t.label); - expect(labels).toEqual(['Details', 'Related', 'Timeline']); + expect(labels).toEqual(['Details', 'Timeline', 'Related']); const related = tabItems(tabs).find((t: any) => t.label === 'Related'); expect(related.children).toHaveLength(2); expect(related.children.map((c: any) => props(c).objectName)).toEqual(['task', 'note']); @@ -431,16 +432,16 @@ describe('buildDefaultPageSchema', () => { }); }); - it('a worked object opens its Timeline second, any other last (objectstack#27055)', () => { + it('every object opens its Timeline second — a worked one and any other (objectstack#27524, Target 5)', () => { const related = [{ objectName: 'task', relationshipField: 'lead_id' }]; const worked = { ...leadDef, stageField: 'status' } as ObjectDefLike; const workedTabs = buildDefaultPageSchema(worked, { related }).regions[0].components.find((c: any) => c.type === 'page:tabs'); expect(tabItems(workedTabs).map((t: any) => t.value)).toEqual(['details', 'timeline', 'related']); const plainTabs = buildDefaultPageSchema(leadDef, { related }).regions[0].components.find((c: any) => c.type === 'page:tabs'); - expect(tabItems(plainTabs).map((t: any) => t.value)).toEqual(['details', 'related', 'timeline']); + expect(tabItems(plainTabs).map((t: any) => t.value)).toEqual(['details', 'timeline', 'related']); }); - it('Details / Related / Approvals / Timeline tab order is stable', () => { + it('Details / Timeline / Related / Approvals tab order is stable', () => { const page = buildDefaultPageSchema(leadDef, { related: [{ objectName: 'task', relationshipField: 'lead_id' }], approvals: { count: 1 }, @@ -448,9 +449,9 @@ describe('buildDefaultPageSchema', () => { const tabs = page.regions[0].components.find((c: any) => c.type === 'page:tabs'); expect(tabItems(tabs).map((t: any) => t.label)).toEqual([ 'Details', + 'Timeline', 'Related', 'Approvals', - 'Timeline', ]); }); @@ -515,8 +516,8 @@ describe('buildDefaultPageSchema', () => { const tabs = page.regions[0].components.find((c: any) => c.type === 'page:tabs'); expect(tabItems(tabs).map((t: any) => t.label)).toEqual([ 'Details', - 'Related', 'Timeline', + 'Related', ]); expect(tabItems(tabs)[0].children).toEqual([{ type: 'div', id: 'custom-details' }]); // record:details default body must be gone @@ -590,15 +591,15 @@ describe('buildDefaultPageSchema', () => { expect(types).toContain('record:path'); }); - it('buildDefaultTabs emits Details/Related/Timeline in order', () => { + it('buildDefaultTabs emits Details/Timeline/Related in order', () => { const tabs = buildDefaultTabs(leadDef, { related: [{ objectName: 'task', relationshipField: 'lead_id' }], }); expect(tabs.type).toBe('page:tabs'); expect(tabItems(tabs).map((t: any) => t.label)).toEqual([ 'Details', - 'Related', 'Timeline', + 'Related', ]); }); @@ -637,7 +638,7 @@ describe('buildDefaultPageSchema', () => { const tabs = buildDefaultTabs(filesDef, { related: [{ objectName: 'task', relationshipField: 'lead_id' }], }); - // `enable.files` makes the object worked: the Timeline opens second. + // The Timeline opens second, as for every object (objectstack#27524). expect(tabItems(tabs).map((t: any) => t.value)).toEqual(['details', 'timeline', 'related']); const related = tabItems(tabs).find((t: any) => t.value === 'related'); expect(related.children.map((c: any) => c.type)).toEqual(['record:related_list', 'record:attachments']); @@ -685,12 +686,12 @@ describe('buildDefaultPageSchema', () => { expect(tab.children).toEqual([{ type: 'record:approvals', properties: { ...nodePayload } }]); }); - it('the Approvals tab sits after Related (and before a non-worked object\'s Timeline)', () => { + it('the Approvals tab sits after Related, for every object (objectstack#27524)', () => { const plain = buildDefaultTabs(leadDef, { related: [{ objectName: 'task', relationshipField: 'lead_id' }], approvals: { node: nodePayload }, }); - expect(tabItems(plain).map((t: any) => t.value)).toEqual(['details', 'related', 'approvals', 'timeline']); + expect(tabItems(plain).map((t: any) => t.value)).toEqual(['details', 'timeline', 'related', 'approvals']); const worked = buildDefaultTabs({ ...leadDef, enable: { files: true } }, { related: [{ objectName: 'task', relationshipField: 'lead_id' }], approvals: { node: nodePayload }, @@ -987,12 +988,12 @@ describe('buildDefaultTabs — stable tab values (objectui#2257)', () => { const relA = { title: 'Invoices', objectName: 'invoice', relationshipField: 'account', isPrimary: true }; const relB = { title: 'Notes', objectName: 'note', relationshipField: 'account' }; - it('emits semantic values: details (primary in its body) / related (rest) / timeline', () => { + it('emits semantic values: details (primary in its body) / timeline / related (rest)', () => { const tabs = buildDefaultTabs(undefined, { related: [relA, relB], }); expect(tabItems(tabs).map((i: any) => i.value)).toEqual([ - 'details', 'related', 'timeline', + 'details', 'timeline', 'related', ]); }); diff --git a/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.switches-27052.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.switches-27052.test.ts index 54fb6b15252..505ea10c872 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.switches-27052.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.switches-27052.test.ts @@ -7,21 +7,25 @@ */ /** - * objectstack#27052 — the record page's column switches: the default a - * reader who never chose gets, derived from the object with no key of its - * own (one row per branch of `recordTimelineDefaultReason`), and how a column - * tier re-flows when a switch is off (`recordPageArrangementFor`). + * objectstack#27052 → objectstack#27524, Target 5 — the record page's + * timeline switch and its object-derived default retired with the timeline + * column (「宽屏时间线放中间不需要了吧」— ruled yes). 动态 is a panel, the + * first of the right column, for EVERY object: nothing derives from the + * object where it goes, so a worked object (a stage, option kinds, files) + * and a container (a price book, a product) synthesize the same strip + * order. What remains of #27052 is the reader's one switch, ⊟ (the right + * column), pinned in `RecordPageColumns.switches-27052.test.tsx` and the + * console's `RecordDetailView.panelToggles-27052.test.tsx`. */ import { describe, it, expect } from 'vitest'; -import { - recordPageArrangementFor, - recordPageColumnDefaults, - recordTimelineDefaultReason, -} from '../recordPageColumns'; +import * as recordPageColumns from '../recordPageColumns'; +import { buildDefaultTabs } from '../buildDefaultPageSchema'; -const lead = { +const worked = { name: 'crm_lead', + stageField: 'status', + enable: { files: true }, fields: { status: { type: 'select', @@ -32,102 +36,20 @@ const lead = { }, }, }; +const container = { name: 'price_book', fields: { name: { type: 'text' } } }; +const RELATED = [{ objectName: 'task', relationshipField: 'parent_id', title: 'Tasks' }]; -describe('recordTimelineDefaultReason — the timeline is on for a worked object (objectstack#27052)', () => { - it('stage-field: `stageField` names a field', () => { - expect(recordTimelineDefaultReason({ stageField: 'phase', fields: {} })).toBe('stage-field'); - }); - - it('option-kinds: a field whose options declare a lifecycle kind', () => { - expect(recordTimelineDefaultReason(lead)).toBe('option-kinds'); - // An array-shaped field list reads the same. - expect(recordTimelineDefaultReason({ fields: Object.values(lead.fields) })).toBe('option-kinds'); - }); - - it('`stageField: false` turns the stage role off: option kinds no longer count', () => { - expect(recordTimelineDefaultReason({ ...lead, stageField: false })).toBeNull(); - }); - - it('files: `enable.files: true`', () => { - expect(recordTimelineDefaultReason({ enable: { files: true }, fields: {} })).toBe('files'); - expect(recordTimelineDefaultReason({ enable: { files: false }, fields: {} })).toBeNull(); - }); - - it('a `master_detail` parent and nothing else is off: owning child rows is a container, not a worked record', () => { - const priceBook = { name: 'price_book', fields: { name: { type: 'text' } } }; - const entry = { name: 'price_book_entry', fields: { price_book: { type: 'master_detail', reference: 'price_book' } } }; - expect(recordTimelineDefaultReason(priceBook)).toBeNull(); - expect(recordPageColumnDefaults(priceBook)).toEqual({ timeline: false, related: true }); - // The child that holds the `master_detail` is not worked by it either. - expect(recordTimelineDefaultReason(entry)).toBeNull(); - }); - - it('none of them: off — a product, a price-book entry', () => { - const product = { name: 'product', fields: { name: { type: 'text' }, family: { type: 'select', options: [{ value: 'a', label: 'A' }] } } }; - expect(recordTimelineDefaultReason(product)).toBeNull(); - expect(recordTimelineDefaultReason(undefined)).toBeNull(); - }); -}); - -describe('recordPageColumnDefaults (objectstack#27052)', () => { - it('the timeline follows the derivation; the related column is on', () => { - expect(recordPageColumnDefaults(lead)).toEqual({ timeline: true, related: true }); - expect(recordPageColumnDefaults({ fields: {} })).toEqual({ timeline: false, related: true }); - }); -}); - -describe('recordPageArrangementFor — a switched-off column frees its room (objectstack#27052)', () => { - it('three columns, both on: details | timeline | related', () => { - expect(recordPageArrangementFor('three-column', { timeline: true, related: true })).toEqual({ - columns: ['details', 'timeline', 'related'], - timelineFolded: false, - relatedRail: false, - }); - }); - - it('three columns, timeline off: details | related, the timeline folded into it', () => { - expect(recordPageArrangementFor('three-column', { timeline: false, related: true })).toEqual({ - columns: ['details', 'related'], - timelineFolded: true, - relatedRail: false, - }); - }); - - it('three columns, related off: details | timeline, and no rail (objectstack#27241)', () => { - expect(recordPageArrangementFor('three-column', { timeline: true, related: false })).toEqual({ - columns: ['details', 'timeline'], - timelineFolded: false, - relatedRail: false, - }); - }); - - it('two columns, related off: details | timeline, and no rail — the switch decides whether the column exists (objectstack#27241)', () => { - expect(recordPageArrangementFor('two-column', { timeline: true, related: false })).toEqual({ - columns: ['details', 'timeline'], - timelineFolded: false, - relatedRail: false, - }); - }); +const tabValues = (def: any) => buildDefaultTabs(def, { related: RELATED }).properties.items.map((i: any) => i.value); - it('two columns: details | timeline with the rail, or details | related when the timeline is off', () => { - expect(recordPageArrangementFor('two-column', { timeline: true, related: true })).toEqual({ - columns: ['details', 'timeline'], - timelineFolded: false, - relatedRail: true, - }); - expect(recordPageArrangementFor('two-column', { timeline: false, related: true })).toEqual({ - columns: ['details', 'related'], - timelineFolded: true, - relatedRail: false, - }); +describe('动态 is a panel for every object (objectstack#27524, Target 5)', () => { + it('the strip places Timeline second for a worked object and a container alike', () => { + expect(tabValues(worked)).toEqual(['details', 'timeline', 'related']); + expect(tabValues(container)).toEqual(['details', 'timeline', 'related']); }); - it('both off: details alone, and no rail (objectstack#27241)', () => { - expect(recordPageArrangementFor('three-column', { timeline: false, related: false })).toEqual({ - columns: ['details'], - timelineFolded: true, - relatedRail: false, - }); - expect(recordPageArrangementFor('two-column', { timeline: false, related: false }).relatedRail).toBe(false); + it('the object-derived default and the column switches are gone from the module', () => { + for (const retired of ['recordTimelineDefaultReason', 'recordPageColumnDefaults', 'recordPageArrangementFor']) { + expect(recordPageColumns).not.toHaveProperty(retired); + } }); }); diff --git a/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.test.ts index 29ec9095f27..566a2320fac 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.test.ts @@ -7,23 +7,25 @@ */ /** - * objectstack#26678 — the synthesized record page at two widths. + * objectstack#26678 / objectstack#27524 (Target 6) — the synthesized record + * page laid out by its width, by one rule. * * Two facts are pinned here, both pure: the tier a container width takes * (`recordPageTierFor`), and how the synthesized tabbed page partitions into - * the column tiers' header and columns (`buildDefaultPageColumns`). The - * partition is read off `buildDefaultPageSchema`'s real output, never a + * its header, details, 动态 and related panels (`buildDefaultPageColumns`). + * The partition is read off `buildDefaultPageSchema`'s real output, never a * hand-built page, so a synthesizer change reaches these assertions. */ import { describe, it, expect } from 'vitest'; import { buildDefaultPageSchema, buildDefaultTabs, type ObjectDefLike } from '../buildDefaultPageSchema'; import { - RECORD_PAGE_THREE_COLUMN_MIN_WIDTH, RECORD_PAGE_TWO_COLUMN_MIN_WIDTH, + RECORD_PAGE_WIDE_MIN_WIDTH, buildDefaultPageColumns, recordPageColumnOfTab, recordPageTierFor, + recordPageTierHasRightColumn, } from '../recordPageColumns'; const types = (nodes: any[]): string[] => nodes.map((n) => n?.type); @@ -59,38 +61,50 @@ const maximal = { approvals: { count: 2, node: { requests: [] } }, }; -describe('recordPageTierFor (objectstack#26678)', () => { - it('three columns from 1120 px, two from 760 px, the tabs below (objectstack#27241)', () => { - expect(RECORD_PAGE_THREE_COLUMN_MIN_WIDTH).toBe(1120); +describe('recordPageTierFor — one rule by the container width (objectstack#27524, Target 6)', () => { + it('wide from 1120 px, details | right column from 760 px, one column below — no tabbed tier', () => { + expect(RECORD_PAGE_WIDE_MIN_WIDTH).toBe(1120); expect(RECORD_PAGE_TWO_COLUMN_MIN_WIDTH).toBe(760); - expect(recordPageTierFor(1440)).toBe('three-column'); - expect(recordPageTierFor(1120)).toBe('three-column'); + expect(recordPageTierFor(1440)).toBe('wide'); + expect(recordPageTierFor(1120)).toBe('wide'); expect(recordPageTierFor(1119.5)).toBe('two-column'); expect(recordPageTierFor(900)).toBe('two-column'); expect(recordPageTierFor(760)).toBe('two-column'); - expect(recordPageTierFor(759)).toBe('tabs'); - expect(recordPageTierFor(600)).toBe('tabs'); + expect(recordPageTierFor(759)).toBe('stack'); + expect(recordPageTierFor(600)).toBe('stack'); }); - it('the windows the thresholds serve, with the 256 px sidebar expanded (objectstack#27241)', () => { + it('the faces the card measures: 1440 / 1200 page, 720 split pane, 390 phone', () => { const SIDEBAR = 256; - // 「1440 窗口(含侧栏)即三栏」 — the record page root measured 1184 px there. - expect(recordPageTierFor(1440 - SIDEBAR)).toBe('three-column'); - // A 1024 px window with the sidebar stays two columns (768 px of container). + // 「1440 窗口(含侧栏)」 — the record page root measured 1184 px there. + expect(recordPageTierFor(1440 - SIDEBAR)).toBe('wide'); + // A 1200 px window with the sidebar: 944 px of container. + expect(recordPageTierFor(1200 - SIDEBAR)).toBe('two-column'); + // A 1024 px window with the sidebar keeps the right column (768 px). expect(recordPageTierFor(1024 - SIDEBAR)).toBe('two-column'); - // A 1280 px window with the sidebar: 1024 px of container, two columns. - expect(recordPageTierFor(1280 - SIDEBAR)).toBe('two-column'); + // The default split pane is 720 px wide: it stacks, it is not tabbed. + expect(recordPageTierFor(720)).toBe('stack'); + expect(recordPageTierFor(390)).toBe('stack'); }); - it('an embedded page is tabbed at every width', () => { - expect(recordPageTierFor(1440, { embedded: true })).toBe('tabs'); - expect(recordPageTierFor(1100, { embedded: true })).toBe('tabs'); + it('only the width counts: there is no embedded answer, and an embedded pane maximized to 1184 px is wide', () => { + // The signature takes the width alone (objectstack#27524: the drawer, the + // pane and the maximized pane use the page's rule). + expect(recordPageTierFor.length).toBe(1); + expect(recordPageTierFor(1184)).toBe('wide'); }); - it('an unknown width keeps the tabs', () => { - expect(recordPageTierFor(0)).toBe('tabs'); - expect(recordPageTierFor(Number.NaN)).toBe('tabs'); - expect(recordPageTierFor(-1)).toBe('tabs'); + it('an unknown width is the stack, the narrowest tier', () => { + expect(recordPageTierFor(0)).toBe('stack'); + expect(recordPageTierFor(Number.NaN)).toBe('stack'); + expect(recordPageTierFor(-1)).toBe('stack'); + }); + + it('the right column, and so ⊟, exists in the two tiers beside the details only', () => { + expect(recordPageTierHasRightColumn('wide')).toBe(true); + expect(recordPageTierHasRightColumn('two-column')).toBe(true); + expect(recordPageTierHasRightColumn('stack')).toBe(false); + expect(recordPageTierHasRightColumn(null)).toBe(false); }); }); @@ -112,11 +126,11 @@ describe('recordPageColumnOfTab — every synthesized tab has a column', () => { }); describe('buildDefaultPageColumns (objectstack#26678)', () => { - it('partitions the synthesized page: one header, details | timeline | related', () => { + it('partitions the synthesized page: one header, the details, the 动态 panel, the related panels', () => { const page = buildDefaultPageSchema(leadDef, maximal); const columns = buildDefaultPageColumns(page); expect(columns).not.toBeNull(); - // The header is every node before the strip — the same nodes the tabbed tier draws. + // The header is every node before the strip — the same nodes the strip draws above it. const main = page.regions.find((r: any) => r.name === 'main'); const tabIndex = main.components.findIndex((n: any) => n.type === 'page:tabs'); expect(columns!.header).toEqual(main.components.slice(0, tabIndex)); @@ -134,7 +148,6 @@ describe('buildDefaultPageColumns (objectstack#26678)', () => { 'record:approvals', ]); expect(columns!.related.slice(0, 2).map((n: any) => n.properties.objectName)).toEqual(['note', 'call']); - expect(columns!.relatedCount).toBe(2); }); it('places every node the page holds exactly once', () => { @@ -169,10 +182,9 @@ describe('buildDefaultPageColumns (objectstack#26678)', () => { const columns = buildDefaultPageColumns(page)!; expect(columns.timeline).toEqual([]); expect(columns.related).toEqual([]); - expect(columns.relatedCount).toBe(0); }); - it('answers null — keep the tabs — for a strip it cannot read', () => { + it('answers null — draw the page as written — for a strip it cannot read', () => { const tabsSlot = { type: 'page:tabs', properties: { items: [{ label: 'Overview', value: 'overview', children: [] }] } }; // An authored strip (a `kind: 'full'` page's own; the slotted `tabs` slot // that once carried one retired, objectstack#27053). diff --git a/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts b/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts index 520f7b1ea29..4005fb56a1d 100644 --- a/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts +++ b/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts @@ -32,7 +32,6 @@ import { detectStatusField } from '@object-ui/types'; // at this depth, and both spellings resolve to one table and one dedupe set. import { isRetiredFieldType, reportRetiredFieldType, resolveNameField } from '@object-ui/core'; import { inferDetailColumns } from '../autoLayout'; -import { recordTimelineDefaultReason } from './recordPageColumns'; import { relatedListEntryNames } from './relatedListEntry'; /** Minimal shape of an object definition we read here. We deliberately @@ -154,7 +153,7 @@ export interface BuildPageOptions { sections?: Array<{ label?: string; columns?: number; fields?: any[] }>; /** * Suppress the timeline stream (objectstack#27055): no Timeline tab, no - * timeline column. It used to suppress the discussion footer the stream + * 动态 panel. It used to suppress the discussion footer the stream * replaced. */ hideDiscussion?: boolean; @@ -1018,8 +1017,9 @@ export function bodyTablePlacement( * then the Timeline tab (the one stream, objectstack#27055: field changes * join it under its Changes filter, so there is no History tab), the Related * tab and the Approvals tab, each of those three only when the options give - * it content. The order is stable: Timeline sits second for a worked object - * and last otherwise. + * it content. The order is stable: Timeline sits second for every object — + * the 动态 panel is the first panel of the record page's right column + * (objectstack#27524, Target 5). * * Useful for slotted authors who want to add a custom tab — they can * call `buildDefaultTabs(def, opts)` and splice their tab into @@ -1081,15 +1081,13 @@ export function buildDefaultTabs( children: bodyTablePlacement(def, options.sections, highlightFields, bodies), }, ]; - // objectstack#27055, Targets 3 and 6 — the tab strip is 详情 / 时间线 / 关联 / - // (审批): the timeline stream is ONE tab, second for a worked object (the - // object-derived default of the timeline column, `recordTimelineDefaultReason`) - // and last otherwise; attachments close the Related tab, as they close the - // related column. + // objectstack#27055, Target 3 — the tab strip is 详情 / 动态 / 关联 / + // (审批): the timeline stream is ONE tab, second for every object + // (objectstack#27524, Target 5: 动态 is a panel, the first of the right + // column, for every object — no object-derived default places it); + // attachments close the Related tab, as they close the right column. const timeline = options.timeline ?? (options.hideDiscussion ? [] : [buildDefaultTimeline()]); - const timelineTab = timeline.length > 0 ? { label: 'Timeline', value: 'timeline', children: timeline } : null; - const worked = recordTimelineDefaultReason(def as never) !== null; - if (timelineTab && worked) items.push(timelineTab); + if (timeline.length > 0) items.push({ label: 'Timeline', value: 'timeline', children: timeline }); // Attachments (objectstack#4358) — for `enable.files: true` objects; the // tab strip's count badge reads the `record:attachments` node. const relatedChildren = [ @@ -1116,7 +1114,6 @@ export function buildDefaultTabs( children: [componentNode('record:approvals', { ...(options.approvals.node || {}) })], }); } - if (timelineTab && !worked) items.push(timelineTab); return componentNode('page:tabs', { items }); } diff --git a/ui/packages/plugin-detail/src/synth/recordPageColumns.ts b/ui/packages/plugin-detail/src/synth/recordPageColumns.ts index cf52ec41e45..17a16fbec48 100644 --- a/ui/packages/plugin-detail/src/synth/recordPageColumns.ts +++ b/ui/packages/plugin-detail/src/synth/recordPageColumns.ts @@ -5,23 +5,30 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * The synthesized record page at two widths (objectstack#26678). + * The synthesized record page, laid out by the width it has + * (objectstack#26678; one rule since objectstack#27524, Target 6). * * The maintainer's ruling on the record-page epic: the drawer and the full * page are ONE page at two widths. So the default record page keeps a single * description — the tabbed page `buildDefaultPageSchema` synthesizes, which is * also what Studio's page-create seeds and what a slotted page fills — and the - * WIDTH decides how its content is laid out: + * WIDTH of the container it occupies decides how its content is laid out, by + * one rule wherever it is drawn (the page, the split pane, the overlay drawer, + * the maximized pane, a phone): * - * - 1120 px and up: three resident columns, details | timeline | related; - * - 760 – 1119 px: details | timeline, the related column folded into a rail - * behind a toggle; - * - below 760 px, and whenever the page is embedded (drawer / split pane): - * the tab strip, exactly as before. + * - 760 px and up: details | the right column (320 px) — the 动态 panel + * first, then the related panels; + * - 1120 px and up: the same, wide — the details field grid takes three + * columns, the right column 380 px, the 动态 panel five entries; + * - below 760 px: ONE column — the details sections, then the right + * column's panels in their order. * - * (Container widths — see the two constants for how they are derived.) + * (Container widths — see the two constants for how they are derived.) There + * is no tabbed tier: the strip's blocks are always laid out + * (objectstack#27524 retired it with the three-column tier and the related + * rail), and the 动态 stream is a panel, never a column. * - * ## Why the columns are derived, never authored + * ## Why the layout is derived, never authored * * The tiers are the renderer's. No metadata key, no page template and no node * type says "columns": the spec's page vocabulary is closed inside its own @@ -30,15 +37,15 @@ * seed carrying it would be refused at save. The layout is therefore read off * the synthesized page itself, by the stable semantic `value` every tab item * carries (`buildDefaultTabs`, objectui#2257): `details` is the details - * column, `timeline` (the one stream, objectstack#27055) is the timeline - * column, and `related`, `approvals` and `attachments` go to the related - * column. The synthesized page has one region (`main`); a page with + * column, `timeline` (the one stream, objectstack#27055) is the right + * column's 动态 panel, and `related`, `approvals` and `attachments` are its + * panels after it. The synthesized page has one region (`main`); a page with * any other region is not the synthesized page (objectstack#27055 removed the * opt-in rail and the `rightRail` slot that used to add an `aside`). * * A page this module cannot read — no synthesized tab strip, or a tab whose * value it does not know (an authored strip) — answers `null`, - * and the host draws the tabs. Tabs are the correct rendering of any strip. + * and the host draws the page as written, tabs and all. */ /** @@ -51,184 +58,85 @@ const EXPANDED_SIDEBAR_WIDTH = 256; /** - * A container at least this wide lays the default record page out in three - * columns. + * A container at least this wide lays the default record page out wide: + * the details field grid at three columns, the right column 380 px, the 动态 + * panel showing five entries (objectstack#27524, Target 6). * * Derived from the window it must serve, not picked (objectstack#27241, the - * maintainer's ruling 「1440 窗口(含侧栏)即三栏」): a 1440 px window with the - * sidebar expanded is three columns. + * maintainer's ruling 「1440 窗口(含侧栏)」): a 1440 px window with the + * sidebar expanded is wide. * * 1440 (window) − 256 (expanded sidebar) = 1184 px of container * 1184 − 64 (headroom: a classic 15–17 px scrollbar, browser zoom * rounding, a sidebar a few px wider) = 1120 * - * At 1120 the v3 template (`1.25fr : 1fr : 20rem`, 24 px page padding each - * side, 24 px gaps) leaves 1120 − 48 − 48 − 320 = 704 px for details + - * timeline (≈ 391 + 313). It stays container-measured: the same page in a + * At 1120 the wide template (`1fr : 380px`, 24 px page padding each side, a + * 24 px gap) leaves 1120 − 48 − 24 − 380 = 668 px for the details, three + * fields of ≈ 210 px a row. It stays container-measured: the same page in a * drawer is narrow on a wide screen. */ -export const RECORD_PAGE_THREE_COLUMN_MIN_WIDTH = 1440 - EXPANDED_SIDEBAR_WIDTH - 64; +export const RECORD_PAGE_WIDE_MIN_WIDTH = 1440 - EXPANDED_SIDEBAR_WIDTH - 64; /** - * A container at least this wide (and narrower than the three-column floor) - * lays it out in two. + * A container at least this wide lays it out in two columns, details | the + * right column; a narrower one stacks them. * * Derived the same way (objectstack#27241): a 1024 px window with the sidebar - * expanded stays two columns, not the tabs. + * expanded keeps the right column. * * 1024 (window) − 256 (expanded sidebar) = 768 px of container * 768 − 8 (headroom) = 760 * - * At 760 the two-column grid (24 px page padding each side, a 24 px gap) - * gives each column (760 − 48 − 24) / 2 = 344 px — the narrowest a details - * section's two-field row stays legible. + * At 760 the two-column template (24 px page padding each side, a 24 px gap, + * the 320 px right column) leaves 760 − 48 − 24 − 320 = 368 px for the + * details — the narrowest a details section's two-field row stays legible. + * The default split pane (720 px) is below it, so the pane stacks. */ export const RECORD_PAGE_TWO_COLUMN_MIN_WIDTH = 1024 - EXPANDED_SIDEBAR_WIDTH - 8; -/** How the synthesized default record page is laid out at the width it has. */ -export type RecordPageTier = 'tabs' | 'two-column' | 'three-column'; +/** + * How the synthesized default record page is laid out at the width it has: + * `stack` is one column (details, then the right column's panels), + * `two-column` is details | the right column, `wide` is the same with the + * wide measures. + */ +export type RecordPageTier = 'stack' | 'two-column' | 'wide'; /** * The tier a record page takes in a container `width` CSS pixels wide. * * Measured on the container the record page occupies, never the viewport: the - * same page in a drawer is narrow on a wide screen. An embedded page (drawer / - * split pane) is always tabbed, whatever its width, and an unknown width (zero - * or not a number — no layout yet, or a test environment) keeps the tabs. + * same page in a drawer is narrow on a wide screen. The rule is the same for + * an embedded page (the split pane, the overlay drawer, the maximized pane): + * only its width counts. An unknown width (zero or not a number — no layout + * yet, or a test environment) is the narrowest tier, the stack: it draws every + * block in one column and assumes no room it was not measured to have. */ -export function recordPageTierFor(width: number, options: { embedded?: boolean } = {}): RecordPageTier { - if (options.embedded) return 'tabs'; - if (!(width > 0)) return 'tabs'; - if (width >= RECORD_PAGE_THREE_COLUMN_MIN_WIDTH) return 'three-column'; +export function recordPageTierFor(width: number): RecordPageTier { + if (!(width > 0)) return 'stack'; + if (width >= RECORD_PAGE_WIDE_MIN_WIDTH) return 'wide'; if (width >= RECORD_PAGE_TWO_COLUMN_MIN_WIDTH) return 'two-column'; - return 'tabs'; + return 'stack'; } -/** The three resident columns of the wide record page. */ -export type RecordPageColumn = 'details' | 'timeline' | 'related'; - -/** - * Which of the two optional columns a reader keeps shown — the reader's own - * switches (objectstack#27052), kept per object in the console's - * workspace-preference store; never metadata. Details is always shown. - */ -export interface RecordPageColumnSwitches { - timeline: boolean; - related: boolean; +/** Whether a tier draws the right column beside the details (`⊟` applies). */ +export function recordPageTierHasRightColumn(tier: RecordPageTier | null | undefined): boolean { + return tier === 'two-column' || tier === 'wide'; } /** - * How a column tier lays the page out for the reader's switches - * (objectstack#27052). The tier says how many columns the WIDTH fits; the - * switches say which columns take part. A column that is off is not drawn: - * - * - timeline off: its blocks fold into a 「动态」 (Activity) card at the foot - * of the related column, and the related column takes the timeline's place; - * - related off: there is no related column and no rail - * (objectstack#27241, 「关联 OFF 就没有栏」) — the switch decides whether - * the column exists; the rail toggle only opens or closes a column the - * switch allows, when the width cannot fit it resident. - * - * So timeline off on a three-column page draws details | related, related off - * draws details | timeline (no rail), and in the two-column tier related on - * draws details | timeline with the rail while timeline off draws details | - * related. With both off the page is the details column alone. Below the - * column tiers the page is tabbed and the switches are not read; they are - * kept for when it widens. + * The 动态 tab's id in the host's `?related=` (objectstack#27524, Target 5): + * the 动态 panel's 「查看全部 N 条 →」 opens it, beside the related lists' + * `object.field` ids. Never an `object.field`, so it names no related list. */ -export interface RecordPageArrangement { - /** The resident columns, left to right. Always starts with `details`. */ - columns: RecordPageColumn[]; - /** The timeline's blocks are drawn as the related column's foot card. */ - timelineFolded: boolean; - /** - * The related column is drawn as a rail behind a toggle, not resident: the - * switch keeps it (`related` on) and the tier has no room for it. Never set - * when the switch is off. - */ - relatedRail: boolean; -} +export const RECORD_PAGE_TIMELINE_PANEL = 'timeline'; /** - * The arrangement of a column tier for the reader's switches. A pure reading - * of the two inputs, so the page re-flows by the same rule wherever it is - * drawn: a column that is off frees its room for the next. + * Where a synthesized tab's blocks land: the details column, the 动态 panel + * (`timeline`, the first panel of the right column) or the related panels + * after it. */ -export function recordPageArrangementFor( - tier: 'two-column' | 'three-column', - switches: RecordPageColumnSwitches, -): RecordPageArrangement { - const room = tier === 'three-column' ? 3 : 2; - const wanted: RecordPageColumn[] = ['details']; - if (switches.timeline) wanted.push('timeline'); - if (switches.related) wanted.push('related'); - const columns = wanted.slice(0, room); - return { - columns, - timelineFolded: !switches.timeline, - relatedRail: switches.related && !columns.includes('related'), - }; -} - -/** The object-definition shape {@link recordPageColumnDefaults} reads. */ -export interface RecordPageObjectLike { - stageField?: unknown; - enable?: { files?: unknown } | null; - fields?: Record | unknown[] | null; -} - -function fieldsOf(object: RecordPageObjectLike | null | undefined): Array> { - const fields = object?.fields; - const list = Array.isArray(fields) ? fields : fields && typeof fields === 'object' ? Object.values(fields) : []; - return list.filter((f): f is Record => !!f && typeof f === 'object' && !Array.isArray(f)); -} - -/** Why {@link recordPageColumnDefaults} turned the timeline on — or `null`, off. */ -export type RecordTimelineDefaultReason = 'stage-field' | 'option-kinds' | 'files' | null; - -/** - * Why an object is "worked" — the reason its record page shows the timeline - * by default — or `null` when it is not (objectstack#27052, the maintainer's - * ruling that the timeline suits only some objects and its presence is each - * reader's switch with an object-derived default). Read off the object as it - * is declared, with no key of its own, in this order: - * - * 1. `stageField` names a field — the object declares a lifecycle; - * 2. otherwise (and unless `stageField: false` turned the stage role off), - * a field whose options declare a lifecycle `kind` (`open` / `won` / - * `lost`) — the same declaration a stage path reads; - * 3. `enable.files: true` — records collect attachments, work products. - * - * Only what the object declares of itself counts. Being the `master_detail` - * parent of another object does not: a parent that merely owns child rows (a - * price book, a campaign, an event) is a container, not a worked record, and - * its middle column would be an empty stream. - */ -export function recordTimelineDefaultReason( - object: RecordPageObjectLike | null | undefined, -): RecordTimelineDefaultReason { - if (!object) return null; - if (typeof object.stageField === 'string' && object.stageField.length > 0) return 'stage-field'; - if (object.stageField !== false) { - const declaresKinds = fieldsOf(object).some( - (field) => - Array.isArray(field.options) && - field.options.some((option) => !!option && typeof option === 'object' && (option as { kind?: unknown }).kind !== undefined), - ); - if (declaresKinds) return 'option-kinds'; - } - if (object.enable?.files === true) return 'files'; - return null; -} - -/** - * The column switches a reader who never chose gets for `object`: the - * timeline on when the object is worked ({@link recordTimelineDefaultReason}), - * the related column on. - */ -export function recordPageColumnDefaults(object: RecordPageObjectLike | null | undefined): RecordPageColumnSwitches { - return { timeline: recordTimelineDefaultReason(object) !== null, related: true }; -} +export type RecordPageColumn = 'details' | 'timeline' | 'related'; /** * The column a synthesized tab lands in, by the tab's stable `value`; `null` @@ -237,7 +145,7 @@ export function recordPageColumnDefaults(object: RecordPageObjectLike | null | u * Every value `buildDefaultTabs` can produce has a row here, and the test * beside this module enumerates them from a maximal synthesis, so a new tab * added to the synthesizer without a column fails there instead of silently - * keeping the whole page tabbed. + * drawing the whole page as its tab strip. */ export function recordPageColumnOfTab(value: unknown): RecordPageColumn | null { if (typeof value !== 'string') return null; @@ -250,26 +158,24 @@ export function recordPageColumnOfTab(value: unknown): RecordPageColumn | null { return null; } -/** The synthesized record page, partitioned for the column tiers. */ +/** The synthesized record page, partitioned for its layout. */ export interface DefaultPageColumns { /** * The record header: every node the page places before its tab strip * (`page:header`, the actions / alerts slots, highlights, the stage path). - * The same nodes the tabbed page draws — one header for every tier. + * The same nodes an authored strip draws above it — one header for every + * tier. */ header: any[]; /** The Details tab's body. */ details: any[]; - /** The Timeline tab's stream (objectstack#27055), after any node the page places below its strip. */ + /** + * The Timeline tab's stream (objectstack#27055), after any node the page + * places below its strip: the 动态 panel's blocks (objectstack#27524). + */ timeline: any[]; /** Related lists in the strip's order, then Approvals and Attachments. */ related: any[]; - /** - * How many related collections the related column shows: one per - * `record:related_list`. Labels the - * two-column tier's rail toggle. - */ - relatedCount: number; } type PageNode = Record; @@ -279,24 +185,15 @@ const asNode = (value: unknown): PageNode | null => const nodeList = (value: unknown): any[] => (Array.isArray(value) ? value.filter((n) => n != null) : []); -/** Count the related collections a related-column node shows. */ -function relatedCollectionsIn(nodes: any[]): number { - let count = 0; - for (const raw of nodes) { - const node = asNode(raw); - if (!node) continue; - if (node.type === 'record:related_list') count += 1; - } - return count; -} - /** * Partition a synthesized default record page (`buildDefaultPageSchema`'s - * output, after any host pruning) into the column tiers' header and columns. + * output, after any host pruning) into its header, details, 动态 and related + * panels. * * Reads the page exactly as the synthesizer writes it: one `main` region * whose one `page:tabs` node carries its items in the node's `properties` - * bag (`componentNode`, ADR-0089 D3a). Answers `null` — draw the tabs — for + * bag (`componentNode`, ADR-0089 D3a). Answers `null` — draw the page as + * written — for * anything else: no `main` region or a second region, no tab strip or more * than one, a strip carrying its items elsewhere, an item gated by * `visibleWhen`, or an item whose value has no column. @@ -332,13 +229,11 @@ export function buildDefaultPageColumns(page: unknown): DefaultPageColumns | nul else relatedLists.push(...children); } - const related = [...relatedLists, ...relatedPanels]; return { header: components.slice(0, tabIndex), details, timeline: [...components.slice(tabIndex + 1), ...timelineTabs], - related, - relatedCount: relatedCollectionsIn(related), + related: [...relatedLists, ...relatedPanels], }; } @@ -346,7 +241,7 @@ export function buildDefaultPageColumns(page: unknown): DefaultPageColumns | nul * The relationship ids (`object.field`) of the body tables a synthesized * record page draws (objectstack#27055, Target 8): every `record:related_list` * among the Details tab's children. Read off the page itself, so the host and - * the column tiers agree with what `buildDefaultTabs` placed. + * the laid-out page agree with what `buildDefaultTabs` placed. */ export function bodyTableIdsOf(page: unknown): Set { const ids = new Set(); diff --git a/ui/packages/plugin-detail/src/useDetailTranslation.ts b/ui/packages/plugin-detail/src/useDetailTranslation.ts index ea5ca4f66f6..6512ca13b28 100644 --- a/ui/packages/plugin-detail/src/useDetailTranslation.ts +++ b/ui/packages/plugin-detail/src/useDetailTranslation.ts @@ -103,10 +103,10 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.new': 'New', 'detail.noValue': 'No value', 'detail.activity': 'Activity', - // objectstack#27052 — the record page's timeline switch, and the card the - // timeline's blocks fold into when the reader switches the column off. - 'detail.timeline': 'Timeline', - 'detail.timelineFolded': 'Activity', + // objectstack#27524 — 动态 (Activity), the record page's stream, and the + // 动态 panel's one-line composer. + 'detail.timeline': 'Activity', + 'detail.timelineComposer': 'Write a note… @ to mention', 'detail.copyRecordId': 'Copy record ID', 'detail.showEmptyFields': 'Show {{count}} empty fields', 'detail.showEmptyFields_one': 'Show {{count}} empty field', diff --git a/ui/packages/plugin-detail/src/useRecordPageTier.ts b/ui/packages/plugin-detail/src/useRecordPageTier.ts index 7d666c4a67e..808d300063f 100644 --- a/ui/packages/plugin-detail/src/useRecordPageTier.ts +++ b/ui/packages/plugin-detail/src/useRecordPageTier.ts @@ -17,26 +17,26 @@ import { recordPageTierFor, type RecordPageTier } from './synth/recordPageColumn const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; export interface UseRecordPageTierOptions { - /** The page is embedded (drawer / split pane): always the tabs. */ - embedded?: boolean; /** - * The page can take the column tiers at all. `false` for a page the column - * layout does not describe (an authored full page, an authored tab strip): - * the answer is the tabs at once and nothing is measured. + * The page is laid out by width at all. `false` for a page the layout does + * not describe (an authored full page, an authored tab strip): nothing is + * measured and the answer is `null`. */ enabled?: boolean; } /** * The record page tier (`recordPageTierFor`) for the container `element`, - * measured on it now and again on every resize (objectstack#26678). + * measured on it now and again on every resize (objectstack#26678). The same + * rule for the page, the split pane, the overlay drawer and the maximized + * pane: only the element's width counts (objectstack#27524, Target 6). * * Answers `null` while the tier is not known yet — the element has not * mounted, or its first reading has not landed — so a host can hold the page - * body for that one commit instead of mounting the tabbed tree and replacing - * it before the first paint. The first reading is taken in a layout effect, so - * in a browser that commit is never painted. Without a layout engine (a test - * environment) the width reads 0 and the answer is the tabs. + * body for that one commit instead of drawing it at a width it does not have. + * The first reading is taken in a layout effect, so in a browser that commit + * is never painted. Without a layout engine (a test environment) the width + * reads 0 and the answer is the stack. * * Re-renders only when the tier flips, not on every resize. ⛔ Not a viewport * breakpoint: the same page in a drawer is narrow on a wide screen. @@ -45,12 +45,11 @@ export function useRecordPageTier( element: Element | null, options: UseRecordPageTierOptions = {}, ): RecordPageTier | null { - const { embedded = false, enabled = true } = options; - const measures = enabled && !embedded; + const { enabled = true } = options; const [measured, setMeasured] = React.useState<{ element: Element; tier: RecordPageTier } | null>(null); useIsomorphicLayoutEffect(() => { - if (!measures || !element) return undefined; + if (!enabled || !element) return undefined; const judge = () => { const tier = recordPageTierFor(element.getBoundingClientRect().width); setMeasured((prev) => (prev && prev.element === element && prev.tier === tier ? prev : { element, tier })); @@ -60,9 +59,8 @@ export function useRecordPageTier( const observer = new ResizeObserver(judge); observer.observe(element); return () => observer.disconnect(); - }, [measures, element]); + }, [enabled, element]); - if (!measures) return 'tabs'; - if (!element || !measured || measured.element !== element) return null; + if (!enabled || !element || !measured || measured.element !== element) return null; return measured.tier; } From e8001d0fc10ffd3746cbe51eac6439d17e26c459 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 06:02:25 +0000 Subject: [PATCH 2/9] =?UTF-8?q?test(app-shell):=20the=20record=20page=20pi?= =?UTF-8?q?ns=20read=20the=20laid-out=20page:=20one=20rule=20by=20width,?= =?UTF-8?q?=20the=20=E5=8A=A8=E6=80=81=20panel=20and=20tab,=20the=20?= =?UTF-8?q?=E2=8A=9F=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pins that opened the tabbed tier now read the anatomy that replaced it: the stack in jsdom, the right column's panels, `?related=timeline` for the whole 动态 stream and `?related=.` for a related list's whole face. Changesets for plugin-detail, components, app-shell and i18n. Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt Co-authored-by: Claude --- .changeset/27524-record-page-activity-i18n.md | 18 ++ .../27524-record-page-tiers-activity-panel.md | 39 ++++ ui/e2e/live/record-history-display.spec.ts | 52 ++--- ...dDetailView.accessibleNames-12625.test.tsx | 20 +- ...dDetailView.activityActorId-12067.test.tsx | 2 +- ...cordDetailView.approvalLock-12220.test.tsx | 15 +- ...cordDetailView.approvalLock-25934.test.tsx | 21 +- ...cordDetailView.approvalRecallGate.test.tsx | 5 +- ...tailView.auditDisplayLocale-10442.test.tsx | 10 +- ...ailView.auditTenantCurrency-27640.test.tsx | 8 +- ...tailView.bodyTablePredicate-27478.test.tsx | 6 +- ...w.childWriteRefreshesParent-12244.test.tsx | 9 +- ...iscussionExplicitComposition-7298.test.tsx | 13 +- ...w.discussionFallbackPipeline-8983.test.tsx | 15 +- ...cordDetailView.panelToggles-27052.test.tsx | 186 ++++++++---------- ...dDetailView.recordOpenWaves-13010.test.tsx | 61 +++--- ...dDetailView.recordPageTiers-26678.test.tsx | 175 ++++++++++------ ...DetailView.relatedBadgeArity-8882.test.tsx | 32 +-- ...tailView.relatedCountsFirst-27056.test.tsx | 8 +- ...DetailView.relatedListFilter-4664.test.tsx | 29 +-- ...iew.relatedListInheritedSort-5795.test.tsx | 27 +-- ...etailView.relatedListsOrder-27241.test.tsx | 10 +- ...DetailView.replyCountOnRead-12144.test.tsx | 2 +- 23 files changed, 446 insertions(+), 317 deletions(-) create mode 100644 .changeset/27524-record-page-activity-i18n.md create mode 100644 .changeset/27524-record-page-tiers-activity-panel.md diff --git a/.changeset/27524-record-page-activity-i18n.md b/.changeset/27524-record-page-activity-i18n.md new file mode 100644 index 00000000000..2069270117b --- /dev/null +++ b/.changeset/27524-record-page-activity-i18n.md @@ -0,0 +1,18 @@ +--- +'@object-ui/i18n': patch +--- + +feat(i18n)!: 「动态」 (Activity) names the record page's stream in all ten packs; the timeline column's two keys leave + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +The record page's stream is called 「动态」 in zh and "Activity" in en, in the record page's panel, its tab and the right-column switch's labels (record page v4). + +- `detail.timeline` changes value in all ten packs: 动态 · Activity · アクティビティ · 활동 · Aktivität · Activité · Actividad · Atividade · Активность · النشاط. +- `detail.relatedColumn` now names the right column (「右栏」, "Right column"), and `console.shortcuts.toggleRelated` shows or hides it. +- `detail.timelineComposer` (the 动态 panel's one-line composer, 「写一条备注… @ 提到同事」) arrives in all ten packs. + +**BREAKING** (`@object-ui/i18n`, TypeScript only): the exported `TranslationKeys` (`typeof en`) loses `detail.timelineFolded` and `console.shortcuts.toggleTimeline`, which named the retired timeline column and its shortcut. Both leave every pack. A `t()` call naming one still compiles, since its key is a `string`, and reads the key back. Nothing replaces them: the column is gone. diff --git a/.changeset/27524-record-page-tiers-activity-panel.md b/.changeset/27524-record-page-tiers-activity-panel.md new file mode 100644 index 00000000000..f23dff1e087 --- /dev/null +++ b/.changeset/27524-record-page-tiers-activity-panel.md @@ -0,0 +1,39 @@ +--- +'@object-ui/plugin-detail': patch +'@object-ui/components': patch +'@object-ui/app-shell': patch +--- + +feat(plugin-detail)!: one record page laid out by its own width — details beside a right column from 760 px, one column below; 动态 is the right column's first panel; ⊟ shows or hides the right column + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +The synthesized record page (record page v4) is laid out by one rule, the width of the container it occupies. The page, the split pane, the overlay drawer and the maximized pane all follow it. + +- **At 760 px and wider**, the details sit beside a right column 320 px wide. **At 1120 px and wider** the right column is 380 px, a details section takes up to three field columns (two otherwise), and the 动态 panel shows five entries (three otherwise). +- **Below 760 px** (the split pane at 720 px, a phone) the page is ONE column: the details, then the right column's panels in their order. +- The three-column tier, the tabbed tier and the 「相关 N」 rail are gone, and so are the resident timeline column, its ⊞ switch and the per-object default that decided it. +- **动态** (formerly the timeline) is the first panel of the right column. It has the same header as a related panel: 「动态」 and the stream's count, and the header row opens the 动态 tab. Its body is a one-line composer (pressed, it opens the full composer, focused), the newest entries one line each, and 「查看全部 N 条」 when the stream holds more. The 动态 tab (`?related=timeline`) is the full stream: the 全部 · 活动 · 讨论 · 变更 chips, threaded replies, and "Load more" for the next page. +- **⊟** (「右栏」, `⌘⇧,`) is one icon at the end of the record header's action row, drawn only where the tier has a right column. Off, the details take the row. It keeps the reader's earlier related-column choice: the stored preference `record.columns.related` is unchanged. The record header's `PanelToggles` (`@object-ui/components`) now draws icon-only toggles named by their label. +- The 动态 panel is on screen at open in every tier, so a synthesized page reads its feed and its audit history at open. An authored page with tabs still waits for the tab that holds them. + +**BREAKING** (`@object-ui/plugin-detail`, TypeScript only). The only importer is the console's own record view. `RecordPageTier` is now `'stack' | 'two-column' | 'wide'`: there is no tabbed tier, and the widest tier is called `wide`. `recordPageTierFor(width)` takes no second argument. `useRecordPageTier` answers `null` only while the page is not laid out (disabled, or not yet measured). `RecordPageColumnsProps.tier` takes every `RecordPageTier`. A caller still naming a retired symbol gets a compile error that names it. + +What leaves, and what to write instead: + +- FROM `RECORD_PAGE_THREE_COLUMN_MIN_WIDTH` TO `RECORD_PAGE_WIDE_MIN_WIDTH`, the same 1120 px floor under its new name. +- FROM `recordPageArrangementFor` TO `recordPageTierHasRightColumn(tier)` and the reader's ⊟: every laid-out page is the details and, where the tier has room, the right column. +- FROM `recordPageColumnDefaults` TO no call: every object's page opens with its right column, and ⊟ defaults to on. +- FROM `recordTimelineDefaultReason` TO no call, for the same reason: no object decides a timeline column any more. +- FROM `RecordPageColumnSwitches` TO a `boolean`, the right column's switch. +- FROM `RecordPageArrangement` TO the `RecordPageTier` itself, read through `recordPageTierHasRightColumn`. +- FROM `RecordPageObjectLike` TO no type: no helper takes an object definition any more. +- FROM `RecordTimelineDefaultReason` TO no type, with `recordTimelineDefaultReason`. +- FROM `DefaultPageColumns.relatedCount` TO the length of `columns.related`; the rail it labelled is gone. +- FROM `UseRecordPageTierOptions.embedded` TO no option: an embedded page follows the same width rule. +- FROM `RecordPageColumnsProps.switches` TO `RecordPageColumnsProps.rightColumn`, one `boolean`. + +`RECORD_PAGE_WIDE_MIN_WIDTH`, `recordPageTierHasRightColumn` and `RECORD_PAGE_TIMELINE_PANEL` (the 动态 tab's `?related=` id) are new. diff --git a/ui/e2e/live/record-history-display.spec.ts b/ui/e2e/live/record-history-display.spec.ts index d3554117577..d9cfdca8b8b 100644 --- a/ui/e2e/live/record-history-display.spec.ts +++ b/ui/e2e/live/record-history-display.spec.ts @@ -9,8 +9,10 @@ import { test, expect, type APIRequestContext } from '@playwright/test'; * audit payloads: ISO datetimes ("2026-08-04T12:00:00.000Z"), raw lookup id * arrays, raw select values, and "Unknown user" for every entry. Record page * v3 (objectstack#27055) removed the History tab at every width: the same - * enriched audit entries are now the field_change lines of the ONE timeline - * stream, under its Changes (变更) chip. + * enriched audit entries are now the field_change lines of the ONE 动态 + * (Activity) stream, under its Changes (变更) chip — in the 动态 tab since + * objectstack#27524 (the right column's 动态 panel shows the newest lines, + * no chips). * * Targets `showcase_account`, the showcase's `enable.trackHistory` object — * the console reads `sys_audit_log` only for an object with that flag. Its @@ -42,29 +44,12 @@ async function seededUsers(request: APIRequestContext): Promise<{ actor: SysUser /** * The synthesized record page lays itself out by the width of the view it - * occupies (objectstack#26678): a column tier at `RECORD_PAGE_TWO_COLUMN_MIN_WIDTH` - * (`@object-ui/plugin-detail`) and wider, the tab strip below it. - * - * A 1440 px window is a column tier whatever the console sidebar does: 1184 px - * of record view with the sidebar expanded (objectstack#27241's three-column - * window), more with it collapsed. ⛔ Not a window near 1000 px: whether that - * is the tabbed tier depends on the sidebar's state. Measured on the showcase - * at 1000×800 the sidebar came up as its icon rail and the page drew two - * columns with no tab strip at all, so a spec that waited for a tab there - * timed out. - * - * In a column tier the stream is NOT resident by default for this object. The - * reader's Timeline switch (the header's Panels control) defaults to off for - * an object that is not worked (`recordTimelineDefaultReason`: showcase_account - * declares no `stageField`, no lifecycle option `kind` and no `enable.files`), - * and a switched-off timeline folds into the Activity (动态) card at the related - * column's foot — closed, its blocks not mounted until the reader opens it - * (`FoldedTimelineCard`, objectstack#27052). Measured under the nightly harness - * (a built Console on a fresh database, so no stored switch): at 1440×900 the - * page drew three columns, the Panels control read Timeline unpressed, and the - * Activity card was folded, so a spec that waited for the Changes chip at open - * timed out. The spec opens that card the way a reader does, and asserts each - * step: the switch is off, the card starts closed, the click opens it. + * occupies, by one rule (objectstack#26678, objectstack#27524): details | the + * right column from `RECORD_PAGE_TWO_COLUMN_MIN_WIDTH` (`@object-ui/plugin-detail`), + * one column below it, no tab strip at any width. The whole stream, chips and + * all, is the 动态 tab (`?related=timeline`) at every width, so the spec opens + * it by the URL and needs no switch. A 1440 px window keeps the record header + * and the face at the width the QA report read. */ test.use({ viewport: { width: 1440, height: 900 } }); @@ -92,22 +77,11 @@ test('the timeline stream\'s Changes chip shows display values (localized dates, expect(res.ok()).toBeTruthy(); } - // 2) Open the synthesized record page, open the folded Activity card that - // holds the stream (see the viewport note above), and select the + // 2) Open the synthesized record page on its 动态 tab and select the // stream's Changes chip — the field changes alone (the audit entries, // plus the activity stream's own `created` row). - await page.goto(`/apps/showcase_app/showcase_account/record/${id}`); - const timelineSwitch = page.locator('[data-panel-toggles] [data-panel-toggle="timeline"]'); - // The page-open wait: the same 15 s budget the spec gives its first element. - await expect(timelineSwitch, 'the Timeline switch is off for an object that is not worked').toHaveAttribute('aria-pressed', 'false', { timeout: 15_000 }); - const activityCard = page.getByRole('region', { name: /^(Activity|动态)$/ }); - // The toggle is the card heading's button: once open, the stream inside the - // card has an "Activity" filter chip of its own. - const activityToggle = activityCard.getByRole('heading', { name: /^(Activity|动态)$/ }).getByRole('button'); - await expect(activityToggle, 'the switched-off timeline is folded into a closed Activity card').toHaveAttribute('aria-expanded', 'false'); - await activityToggle.click(); - await expect(activityToggle).toHaveAttribute('aria-expanded', 'true'); - const stream = activityCard.getByRole('region', { name: /^(Timeline|时间线)$/ }); + await page.goto(`/apps/showcase_app/showcase_account/record/${id}?related=timeline`); + const stream = page.locator('[data-related-enlarged="timeline"]'); const changesChip = stream.locator('[data-stream-filter="changes"]'); await changesChip.waitFor({ state: 'visible', timeout: 15_000 }); await changesChip.click(); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.accessibleNames-12625.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.accessibleNames-12625.test.tsx index 6b20296adcf..a116414337c 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.accessibleNames-12625.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.accessibleNames-12625.test.tsx @@ -9,9 +9,11 @@ /** * objectui#12625 — the console record page's controls have accessible names, * read on the REAL `RecordDetailView` mount (the synthesized default page: - * `page:header`, `page:tabs`, `record:details`), by role and name. + * `page:header`, `record:details`, the right column), by role and name. * - * - The tabs are named by their labels (Details, Related). + * - The page's regions are named by their labels: the right column, the 动态 + * panel (Activity) and a related panel (Task). They were the Details and + * Related tabs until objectstack#27524 laid every width out as columns. * - A declared `record_header` action is named by its label (Log a Call). * - A collapsible field group's header is a button named by its label * (Lead Information) that toggles `aria-expanded` — the half the card found @@ -24,7 +26,7 @@ import * as React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, waitFor, screen, cleanup } from '@testing-library/react'; +import { render, waitFor, screen, cleanup, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import { MemoryRouter } from 'react-router-dom'; @@ -98,7 +100,7 @@ const LEAD = { ], }; -/** A child with a lookup to the lead, so the page carries a Related tab. */ +/** A child with a lookup to the lead, so the page carries a related panel. */ const TASK = { name: 'task', label: 'Task', @@ -167,11 +169,13 @@ afterEach(() => { vi.clearAllMocks(); }); -describe('objectui#12625 — the record page names its tabs, header actions and section headers', () => { - it('the tabs are named by their labels', async () => { +describe('objectui#12625 — the record page names its regions, header actions and section headers', () => { + it('the right column and its panels are named by their labels', async () => { await mountLeadPage(); - expect(screen.getByRole('tab', { name: 'Details' })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'Related' })).toBeInTheDocument(); + const right = screen.getByRole('complementary', { name: 'Right column' }); + expect(right).toBeInTheDocument(); + expect(within(right).getByRole('region', { name: 'Activity' })).toBeInTheDocument(); + expect(await within(right).findByRole('region', { name: 'Task' })).toBeInTheDocument(); }); it('the header actions are named by their labels', async () => { diff --git a/ui/packages/app-shell/src/views/RecordDetailView.activityActorId-12067.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.activityActorId-12067.test.tsx index 06bbc08431c..7c2102987f5 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.activityActorId-12067.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.activityActorId-12067.test.tsx @@ -149,7 +149,7 @@ describe('the record page feed names the actor of an id-only activity row (objec it('reads `sys_activity` with `actor_id` expanded and shows the user, not "System"', async () => { const dataSource = makeDataSource(); render( - + probe.getAttribute(`data-${name}`); +/** + * The probe. The page draws its blocks through several renderers since + * objectstack#27524 (the header, the details, each right-column panel), all + * inside the one ``, so every probe prints the same + * values: the first is read. + */ +const pageProbe = async () => (await screen.findAllByTestId('inline-probe'))[0]; beforeEach(() => { cleanup(); @@ -213,7 +220,7 @@ describe('record page approval lock follows the answered read (objectui#12220)', it('a `pending` record whose approvals read answers 200 empty opens editable, with no band to thread', async () => { stubApprovalsApi({ row: null }); renderRecordPage(); - const probe = await screen.findByTestId('inline-probe'); + const probe = await pageProbe(); // Waiting on the flag the read flips is what makes the values below a // measurement of the answered state, not of the pre-read render. @@ -227,7 +234,7 @@ describe('record page approval lock follows the answered read (objectui#12220)', it('a record with an open locking request stays locked', async () => { stubApprovalsApi({ row: pendingRequest() }); renderRecordPage(); - const probe = await screen.findByTestId('inline-probe'); + const probe = await pageProbe(); await waitFor(() => expect(attr(probe, 'approval-read-answered')).toBe('true')); await waitFor(() => expect(attr(probe, 'locked')).toBe('true')); @@ -239,7 +246,7 @@ describe('record page approval lock follows the answered read (objectui#12220)', it(`with the approvals route absent (${status}), the column fallback still locks`, async () => { stubApprovalsApi({ absent: status }); renderRecordPage(); - const probe = await screen.findByTestId('inline-probe'); + const probe = await pageProbe(); await waitFor(() => expect(listGets.length).toBeGreaterThan(0)); // Let the refused read settle before reading what it left behind. @@ -256,7 +263,7 @@ describe('record page approval lock follows the answered read (objectui#12220)', it('control (objectui#2902): an open `lockRecord: false` request reads pending and editable over a `pending` column', async () => { stubApprovalsApi({ row: pendingRequest({ lock_record: false }) }); renderRecordPage(); - const probe = await screen.findByTestId('inline-probe'); + const probe = await pageProbe(); await waitFor(() => expect(attr(probe, 'approval-read-answered')).toBe('true')); await waitFor(() => expect(attr(probe, 'approval-pending')).toBe('true')); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.approvalLock-25934.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.approvalLock-25934.test.tsx index c487f1e6372..20fac10b1a2 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.approvalLock-25934.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.approvalLock-25934.test.tsx @@ -235,6 +235,13 @@ function renderRecordPage() { } const attr = (probe: HTMLElement, name: string) => probe.getAttribute(`data-${name}`); +/** + * The probe. The page draws its blocks through several renderers since + * objectstack#27524 (the header, the details, each right-column panel), all + * inside the one ``, so every probe prints the same + * values: the first is read. + */ +const pageProbe = async () => (await screen.findAllByTestId('inline-probe'))[0]; let consoleError: ReturnType; @@ -258,7 +265,7 @@ describe('record page lock for a reader outside the approval (objectstack#25934) it('a non-participant on a record held by a locking approval is offered no inline edit', async () => { stubApprovalsApi({ row: null }, { locked: true }); renderRecordPage(); - const probe = await screen.findByTestId('inline-probe'); + const probe = await pageProbe(); await waitFor(() => expect(attr(probe, 'locked')).toBe('true')); expect(lockGets).toHaveLength(1); @@ -272,7 +279,7 @@ describe('record page lock for a reader outside the approval (objectstack#25934) it('a record the server reports unlocked opens editable', async () => { stubApprovalsApi({ row: null }, { locked: false }); renderRecordPage(); - const probe = await screen.findByTestId('inline-probe'); + const probe = await pageProbe(); await waitFor(() => expect(lockGets).toHaveLength(1)); await waitFor(() => expect(attr(probe, 'approval-read-answered')).toBe('true')); @@ -284,7 +291,7 @@ describe('record page lock for a reader outside the approval (objectstack#25934) it('without the approvals service (lock read 501) nothing changes: an answered empty read is not locked', async () => { stubApprovalsApi({ row: null }, { refused: 501 }); renderRecordPage(); - const probe = await screen.findByTestId('inline-probe'); + const probe = await pageProbe(); await waitFor(() => expect(lockGets).toHaveLength(1)); await waitFor(() => expect(attr(probe, 'approval-read-answered')).toBe('true')); @@ -296,7 +303,7 @@ describe('record page lock for a reader outside the approval (objectstack#25934) it('the 501 of a composition without the approvals service logs nothing, and the lock is not read again (objectstack#26632)', async () => { stubApprovalsApi({ row: null }, { refused: 501 }); renderRecordPage(); - const probe = await screen.findByTestId('inline-probe'); + const probe = await pageProbe(); await waitFor(() => expect(attr(probe, 'approval-read-answered')).toBe('true')); await waitFor(() => expect(lockGets).toHaveLength(1)); expect(lockErrorLines()).toEqual([]); @@ -306,7 +313,7 @@ describe('record page lock for a reader outside the approval (objectstack#25934) cleanup(); renderRecordPage(); await waitFor(() => expect(listGets.length).toBeGreaterThanOrEqual(2)); - const again = await screen.findByTestId('inline-probe'); + const again = await pageProbe(); await waitFor(() => expect(attr(again, 'approval-read-answered')).toBe('true')); expect(attr(again, 'locked')).toBe('false'); expect(lockGets).toHaveLength(1); @@ -316,7 +323,7 @@ describe('record page lock for a reader outside the approval (objectstack#25934) it('any other lock-read failure still logs, and the next open asks again', async () => { stubApprovalsApi({ row: null }, { refused: 500 }); renderRecordPage(); - const probe = await screen.findByTestId('inline-probe'); + const probe = await pageProbe(); await waitFor(() => expect(attr(probe, 'approval-read-answered')).toBe('true')); await waitFor(() => expect(lockErrorLines()).toHaveLength(1)); expect(String(lockErrorLines()[0][0])).toContain(`GET `); @@ -331,7 +338,7 @@ describe('record page lock for a reader outside the approval (objectstack#25934) it('a participant\'s pending request decides, and the record lock is not read', async () => { stubApprovalsApi({ row: pendingRequest({ lock_record: false }) }, { locked: true }); renderRecordPage(); - const probe = await screen.findByTestId('inline-probe'); + const probe = await pageProbe(); await waitFor(() => expect(attr(probe, 'approval-pending')).toBe('true')); expect(attr(probe, 'locked')).toBe('false'); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.approvalRecallGate.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.approvalRecallGate.test.tsx index 1bb4b111ce0..a89fc84434d 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.approvalRecallGate.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.approvalRecallGate.test.tsx @@ -229,7 +229,10 @@ function renderRecordPage() { * make the "no approvals API" case pass without ever exercising it. */ async function settledProbe(expectPending: boolean) { - const probe = await screen.findByTestId('inline-probe'); + // The page draws its blocks through several renderers since + // objectstack#27524, all inside the one ``: every probe + // prints the same values, so the first is read. + const probe = (await screen.findAllByTestId('inline-probe'))[0]; await waitFor(() => expect(probe.getAttribute('data-approval-pending')).toBe(String(expectPending)), ); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.auditDisplayLocale-10442.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.auditDisplayLocale-10442.test.tsx index 2a42c3166a2..cdb6cfca91c 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.auditDisplayLocale-10442.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.auditDisplayLocale-10442.test.tsx @@ -19,9 +19,9 @@ * The real `I18nProvider` runs with an ENGLISH UI in every case, and the * display locale is declared through `LocalizationProvider`, so the display * locale is the only thing that differs between the `de-CH` pin and the - * `en-US` control. The page is the synthesized one, opened on its History tab - * through the `tab` URL param; the diff line is read off the rendered - * timeline, the way a user sees it. + * `en-US` control. The page is the synthesized one, opened on its 动态 tab + * through the `related` URL param; the diff line is read off the rendered + * stream, the way a user sees it. */ import * as React from 'react'; @@ -135,7 +135,7 @@ function renderHistory(locale: string) { return render( - + { diff --git a/ui/packages/app-shell/src/views/RecordDetailView.auditTenantCurrency-27640.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.auditTenantCurrency-27640.test.tsx index 1b90d33fb2e..22e3390d6f3 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.auditTenantCurrency-27640.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.auditTenantCurrency-27640.test.tsx @@ -22,9 +22,9 @@ * the record page's cells read. A `record`-mode field keeps its code in a * sibling field of the row, which the per-value call is not handed, so it * stays a plain number with the tenant currency set: the tenant default is - * never its fallback. The page is the synthesized one, opened on its History - * stream through the `tab` URL param; the diff line is read off the rendered - * timeline, the way a user sees it. + * never its fallback. The page is the synthesized one, opened on its 动态 tab + * through the `related` URL param; the diff line is read off the rendered + * stream, the way a user sees it. */ import * as React from 'react'; @@ -150,7 +150,7 @@ async function diffLines(currency: string | undefined): Promise - + ) { } const cardOrder = (container: HTMLElement) => - Array.from(container.querySelectorAll('[data-record-column="related"] [data-related-card-id]')).map((el) => + Array.from(container.querySelectorAll('[data-record-column="right"] [data-related-card-id]')).map((el) => el.getAttribute('data-related-card-id'), ); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.childWriteRefreshesParent-12244.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.childWriteRefreshesParent-12244.test.tsx index bdf5cbdaa96..b578fd142e0 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.childWriteRefreshesParent-12244.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.childWriteRefreshesParent-12244.test.tsx @@ -33,7 +33,8 @@ * 3. A child row action that bypasses the dataSource (posted as an * object-wide change by `notifyRelatedChanged`) does the same. * 4. The refetch is IN PLACE (AGENTS.md #8): the header is the same DOM node - * and the Related tab stays the active tab. + * and the related list's whole face (`?related=`, which took the Related + * tab's place in objectstack#27524) stays open. * 5. Scope stays tight: a write to an object that is not one of the page's * related children does not re-read the parent, and a `'*'` or a change * to the parent itself reads it once, not twice. @@ -215,7 +216,7 @@ function renderPage(ds: FakeDataSource) { getItemsByType: () => [], } as unknown as React.ContextType; return render( - + @@ -288,9 +289,9 @@ describe('RecordDetailView — a child write in a related list refreshes the par await waitFor(() => expect(parentReads(ds)).toBe(2)); await waitFor(() => expect(figureShown(3000)).toBe(true)); expect(figureShown(9000)).toBe(false); - // In place: the same header node, the Related tab still the active one. + // In place: the same header node, the list's face still the open one. expect(document.querySelector('header')).toBe(headerBefore); - expect(screen.getByRole('tab', { name: /Related/i }).getAttribute('aria-selected')).toBe('true'); + expect(document.querySelector(`[data-related-enlarged="${CHILD}.${PARENT}"]`)).not.toBeNull(); }); it('a child update through the dataSource (the form overlay\'s save) re-reads the parent', async () => { diff --git a/ui/packages/app-shell/src/views/RecordDetailView.discussionExplicitComposition-7298.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.discussionExplicitComposition-7298.test.tsx index 153d32ff62d..1b9d1765cc1 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.discussionExplicitComposition-7298.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.discussionExplicitComposition-7298.test.tsx @@ -281,12 +281,13 @@ describe('objectui#7298 — the discussion panel is composed, never appended', ( expect(composerCount()).toBe(0); }); - it('the SYNTHESIZED default page still shows the discussion — its Timeline stream declares it', async () => { - // No authored page ⇒ `buildDefaultPageSchema`, which composes the timeline - // stream itself (objectstack#27055: discussion, activity and changes in the - // Timeline tab's one `record:activity`). The ruling removes the host's - // append, not the default page's discussion. - renderDetail({ objects: OBJECTS_FEEDS_ON, pages: [], comments: ONE_COMMENT, search: '?tab=timeline' }); + it('the SYNTHESIZED default page still shows the discussion — its 动态 stream declares it', async () => { + // No authored page ⇒ `buildDefaultPageSchema`, which composes the 动态 + // stream itself (objectstack#27055: discussion, activity and changes in + // one `record:activity`; its whole face is the 动态 tab since + // objectstack#27524). The ruling removes the host's append, not the + // default page's discussion. + renderDetail({ objects: OBJECTS_FEEDS_ON, pages: [], comments: ONE_COMMENT, search: '?related=timeline' }); await settled(); await waitFor(() => expect(count(COMMENT_MARKER)).toBeGreaterThan(0)); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.discussionFallbackPipeline-8983.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.discussionFallbackPipeline-8983.test.tsx index 1e7cbcfde09..6680b499837 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.discussionFallbackPipeline-8983.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.discussionFallbackPipeline-8983.test.tsx @@ -241,8 +241,9 @@ function makeMetadata(pages: any[]) { } function renderDetail(fixture: Fixture, pages: any[]) { - // The synthesized page's feed is its Timeline tab (objectstack#27055). - const search = pages.length === 0 ? '?tab=timeline' : ''; + // The synthesized page's whole feed is its 动态 tab (objectstack#27055, + // objectstack#27524): the right column's panel draws only its newest lines. + const search = pages.length === 0 ? '?related=timeline' : ''; return render( @@ -296,10 +297,10 @@ function renderedRows(fixture: Fixture): string[] { return markers.filter((m) => screen.queryAllByText(m).length > 0); } -const loadMoreCount = () => - screen.queryAllByRole('button', { name: LOAD_MORE }).length + - // The stream's paging affordance (objectstack#27055): 「查看全部 N 条 →」. - document.querySelectorAll('[data-stream-view-all]').length; +// Both surfaces page with one "Load more" button: the authored block's, and +// the 动态 stream's (`data-stream-load-more`; its 「查看全部 N 条 →」 retired to +// the 动态 panel in objectstack#27524). +const loadMoreCount = () => screen.queryAllByRole('button', { name: LOAD_MORE }).length; /** Wait until BOTH feed reads have settled, so an absent row is an answer. */ async function settled() { @@ -354,7 +355,7 @@ describe('both composition routes render the same feed (objectui#8983)', () => { const authored = await viaAuthoredBlock(fixture); expect(authored.rows).toEqual(expected); - // The synthesized route is the Timeline stream (objectstack#27055), whose + // The synthesized route is the 动态 stream (objectstack#27055), whose // node DECLARES `showCompleted: true` — a stream shows what happened. It // still runs the one pipeline: the completed row is there because the // declared config says so, and the paging case below holds the window. diff --git a/ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx index 8244b001cb8..f29aa36fd85 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx @@ -7,31 +7,27 @@ */ /** - * objectstack#27052 — the record page's panel toggles: whether the timeline - * and the related column are shown is each reader's switch, per object, with - * a default derived from the object and never metadata. + * objectstack#27052 / objectstack#27524 (Target 4) — the record page's + * right-column switch ⊟ (「右栏」): whether the right column (the 动态 panel + * and the related panels) is drawn beside the details is each reader's + * switch, per object, on by default and never metadata. * * Measured from a real `RecordDetailView` mount (the `recordPageTiers-26678` * harness: the view root's measured width is the one input varied). * - * - a worked object (its status options declare lifecycle kinds) opens with - * three columns; switching the timeline off draws details | related with - * the timeline folded into a 「动态」 card, and switching it on restores; - * - the choice survives a remount (a reload reads the same storage) and is - * per object; - * - an object that is not worked opens with the timeline off — including - * one whose only claim is being another object's `master_detail` parent; - * - `⌘⇧.` / `⌘⇧,` flip the switches, matched by the physical key (Shift - * types `>` / `<` there) or, for an event that carries no `code`, by the - * character it typed (objectstack#27241), and do nothing while a text - * field has focus; the browser's own `⌘⇧T` / `⌘⇧R` are not theirs; - * - the toggles are ONE segmented control at the end of the record header's - * action row (objectstack#27241), in both column tiers — not on the crumb - * row, which keeps only the ways back and has no child without one; - * - they are drawn only in a column tier: not in the tabbed tier, not in the - * drawer / split pane; - * - the related switch decides whether the related column exists: OFF in the - * two-column tier leaves no rail toggle (objectstack#27241). + * - every object opens with the right column, worked or not: the resident + * timeline column, its switch and its derived default retired; + * - ⊟ off draws the details alone, the full width; on restores; the choice + * survives a remount (a reload reads the same storage) and is per object; + * - `⌘⇧,` flips it, matched by the physical key (Shift types `<` there) or, + * for an event that carries no `code`, by the character it typed + * (objectstack#27241), and does nothing while a text field has focus; + * `⌘⇧.` (the timeline column's) is no longer the page's; + * - ⊟ is ONE icon, named by its label, at the end of the record header's + * action row (objectstack#27241) — not on the crumb row, which keeps only + * the ways back and has no child without one; + * - it is drawn only where the tier has a right column: both two-column + * tiers, the embedded record at those widths too, never the one column. */ import * as React from 'react'; @@ -171,10 +167,10 @@ function renderPage({ object = LEAD, width = 1440, embedded = false }: { object? const shown = (container: HTMLElement) => container.querySelector('[data-record-columns]')?.getAttribute('data-record-columns-shown') ?? null; -const toggle = (name: RegExp) => screen.getByRole('button', { name }); -// What a browser hands the page for ⌘⇧. / ⌘⇧, on a US layout: Shift types -// `>` / `<`, and `code` names the key itself. -const CHORD_KEYS = { timeline: { key: '>', code: 'Period' }, related: { key: '<', code: 'Comma' } } as const; +const rightColumnSwitch = () => screen.getByRole('button', { name: /^Right column$/ }); +// What a browser hands the page for ⌘⇧, (and the retired ⌘⇧.) on a US +// layout: Shift types `<` / `>`, and `code` names the key itself. +const CHORD_KEYS = { related: { key: '<', code: 'Comma' }, timeline: { key: '>', code: 'Period' } } as const; const press = (chord: keyof typeof CHORD_KEYS | { key: string; code: string }, target: Element = document.body) => fireEvent.keyDown(target, { ...(typeof chord === 'string' ? CHORD_KEYS[chord] : chord), @@ -182,6 +178,7 @@ const press = (chord: keyof typeof CHORD_KEYS | { key: string; code: string }, t shiftKey: true, bubbles: true, }); +const RIGHT_KEY = (object: string) => `os:pref:record.columns.related@object:${object}`; beforeEach(() => { cleanup(); @@ -205,8 +202,8 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe('RecordDetailView — the panel toggles (objectstack#27052)', () => { - it('a worked object opens with three columns; timeline off draws details | related with a folded 「动态」 card; on restores', async () => { +describe('RecordDetailView — the right-column switch ⊟ (objectstack#27052, objectstack#27524)', () => { + it('one ⊟ icon at the end of the header\'s action row, on; off draws the details alone; on restores', async () => { const { container } = renderPage(); const group = await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); // objectstack#27241 — the end of the header's action row, after ⋯ and a @@ -219,128 +216,119 @@ describe('RecordDetailView — the panel toggles (objectstack#27052)', () => { expect(trailing.querySelector('[role="separator"][aria-orientation="vertical"]')).not.toBeNull(); expect(toolbar.lastElementChild).toBe(trailing); expect(container.querySelector('[data-record-crumb-row]')?.childElementCount ?? 0).toBe(0); - expect(shown(container)).toBe('details timeline related'); - expect(toggle(/^Timeline$/).getAttribute('aria-pressed')).toBe('true'); - expect(toggle(/^Related column$/).getAttribute('aria-pressed')).toBe('true'); - - fireEvent.click(toggle(/^Timeline$/)); - await waitFor(() => expect(shown(container)).toBe('details related')); - expect(toggle(/^Timeline$/).getAttribute('aria-pressed')).toBe('false'); - const related = container.querySelector('[data-record-column="related"]')!; - expect(related.querySelector('[data-record-folded-timeline]')).not.toBeNull(); - - fireEvent.click(toggle(/^Timeline$/)); - await waitFor(() => expect(shown(container)).toBe('details timeline related')); - expect(container.querySelector('[data-record-folded-timeline]')).toBeNull(); + // ONE switch, an icon named by its label: the segmented text control and + // the timeline segment are gone. + expect(group.querySelectorAll('button')).toHaveLength(1); + expect(group.querySelector('button')!.getAttribute('aria-label')).toBe('Right column'); + expect(rightColumnSwitch().textContent).toBe(''); + expect(rightColumnSwitch().getAttribute('aria-pressed')).toBe('true'); + expect(shown(container)).toBe('details right'); + + fireEvent.click(rightColumnSwitch()); + await waitFor(() => expect(shown(container)).toBe('details')); + expect(rightColumnSwitch().getAttribute('aria-pressed')).toBe('false'); + expect(container.querySelector('[data-record-column="right"]')).toBeNull(); + expect(container.querySelector('[data-record-timeline-panel]')).toBeNull(); + + fireEvent.click(rightColumnSwitch()); + await waitFor(() => expect(shown(container)).toBe('details right')); + expect(container.querySelector('[data-record-column="right"] > [data-record-timeline-panel]')).not.toBeNull(); }); it('the choice survives a remount and is per object', async () => { const first = renderPage(); - fireEvent.click(await screen.findByRole('button', { name: /^Timeline$/ }, { timeout: 5000 })); - await waitFor(() => expect(shown(first.container)).toBe('details related')); + fireEvent.click(await screen.findByRole('button', { name: /^Right column$/ }, { timeout: 5000 })); + await waitFor(() => expect(shown(first.container)).toBe('details')); first.unmount(); const again = renderPage(); await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); - expect(shown(again.container)).toBe('details related'); - expect(toggle(/^Timeline$/).getAttribute('aria-pressed')).toBe('false'); + expect(shown(again.container)).toBe('details'); + expect(rightColumnSwitch().getAttribute('aria-pressed')).toBe('false'); again.unmount(); - // The product's switch is its own: still its default. + // The product's switch is its own: still on. const product = renderPage({ object: PRODUCT }); await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); - expect(window.localStorage.getItem(`os:pref:record.columns.timeline@object:${PRODUCT}`)).toBeNull(); - expect(shown(product.container)).toBe('details related'); - }); - - it('an object that is not worked opens with the timeline off: two columns, details | related', async () => { - const { container } = renderPage({ object: PRODUCT }); - await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); - expect(shown(container)).toBe('details related'); - expect(toggle(/^Timeline$/).getAttribute('aria-pressed')).toBe('false'); - expect(container.querySelector('[data-record-folded-timeline]')).not.toBeNull(); + expect(window.localStorage.getItem(RIGHT_KEY(PRODUCT))).toBeNull(); + expect(shown(product.container)).toBe('details right'); }); - it('an object that is only another object\'s `master_detail` parent is not worked: the timeline is off', async () => { - const { container } = renderPage({ object: PRICE_BOOK }); + it.each([ + ['an object that is not worked', PRODUCT], + ['an object that is only another object\'s `master_detail` parent', PRICE_BOOK], + ])('%s opens with the right column too, 动态 first', async (_name, object) => { + const { container } = renderPage({ object }); await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); - expect(shown(container)).toBe('details related'); - expect(toggle(/^Timeline$/).getAttribute('aria-pressed')).toBe('false'); + expect(shown(container)).toBe('details right'); + expect(rightColumnSwitch().getAttribute('aria-pressed')).toBe('true'); + expect(container.querySelector('[data-record-column="right"]')?.firstElementChild?.hasAttribute('data-record-timeline-panel')).toBe(true); }); - it('⌘⇧. / ⌘⇧, flip the switches; with a text field focused they do nothing', async () => { + it('⌘⇧, flips ⊟; with a text field focused it does nothing; ⌘⇧. is not the page\'s any more', async () => { const { container } = renderPage(); await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); const input = document.createElement('input'); document.body.appendChild(input); input.focus(); - press('timeline', input); press('related', input); - expect(shown(container)).toBe('details timeline related'); + expect(shown(container)).toBe('details right'); input.remove(); - // The browser's chords are not the page's: reopen-tab and hard-reload. - press({ key: 'T', code: 'KeyT' }); + // The browser's hard reload is not the page's; nor is the retired ⌘⇧. press({ key: 'R', code: 'KeyR' }); - expect(shown(container)).toBe('details timeline related'); + press('timeline'); + expect(shown(container)).toBe('details right'); + expect(window.localStorage.getItem(RIGHT_KEY(LEAD))).toBeNull(); expect(window.localStorage.getItem(`os:pref:record.columns.timeline@object:${LEAD}`)).toBeNull(); - press('timeline'); - await waitFor(() => expect(shown(container)).toBe('details related')); press('related'); await waitFor(() => expect(shown(container)).toBe('details')); - expect(toggle(/^Related column$/).getAttribute('aria-pressed')).toBe('false'); - press('timeline'); - await waitFor(() => expect(shown(container)).toBe('details timeline')); + expect(rightColumnSwitch().getAttribute('aria-pressed')).toBe('false'); + press('related'); + await waitFor(() => expect(shown(container)).toBe('details right')); }); - it('an event that carries no `code` still flips the switches by the character it typed (objectstack#27241)', async () => { + it('an event that carries no `code` still flips ⊟ by the character it typed (objectstack#27241)', async () => { const { container } = renderPage(); await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); - press({ key: '>', code: '' }); - await waitFor(() => expect(shown(container)).toBe('details related')); - press({ key: '.', code: '' }); - await waitFor(() => expect(shown(container)).toBe('details timeline related')); press({ key: '<', code: '' }); - await waitFor(() => expect(shown(container)).toBe('details timeline')); + await waitFor(() => expect(shown(container)).toBe('details')); press({ key: ',', code: '' }); - await waitFor(() => expect(shown(container)).toBe('details timeline related')); + await waitFor(() => expect(shown(container)).toBe('details right')); }); - it('the two-column tier draws the same control in the header row; related OFF leaves no rail toggle (objectstack#27241)', async () => { + it('the two-column tier draws the same control; off, the details take the row (no rail to fall back to)', async () => { const { container } = renderPage({ width: 900 }); const group = await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); expect(container.querySelector('[data-record-page-tier]')?.getAttribute('data-record-page-tier')).toBe('two-column'); expect(group.closest('[role="toolbar"]')).not.toBeNull(); - expect(shown(container)).toBe('details timeline'); - // Related ON with no room: the rail toggle, and it opens the column. - fireEvent.click(screen.getByTestId('record-related-rail-toggle')); - await waitFor(() => expect(container.querySelector('[data-record-column="related"]')).not.toBeNull()); - // Related OFF: no column, no rail toggle. - fireEvent.click(toggle(/^Related column$/)); - await waitFor(() => expect(screen.queryByTestId('record-related-rail-toggle')).toBeNull()); - expect(container.querySelector('[data-record-column="related"]')).toBeNull(); - expect(shown(container)).toBe('details timeline'); + expect(shown(container)).toBe('details right'); + fireEvent.click(rightColumnSwitch()); + await waitFor(() => expect(shown(container)).toBe('details')); + expect(screen.queryByTestId('record-related-rail-toggle')).toBeNull(); + expect(container.querySelector('[data-record-column="right"]')).toBeNull(); }); - it('the tooltip names each shortcut, and the shortcut is announced', async () => { + it('the tooltip names the shortcut, and the shortcut is announced', async () => { renderPage(); await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); - expect(toggle(/^Timeline$/).getAttribute('aria-keyshortcuts')).toMatch(/^(Meta|Control)\+Shift\+\.$/); - expect(toggle(/^Related column$/).getAttribute('aria-keyshortcuts')).toMatch(/^(Meta|Control)\+Shift\+,$/); + expect(rightColumnSwitch().getAttribute('aria-keyshortcuts')).toMatch(/^(Meta|Control)\+Shift\+,$/); }); - it('not drawn in the tabbed tier, nor in the drawer / split pane; the shortcut does nothing there', async () => { - const tabbed = renderPage({ width: 700 }); - expect(await screen.findByRole('tablist', undefined, { timeout: 5000 })).toBeTruthy(); + it('not drawn in the one column, where there is no right column to hide; the shortcut does nothing there. The embedded record at 1440 px has one, and draws it', async () => { + const stacked = renderPage({ width: 700 }); + await waitFor(() => expect(stacked.container.querySelector('[data-record-page-tier]')?.getAttribute('data-record-page-tier')).toBe('stack'), { timeout: 5000 }); expect(screen.queryByRole('group', { name: 'Panels' })).toBeNull(); - press('timeline'); - expect(window.localStorage.getItem(`os:pref:record.columns.timeline@object:${LEAD}`)).toBeNull(); - tabbed.unmount(); + press('related'); + expect(window.localStorage.getItem(RIGHT_KEY(LEAD))).toBeNull(); + // The panels follow the details there, whatever the switch. + expect(stacked.container.querySelector('[data-record-column="right"]')).not.toBeNull(); + stacked.unmount(); - renderPage({ width: 1440, embedded: true }); - expect(await screen.findByRole('tablist', undefined, { timeout: 5000 })).toBeTruthy(); - expect(screen.queryByRole('group', { name: 'Panels' })).toBeNull(); + const embedded = renderPage({ width: 1440, embedded: true }); + await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); + expect(shown(embedded.container)).toBe('details right'); }); }); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx index 18476c35700..dff9ecbd22b 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.recordOpenWaves-13010.test.tsx @@ -619,17 +619,23 @@ describe('the timeline, comments, attachments and user list wait for their panel expect(screen.getByText('signed-contract.pdf')).toBeTruthy(); }); - // The synthesized page has no discussion footer any more (#27055): its one - // timeline stream is a tab in the tabbed tier, so it reads like any other tab. - it('the synthesized page opened on its Details tab reads neither the feed nor the user list', async () => { + // The synthesized page has no discussion footer any more (#27055), and no + // tab strip at any width since objectstack#27524: its one stream is the 动态 + // panel, the first panel of the right column — on screen at open in every + // tier, so the feed is read at open, like an authored page opened on its + // Activity tab. A page with tabs still waits for its tab (above). + it('the synthesized page reads the feed and the user list at open: its 动态 panel is on screen', async () => { const { held } = await openRecordPage({ page: { ...RECORD_PAGE, isDefault: false, object: 'os_13010_other' }, observer: FoldObserver }); const table = waveTable(held); - expect(screen.getByRole('tab', { name: /^Timeline/ }), table).toBeTruthy(); - for (const isRead of [isActivityRead, isCommentRead, isUserListRead]) expect(held.filter(isRead), table).toEqual([]); + expect(screen.queryByRole('tab', { name: /^Timeline/ }), table).toBeNull(); + expect(document.querySelector('[data-record-timeline-panel]'), table).not.toBeNull(); + for (const isRead of [isActivityRead, isCommentRead, isUserListRead]) { + expect(held.filter(isRead).map((h) => h.wave), table).toEqual([1]); + } }); - it('CONTROL: the synthesized page opened on its Timeline tab reads the feed and the user list at open', async () => { - const { held } = await openRecordPage({ page: { ...RECORD_PAGE, isDefault: false, object: 'os_13010_other' }, observer: FoldObserver, search: '?tab=timeline' }); + it('CONTROL: the synthesized page opened on its 动态 tab (?related=timeline) reads the feed and the user list at open', async () => { + const { held } = await openRecordPage({ page: { ...RECORD_PAGE, isDefault: false, object: 'os_13010_other' }, observer: FoldObserver, search: '?related=timeline' }); const table = waveTable(held); for (const isRead of [isActivityRead, isCommentRead, isUserListRead]) { expect(held.filter(isRead).map((h) => h.wave), table).toEqual([1]); @@ -637,7 +643,7 @@ describe('the timeline, comments, attachments and user list wait for their panel }); }); -// ── objectui#13010 part 5: the audit read waits for the Timeline tab (#27055 merged History into it) ── +// ── objectui#13010 part 5: the audit read goes out with its stream (#27055 merged History into it; #27524 made the stream a panel) ── /** Registered, so the view merges field changes into the timeline of a `trackHistory` object. */ const auditLogDef = { @@ -662,7 +668,7 @@ const isAuditUserLookup = (h: Held) => isPanelRead('sys_user')(h) && h.path.incl const openHistory = (options: OpenOptions = {}) => openRecordPage({ page: SYNTH, object: historyObject, extraObjects: [auditLogDef], observer: FoldObserver, ...options }); -describe('the Timeline tab reads the audit log when it is shown (objectui#13010 part 5)', () => { +describe('the 动态 stream reads the audit log when it is shown (objectui#13010 part 5)', () => { beforeEach(() => { offscreen.length = 0; }); @@ -671,36 +677,39 @@ describe('the Timeline tab reads the audit log when it is shown (objectui#13010 // object. Before this part: `sys_audit_log top=50` in wave 1 beside the // record read and its `sys_user` name lookup in wave 2, with the History tab // closed. After it: neither until the tab opens. Since #27055 the audit rows - // are the field changes of the one Timeline stream, so that tab gates them. - it('a trackHistory record opened on its Details tab reads neither the audit log nor its user names', async () => { + // are the field changes of the one stream, so the stream gates them; since + // objectstack#27524 that stream is the 动态 panel, on screen at open, so + // they are read at open — once, and not again when its tab opens. + it('a trackHistory record reads the audit log at open and its user names next: the 动态 panel shows the change', async () => { const { held } = await openHistory(); const table = waveTable(held); expect(held.filter(isRecordRead).map((h) => h.wave), table).toEqual([1]); - // The tab is offered, so the silence is not a missing tab. - expect(screen.getByRole('tab', { name: /^Timeline/ }), table).toBeTruthy(); - expect(screen.queryByRole('tab', { name: /^History/ }), table).toBeNull(); - expect(held.filter(isAuditRead), table).toEqual([]); - expect(held.filter(isAuditUserLookup), table).toEqual([]); - expect(screen.queryByText('Grace'), table).toBeNull(); + expect(screen.queryByRole('tab', { name: /^(Timeline|History)/ }), table).toBeNull(); + expect(held.filter(isAuditRead).map((h) => h.wave), table).toEqual([1]); + expect(held.filter(isAuditUserLookup).map((h) => h.wave), table).toEqual([2]); + const panel = document.querySelector('[data-record-timeline-panel]') as HTMLElement; + expect(panel.textContent, table).toMatch(/negotiation/); }); - it('opening the Timeline tab reads the audit log and its user names once each, and draws the rows', async () => { + it('opening the 动态 tab from its panel reads nothing again, and draws the rows with their actor', async () => { const { held, state } = await openHistory(); - const openedWith = held.length; - await openTab('Timeline'); + const audit = () => held.filter(isAuditRead); + expect(audit(), waveTable(held)).toHaveLength(1); + await act(async () => { + fireEvent.click(document.querySelector('[data-record-timeline-panel-open]') as HTMLElement); + }); await runWaves(held, state); const table = waveTable(held); - const audit = held.filter(isAuditRead); - expect(audit, table).toHaveLength(1); - expect(audit[0].seq, table).toBeGreaterThanOrEqual(openedWith); - expect(topOf(audit[0]), table).toBe(50); + expect(document.querySelector('[data-related-enlarged="timeline"]'), table).not.toBeNull(); + expect(audit(), table).toHaveLength(1); + expect(topOf(audit()[0]), table).toBe(50); expect(held.filter(isAuditUserLookup), table).toHaveLength(1); expect(screen.getByText('Grace')).toBeTruthy(); expect(screen.getByText(/negotiation/)).toBeTruthy(); }); - it('CONTROL: a trackHistory record opened with ?tab=timeline reads the audit log at open and draws the rows', async () => { - const { held } = await openHistory({ search: '?tab=timeline' }); + it('CONTROL: a trackHistory record opened with ?related=timeline reads the audit log at open and draws the rows', async () => { + const { held } = await openHistory({ search: '?related=timeline' }); const table = waveTable(held); expect(held.filter(isAuditRead).map((h) => h.wave), table).toEqual([1]); expect(held.filter(isAuditUserLookup).map((h) => h.wave), table).toEqual([2]); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.recordPageTiers-26678.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordPageTiers-26678.test.tsx index aa2ec1b462c..0ebe4fdd74d 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 @@ -7,26 +7,30 @@ */ /** - * objectstack#26678 — the synthesized record page is one page at two widths. + * objectstack#26678 / objectstack#27524 (Target 6) — the synthesized record + * page is one page, laid out by one rule: the width of the container it + * occupies. * * Measured from a real `RecordDetailView` mount (the `relatedListTitlePlural` - * harness). The page takes its tier from the width of the container it - * occupies — this view's root — and never from the viewport, so the root's - * measured width is the one input varied here: every other element measures - * as the test environment measures it. + * harness). The page takes its tier from the width of this view's root and + * never from the viewport, so the root's measured width is the one input + * varied here: every other element measures as the test environment measures + * it. * - * - 1440 px: three resident columns, no tab strip; the related lists sit in - * the related column, and History is resident, so its read goes out at open. - * - 1100 px: two columns and the related toggle; the related lists are not - * mounted until the rail opens. - * - 700 px (under the 760 px two-column floor, objectstack#27241), and an - * embedded view at any width: the tab strip as before. - * - `?tab=` addresses the strip, so a column tier ignores it. + * - ≥ 1120 px: details | the right column at 380 px, the details grid at + * 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. + * - 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. */ import * as React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { MetadataCtx } from '@object-ui/react'; import { RelatedCountStore } from '@object-ui/components'; @@ -74,6 +78,13 @@ const BASE_FIELDS = { 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, @@ -82,11 +93,12 @@ const OBJECTS = [ 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 — so the - // reader's timeline switch defaults on and the wide tier is three - // columns (objectstack#27052; owning the tasks below does not count). + // 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', @@ -122,7 +134,12 @@ 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}` })), + 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 () => ({})), }; @@ -188,62 +205,110 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe('RecordDetailView — one record page at two widths (objectstack#26678)', () => { - it('1440 px: three resident columns and no tab strip; History is read at open', async () => { +const shown = (container: HTMLElement) => + container.querySelector('[data-record-columns-shown]')?.getAttribute('data-record-columns-shown') ?? null; +const columnsGrid = (container: HTMLElement) => + container.querySelector('[data-record-columns]')?.getAttribute('class') ?? ''; +/** The details' field grid, by the classes it draws (container queries). */ +const detailsGrid = (container: HTMLElement) => + column(container, 'details')?.querySelector('[data-detail-section-face="frameless"] .grid')?.getAttribute('class') ?? ''; +/** The right column's panels, in order: `timeline` for 动态, else the related card's id. */ +const rightPanels = (container: HTMLElement) => + Array.from(column(container, 'right')?.querySelectorAll('[data-record-timeline-panel], [data-related-card-id]') ?? []).map( + (el) => (el.hasAttribute('data-record-timeline-panel') ? 'timeline' : el.getAttribute('data-related-card-id')), + ); + +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 }); await screen.findAllByText('Tasks', undefined, { timeout: 5000 }); - expect(tierOf(container)).toBe('three-column'); + expect(tierOf(container)).toBe('wide'); + 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(); - expect(column(container, 'details')).not.toBeNull(); - expect(column(container, 'timeline')).not.toBeNull(); - const related = column(container, 'related')!; - expect(related.textContent).toContain('Tasks'); - expect(related.textContent).toContain('Notes'); + 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(); + expect(column(container, 'related')).toBeNull(); + expect(screen.queryByTestId('record-related-rail-toggle')).toBeNull(); // The header is drawn once. expect(container.querySelectorAll('h1')).toHaveLength(1); await waitFor(() => expect(auditReads()).toBeGreaterThan(0)); }); - it('1100 px: two columns and the related toggle; the lists mount when the rail opens', async () => { + it('1200 px is wide too (the floor is 1120 px); 1100 px: the right column at 320 px, the details grid at two columns', async () => { + const wide = renderPage({ width: 1200 }); + await screen.findAllByText('Tasks', undefined, { timeout: 5000 }); + expect(tierOf(wide.container)).toBe('wide'); + wide.unmount(); + const { container } = renderPage({ width: 1100 }); - // The rail toggle — not the crumb row's related-column switch (objectstack#27052). - const toggle = await screen.findByTestId('record-related-rail-toggle', undefined, { timeout: 5000 }); + await screen.findAllByText('Tasks', undefined, { timeout: 5000 }); expect(tierOf(container)).toBe('two-column'); + expect(shown(container)).toBe('details right'); + expect(columnsGrid(container)).toContain('20rem'); + expect(detailsGrid(container)).toContain('@sm:grid-cols-2'); + expect(detailsGrid(container)).not.toContain('@lg:grid-cols-3'); + // 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(); - expect(column(container, 'details')).not.toBeNull(); - expect(column(container, 'timeline')).not.toBeNull(); - expect(column(container, 'related')).toBeNull(); - expect(screen.queryByText('Tasks')).toBeNull(); - fireEvent.click(toggle); - expect(await screen.findAllByText('Tasks', undefined, { timeout: 5000 })).not.toHaveLength(0); - expect(column(container, 'related')!.textContent).toContain('Notes'); }); - it('700 px: the tab strip, as before; History waits for its tab', async () => { - const { container } = renderPage({ width: 700 }); - expect(await screen.findByRole('tablist', undefined, { timeout: 5000 })).toBeTruthy(); - expect(screen.getByRole('tab', { name: /^Details/ })).toBeTruthy(); - expect(screen.getByRole('tab', { name: /^Related/ })).toBeTruthy(); - expect(tierOf(container)).toBeNull(); - expect(auditReads()).toBe(0); + 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) => { + 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(); + // The panels follow the details, in the right column's order. + const details = column(container, 'details')!; + const right = column(container, 'right')!; + expect(details.compareDocumentPosition(right) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(rightPanels(container)).toEqual(['timeline', 'showcase_task.project', 'showcase_note.project']); + await waitFor(() => expect(auditReads()).toBeGreaterThan(0)); }); - it('embedded (drawer / split pane): the tab strip at any width', async () => { - const { container } = renderPage({ width: 1440, embedded: true }); - expect(await screen.findByRole('tablist', undefined, { timeout: 5000 })).toBeTruthy(); - expect(tierOf(container)).toBeNull(); - }); + it('embedded (drawer / split pane): the same rule — two columns at 1440 px, one at 720 px', async () => { + const wide = renderPage({ width: 1440, embedded: true }); + await screen.findAllByText('Tasks', undefined, { timeout: 5000 }); + expect(tierOf(wide.container)).toBe('wide'); + expect(shown(wide.container)).toBe('details right'); + wide.unmount(); - it('`?tab=` keeps working in the tabbed tier and is ignored in the column tiers', async () => { - const tabbed = renderPage({ width: 700, search: '?tab=related' }); - const relatedTab = await screen.findByRole('tab', { name: /^Related/ }, { timeout: 5000 }); - expect(relatedTab.getAttribute('aria-selected')).toBe('true'); - tabbed.unmount(); + const { container } = renderPage({ width: 720, embedded: true }); + await screen.findAllByText('Tasks', undefined, { timeout: 5000 }); + expect(tierOf(container)).toBe('stack'); + expect(screen.queryByRole('tablist')).toBeNull(); + }); - const { container } = renderPage({ width: 1440, search: '?tab=related' }); + 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(container)).toBe('three-column'); + expect(tierOf(tabbed.container)).toBe('stack'); expect(screen.queryByRole('tablist')).toBeNull(); - expect(column(container, 'details')).not.toBeNull(); + expect(column(tabbed.container, 'details')).not.toBeNull(); + tabbed.unmount(); + + const { container } = renderPage({ width: 1440, search: '?related=timeline' }); + const face = await waitFor( + () => { + const el = container.querySelector('[data-related-enlarged="timeline"]'); + expect(el).not.toBeNull(); + return el as HTMLElement; + }, + { timeout: 5000 }, + ); + expect(face.getAttribute('aria-label')).toBe('Activity'); + expect(face.querySelector('[data-stream-filters]')).not.toBeNull(); + expect(face.querySelector('[data-record-timeline-peek]')).toBeNull(); + // 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.relatedBadgeArity-8882.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.relatedBadgeArity-8882.test.tsx index 2658132e620..134b97c2eef 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.relatedBadgeArity-8882.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.relatedBadgeArity-8882.test.tsx @@ -9,6 +9,8 @@ /** * objectui#8882 — the related-list tab BADGE compiles the parent-relationship * condition to match the relationship field's ARITY, exactly as the ROWS do. + * Since objectstack#27524 the badge is the related panel's count in the + * record page's right column, and the rows are the panel's first rows. * * `RelatedList` has compiled that condition by arity since objectui#7299: a * `multiple: true` relationship stores an ARRAY of parent ids, so the question @@ -69,7 +71,7 @@ vi.mock('sonner', () => ({ })); // Orthogonal chrome — stubbed so the only asynchrony in this file is the -// related list's own fetch and the tab strip's count probe. +// related panel's own row fetch and its count probe. vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null })); vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null })); @@ -268,7 +270,7 @@ function renderPage(objects: any[], dataSource: any) { getItemsByType: () => [], } as any; return render( - + !p?.$count && typeof p?.$top === 'number'; const isBadgeProbe = (p: any) => p?.$count === true; @@ -318,12 +321,10 @@ async function renderAndCollect(child: any, rows: Record[]) { }; } -/** The digits rendered inside the Related tab's count badge, or `null`. */ -async function relatedTabBadge(): Promise { - const tab = await screen.findByRole('tab', { name: /Related/i }); - // The badge is the only span in the trigger carrying an accessible name - // (the label span has none) — see `page:tabs` in components/layout. - return tab.querySelector('span[aria-label]')?.textContent?.trim() ?? null; +/** The digits the related panel's header counts, or `null`. */ +async function relatedPanelCount(): Promise { + const card = await screen.findByRole('region', { name: 'Check Item' }); + return card.querySelector('[data-related-card-count]')?.textContent?.trim() ?? null; } /** How many child rows the related list actually drew. */ @@ -336,6 +337,9 @@ beforeEach(() => { // The count store is module-scoped and shared by every consumer in the // process, so a warm entry from a previous case would badge this one. RelatedCountStore._reset(); + // No viewport: every related panel is in view, so it reads its first rows + // (`useSeenOnce`) once its count is in. + vi.stubGlobal('IntersectionObserver', undefined); vi.stubGlobal( 'fetch', vi.fn(async () => @@ -423,10 +427,10 @@ describe('related-list tab badge — parent scope compiled by ARITY (objectui#88 // rows and badges none, which is a different bug wearing this one's face. expect(drawn).toBeGreaterThan(0); await waitFor(async () => { - expect(await relatedTabBadge()).toBe(String(drawn)); + expect(await relatedPanelCount()).toBe(String(drawn)); }); - expect(await relatedTabBadge()).not.toBe('0'); - expect(await relatedTabBadge()).not.toBeNull(); + expect(await relatedPanelCount()).not.toBe('0'); + expect(await relatedPanelCount()).not.toBeNull(); }); it('LIVE CONTROL — a single-value relationship is correct today and stays correct', async () => { @@ -441,7 +445,7 @@ describe('related-list tab badge — parent scope compiled by ARITY (objectui#88 const drawn = renderedRowNames(SINGLE_ROWS).length; expect(drawn).toBeGreaterThan(0); await waitFor(async () => { - expect(await relatedTabBadge()).toBe(String(drawn)); + expect(await relatedPanelCount()).toBe(String(drawn)); }); // The other parent's row is excluded on both sides, arity or no arity. expect(screen.queryByText('Other Parent Only')).toBeNull(); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.relatedCountsFirst-27056.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.relatedCountsFirst-27056.test.tsx index a9e90e3ea1b..25a49bd0890 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.relatedCountsFirst-27056.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.relatedCountsFirst-27056.test.tsx @@ -7,7 +7,7 @@ */ /** - * objectstack#27056 — the record page's related column loads counts first, + * objectstack#27056 — the record page's related panels load counts first, * then the first rows of only the cards in view, and never blocks the details. * * The REAL page, at a desktop width (the column tiers), over a data source @@ -15,7 +15,7 @@ * related lists. Measured: * * - the details draw from the record read alone, while every related read is - * still out, and the related column shows one placeholder per card; + * still out, and the right column shows one placeholder per card; * - the six counts go out as ONE request set, before any of them answers; * - once they answer, rows are read for the two counted cards in view only, * three rows each — never for a card counted at 0 (in view too), never for @@ -218,7 +218,7 @@ describe('RecordDetailView — the related column loads counts first (objectstac expect(container.textContent).toContain('Acme'); expect(container.textContent).toContain('Retail'); expect(column(container, 'details')?.textContent).toContain(RECORD_ID); - const related = column(container, 'related')!; + const related = column(container, 'right')!; expect(related.querySelectorAll('[data-related-card-pending]')).toHaveLength(CHILDREN.length); expect(related.querySelector('[data-related-card-row]')).toBeNull(); }); @@ -251,7 +251,7 @@ describe('RecordDetailView — the related column loads counts first (objectstac for (const r of reads.filter((read) => !read.answered)) r.answer(); }); await idle(); - const related = column(container, 'related')!; + const related = column(container, 'right')!; expect(related.querySelector(`[data-related-card-id="${C1}.account"]`)?.querySelectorAll('[data-related-card-row]')).toHaveLength(3); expect(related.querySelector(`[data-related-card-id="${C3}.account"]`)?.querySelectorAll('[data-related-card-row]')).toHaveLength(2); expect(related.querySelector('[data-related-card-pending]')).toBeNull(); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.relatedListFilter-4664.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.relatedListFilter-4664.test.tsx index 6cdb1e3b267..095bef81178 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.relatedListFilter-4664.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.relatedListFilter-4664.test.tsx @@ -257,7 +257,7 @@ function renderPage(objects: any[], dataSource: any) { getItemsByType: () => [], } as any; return render( - + { - const tab = await screen.findByRole('tab', { name: /Related/i }); - // The badge is the only span in the trigger carrying an accessible name - // (the label span has none) — see `page:tabs` in components/layout. - return tab.querySelector('span[aria-label]')?.textContent?.trim() ?? null; +/** The digits the related panel's header counts, or `null`. */ +async function relatedPanelCount(): Promise { + const card = await waitFor(() => { + const el = document.querySelector('[data-related-card]'); + expect(el).not.toBeNull(); + return el as HTMLElement; + }); + return card.querySelector('[data-related-card-count]')?.textContent?.trim() ?? null; } beforeEach(() => { @@ -312,6 +316,9 @@ beforeEach(() => { // The count store is module-scoped and shared by every consumer in the // process, so a warm entry from a previous case would badge this one. RelatedCountStore._reset(); + // No viewport: every related panel is in view, so it reads its first rows + // (`useSeenOnce`) once its count is in. + vi.stubGlobal('IntersectionObserver', undefined); vi.stubGlobal( 'fetch', vi.fn(async () => @@ -379,7 +386,7 @@ describe('derived related list — declared relatedListFilter on the wire (objec // 2, so a badge that ignored the filter would read '2' here — which is the // exact "badge says 7 above a list showing 3" defect, at fixture scale. await waitFor(async () => { - expect(await relatedTabBadge()).toBe('1'); + expect(await relatedPanelCount()).toBe('1'); }); expect(String(FIXTURE_ARITHMETIC.parentScopeAlone)).not.toBe('1'); }); @@ -396,7 +403,7 @@ describe('derived related list — declared relatedListFilter on the wire (objec // badge 2. Without this the case above could pass by fetching nothing. expect(await screen.findByText('Archived Item')).toBeTruthy(); await waitFor(async () => { - expect(await relatedTabBadge()).toBe('2'); + expect(await relatedPanelCount()).toBe('2'); }); // …and the other parent's row is still excluded, filter or no filter. expect(screen.queryByText('Other Parent Item')).toBeNull(); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.relatedListInheritedSort-5795.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.relatedListInheritedSort-5795.test.tsx index 852662caf3e..9686f07461e 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.relatedListInheritedSort-5795.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.relatedListInheritedSort-5795.test.tsx @@ -12,7 +12,7 @@ * Ruled on objectstack#11345 (maintainer, 2026-08-23 15:02Z), direction 1: * derived related lists inherit the child object's default list view `sort`, * with NO new spec key. The user-visible bug this closes: a task version's - * "check items" tab rendered 20/30/10/40 (the server's primary-key fallback) + * "check items" list rendered 20/30/10/40 (the server's primary-key fallback) * while the child object's own list, sorted by `seq_no`, rendered 10/20/30/40. * * ## Why this file renders the whole page instead of asserting the descriptor @@ -174,7 +174,7 @@ function renderPage(objects: any[], dataSource: any) { getItemsByType: () => [], } as any; return render( - + .`, the record + * page's one path to a related list's every record since objectstack#27055; + * the Related tab it replaces was addressed by `?tab=related`, ADR-0054 C3) + * rather than by clicking, so the list is mounted by the page's own routing + * rather than by a synthetic event this test would then also be asserting + * about. * - * The child object is queried TWICE on this page and only one of the two is - * the subject: the tab strip auto-derives its count badge with a - * `$top: 1, $count: true` probe that carries no ordering by design. Picking - * the windowed page fetch explicitly keeps a counter-probe from passing - * because it read the count probe instead. + * The child object may be queried more than once on this page and only the + * windowed page fetch is the subject: a count probe (`$top: 1, $count: true`) + * carries no ordering by design. Picking the windowed fetch explicitly keeps + * a counter-probe from passing because it read the count probe instead. */ const isListFetch = (params: any) => !params?.$count && typeof params?.$top === 'number'; diff --git a/ui/packages/app-shell/src/views/RecordDetailView.relatedListsOrder-27241.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.relatedListsOrder-27241.test.tsx index 4f591899845..420dea9bbc4 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.relatedListsOrder-27241.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.relatedListsOrder-27241.test.tsx @@ -9,11 +9,11 @@ /** * objectstack#27069 / objectstack#27241 — the record page's default related * order reads the object's declared `relatedLists`: a reader who never - * arranged the related column sees the cards in that order, the rest after + * arranged the related panels sees the cards in that order, the rest after * them in the derived order (owned `master_detail` children first, then - * lookups). Measured from a real `RecordDetailView` mount at three columns - * (the `recordPageTiers-26678` harness: the root's measured width is the one - * input varied). + * lookups). Measured from a real `RecordDetailView` mount at 1440 px, details + * beside the right column (the `recordPageTiers-26678` harness: the root's + * measured width is the one input varied). */ import * as React from 'react'; @@ -129,7 +129,7 @@ function renderPage(objects: ReturnType) { } const cardOrder = (container: HTMLElement) => - Array.from(container.querySelectorAll('[data-record-column="related"] [data-related-card-id]')).map((el) => + Array.from(container.querySelectorAll('[data-record-column="right"] [data-related-card-id]')).map((el) => el.getAttribute('data-related-card-id'), ); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.replyCountOnRead-12144.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.replyCountOnRead-12144.test.tsx index 4c2007fe16f..db8cf62a584 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.replyCountOnRead-12144.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.replyCountOnRead-12144.test.tsx @@ -172,7 +172,7 @@ interface MountOptions { async function mount(comments: Record[], options: MountOptions = {}) { const dataSource = makeDataSource(comments, options.activities); const page = ( - + Date: Fri, 30 Oct 2026 06:13:09 +0000 Subject: [PATCH 3/9] fix(components): the synthesized stream tab has its own token; an authored Timeline tab stays a timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `detail.timeline` names the record page's 动态 stream now, so the tab strip no longer maps the bare word `Timeline` to it: that word is an author's too (the served approval page's history tab). The synthesizer writes `Activity stream` for its stream tab, read as `detail.timeline`; an authored `Timeline` reads the pack's own word for a timeline. The slotted approval request page is laid out, so its actions list opens by `?related=`. Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt Co-authored-by: Claude --- .../27524-record-page-tiers-activity-panel.md | 1 + ...alRequestsDataSource.decisionPage.test.tsx | 12 ++--- ...page-tabs-builtin-label-i18n-4645.test.tsx | 48 +++++++++++++++++-- .../src/renderers/layout/containers.tsx | 11 ++++- ...ildDefaultPageSchema.strictPayload.test.ts | 2 +- .../__tests__/buildDefaultPageSchema.test.ts | 12 ++--- .../src/synth/buildDefaultPageSchema.ts | 6 ++- 7 files changed, 70 insertions(+), 22 deletions(-) diff --git a/.changeset/27524-record-page-tiers-activity-panel.md b/.changeset/27524-record-page-tiers-activity-panel.md index f23dff1e087..e43555c7184 100644 --- a/.changeset/27524-record-page-tiers-activity-panel.md +++ b/.changeset/27524-record-page-tiers-activity-panel.md @@ -19,6 +19,7 @@ The synthesized record page (record page v4) is laid out by one rule, the width - **动态** (formerly the timeline) is the first panel of the right column. It has the same header as a related panel: 「动态」 and the stream's count, and the header row opens the 动态 tab. Its body is a one-line composer (pressed, it opens the full composer, focused), the newest entries one line each, and 「查看全部 N 条」 when the stream holds more. The 动态 tab (`?related=timeline`) is the full stream: the 全部 · 活动 · 讨论 · 变更 chips, threaded replies, and "Load more" for the next page. - **⊟** (「右栏」, `⌘⇧,`) is one icon at the end of the record header's action row, drawn only where the tier has a right column. Off, the details take the row. It keeps the reader's earlier related-column choice: the stored preference `record.columns.related` is unchanged. The record header's `PanelToggles` (`@object-ui/components`) now draws icon-only toggles named by their label. - The 动态 panel is on screen at open in every tier, so a synthesized page reads its feed and its audit history at open. An authored page with tabs still waits for the tab that holds them. +- **Tab labels** (`page:tabs`, `@object-ui/components`). A synthesized page that keeps its tab strip (one the layout cannot partition) labels its stream tab `Activity stream`, which the strip reads as 「动态」. An authored `Timeline` tab, such as the served approval page's history, is no longer read as the record stream: it keeps the pack's own word for a timeline (「时间线」, タイムライン, and so on). **BREAKING** (`@object-ui/plugin-detail`, TypeScript only). The only importer is the console's own record view. `RecordPageTier` is now `'stack' | 'two-column' | 'wide'`: there is no tabbed tier, and the widest tier is called `wide`. `recordPageTierFor(width)` takes no second argument. `useRecordPageTier` answers `null` only while the page is not laid out (disabled, or not yet measured). `RecordPageColumnsProps.tier` takes every `RecordPageTier`. A caller still naming a retired symbol gets a compile error that names it. diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx index 3c0f092bcc0..a90beab9cc8 100644 --- a/ui/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx +++ b/ui/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx @@ -241,14 +241,14 @@ const metadata = { const EN: I18nConfig = { defaultLanguage: 'en', detectBrowserLanguage: false }; -function openRequestPage() { +function openRequestPage(search = '') { const ds = routeApprovalRecordReads(host as unknown as DataSource, { fetch: transport, baseUrl: 'http://test.local/api/v1', }); return render( - + { - openRequestPage(); - const related = await screen.findByRole('tab', { name: /Related/ }, { timeout: 8000 }); - fireEvent.mouseDown(related); - fireEvent.click(related); + // The actions list's whole face (`?related=`): the slotted page is laid + // out since objectstack#27524, so the list is a right-column panel and no + // longer a Related tab to open. + openRequestPage(`?related=${APPROVAL_ACTION_OBJECT}.request_id`); expect(await screen.findByText('Budget checked', undefined, { timeout: 8000 })).toBeTruthy(); expect(wire.some((w) => w.path === `/api/v1/approvals/requests/${REQ}/actions`)).toBe(true); expect(host.find.mock.calls.some(([resource]) => resource === APPROVAL_ACTION_OBJECT)).toBe(false); diff --git a/ui/packages/components/src/__tests__/page-tabs-builtin-label-i18n-4645.test.tsx b/ui/packages/components/src/__tests__/page-tabs-builtin-label-i18n-4645.test.tsx index 1acfd4c562c..f75c43e4d89 100644 --- a/ui/packages/components/src/__tests__/page-tabs-builtin-label-i18n-4645.test.tsx +++ b/ui/packages/components/src/__tests__/page-tabs-builtin-label-i18n-4645.test.tsx @@ -47,12 +47,13 @@ import '../renderers'; /** * The tokens `buildDefaultPageSchema` writes onto its tab nodes (Details, - * Timeline, Related, Approvals since objectstack#27055), plus the ones it wrote - * before, which the strip still localizes. + * the stream's `Activity stream` — 「动态」, objectstack#27524 — Related, + * Approvals since objectstack#27055), plus the ones it wrote before, which the + * strip still localizes. */ const SYNTHESIZED_TABS: Array<{ token: string; packKey: keyof typeof builtInLocales.en.detail }> = [ { token: 'Details', packKey: 'details' }, - { token: 'Timeline', packKey: 'timeline' }, + { token: 'Activity stream', packKey: 'timeline' }, { token: 'Related', packKey: 'related' }, { token: 'Attachments', packKey: 'attachments' }, { token: 'Activity', packKey: 'activity' }, @@ -143,11 +144,11 @@ describe('page:tabs — built-in record-detail labels speak the session locale ( expect(texts).toContain('詳情'); expect(texts).toContain('相關'); - expect(texts).toContain('時間線'); + expect(texts).toContain('動態'); // The Simplified pack values, which a pack-first order would have produced. expect(texts).not.toContain('详情'); expect(texts).not.toContain('相关'); - expect(texts).not.toContain('时间线'); + expect(texts).not.toContain('动态'); }); /** @@ -175,4 +176,41 @@ describe('page:tabs — built-in record-detail labels speak the session locale ( expect(tabTexts(container)).toEqual(['Invoices', '詳細']); }); + + /** + * objectstack#27524 — the record page's stream is 「动态」 (Activity) now, and + * `Timeline` is an author's word for something else: the served approval + * page's history tab is labelled `Timeline`. The strip keeps it a timeline + * in every pack (the pack's own word for one) — never the stream's 动态. + */ + it('leaves an authored Timeline tab a timeline, not the record stream\'s 动态', () => { + const strip = (language: string) => + render( + + + , + ); + const en = strip('en'); + expect(tabTexts(en.container)).toEqual(['Details', 'Timeline']); + en.unmount(); + const zhTW = strip('zh-TW'); + expect(tabTexts(zhTW.container)).toEqual(['詳情', '時間線']); + zhTW.unmount(); + const zh = strip('zh'); + expect(tabTexts(zh.container)).toEqual(['详情', '时间线']); + zh.unmount(); + const ja = strip('ja'); + expect(tabTexts(ja.container)).toEqual(['詳細', 'タイムライン']); + }); }); diff --git a/ui/packages/components/src/renderers/layout/containers.tsx b/ui/packages/components/src/renderers/layout/containers.tsx index c6847c691e2..64ab86e233c 100644 --- a/ui/packages/components/src/renderers/layout/containers.tsx +++ b/ui/packages/components/src/renderers/layout/containers.tsx @@ -213,6 +213,7 @@ const KNOWN_LABEL_DICT: Record> = { Details: '詳情', Related: '相關', Timeline: '時間線', + 'Activity stream': '動態', Activity: '活動', History: '歷史', Notes: '備註', @@ -260,8 +261,14 @@ const KNOWN_LABEL_KEYS: Record = { Related: 'detail.related', Activity: 'detail.activity', History: 'detail.history', - // The synthesized record page's one timeline stream tab (objectstack#27055). - Timeline: 'detail.timeline', + // The synthesized record page's one stream tab (objectstack#27055), 「动态」 + // since objectstack#27524. Its own token, not `Timeline`: that word is an + // author's too (the served approval page's history tab), and mapping it to + // `detail.timeline` would now rename an authored Timeline tab 动态. + 'Activity stream': 'detail.timeline', + // An authored `Timeline` stays a timeline: the pack's own word for one (the + // timeline view type's label, in all ten packs). + Timeline: 'console.objectView.viewTypeTimeline', Attachments: 'detail.attachments', Approvals: 'detail.approvalsPanelTitle', Discussion: 'detail.discussion', diff --git a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.strictPayload.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.strictPayload.test.ts index 8356b46629f..788a1b9308a 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.strictPayload.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.strictPayload.test.ts @@ -229,7 +229,7 @@ describe('the accepted shape still carries the semantics (objectui#4232)', () => related: [{ objectName: 'task', relationshipField: 'lead_id', title: 'Tasks' }], }); const parsed = PageTabsProps.parse(tabs.properties) as any; - expect(parsed.items.map((i: any) => i.label)).toEqual(['Details', 'Timeline', 'Related']); + expect(parsed.items.map((i: any) => i.label)).toEqual(['Details', 'Activity stream', 'Related']); expect(parsed.items.map((i: any) => i.value)).toEqual(['details', 'timeline', 'related']); expect(parsed.items[0].children[0].type).toBe('record:details'); expect(parsed.items[2].children[0].properties.objectName).toBe('task'); diff --git a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts index 9ba13f27abd..a51e6e4c7ec 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.test.ts @@ -384,7 +384,7 @@ describe('buildDefaultPageSchema', () => { ], }); const labels = tabItems(tabs).map((t: any) => t.label); - expect(labels).toEqual(['Details', 'Timeline']); + expect(labels).toEqual(['Details', 'Activity stream']); expect(tabItems(tabs)[0].children.map((c: any) => c.type)).toContain('record:related_list'); }); @@ -397,7 +397,7 @@ describe('buildDefaultPageSchema', () => { ], }); const labels = tabItems(tabs).map((t: any) => t.label); - expect(labels).toEqual(['Details', 'Timeline', 'Related']); + expect(labels).toEqual(['Details', 'Activity stream', 'Related']); const related = tabItems(tabs).find((t: any) => t.label === 'Related'); expect(related.children).toHaveLength(2); expect(related.children.map((c: any) => props(c).objectName)).toEqual(['task', 'note']); @@ -425,7 +425,7 @@ describe('buildDefaultPageSchema', () => { expect(values).not.toContain('activity'); expect(values).not.toContain('history'); const timeline = tabItems(tabs).find((t: any) => t.value === 'timeline'); - expect(timeline.label).toBe('Timeline'); + expect(timeline.label).toBe('Activity stream'); expect(timeline.children[0]).toEqual({ type: 'record:activity', properties: { showCompleted: true, enableThreading: true, enableReactions: true }, @@ -449,7 +449,7 @@ describe('buildDefaultPageSchema', () => { const tabs = page.regions[0].components.find((c: any) => c.type === 'page:tabs'); expect(tabItems(tabs).map((t: any) => t.label)).toEqual([ 'Details', - 'Timeline', + 'Activity stream', 'Related', 'Approvals', ]); @@ -516,7 +516,7 @@ describe('buildDefaultPageSchema', () => { const tabs = page.regions[0].components.find((c: any) => c.type === 'page:tabs'); expect(tabItems(tabs).map((t: any) => t.label)).toEqual([ 'Details', - 'Timeline', + 'Activity stream', 'Related', ]); expect(tabItems(tabs)[0].children).toEqual([{ type: 'div', id: 'custom-details' }]); @@ -598,7 +598,7 @@ describe('buildDefaultPageSchema', () => { expect(tabs.type).toBe('page:tabs'); expect(tabItems(tabs).map((t: any) => t.label)).toEqual([ 'Details', - 'Timeline', + 'Activity stream', 'Related', ]); }); diff --git a/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts b/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts index 4005fb56a1d..84ed98a0d19 100644 --- a/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts +++ b/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts @@ -1085,9 +1085,11 @@ export function buildDefaultTabs( // (审批): the timeline stream is ONE tab, second for every object // (objectstack#27524, Target 5: 动态 is a panel, the first of the right // column, for every object — no object-derived default places it); - // attachments close the Related tab, as they close the right column. + // attachments close the Related tab, as they close the right column. The + // stream tab's token is `Activity stream`, which the strip reads as + // `detail.timeline` (「动态」); a bare `Timeline` is an author's word. const timeline = options.timeline ?? (options.hideDiscussion ? [] : [buildDefaultTimeline()]); - if (timeline.length > 0) items.push({ label: 'Timeline', value: 'timeline', children: timeline }); + if (timeline.length > 0) items.push({ label: 'Activity stream', value: 'timeline', children: timeline }); // Attachments (objectstack#4358) — for `enable.files: true` objects; the // tab strip's count badge reads the `record:attachments` node. const relatedChildren = [ From 30a9e15090dd9457d0184edcf8202b1faf31c186 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 06:15:14 +0000 Subject: [PATCH 4/9] =?UTF-8?q?docs:=20the=20synthesized=20record=20page?= =?UTF-8?q?=20is=20the=20details=20and=20a=20right=20column,=20=E5=8A=A8?= =?UTF-8?q?=E6=80=81=20first,=20laid=20out=20by=20one=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pages guide and the three QA checklist items that located the stream by the retired timeline column, its switch and the tabbed tier now point at the 动态 panel and its tab (`?related=timeline`). Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt Co-authored-by: Claude --- content/docs/ui/pages.mdx | 60 +++++++++---------- .../areas/records-forms.json | 8 +-- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index c9eb19d418f..0ff71d0733d 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -180,33 +180,33 @@ replacement. ### The synthesized default A record page needs no authoring. With no page assigned, the Console synthesizes one from -the object: a header, the highlights strip and a tab strip of **Details**, **Timeline** and -**Related**, plus **Approvals** when the record has approval requests. A relationship whose -child field declares `relatedList: 'primary'` is part of the record's body: its list is a -full-width table inside **Details**, after the first field section, with no tab of its own. -The other related lists are cards in the shared **Related** tab, which an object with -`enable: { files: true }` closes with its attachments. **Timeline** is one stream: the -discussion composer on top, then the discussion, the activity and, with -`enable: { trackHistory: true }`, the record's field changes, filtered by All / Activity / -Discussion / Changes; there is no separate History tab. Moving a child table into the -record's body is therefore a one-word change on the relationship field, not a -page-authoring task, and `relatedList: false` leaves a child off the page. - -The Console lays the page out by the width of the surface it occupies (the drawer, the -split pane or the full page), never by the viewport. From 1280 px the tabs become three -columns: details, the timeline stream and the -related lists as cards with Approvals, Attachments and the created/updated line. From -1024 px the related column folds into a rail behind a **Related (N)** toggle. Below that, and -always in a drawer or split pane, the tab strip is drawn. `?tab=` selects a tab in the strip. - -In the column tiers the related column loads in steps, and the details column never waits for -it. The page asks every card's count in one request set, sent beside the record read, and the -cards show placeholders until those counts land. A card counted at 0 is its title row alone -and reads no rows. A card above 0 reads its first three rows once it scrolls into view, asking -only for the record's name field and one status or date field. When the timeline column is -switched off, its **Activity** card in the related column stays closed, and reads nothing, until -it is opened. In a drawer, the record that **↑** / **↓** would show next has its related counts -read ahead. +the object: a header, the highlights strip, the record's **details**, and a right column of +panels: **动态** (Activity) first, then the related lists, **Approvals** when the record has +approval requests, and the attachments of an object with `enable: { files: true }`. A +relationship whose child field declares `relatedList: 'primary'` is part of the record's body: +its list is a full-width table in the details, after the first field section, with no panel of +its own. **动态** is one stream: the discussion, the activity and, with +`enable: { trackHistory: true }`, the record's field changes; there is no separate History tab. +Its panel shows a one-line composer and the newest entries, and its header opens the whole +stream (`?related=timeline`), filtered by All / Activity / Discussion / Changes. A related +panel's header opens that list's whole table the same way (`?related=.`). +Moving a child table into the record's body is therefore a one-word change on the +relationship field, not a page-authoring task, and `relatedList: false` leaves a child off the +page. + +The Console lays the page out by the width of the surface it occupies (the full page, the split +pane or the drawer), never by the viewport, and by one rule. From 760 px the details sit beside +the right column, 320 px wide; from 1120 px the right column is 380 px and a details section +takes up to three field columns. Below 760 px (the split pane, a phone) the page is one column: +the details, then the right column's panels in their order. The **⊟** switch at the end of the +record header's actions (`⌘⇧,`) hides the right column for the reader, per object. + +The related panels load in steps, and the details never wait for them. The page asks every +panel's count in one request set, sent beside the record read, and the panels show +placeholders until those counts land. A panel counted at 0 reads no rows, and two or more of +them fold into one line. A panel above 0 reads its first three rows once it scrolls into view, +asking only for the record's name field and one status or date field. In a drawer, the record +that **↑** / **↓** would show next has its related counts read ahead. ### Slotted pages @@ -219,12 +219,12 @@ synthesizer fills in the rest. A slotted page authors `slots`, not `regions`: | `actions` | The action bar (`record:quick_actions`) | | `alerts` | The alert banners above the body | | `highlights` | The highlights strip | -| `details` | The body of the Details tab; the other tabs stay synthesized | +| `details` | The record's details; the rest of the page stays synthesized | | `discussion` | The discussion panel | Each slot takes one component or an array of them, and replaces its part whole: there is no -deep merge. There is no `tabs` slot: the tab strip is composed from the object's fields and -relationship flags. To change which related lists appear or their order, set `relatedList` / +deep merge. There is no `tabs` slot: the page's panels are composed from the object's fields +and relationship flags. To change which related lists appear or their order, set `relatedList` / `relatedList: 'primary'` on the child's reference field; a page that needs its own strip is `kind: 'full'`, drawn as authored. `slots.tabs`, `slots.related` and `slots.relatedLists` are refused by name, and `os migrate meta --from 17` drops a stored `slots.tabs`. diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index 00504de0656..dc34c83ab98 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -213,7 +213,7 @@ "capture the create request/response (expect POST /api/v1/data/showcase_account). The payload also carries the select defaults of fields the step leaves untouched — sales_region:'amer' (its AMER option is default:true, examples/app-showcase/src/data/objects/account.object.ts) — so expect that key in the body you diff (QA run #24556)", "re-read server-side: GET /api/v1/data/showcase_account?$filter=[[\"name\",\"=\",\"os-qa-\"]] — field-by-field vs the submitted payload", "open the record detail; edit exactly one field (annual_revenue); submit it (an edit form's button reads 'Update'); capture the PATCH /api/v1/data/showcase_account/; re-read the full row via GET /api/v1/data/showcase_account/", - "read the record's field-change history on the record page's timeline stream, under its Changes chip (变更). Record page v3 draws no tab named History at any width (#27055). Where the stream sits depends on the record view's width (ui/packages/plugin-detail/src/synth/recordPageColumns.ts#recordPageTierFor), the window minus the console sidebar: at 760 px and wider it is resident (the Timeline column, or, while the reader's timeline switch is off, the Activity card at the foot of the related column); narrower, it is the Timeline tab. A 1440×900 window is a column tier whatever the sidebar does. Screenshot first, then read the change lines", + "read the record's field-change history on the record page's timeline stream, under its Changes chip (变更). Record page v3 draws no tab named History at any width (#27055). The stream's whole face, with its chips, is the 动态 (Activity) tab: open it from the header of the 动态 panel, the first panel of the record page's right column (below the details in one column under 760 px, ui/packages/plugin-detail/src/synth/recordPageColumns.ts#recordPageTierFor), or by ?related=timeline (#27524). The panel itself shows only the newest lines, with no chips. Screenshot first, then read the change lines", "clone the record: POST /api/v1/data/showcase_account//clone (optionally with {overrides:{name:'os-qa--clone'}}); capture the 201 result (new id ≠ source, sourceId, record); re-read the clone via GET /api/v1/data/showcase_account/", "RLS probe on clone: as a persona WITHOUT read on the source row (a permission-zoo persona, or a non-owner under owner-RLS), POST the same clone route and capture the refusal — expect 404 RECORD_NOT_FOUND, never a silent duplicate (record blocked(fixture) if no non-reading persona is bound on this boot)", "delete via the row/detail action; confirm the dialog; re-read via the filtered GET. ⚠️ If clause 7's recipe gave the member an own-row invoice pointing at THIS account, the delete answers 409 DELETE_RESTRICTED: the invoice's account lookup is required, and the engine turns a required set_null into restrict (packages/objectql/src/engine.ts). Point that invoice at a seeded account instead (the recipe's step 5 says so)", @@ -3725,7 +3725,7 @@ "steps": [ "open a record; run the packaged undoable action (fixtures.requires[0]) on it; confirm the success toast shows an Undo button. An inline edit offers none by design", "click Undo (or press Ctrl+Z); capture the resulting update; re-read the record over the API and confirm the prior value is restored", - "read the record's field-change history on the record page's timeline stream, under its Changes chip (变更). Record page v3 draws no tab named History at any width (#27055). Where the stream sits depends on the record view's width (ui/packages/plugin-detail/src/synth/recordPageColumns.ts#recordPageTierFor), the window minus the console sidebar: at 760 px and wider it is resident (the Timeline column, or, while the reader's timeline switch is off, the Activity card at the foot of the related column); narrower, it is the Timeline tab. A 1440×900 window is a column tier whatever the sidebar does. Also read sys_audit_log for the record over the API, and confirm a revert entry was added (a forward write, not a silent rollback)", + "read the record's field-change history on the record page's timeline stream, under its Changes chip (变更). Record page v3 draws no tab named History at any width (#27055). The stream's whole face, with its chips, is the 动态 (Activity) tab: open it from the header of the 动态 panel, the first panel of the record page's right column (below the details in one column under 760 px, ui/packages/plugin-detail/src/synth/recordPageColumns.ts#recordPageTierFor), or by ?related=timeline (#27524). The panel itself shows only the newest lines, with no chips. Also read sys_audit_log for the record over the API, and confirm a revert entry was added (a forward write, not a silent rollback)", "press Ctrl+Shift+Z (redo); confirm the edited value is restored", "CONCURRENT-CLOBBER probe: in session A make the undoable edit; in session B change the SAME field and save; back in A, click Undo", "observe and FLAG whether A's undo carries an OCC guard or silently overwrites B's value" @@ -5171,7 +5171,7 @@ "boot the showcase on its own port and DB; sign in as admin; open a seeded account record", "edit the TRACKED select `industry` (e.g. Technology → Finance); save; read the record's sys_activity rows over the API and open the timeline", "edit an UNTRACKED field (e.g. website) alone; save; re-read sys_activity for the record", - "record the network log from page load, open the account record and select its timeline stream's Changes chip (变更). Record page v3 has no History tab at any width (#27055). Where the stream sits depends on the record view's width (ui/packages/plugin-detail/src/synth/recordPageColumns.ts#recordPageTierFor), which is the window minus the console sidebar. At 760 px and wider it is resident: the Timeline column, or, while the reader's timeline switch is off, the Activity card at the foot of the related column. The switch is off by default for an object that is not worked, and showcase_account is not one (ui/packages/plugin-detail/src/synth/recordPageColumns.ts#recordTimelineDefaultReason answers null). Narrower, it is the Timeline tab. Measured on this item's re-point: a 1440×900 window gave 1184 px (sidebar expanded) and a 1000×800 window 952 px (sidebar as its icon rail), both columns with no tab strip; only a 700×800 window drew the tabs (Details / Related / Timeline). Then open a seeded task's detail and read the network log again", + "record the network log from page load, open the account record and select its timeline stream's Changes chip (变更). Record page v3 has no History tab at any width (#27055). The chips are on the stream's whole face, the 动态 (Activity) tab: open it from the header of the 动态 panel, the first panel of the record page's right column at every width (below the details in one column under 760 px, ui/packages/plugin-detail/src/synth/recordPageColumns.ts#recordPageTierFor), or by ?related=timeline (#27524). No width draws a tab strip on the synthesized page. Then open a seeded task's detail and read the network log again", "GET /api/v1/auth/me/permissions and compare apiOperations for showcase_account vs showcase_task" ], "acceptance": [ @@ -5190,7 +5190,7 @@ { "clause": "the record page's sys_audit_log field-change history gates on OBJECT-level enable.trackHistory, both ways: the account detail (flag true) reads sys_audit_log and its timeline stream's Changes chip lists the tracked edits as old → new lines with display values; the task detail (no enable block — per-field flags alone do not summon it) makes NO sys_audit_log read", "oracle": "network", - "verify": "the network log after settle: one sys_audit_log read for the account record (at page load in a column tier; in the tabbed tier when the Timeline tab opens, or at load under ?tab=timeline) and none for the task; screenshot the account's Changes chip showing the industry edit. Gating ruled at ui/packages/app-shell/src/views/RecordDetailView.tsx (historyEnabled: enable.trackHistory explicitly true, sys_audit_log registered, the viewed object not sys_audit_log itself; and the read serves only the synthesized page's stream, never an assigned page); the read's rows reach the stream through RecordFieldChangesContext as field_change items (ui/packages/plugin-detail/src/recordTimelineStream.ts#auditEntriesToFeedItems). The task half is over-determined (knownGaps): record which gate the verdict rests on. ⛔ An empty Changes chip is never an absence oracle: the chip also lists the stream's sys_activity created/updated rows (ACTIVITY_TYPE_TO_FEED_TYPE maps both to field_change). Measured on a seeded account, the chip showed a sys_activity 'Updated Account …' row beside the audit read. ⛔ Nor is a History tab: record page v3 removed it at every width (#27055; buildDefaultPageSchema.test.ts asserts 'no Activity or History tab'), so looking for one fails a working product (QA run #27438)", + "verify": "the network log after settle: one sys_audit_log read for the account record (at page load, at every width: the 动态 panel is on screen at open since #27524) and none for the task; screenshot the account's Changes chip showing the industry edit. Gating ruled at ui/packages/app-shell/src/views/RecordDetailView.tsx (historyEnabled: enable.trackHistory explicitly true, sys_audit_log registered, the viewed object not sys_audit_log itself; and the read serves only the synthesized page's stream, never an assigned page); the read's rows reach the stream through RecordFieldChangesContext as field_change items (ui/packages/plugin-detail/src/recordTimelineStream.ts#auditEntriesToFeedItems). The task half is over-determined (knownGaps): record which gate the verdict rests on. ⛔ An empty Changes chip is never an absence oracle: the chip also lists the stream's sys_activity created/updated rows (ACTIVITY_TYPE_TO_FEED_TYPE maps both to field_change). Measured on a seeded account, the chip showed a sys_activity 'Updated Account …' row beside the audit read. ⛔ Nor is a History tab: record page v3 removed it at every width (#27055; buildDefaultPageSchema.test.ts asserts 'no Activity or History tab'), so looking for one fails a working product (QA run #27438)", "evidence": "the two network logs + the account's Changes screenshot" }, { From 81683b80b74a9396a5f422fabe0232b39a57e70f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 08:16:44 +0000 Subject: [PATCH 5/9] test(plugin-detail): call framelessSectionColumns with the count alone, and pin its two-column cap The section-columns helper took a second argument (the cap beside a 320 px right column), so passing it straight to Array.map handed it the index as a cap: a type error in the test project, and a cap nobody meant at runtime. Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt Co-authored-by: Claude --- .../src/__tests__/recordPageV3Face-27055.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/packages/plugin-detail/src/__tests__/recordPageV3Face-27055.test.tsx b/ui/packages/plugin-detail/src/__tests__/recordPageV3Face-27055.test.tsx index bed245363d7..11cb747bba1 100644 --- a/ui/packages/plugin-detail/src/__tests__/recordPageV3Face-27055.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/recordPageV3Face-27055.test.tsx @@ -53,7 +53,11 @@ const gridOf = (container: HTMLElement) => describe('framelessSectionColumns — 1–3 columns by the fields a section shows', () => { it('one field one column, two to five two, six and more three', () => { - expect([0, 1, 2, 5, 6, 12].map(framelessSectionColumns)).toEqual([1, 1, 2, 2, 3, 3]); + expect([0, 1, 2, 5, 6, 12].map((n) => framelessSectionColumns(n))).toEqual([1, 1, 2, 2, 3, 3]); + }); + + it('never more than the cap: two beside a 320 px right column (objectstack#27524)', () => { + expect([0, 1, 2, 6, 12].map((n) => framelessSectionColumns(n, 2))).toEqual([1, 1, 2, 2, 2]); }); }); From a38376b7beb368c726c179e82b91391bcd095216 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 11:55:11 +0000 Subject: [PATCH 6/9] test(showcase): the Contoso detail-shapes case reads the one-column stack tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tabbed tier this case pinned is retired: below the 760 px view floor the record page is one column, the details then the right column's panels. The case asserts the stack and the absent tab strip, Invoices and Projects as body tables in the details, 动态 as the right column's first panel, and the Contacts panel reading its rows once scrolled into view. Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt Co-authored-by: Claude --- .../app-showcase/e2e/detail-shapes.spec.ts | 73 ++++++++++--------- 1 file changed, 38 insertions(+), 35 deletions(-) diff --git a/examples/app-showcase/e2e/detail-shapes.spec.ts b/examples/app-showcase/e2e/detail-shapes.spec.ts index 2961a092371..ffd8dc2fc10 100644 --- a/examples/app-showcase/e2e/detail-shapes.spec.ts +++ b/examples/app-showcase/e2e/detail-shapes.spec.ts @@ -13,10 +13,11 @@ import { test, expect } from '@playwright/test'; * - stageField:false → NO `record:path` stepper despite a `status` field * named to bait the heuristic * - related-heavy → related lists surface by prominence (ADR-0085), in the - * tabbed tier as record page v3 draws it (objectstack#27055, - * Target 8): a `relatedList: 'primary'` list is a body - * table in the Details tab, the rest sit behind the - * Related tab and self-fetch when it is shown + * one-column stack tier (objectstack#27524): a + * `relatedList: 'primary'` list is a body table in the + * details (objectstack#27055, Target 8), the rest are + * panels of the right column below them and read their + * rows when they come into view * * Runs against the console the backend serves at /_console (baseURL from * playwright.config.ts; storageState carries the admin session). The @@ -157,27 +158,28 @@ test('stageField:false: status renders as a plain field, no stepper', async ({ p expect(errors, 'uncaught page errors on stageField:false detail').toEqual([]); }); -test('ungrouped + related-heavy: flat details and related-list tabs on Contoso', async ({ page }) => { +test('ungrouped + related-heavy: flat details and the related panels on Contoso', async ({ page }) => { // The synthesized record page lays itself out by the width of the view it - // occupies (objectstack#26678): the tab strip this case opens exists only - // below the two-column floor, a record view narrower than - // `RECORD_PAGE_TWO_COLUMN_MIN_WIDTH` (760 px, `@object-ui/plugin-detail`). - // The case stays in that tabbed tier on purpose: the column tiers are what - // every other page of this smoke draws at the Desktop Chrome viewport, and - // the tab strip is also what the drawer and split pane draw at any width. + // occupies, by one rule (objectstack#26678, objectstack#27524): details | + // the right column from `RECORD_PAGE_TWO_COLUMN_MIN_WIDTH` (760 px, + // `@object-ui/plugin-detail`), and ONE column below it — the details, then + // the right column's panels in their order. No tier draws a tab strip. The + // case stays in that stack tier on purpose: the column tiers are what every + // other page of this smoke draws at the Desktop Chrome viewport, and the + // stack is also what the split pane (720 px) and a phone draw. // // The window is derived, not picked. Measured on this harness (a fresh // browser context, so no stored sidebar cookie): from a 768 px window // (`MOBILE_BREAKPOINT`, the desktop shell) to 1000 px the console sidebar // came up as its 48 px icon rail, and the record view was the window minus - // that rail — two columns at 808 px (760 of view), tabs at 800 px (752). - // So the desktop tabbed tier is a window of 768 to 807 px, and an expanded + // that rail — two columns at 808 px (760 of view), one at 800 px (752). + // So the desktop stack tier is a window of 768 to 807 px, and an expanded // sidebar (256 px) only narrows the view further. 780 sits inside it with // headroom on both sides: 732 px of view against the 760 floor, 12 px above // the mobile breakpoint. ⛔ Not 1200 px: since objectstack#27473 moved the - // floor to a 760 px view, 1200 draws columns and no tablist. ⛔ Not below - // 768 px: that is the mobile shell, where the off-canvas sidebar gives the - // view the whole window (760 px measured as two columns). + // floor to a 760 px view, 1200 draws two columns. ⛔ Not below 768 px: that + // is the mobile shell, where the off-canvas sidebar gives the view the + // whole window (760 px measured as two columns). await page.setViewportSize({ width: 780, height: 800 }); const errors = await openRecord(page, recordUrl('showcase_account', contosoId)); @@ -185,28 +187,31 @@ test('ungrouped + related-heavy: flat details and related-list tabs on Contoso', // record:path renders a desktop + a mobile variant — assert the visible one. await expect(page.locator(PATH_STEPPER).first()).toBeVisible(); + // One column and no tab strip: the details, then the right column. + await expect(page.locator('[data-record-page-tier]')).toHaveAttribute('data-record-page-tier', 'stack'); + await expect(page.locator('[data-record-columns]')).toHaveAttribute('data-record-columns-shown', 'details'); + await expect(page.getByRole('tab', { name: /Details|Related|Invoices|Projects/i })).toHaveCount(0); + // Related lists surface by prominence (ADR-0085), as record page v3 draws // them (objectstack#27055, Target 8): Invoices and Projects are // `relatedList: 'primary'` (CORE relationships), so each is a body table in - // the Details tab, with its rows read at open; the rest of Contoso's lists - // (Contacts and the Field Zoo lookups) sit behind the Related tab. - const detailsTab = page.getByRole('tab', { name: /Details/i }); - const relatedTab = page.getByRole('tab', { name: /^Related/ }); - await expect(detailsTab).toBeVisible(); - await expect(detailsTab).toHaveAttribute('aria-selected', 'true'); - await expect(relatedTab).toBeVisible(); - await expect(page.getByRole('tab', { name: /Invoices|Projects/i })).toHaveCount(0); - const detailsPanel = page.getByRole('tabpanel', { name: /Details/i }); - const invoicesTable = detailsPanel + // the details, with its rows read at open; the rest of Contoso's lists + // (Contacts and the Field Zoo lookups) are panels of the right column, + // after its first panel, 动态 (objectstack#27524, Target 5). + const details = page.locator('[data-record-column="details"]'); + const invoicesTable = details .getByRole('table') .filter({ has: page.getByRole('columnheader', { name: 'Invoice Number' }) }); - const projectsTable = detailsPanel + const projectsTable = details .getByRole('table') .filter({ has: page.getByRole('columnheader', { name: 'Project Name' }) }); await expect(invoicesTable.getByRole('cell', { name: 'INV-1002', exact: true })).toBeVisible(); await expect(projectsTable.getByRole('cell', { name: 'Data Platform', exact: true })).toBeVisible(); + const rightColumn = page.locator('[data-record-column="right"]'); + await expect(rightColumn.locator(':scope > *').first()).toHaveAttribute('data-record-timeline-panel', ''); + await expect(rightColumn.getByRole('region', { name: /^(Invoices|Projects)$/ })).toHaveCount(0); - // Ungrouped shape: the Details body is flat — no fieldGroup section cards. + // Ungrouped shape: the details body is flat — no fieldGroup section cards. // (Contoso declares no fieldGroups; spot-check a body field label.) await expect(page.getByText('Basics', { exact: true })).toHaveCount(0); @@ -220,13 +225,11 @@ test('ungrouped + related-heavy: flat details and related-list tabs on Contoso', expect(footerText, 'footer dangles "Created by" without an actor').not.toMatch(/Created by/); expect(footerText).toMatch(/Created/); - // The non-primary lists self-fetch lazily when their tab is shown: the - // Related tab's Contacts list reads Contoso's seeded contacts. - await relatedTab.click(); - await expect(relatedTab).toHaveAttribute('aria-selected', 'true'); - const relatedPanel = page.getByRole('tabpanel', { name: /^Related/ }); - await expect(relatedPanel).toBeVisible(); - await expect(relatedPanel.getByRole('cell', { name: 'Carl Fox', exact: true })).toBeVisible(); + // A panel reads its rows once it is in view: below the details here, so + // the Contacts panel reads Contoso's seeded contacts when scrolled to. + const contactsPanel = rightColumn.getByRole('region', { name: 'Contacts', exact: true }); + await contactsPanel.scrollIntoViewIfNeeded(); + await expect(contactsPanel.locator('[data-related-card-row]').filter({ hasText: 'Carl Fox' })).toBeVisible(); expect(errors, 'uncaught page errors on related-heavy detail').toEqual([]); }); From d98543cb2897c0405c2a3fa385d6ec41cea10697 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 13:38:48 +0000 Subject: [PATCH 7/9] =?UTF-8?q?fix(plugin-detail,app-shell):=20Esc=20on=20?= =?UTF-8?q?an=20open=20face=20in=20the=20record=20pane=20returns=20to=20th?= =?UTF-8?q?e=20record;=20=E2=8C=98=E2=87=A7,=20is=20the=20topmost=20record?= =?UTF-8?q?=20view's?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inside the record pane the 动态 tab and a related list's whole face never closed on Esc: the face's guard found the pane's own dialog, and the pane's Esc (the drawer's dialog layer, the split pane's document listener) closed or stepped the pane instead. The face now listens on the window in the capture phase and takes the key unless a text field, an open menu or alert, or a dialog the face is not inside owns it. A drawer wide enough for its own right column draws its own ⊟, and the page under it keeps its tree mounted, so one ⌘⇧, ran both views' listeners and the page's ran first: it flipped the hidden page's stored switch, not the drawer's. Only the topmost record root, the last in document order, answers the chord now. Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt Co-authored-by: Claude --- ...cordDetailView.panelToggles-27052.test.tsx | 52 ++++++- .../app-shell/src/views/RecordDetailView.tsx | 19 ++- .../plugin-detail/src/RecordPageColumns.tsx | 36 ++++- ...olumns.enlargedEscapeInPane-27524.test.tsx | 137 ++++++++++++++++++ 4 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 ui/packages/plugin-detail/src/__tests__/RecordPageColumns.enlargedEscapeInPane-27524.test.tsx diff --git a/ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx index f29aa36fd85..b420b7418fa 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx @@ -27,12 +27,14 @@ * action row (objectstack#27241) — not on the crumb row, which keeps only * the ways back and has no child without one; * - it is drawn only where the tier has a right column: both two-column - * tiers, the embedded record at those widths too, never the one column. + * tiers, the embedded record at those widths too, never the one column; + * - the chord is the topmost record view's: a drawer wide enough for its + * own right column flips its own switch, never the page's under it. */ import * as React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, cleanup, fireEvent, waitFor, within } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { MetadataCtx } from '@object-ui/react'; import { RelatedCountStore } from '@object-ui/components'; @@ -125,6 +127,8 @@ const OBJECTS = [ ]; let rootWidth = 0; +/** A record view inside a dialog (a drawer over the page) measures this, when set. */ +let dialogRootWidth: number | null = null; function makeDataSource() { return { @@ -136,8 +140,13 @@ function makeDataSource() { }; } -function renderPage({ object = LEAD, width = 1440, embedded = false }: { object?: string; width?: number; embedded?: boolean } = {}) { - rootWidth = width; +function renderPage({ + object = LEAD, + width = 1440, + embedded = false, + container, +}: { object?: string; width?: number; embedded?: boolean; container?: HTMLElement } = {}) { + if (!container) rootWidth = width; const metadata = { objects: OBJECTS, pages: [], @@ -162,6 +171,7 @@ function renderPage({ object = LEAD, width = 1440, embedded = false }: { object? /> , + container ? { container } : undefined, ); } @@ -182,6 +192,7 @@ const RIGHT_KEY = (object: string) => `os:pref:record.columns.related@object:${o beforeEach(() => { cleanup(); + dialogRootWidth = null; window.localStorage.clear(); RelatedCountStore._reset(); vi.stubGlobal( @@ -193,7 +204,8 @@ beforeEach(() => { 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; + const width = dialogRootWidth !== null && this.closest('[role="dialog"]') ? dialogRootWidth : rootWidth; + return { x: 0, y: 0, top: 0, left: 0, right: width, bottom: 800, width, height: 800, toJSON: () => ({}) } as DOMRect; }); }); @@ -331,4 +343,34 @@ describe('RecordDetailView — the right-column switch ⊟ (objectstack#27052, o await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); expect(shown(embedded.container)).toBe('details right'); }); + + it('⌘⇧, flips the topmost record view\'s switch alone: a 1000 px drawer over a 1440 px page (objectstack#27524)', async () => { + // A record page keeps its own tree mounted under the drawer its rows and + // lookups open (`RecordPageDrawerHost`), and the drawer's sheet is + // portaled after it, to the end of the body. A drawer dragged to 1000 px + // has a right column of its own, so both views draw ⊟: the chord is the + // drawer's, and never writes the hidden page's stored choice. + dialogRootWidth = 1000; + const page = renderPage(); + await waitFor(() => expect(shown(page.container)).toBe('details right'), { timeout: 5000 }); + const sheet = document.createElement('div'); + sheet.setAttribute('role', 'dialog'); + document.body.appendChild(sheet); + const drawer = renderPage({ object: PRODUCT, embedded: true, container: sheet }); + await waitFor(() => expect(shown(sheet)).toBe('details right'), { timeout: 5000 }); + expect(sheet.querySelector('[data-record-page-tier]')?.getAttribute('data-record-page-tier')).toBe('two-column'); + expect(within(sheet).queryByRole('group', { name: 'Panels' })).not.toBeNull(); + + press('related'); + await waitFor(() => expect(shown(sheet)).toBe('details')); + expect(window.localStorage.getItem(RIGHT_KEY(PRODUCT))).toContain('off'); + expect(shown(page.container)).toBe('details right'); + expect(window.localStorage.getItem(RIGHT_KEY(LEAD))).toBeNull(); + + // The drawer closes: the chord is the page's again. + drawer.unmount(); + sheet.remove(); + press('related'); + await waitFor(() => expect(shown(page.container)).toBe('details')); + }); }); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index d34511c6e56..94f49bd8f8d 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -164,6 +164,20 @@ function isTypingTarget(target: EventTarget | null): boolean { return target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"]') !== null; } +/** + * Whether this record view is the topmost one, the one a page-wide chord is + * for. A record page keeps its own tree mounted under the drawer its rows and + * lookups open (`RecordPageDrawerHost`), and the drawer's sheet is portaled + * after it, to the end of the body; once the drawer is wide enough for a right + * column its view listens too (objectstack#27524). The last record root in + * document order is the one on top. + */ +function isTopmostRecordView(root: Element | null): boolean { + if (!root) return false; + const roots = root.ownerDocument.querySelectorAll('[data-record-page-root]'); + return roots[roots.length - 1] === root; +} + /** `aria-keyshortcuts` spelling of a chord, for the platform the page runs on. */ function ariaKeyShortcutsOf(chord: ShortcutChord): string { const mac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || ''); @@ -972,7 +986,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri ); // Whether the toolbar draws ⊟ — a right column the page has content for — // set at render below and read by the shortcut, so a shortcut never flips a - // switch the reader cannot see. + // switch the reader cannot see: not this page's under a drawer either. const drawnPanelSwitch = useRef(false); useEffect(() => { if (!pageHasRightColumn) return undefined; @@ -981,12 +995,13 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri if (!matchesPanelChord(e, TOGGLE_RELATED_SHORTCUT.chord)) return; if (isTypingTarget(e.target)) return; if (!drawnPanelSwitch.current) return; + if (!isTopmostRecordView(pageRootEl)) return; e.preventDefault(); setRelatedSwitch(relatedSwitch === 'on' ? 'off' : 'on'); }; document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); - }, [pageHasRightColumn, relatedSwitch, setRelatedSwitch]); + }, [pageHasRightColumn, pageRootEl, relatedSwitch, setRelatedSwitch]); useAdvertiseShortcut(TOGGLE_RELATED_SHORTCUT, pageHasRightColumn); const [pageRecord, setPageRecord] = useState(null); // 'idle' | 'loading' | 'loaded' | 'missing' | 'forbidden' | 'failed' — diff --git a/ui/packages/plugin-detail/src/RecordPageColumns.tsx b/ui/packages/plugin-detail/src/RecordPageColumns.tsx index 9ef292d98d1..ec2cd4c6a25 100644 --- a/ui/packages/plugin-detail/src/RecordPageColumns.tsx +++ b/ui/packages/plugin-detail/src/RecordPageColumns.tsx @@ -88,6 +88,25 @@ function relatedNodeId(node: any): RelatedCardId | null { * 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. */ +/** + * Whether an `Esc` belongs to something other than the open face: a field the + * reader types in, an open menu or alert, or a dialog the face is not inside + * (a popover, a confirm, a drawer opened over the page that holds the face). + * The dialog that holds the face — the record pane's drawer — is not one. + */ +function escapeOwnedElsewhere(target: EventTarget | null, face: Element | null): boolean { + if (!face) return true; + if ( + target instanceof Element && + target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"]') + ) { + return true; + } + const doc = face.ownerDocument; + if (doc.querySelector('[role="alertdialog"], [role="menu"]')) return true; + return Array.from(doc.querySelectorAll('[role="dialog"]')).some((dialog) => !dialog.contains(face)); +} + const EnlargedFace: React.FC<{ id: string; title: string; onClose?: () => void; children: React.ReactNode }> = ({ id, title, @@ -95,18 +114,25 @@ const EnlargedFace: React.FC<{ id: string; title: string; onClose?: () => void; children, }) => { const { t } = useDetailTranslation(); + const faceRef = React.useRef(null); React.useEffect(() => { if (!onClose) return undefined; + // On the window, in the capture phase: inside the record pane the pane's + // own `Esc` (the drawer's dialog layer, the split pane's document + // listener) would act first and close or step the pane (objectstack#27524). + // The face takes the key, and keeps it from the pane. const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Escape' || event.defaultPrevented) return; - if (document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"]')) return; + if (event.key !== 'Escape' || event.defaultPrevented || event.isComposing) return; + if (escapeOwnedElsewhere(event.target, faceRef.current)) return; + event.preventDefault(); + event.stopPropagation(); onClose(); }; - document.addEventListener('keydown', onKeyDown); - return () => document.removeEventListener('keydown', onKeyDown); + window.addEventListener('keydown', onKeyDown, true); + return () => window.removeEventListener('keydown', onKeyDown, true); }, [onClose]); return ( -
+