Skip to content

Commit eef8373

Browse files
objectstack-fleet[bot]claudehotlong
authored
feat(plugin-detail,app-shell,components): record page v3 layout (#27310)
Part of #27055 — this lands the record page v3 layout. The two fenced read points stay for #27069: the related cards' default order (primary first, then derived order, without reading `relatedLists`) and the body tables' placement (after the first field group, without a `fieldGroups` placement entry). Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:没有指定页面的对象,记录页换成 v3 布局。详情区无边框,空区段折成一行。时间线合成一条流,带「全部/活动/讨论/变更」筛选。关联区是紧凑卡片,读者可拖动、折叠、隐藏,还能 ⤢ 原地放大。主从子表直接嵌在详情里。 - 为什么改:执行 #27055 已批准的 v3 设计。删掉了 ADR-0085 判死的页面开关和 rightRail 插槽,把三处重复的历史、动态、讨论入口并成一个。 - 风险与回滚:只改渲染层,作者写的元数据不变。BuildPageOptions 删字段属于 TS 层面的破坏性变更,控制台内部没有调用方传这些字段。回滚就是 revert 本 PR。读者的卡片排列存在已有的 workspacePreference 里,回滚后不会被读取,没有副作用。 - 先看:ui/packages/app-shell/src/views/RecordDetailView.tsx(放大的 URL 往返、排列偏好、字段变更并入时间线),ui/packages/plugin-detail/src/synth/relatedArrangement.ts(排列合并规则),ui/packages/plugin-detail/src/RecordPageColumns.tsx(三栏模板和放大视图)。 - 两分钟验证:打开下方证据分支里的 contact-1440-three-columns.png,应看到「详情|时间线|关联卡片」三栏,时间线里有一条灰色的字段变更行和两条评论。 - 需要你做的:人工评审并合并。本 PR 改动 5828 行,超过 3000 行的人工合并线。 ## What changes The synthesized default record page (an object with no assigned record page) takes the v3 layout. Authored pages are untouched apart from the shared header and the renderer faces noted below. - **Header.** One filled primary action, two outline actions, the rest in `⋯`. Refresh is the first item of `⋯` (no separate ⟳ button). Up to six highlights, as an equal grid. - **Details.** Frameless sections in one to three columns. All-empty sections fold into one line at the foot. The created / updated / owner / id line closes the details column. - **Timeline.** One stream merging comments, `sys_activity` rows and audit field changes, newest first. The composer is on top, the chips are All / Activity / Discussion / Changes, system entries are one muted line, and a "View all N →" button shows the rest. The History tab, the Activity tab and the discussion footer are retired on this page. - **Related column.** Compact cards (title, count, +, ⤢, up to three rows of name plus one chip, no table header, no View All footer) in a fixed 320 px column. The columns are 1.25fr : 1fr : 320px. - **Reader arrangement.** Drag (desktop), Move up / Move down / Collapse / Hide from the card menu, and "N hidden · Show" at the foot. It is stored in the #27052 workspace preference under `record.related.arrangement`, scope `object:<name>`. The value is `{ v, order, collapsed, hidden, basedOn, seen }`. A default-order change shows "Default order updated · Restore default". A new relationship lands after its nearest default neighbour with a "New" badge for 48 hours. The tabbed tier's Related tab follows the same arrangement. - **Enlarge in place.** ⤢ opens the list full width under `?related=<object>.<field>`. Esc or ‹ Back returns. - **Body tables.** A `master_detail` FK declaring `inlineEdit`, or `relatedList: 'primary'`, is drawn as a table after the first field group. It opens on ten rows with a totals line and has ⤢. - **Tabbed tier** (drawer, narrow widths): Details / Timeline / Related / (Approvals). Timeline is second for an object whose timeline is on by default (#27052's derivation), last otherwise. Attachments sit in Related. - **BuildPageOptions** loses `hideHighlights`, `hidePath`, `showReferenceRail`, `hideReferenceRail`, `hideRelatedTab`, `relatedLayout`, `recordChrome`, `showActivity`, `history` and `slots.rightRail` (TypeScript-only break, named in the changeset with its ADR-0087 marker). Fence read points for #27069: - **Default order.** `synthParts` related ordering in `RecordDetailView.tsx`, commented as the fence. - **Body-table placement.** `bodyTablePlacement` in `buildDefaultPageSchema.ts`, commented as the fence. ## Tests - New suites cover the face, the arrangement merge rules, the card stack (drag, menu, collapse, hide, enlarge, tabbed tier), body tables, body-table placement, and the timeline stream including its newest-first window. A last suite pins the retired hints as compile errors. - Existing pins moved with the page: History and discussion-footer reads now open the Timeline tab, the header menu has Refresh first, the highlight cap is six, and the meta line sits in the details column. | Suite (local, under the verify lock) | Result | |---|---| | `@object-ui/plugin-detail` | 331 files: 329 passed, 1 skipped, 2 failed on the first run and passed on rerun after the pin fixes | | `@object-ui/components` | 446 passed, 1 skipped | | `@object-ui/app-shell` | 1858 files: 1856 passed, 1 skipped, 1 failed on the first run and passed on rerun after the fix | | console `approvalRequestsDataSource.recordPage` | passed | | type-check components / plugin-detail / app-shell | clean | ## Acceptance notes - **Screenshots** are on the never-merged branch `claude/issue-27055-evidence-20261028`: https://github.com/objectstack-ai/objectstack/tree/claude/issue-27055-evidence-20261028/evidence/27055 - Shots: contact 1440 three columns, contact 1100 two columns, contact drawer, account 1440, opportunity 1440, related column after a drag, and the enlarged state. - There is no product object in this repo, so the opportunity (with its Line Items body table) stands in. - **Three columns at 1440** need the app sidebar collapsed. The tier threshold (1280 px page container, #26678) is unchanged. - **Accounts and contacts** fold the timeline by #27052's object-derived default. The shots turn the reader's switch on; the switch semantics are unchanged. - **Deviations:** - The arrangement value carries a `seen` member, a first-seen stamp per id, as the clock for the 48-hour "New" badge. - Body-table totals are a summary line over the page's rows, not an in-table row. - Body-table collapse is per view, not persisted. - The host's Edit stays a labelled filled button outside the three-action cap. - The enlarged list keeps the full list page's "View All" link, for sort, filter and export. Only the card footer is gone. - **Out of scope, carrier #27069:** - The spec describe of `relatedList: 'primary'` still says "promoted to its own tab". - The spec describe of `enable.trackHistory` still says "Show the record History tab". - The generated reference docs repeat both describes. - These live under `packages/spec/**`, which this card does not touch. The hand-written security doc line is updated here. ## Verification - **Gates.** Every gate `dispatch-gates.mjs --changed` derives for this diff ran on the final tree. The `--ran` reconciliation verdict is below. - **Console type-check.** It fails locally only on plugin packages outside the build closure (`plugin-gantt`, `plugin-map`, `plugin-markdown`, `plugin-timeline`, `plugin-tree` have no `dist` here). No error is in a file this PR touches. CI builds the full graph. - **Browser.** Checked in a real browser against the app-crm example on a fresh database. The console dev server ran from this branch. <details><summary>Gate reconciliation (66 derived, 66 run, all exit 0)</summary> ``` Run reconciliation — 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN. dispatch-gates --ran: 66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED changed lines: 5828 (+4516 / -1312; 78 files) vs the human-merge threshold 3000: OVER ``` </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Q4EjjN14dHBs3CJce73zNm --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com>
1 parent 73725c2 commit eef8373

80 files changed

Lines changed: 4524 additions & 1320 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
'@object-ui/components': patch
4+
'@object-ui/app-shell': patch
5+
'@object-ui/i18n': patch
6+
---
7+
8+
feat(plugin-detail,app-shell,components): the record page v3 — frameless details, one timeline, compact related cards the reader arranges, body tables, enlarge in place
9+
10+
Clause-②: no
11+
Metadata-protocol: none
12+
13+
The synthesized default record page (an object with no assigned page) takes the v3 layout. Nothing an author writes changes: the page is still drawn from the object's semantic roles (ADR-0085), and every change below is the renderer's.
14+
15+
What changes for a reader:
16+
17+
- **Header.** One filled primary action, two outline actions, the rest in the `⋯` menu. The header's Refresh moved into that menu as its first item. Up to six highlights, drawn as an equal-width grid on the default page.
18+
- **Details.** Sections are frameless: a title over a hairline, then the fields in one to three columns by how many fields the section shows. Every titled section collapses from its title. Sections with no value at all fold into one line at the foot, such as 「Address · Conversion · Extra · 3 empty sections · Expand」. The created / updated line closes the details column and also names the owner and the record id.
19+
- **Timeline.** One stream: comments, `sys_activity` rows and field changes (`trackHistory`), newest first, with the composer on top and the chips All / Activity / Discussion / Changes. Field changes and system events are one muted line each. 「View all N →」 shows the rest. The separate History tab, the Activity tab and the discussion footer are gone. With `enable.feeds: false` the stream keeps activity and changes and offers no composer.
20+
- **Related cards.** In the related column each related list is a compact card: title, count and + in one row, no table header, up to three rows of the record's name and one status or date chip. The column is 320 px, beside details and the timeline at 1.25fr : 1fr. 「查看全部」 is gone from the card.
21+
- **The reader arranges the cards.** On desktop the title row drags a card, with a drop line. The card's `⋯` menu has Move up, Move down, Collapse and Hide. A collapsed card shows its title and count and reads no rows. The order, collapsed and hidden state are kept per user and object in the console's workspace preferences, the same store as the column switches, and apply in the tabbed tier's Related tab too. A reader who never dragged sees the default order. A later change to the default does not reorder a dragged column; the column then says 「Default order updated · Restore default」. A relationship added since the reader's last drag lands after its nearest default neighbour and carries a 「New」 badge for 48 hours. Hidden cards are listed at the column's foot (「N hidden · Show」).
22+
- **Enlarge in place.** A card's ⤢ or its count opens the list as a full-width table over the page body, with every column, paging, sort and inline edit. The address is `?related=object.field`; Esc or ‹ returns to the columns.
23+
- **Body tables.** A related list that is the record's own body is drawn as a table inside the details column, right after the first field section, instead of a side card. A body list is a `master_detail` FK declaring `inlineEdit`, or a list marked `relatedList: 'primary'`. A body table opens on ten rows and has a totals line for its numeric columns over the rows on the page.
24+
- **Tabs.** The drawer and narrow widths show Details / Timeline / Related / (Approvals). The Timeline is second for a worked object and last otherwise. Attachments close the Related tab.
25+
26+
**BREAKING** (`@object-ui/plugin-detail`, TypeScript only): `BuildPageOptions` no longer accepts `hideHighlights`, `hidePath`, `showReferenceRail`, `hideReferenceRail`, `hideRelatedTab`, `relatedLayout`, `recordChrome`, `showActivity`, `history`, or the `slots.rightRail` slot. No console caller passed any of them. A caller still passing one gets a compile error naming the key, and the page it draws is the same with the key or without it. The `record:reference_rail` renderer stays for authored pages. `buildDefaultHeader` takes `{ actions }` only, and `buildDefaultHighlights` drops its two hide flags.
27+
28+
<!-- adr-0087: not-required (runtime-interface-only ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts#BuildPageOptions) a renderer's options interface: no Zod schema, no spec declaration and no stored representation reads it, so the compiler is the channel that reaches every caller -->

‎content/docs/protocol/objectql/security.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -601,7 +601,7 @@ Set `deterministicEncryption: true` to allow equality queries on the ciphertext,
601601

602602
Object- and field-level history is opt-in metadata, not a free-form `enable.audit` block.
603603

604-
- **Object history tab** — set `enable.trackHistory: true` on the object (the `ObjectCapabilities` block in `object.zod.ts`) to surface the record History tab.
604+
- **Object history** — set `enable.trackHistory: true` on the object (the `ObjectCapabilities` block in `object.zod.ts`) to surface the record's field changes. On the default record page they join the one Timeline stream (its Changes filter); an assigned page shows them where it places a `record:history` block.
605605
- **Per-field history** — set `trackHistory: true` on an individual field to render that field's value changes on the record activity timeline. (The former per-field `auditTrail` flag was pruned in 2026-06 — it had no runtime consumer.)
606606
- **RLS audit** — removed (2026-07, ADR-0056 D8): the `RLSAuditConfigSchema`/`RLSAuditEventSchema` shapes were never emitted or read by the runtime RLS path and were deleted from the spec. Enforcement decisions surface today through the security plugin's fail-closed warnings and the standard audit log (`plugin-audit`), not a dedicated RLS event stream.
607607

‎ui/apps/console/src/services/approvalRequestsDataSource.recordPage.test.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ const host = {
9393

9494
const EN: I18nConfig = { defaultLanguage: 'en', detectBrowserLanguage: false };
9595

96-
function openRequestPage() {
96+
function openRequestPage(search = '') {
9797
const ds = createApprovalRequestsDataSource({
9898
host: host as unknown as DataSource,
9999
scope: { kind: 'awaiting_me', approverIds: ['u1'] },
@@ -107,7 +107,7 @@ function openRequestPage() {
107107
} as never;
108108
render(
109109
<I18nProvider config={EN} persistLanguage={false}>
110-
<MemoryRouter initialEntries={[`/apps/demo/${APPROVAL_REQUEST_OBJECT}/record/${REQ}`]}>
110+
<MemoryRouter initialEntries={[`/apps/demo/${APPROVAL_REQUEST_OBJECT}/record/${REQ}${search}`]}>
111111
<MetadataCtx.Provider value={metadata}>
112112
<RecordDetailView
113113
dataSource={ds as never}
@@ -158,7 +158,9 @@ describe('the record page over the routed approvals source (objectui#12032)', ()
158158

159159
it('control: a found request renders, read once from the get route with no query', async () => {
160160
answer = 'ok';
161-
openRequestPage();
161+
// Opened on the Timeline tab: the synthesized page's feed is a tab since
162+
// #27055 (no footer), and its reads are the side reads this pins.
163+
openRequestPage('?tab=timeline');
162164
expect((await screen.findAllByText('invoice_approval', undefined, { timeout: 5000 })).length).toBeGreaterThan(0);
163165
await waitFor(() => expect(screen.queryByTestId('record-load-failed')).toBeNull());
164166
expect(new Set(getReads)).toEqual(new Set([`/api/v1/approvals/requests/${REQ}`]));

‎ui/content/docs/guide/slotted-pages.md‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,6 @@ if you'd authored nothing else. The default tab strip is **Details** → one
130130
tab per related list flagged `relatedList: 'primary'` on its relationship → a
131131
shared **Related** tab for the rest → **History**; so promoting a child table to
132132
its own tab is a one-word change on the relationship, not a page-authoring task.
133-
`relatedLayout: 'tabs' | 'stack'` remains an app-level override (force
134-
all-own-tabs / all-stacked).
135133

136134
That strip is the page at its narrow width. The console lays the same page
137135
out by the width of the view it occupies (the drawer, the split pane or the

‎ui/e2e/record-header-title-width.spec.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -574,16 +574,18 @@ const PAGE_HEADER = {
574574
'sm:flex-row',
575575
]),
576576
titleColumn: classBefore(containersSrc, CONTAINERS_FILE, '<RecordTitleChip', ['flex-col', 'min-w-0']),
577-
// The tail as it renders when the host wires `refresh` (the console does).
578-
tail: classAt(containersSrc, CONTAINERS_FILE, 'if (!refreshButton) return actions;', 1, ['shrink-0']),
577+
// The tail is `PageHeaderActions`' toolbar — the one row whether or not the
578+
// host wires `refresh`, which is the first item of its `⋯` menu since
579+
// objectstack#27055 (no separate `⟳` button).
580+
tail: classBefore(containersSrc, CONTAINERS_FILE, 'role="toolbar"', ['shrink-0']),
579581
chip: classByPattern(chipSrc, CHIP_FILE, /<div className=\{cn\('([^']+)', className\)\}>/, ['min-w-0']),
580582
chipRow: classBefore(chipSrc, CHIP_FILE, '{icon && (', ['flex-wrap']),
581583
// #1 after the anchor is the icon's own span; #2 is the h1, #3 the object label.
582584
h1: classAt(chipSrc, CHIP_FILE, '{icon && (', 2, ['truncate']),
583585
objectLabel: classAt(chipSrc, CHIP_FILE, '{icon && (', 3, ['shrink-0']),
584586
};
585587

586-
/** The mobile tail: one inline action, the `⋯` trigger and the `⟳` chrome. */
588+
/** The mobile tail: one inline action (`mobileMaxVisible` 1) and the `⋯` trigger, which carries refresh. */
587589
const PAGE_HEADER_TAIL_BOX = 'h-9 w-12 shrink-0';
588590

589591
const pageHeaderFixture = (title: string) =>
@@ -598,7 +600,7 @@ const pageHeaderFixture = (title: string) =>
598600
`<h1 class="${PAGE_HEADER.h1}">${title}</h1><span class="${PAGE_HEADER.objectLabel}">Case</span>`,
599601
),
600602
),
601-
) + box(PAGE_HEADER.tail, Array.from({ length: 3 }, () => box(PAGE_HEADER_TAIL_BOX)).join('')),
603+
) + box(PAGE_HEADER.tail, Array.from({ length: 2 }, () => box(PAGE_HEADER_TAIL_BOX)).join('')),
602604
);
603605

604606
const PAGE_HEADER_CANDIDATES = [...Object.values(PAGE_HEADER), PAGE_HEADER_TAIL_BOX]

‎ui/packages/app-shell/src/urlParams.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ export const RECORD_FORM_LINK_PARAM = 'formLink';
9797
/** Active record-detail tab (objectui#2257; stable semantic values). */
9898
export const RECORD_DETAIL_TAB_PARAM = 'tab';
9999

100+
/**
101+
* The record page's enlarged related list, `object.field` — the child object
102+
* and its FK field (objectstack#27055): the list drawn as a full-width table
103+
* over the page body. Read by `RecordDetailView` in the column tiers.
104+
*/
105+
export const RECORD_RELATED_ENLARGE_PARAM = 'related';
106+
100107
/** Command palette overlay (ADR-0054 Phase 1; alias `cmdk`). */
101108
export const COMMAND_PALETTE_PARAM = 'palette';
102109

@@ -138,6 +145,7 @@ export const RESERVED_URL_PARAMS: readonly string[] = [
138145
RECORD_FORM_OBJECT_PARAM,
139146
RECORD_FORM_LINK_PARAM,
140147
RECORD_DETAIL_TAB_PARAM,
148+
RECORD_RELATED_ENLARGE_PARAM,
141149
COMMAND_PALETTE_PARAM,
142150
KEYBOARD_SHORTCUTS_PARAM,
143151
RECORD_TRAIL_PARAM,

‎ui/packages/app-shell/src/utils/workspacePreference.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
*/
88

99
import { useCallback, useSyncExternalStore } from 'react';
10+
import {
11+
relatedArrangementFromRecord,
12+
relatedArrangementToRecord,
13+
type RelatedArrangement,
14+
} from '@object-ui/plugin-detail';
1015

1116
/**
1217
* The console's per-reader workspace preferences — how a reader likes the
@@ -151,6 +156,24 @@ export const RECORD_RELATED_COLUMN_PREFERENCE = defineWorkspacePreference<Prefer
151156
codec: switchCodec,
152157
});
153158

159+
/**
160+
* How the reader arranged the record page's related cards, per object
161+
* (scope `object:OBJECT_NAME`, objectstack#27055): `{ v, order, collapsed,
162+
* hidden, basedOn }` — plus `seen`, the first-seen stamps of relationships new
163+
* since the last drag (the 48 h 「新」 badge needs a clock). The value is a
164+
* DIFF against the default order; `resolveRelatedArrangement` in
165+
* `@object-ui/plugin-detail` lays it over the default. With no choice the
166+
* host passes `EMPTY_RELATED_ARRANGEMENT`: the default order, every card open.
167+
*/
168+
export const RECORD_RELATED_ARRANGEMENT_PREFERENCE = defineWorkspacePreference<RelatedArrangement>({
169+
name: 'record.related.arrangement',
170+
codec: versionedPreferenceCodec<RelatedArrangement>({
171+
version: 1,
172+
toRecord: relatedArrangementToRecord,
173+
fromRecord: relatedArrangementFromRecord,
174+
}),
175+
});
176+
154177
/** The storage surface a read takes — `localStorage`, or a test double. */
155178
export type WorkspacePreferenceReadStorage = Pick<Storage, 'getItem'>;
156179

‎ui/packages/app-shell/src/views/RecordDetailView.activityActorId-12067.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ describe('the record page feed names the actor of an id-only activity row (objec
149149
it('reads `sys_activity` with `actor_id` expanded and shows the user, not "System"', async () => {
150150
const dataSource = makeDataSource();
151151
render(
152-
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}`]}>
152+
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}?tab=timeline`]}>
153153
<MetadataCtx.Provider value={makeMetadata()}>
154154
<RecordDetailView
155155
dataSource={dataSource}

‎ui/packages/app-shell/src/views/RecordDetailView.activityMapIdentity-5878.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ function makeMetadata() {
180180

181181
function tree(dataSource: any) {
182182
return (
183-
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}`]}>
183+
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}?tab=timeline`]}>
184184
<MetadataCtx.Provider value={makeMetadata()}>
185185
<RecordDetailView
186186
dataSource={dataSource}

‎ui/packages/app-shell/src/views/RecordDetailView.activityUnmappedType-5896.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ function makeMetadata() {
204204
/** Render the console record page over the seeded `sys_activity` rows. */
205205
const renderWith = (rows: Array<Record<string, any>>) =>
206206
render(
207-
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}`]}>
207+
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}?tab=timeline`]}>
208208
<MetadataCtx.Provider value={makeMetadata()}>
209209
<RecordDetailView
210210
dataSource={makeDataSource(rows)}

0 commit comments

Comments
 (0)