Skip to content

Commit df4c06e

Browse files
fix(plugin-detail): the activity feed names the record as the reader reads it when the object is named by a formula (#30438)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuMF4rV72CDoWfEYjfxmXjCu)_ domain:ui#6 · os-sales · session_01L6bhgVKbmZ6tBvw9qv8rir Fixes #29962 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:记录页的动态(活动面板、动态 tab、动态小面板)里,活动摘要引号中的记录名,现在按读者的语言显示,与记录页标题一致。只对「名称字段是公式」的对象生效(例如 `label(record.status) + ' · ' + record.name`)。 - **为什么改**:服务端在写入时把记录名按写入者语言存进 `sys_activity.record_label` 和 `summary`;读取时虽按读者语言重组整句,但记录名仍是写入者那份。中文用户创建的记录,英文读者看到 `Created Project "计划中 · Atlas Rollout"`,而标题是 `Active · Atlas Rollout`。 - **风险与回滚**:纯前端读时替换,不动数据、不动服务端、不动元数据。名称字段是普通文本的对象完全不变(改名不改写旧记录)。代价:公式名对象的旧动态显示的是记录「现在」的名字(例如创建时是「计划中」,现在显示「Active」),与标题同源。回滚即 revert 本 PR。 - **先看**:`ui/packages/plugin-detail/src/renderers/recordActivityFeed.ts` 的 `readerRecordNameOf` / `withReaderRecordName`(规则本身);`RecordActivityTimeline.tsx` 顶部那段 `useMemo`(唯一接入点);新测试文件。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/plugin-detail/src/__tests__/RecordActivityTimeline.readerRecordName-29962.test.tsx`,预期 14 条全绿;或看下方前后截图。 - **需要你做的**:无,席位落地。 ## What changes The record page's activity feed names the page record as the reader reads it, for an object whose name field is a formula. **This is the read-time ui half; no server read is changed** (triage 6612785923 asked the PR to say which side resolves the name). Measured first, how a row's text is composed: plugin-audit stores `summary` and `record_label` at write time; on read, `installActivitySummaryLocale` re-composes `summary` in the reader's language from the code stored in `metadata` (`activityCreated` / `activityUpdated` / `activityUpdatedFields` / `activityDeleted`), but the code's `label` parameter is the stored writer-language text, and the code is stripped from what a non-system reader is served. So the ui receives a reader-language sentence that quotes the writer's name, plus `record_label` (the same text) and `record_id`. - `activityRowToFeedItem` (`renderers/recordActivityFeed.ts`) carries the row's `record_id` and `record_label` onto the item as a package-local hand-off (`ActivityRecordFacts`, the same pattern as `recordTimelineStream.ts`'s `AuditFeedFacts`), so both readers of `sys_activity` (the record page's own feed read and the `record:activity` block's self-fetch) carry it. No `@object-ui/types` member is added. - `readerRecordNameOf(objectSchema, record, recordId)`: the value of the object's name field (`resolveNameField`, the ADR-0079 ladder plugin-audit titles the row by) on the record the page read, which is the H1's name, answered only when that field is a `formula`. - `withReaderRecordName(items, reader)`: for each item of the page record whose stored name the summary quotes (`"…"`, or `「…」` for ja-JP), replaces the first quoted occurrence with the reader's name. Returns the input array when nothing changes. - `RecordActivityTimeline` applies it once, at the top, from the `RecordContextProvider` it already reads for field labels, so the panel, the stream (动态 tab) and the peek (动态 panel) all draw the reader's name. ## Why hotclm names four child objects with a formula `display_name` over `label(record.FIELD)` (#26608), so toasts, related lists and the H1 follow the reader's locale; the activity panel did not. ## Tests `ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.readerRecordName-29962.test.tsx`, 14 tests, real subjects `activityRowToFeedItem` + `RecordActivityTimeline` under `RecordContextProvider`: - en-US reader of a zh-CN writer's row reads the H1's name, in the `panel`, `stream` and `peek` variants; a system update in zh-CN; a ja-JP `「…」` summary. - Kept as stored: an object named by a text field (a rename does not rewrite older rows), a row of another record, no record context, a record with an empty name. - Pure half: `readerRecordNameOf` answers formula name fields only; `readerRecordSummary` touches only the first quoted name (an unquoted field-change value equal to the name is left); `withReaderRecordName` returns the same array when nothing changes. **Positive control on main** (src files at `fdd06415`, test file kept): 9 failed / 5 passed; the 5 DOM tests that assert the reader's name are among the 9 (the other 4 are the pure tests, whose exports do not exist on main). **Ablation** (`scripts/ablation-replace.mjs`, anchor `readerRecordId !== undefined && readerRecordName !== undefined` → `false` in `RecordActivityTimeline.tsx`, anchor 1 → 0, restored to the HEAD blob, `git diff HEAD` empty): 5 failed / 9 passed, exactly the five reader-name DOM tests. The test file imports the timeline by relative path and the ui vitest config aliases `@object-ui/*` to source, so no `dist/` is involved. ## Acceptance notes - **Current name, not name-at-the-time, for formula names.** The replacement is the record's name now, as the H1 shows it (the issue's "resolve the name at read time when the object's name field is a formula"). A formula whose inputs changed since the row was written shows the current composition: in the evidence below, the `created` row reads `"Active · Atlas Rollout"` although the project was created `planned`. A stored (non-formula) name field is not touched, so a renamed record's older rows keep their old name, as before. - **Scope is the record page's feed.** Other surfaces that draw `sys_activity.summary` (a user's home/My Work feed in `app-shell`, Setup dashboards) still show the stored name; they have no page record to name it by. A fully per-reader name everywhere is the server half (store the record reference and render the label on read), `domain:server`'s, outside this card. - A fired milestone's author template is not quoted by the platform templates; if it quotes the stored name the same way, it is replaced too; otherwise left. - Branch name is the harness-assigned `claude/project-thread-d97x01`, not `claude/issue-29962-…` (Duplicate Fix Guard). The two `chore(evidence)` commits add and then remove the screenshots below; the squash merge carries neither. ## Verification Head `8f4210c8` (the fix is `736d4ace`; the two later commits add and remove evidence only, net diff identical). Screenshots, real stack (`examples/app-showcase` with a formula name patched in, `os dev --seed-admin`, the Console dev server proxied to it; zh-CN writer through REST, en-US browser reader; how it was rendered: `evidence/29962/README.md` at the evidence commit): | before (main behaviour, timeline wiring ablated) | after (this branch) | | --- | --- | | ![before](https://github.com/objectstack-ai/objectstack/blob/c86b862fb4208322b4a7642423309e2e253e9ad3/evidence/29962/before-en-US.png?raw=true) | ![after](https://github.com/objectstack-ai/objectstack/blob/c86b862fb4208322b4a7642423309e2e253e9ad3/evidence/29962/after-en-US.png?raw=true) | Served on that stack to an en-US reader: `summary` `Created Project "计划中 · Atlas Rollout"` with `record_label` `计划中 · Atlas Rollout`, and the record's `display_name` `Active · Atlas Rollout`. <details><summary>Local runs</summary> - `pnpm --dir ui exec vitest run packages/plugin-detail/src/__tests__/RecordActivityTimeline.readerRecordName-29962.test.tsx`: Test Files 1 passed, Tests 14 passed. - `pnpm --dir ui exec vitest run packages/plugin-detail/ --maxWorkers=2` (under the verify lock): Test Files 371 passed, 1 skipped; Tests 3311 passed, 18 skipped. VERDICT command-exit 0. - `pnpm --filter @object-ui/plugin-detail run type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): VERDICT command-exit 0. - `node scripts/pm/dispatch-gates.mjs --commands`: 35 derived families, every one run, each exit 0 (including `pnpm --dir ui exec vitest run scripts/`: 170 files passed, 2 skipped). `--ran` reconciliation: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. - Type-check CI lanes (`Type Check UI` and the workspace lanes) are CI's; not run locally beyond the package type-check above. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is CI's. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 section is the maintainer's Chinese summary). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgVKbmZ6tBvw9qv8rir --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgVKbmZ6tBvw9qv8rir)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3abe011 commit df4c06e

4 files changed

Lines changed: 357 additions & 6 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
---
4+
5+
fix(plugin-detail): the record page's activity feed names the record as the reader reads it, as the record's heading does, when the object is named by a formula
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
An activity summary quotes the record's name as its writer read it: `sys_activity.record_label`, and the same text inside `summary`, are stored when the write happens. The server composes the rest of the summary in the reader's language on read, but not the name. For an object whose name field is a formula over `label(record.FIELD)`, that name is in the writer's language, so an en-US reader of a review a zh-CN user created read `Created Review "法务 · Legal Counsel 1"` while the heading above it read `Legal · Legal Counsel 1`.
11+
12+
Each activity row of the page's own record now names the record by the value of the object's name field on the record the page read, which is the heading's name. The change is made where the feed is drawn (`RecordActivityTimeline`), so the activity panel, the 动态 stream and the 动态 panel all read it. Only the name the summary quotes (`"…"`, or `「…」` in Japanese) is replaced.
13+
14+
What stays as stored:
15+
16+
- an object whose name field is a stored field (text and the like): the name a row stored is the name the record had then, and a rename does not rewrite older rows;
17+
- a row about another record, a row that carries no `record_label`, and a summary that does not quote the stored name;
18+
- a page with no record around the feed, or whose record has no name to show.

‎ui/packages/plugin-detail/src/RecordActivityTimeline.tsx‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,12 @@ import { CommentBody } from './CommentAuthorControls';
4141
import { useDisplayLocale } from '@object-ui/i18n';
4242
import { useRecordContext, useSafeFieldLabel } from '@object-ui/react';
4343
import { useDetailTranslation } from './useDetailTranslation';
44-
import { foldFeedItems, type ActivityTimelineEntry } from './renderers/recordActivityFeed';
44+
import {
45+
foldFeedItems,
46+
readerRecordNameOf,
47+
withReaderRecordName,
48+
type ActivityTimelineEntry,
49+
} from './renderers/recordActivityFeed';
4550
import {
4651
TIMELINE_STREAM_FILTERS,
4752
fieldHistoryEntriesOf,
@@ -318,7 +323,7 @@ const FOLD_FIELDS_SHOWN = 3;
318323
* Aligned with @objectstack/spec RecordActivityProps.
319324
*/
320325
export const RecordActivityTimeline: React.FC<RecordActivityTimelineProps> = ({
321-
items,
326+
items: handedItems,
322327
config,
323328
filterMode: controlledFilter,
324329
onFilterChange,
@@ -368,6 +373,28 @@ export const RecordActivityTimeline: React.FC<RecordActivityTimelineProps> = ({
368373
// `<section>` below (objectui#9998).
369374
const title = titleLabel ?? t('detail.activity');
370375

376+
// The page's record (`RecordContextProvider`): its object schema labels a
377+
// changed field below, and its name is the one the summaries say.
378+
const recordContext = useRecordContext();
379+
// objectstack#29962 — an activity summary names the record as its WRITER
380+
// read it, stored when the write happened. For an object whose name field is
381+
// a formula (`label(record.FIELD)`) that is the writer's language, so each
382+
// row of this record names it as this reader reads it: the H1's name. Every
383+
// place below that draws a summary reads these items.
384+
const readerName = readerRecordNameOf(recordContext?.objectSchema, recordContext?.data, recordContext?.recordId);
385+
const readerRecordId = readerName?.recordId;
386+
const readerRecordName = readerName?.name;
387+
const items = React.useMemo(
388+
() =>
389+
withReaderRecordName(
390+
handedItems,
391+
readerRecordId !== undefined && readerRecordName !== undefined
392+
? { recordId: readerRecordId, name: readerRecordName }
393+
: null,
394+
),
395+
[handedItems, readerRecordId, readerRecordName],
396+
);
397+
371398
// objectui#11195: which of the feed's reads the server REFUSED, one line per
372399
// refused source. It renders in place of the empty state when there are no
373400
// rows, and above the rows when there are some. Visually distinct from the
@@ -503,7 +530,6 @@ export const RecordActivityTimeline: React.FC<RecordActivityTimelineProps> = ({
503530
// A changed field's label: the object's field label where the page has the
504531
// object schema (`RecordContextProvider`), through the same translated
505532
// `fieldLabel` the record's own sections use; the machine name otherwise.
506-
const recordContext = useRecordContext();
507533
const { fieldLabel } = useSafeFieldLabel();
508534
const labelOfField = React.useCallback(
509535
(name: string): string => {
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The activity feed names the page record as the READER reads it
11+
* (objectstack#29962).
12+
*
13+
* Found by an app whose child objects are named by a formula `display_name`
14+
* over `label(record.FIELD)`: the record H1 follows the reader's locale, the
15+
* activity panel did not. plugin-audit stores `sys_activity.record_label` (and
16+
* interpolates the same text, quoted, into `summary`) when the write happens,
17+
* in the writer's language; the server composes the rest of the summary in
18+
* the reader's language on read, but the name stays the writer's. So an en-US
19+
* reader of a review a zh-CN user created read
20+
* `Created Review "法务 · Legal Counsel 1"`.
21+
*
22+
* Real subjects: `activityRowToFeedItem` (the mapper the record page and the
23+
* `record:activity` block both use) and `RecordActivityTimeline` under the
24+
* page's `RecordContextProvider`. The rows are `sys_activity` rows as the
25+
* reader is served them: `summary` in the reader's language around the
26+
* writer's name, `record_label` the writer's name.
27+
*/
28+
29+
import * as React from 'react';
30+
import { describe, it, expect, afterEach } from 'vitest';
31+
import { render, screen, cleanup } from '@testing-library/react';
32+
import type { FeedItem } from '@object-ui/types';
33+
import { RecordContextProvider } from '@object-ui/react';
34+
import { RecordActivityTimeline } from '../RecordActivityTimeline';
35+
import {
36+
activityRowToFeedItem,
37+
readerRecordNameOf,
38+
readerRecordSummary,
39+
withReaderRecordName,
40+
type SysActivityRow,
41+
} from '../renderers/recordActivityFeed';
42+
43+
afterEach(cleanup);
44+
45+
/** A child object named by a formula over `label(...)`, as the app declares it. */
46+
const REVIEW_SCHEMA = {
47+
name: 'clm_review',
48+
label: 'Review',
49+
nameField: 'display_name',
50+
fields: {
51+
display_name: { name: 'display_name', type: 'formula', label: 'Name' },
52+
role: { name: 'role', type: 'select', label: 'Role' },
53+
reviewer: { name: 'reviewer', type: 'text', label: 'Reviewer' },
54+
},
55+
};
56+
57+
/** The same object named by a stored text field. */
58+
const PLAIN_SCHEMA = {
59+
...REVIEW_SCHEMA,
60+
fields: { ...REVIEW_SCHEMA.fields, display_name: { name: 'display_name', type: 'text', label: 'Name' } },
61+
};
62+
63+
const WRITER_NAME = '法务 · Legal Counsel 1';
64+
const READER_NAME = 'Legal · Legal Counsel 1';
65+
66+
/** The review as the en-US reader's page read it: the formula composed in the reader's language. */
67+
const READER_RECORD = { id: 'rv-1', display_name: READER_NAME, role: 'legal', reviewer: 'Legal Counsel 1' };
68+
69+
function row(over: Partial<SysActivityRow> = {}): SysActivityRow {
70+
return {
71+
id: 'act-1',
72+
type: 'created',
73+
summary: `Created Review "${WRITER_NAME}"`,
74+
timestamp: '2026-11-03T08:00:00.000Z',
75+
actor_id: { id: 'u-li', name: 'Li Wei' },
76+
object_name: 'clm_review',
77+
record_id: 'rv-1',
78+
record_label: WRITER_NAME,
79+
...over,
80+
};
81+
}
82+
83+
function itemsOf(...rows: SysActivityRow[]): FeedItem[] {
84+
return rows.map((r) => activityRowToFeedItem(r, 'System')).filter((i): i is FeedItem => i !== null);
85+
}
86+
87+
function mount(
88+
items: FeedItem[],
89+
opts: { schema?: unknown; record?: unknown; variant?: 'panel' | 'stream' | 'peek' } = {},
90+
) {
91+
return render(
92+
<RecordContextProvider
93+
objectName="clm_review"
94+
recordId="rv-1"
95+
objectSchema={opts.schema ?? REVIEW_SCHEMA}
96+
data={opts.record ?? READER_RECORD}
97+
>
98+
<RecordActivityTimeline items={items} variant={opts.variant} />
99+
</RecordContextProvider>,
100+
);
101+
}
102+
103+
describe('the activity feed names the record as the reader reads it (objectstack#29962)', () => {
104+
for (const variant of ['panel', 'stream', 'peek'] as const) {
105+
it(`an en-US reader of a row a zh-CN user wrote reads the H1's name (${variant})`, () => {
106+
const { container } = mount(itemsOf(row()), { variant });
107+
expect(container.textContent).toContain(`Created Review "${READER_NAME}"`);
108+
expect(container.textContent).not.toContain(WRITER_NAME);
109+
});
110+
}
111+
112+
it("a system update's row names the record in the reader's language", () => {
113+
const { container } = mount(
114+
itemsOf(
115+
row({
116+
id: 'act-2',
117+
type: 'updated',
118+
actor_id: null,
119+
summary: '更新了 合同版本 "v6 · Clean"',
120+
record_label: 'v6 · Clean',
121+
}),
122+
),
123+
{ record: { ...READER_RECORD, display_name: 'v6 · 清洁稿' } },
124+
);
125+
expect(container.textContent).toContain('更新了 合同版本 "v6 · 清洁稿"');
126+
expect(container.textContent).not.toContain('v6 · Clean');
127+
});
128+
129+
it('a ja-JP summary quotes the name in 「」 and is answered the same way', () => {
130+
const { container } = mount(itemsOf(row({ summary: `レビュー「${WRITER_NAME}」を作成しました` })));
131+
expect(container.textContent).toContain(`レビュー「${READER_NAME}」を作成しました`);
132+
});
133+
134+
it('an object named by a stored field keeps the stored name: a rename does not rewrite older rows', () => {
135+
const { container } = mount(itemsOf(row({ summary: 'Created Review "Old name"', record_label: 'Old name' })), {
136+
schema: PLAIN_SCHEMA,
137+
record: { ...READER_RECORD, display_name: 'New name' },
138+
});
139+
expect(container.textContent).toContain('Created Review "Old name"');
140+
expect(container.textContent).not.toContain('New name');
141+
});
142+
143+
it("a row about another record keeps that record's name", () => {
144+
const { container } = mount(itemsOf(row({ record_id: 'rv-2', summary: 'Created Review "Other"', record_label: 'Other' })));
145+
expect(container.textContent).toContain('Created Review "Other"');
146+
});
147+
148+
it('a timeline with no record around it draws the summary as served', () => {
149+
const { container } = render(<RecordActivityTimeline items={itemsOf(row())} />);
150+
expect(container.textContent).toContain(`Created Review "${WRITER_NAME}"`);
151+
});
152+
153+
it('a page whose record has no name to show keeps the stored one', () => {
154+
const { container } = mount(itemsOf(row()), { record: { ...READER_RECORD, display_name: '' } });
155+
expect(container.textContent).toContain(`Created Review "${WRITER_NAME}"`);
156+
});
157+
158+
it('the summary is the only text touched: actor and activity row stay as served', () => {
159+
mount(itemsOf(row()));
160+
expect(screen.getAllByText('Li Wei').length).toBeGreaterThan(0);
161+
});
162+
});
163+
164+
describe('the pure half (objectstack#29962)', () => {
165+
it('readerRecordNameOf answers a formula name field only, on the page record', () => {
166+
expect(readerRecordNameOf(REVIEW_SCHEMA, READER_RECORD, 'rv-1')).toEqual({ recordId: 'rv-1', name: READER_NAME });
167+
expect(readerRecordNameOf(PLAIN_SCHEMA, READER_RECORD, 'rv-1')).toBeNull();
168+
expect(readerRecordNameOf(REVIEW_SCHEMA, READER_RECORD, null)).toBeNull();
169+
expect(readerRecordNameOf(undefined, READER_RECORD, 'rv-1')).toBeNull();
170+
expect(readerRecordNameOf(REVIEW_SCHEMA, undefined, 'rv-1')).toBeNull();
171+
});
172+
173+
it('readerRecordSummary replaces the first quoted stored name and nothing else', () => {
174+
expect(readerRecordSummary('Updated Review "A": Name', 'A', 'B')).toBe('Updated Review "B": Name');
175+
// Unquoted text equal to the name (a field-change value) is not the name.
176+
expect(readerRecordSummary('Name: A → C', 'A', 'B')).toBe('Name: A → C');
177+
expect(readerRecordSummary('Created Review "A"', 'A', 'A')).toBe('Created Review "A"');
178+
});
179+
180+
it('withReaderRecordName hands back the same feed when no row changes', () => {
181+
const items = itemsOf(row({ summary: `Created Review "${READER_NAME}"`, record_label: READER_NAME }));
182+
expect(withReaderRecordName(items, { recordId: 'rv-1', name: READER_NAME })).toBe(items);
183+
expect(withReaderRecordName(items, null)).toBe(items);
184+
});
185+
186+
it('a row with no stored record columns carries nothing to replace', () => {
187+
const items = itemsOf(row({ record_id: undefined, record_label: undefined }));
188+
expect(withReaderRecordName(items, { recordId: 'rv-1', name: READER_NAME })).toBe(items);
189+
});
190+
});

0 commit comments

Comments
 (0)