Skip to content

Commit 8a0516c

Browse files
refactor(plugin-detail): pair timeline audit entries by the spec's folded activity types (#30517)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuXpPPZiBKyj1HzrdQ6La3ZH)_ domain:ui#6 · os-sales · session_01L6bhgUR8Nu4M7tbtgES7q2 Fixes #30408 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:记录页「动态」流配对审计条目与活动行时,不再用组件内自抄的映射表,改读 spec 的 `RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES`。 - 为什么改:服务端计数(动态角标)已按这个 spec 常量折叠,客户端另抄一份,两边一旦只改一处,角标数与实际画出的行数会再次对不上。 - 风险与回滚:无行为变化,仍是 create/update/delete 三对;回滚即 revert 本 PR,仅涉及一个 ui 文件与其测试。 - 先看:`ui/packages/plugin-detail/src/recordTimelineStream.ts` 的 `foldedActivityTypeOf`,再看同包测试文件末尾新增的 describe。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-detail/src/__tests__/recordTimelineStream-27055.test.tsx`,预期 42 个测试全部通过。 - 需要你做的:无,席位落地。 ## What changes - `ui/packages/plugin-detail/src/recordTimelineStream.ts`: the local `AUDIT_ACTION_TO_ACTIVITY_TYPE` map is gone. `pairGap` now asks `foldedActivityTypeOf(action)`, which reads `RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES` from `@objectstack/spec/api` (the module the file already imported `RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE` from) through an own-key check, so an action outside the constant (`restore`, an unknown action, a prototype key such as `toString`) pairs with nothing. - One root changeset: `@object-ui/plugin-detail` patch. - No `packages/**` change. ## Tests A new top-level describe in `recordTimelineStream-27055.test.tsx` (the file that already holds the pairing pins): for each entry of the spec constant, the audit action pairs with its activity type and with no other folded type; for `restore`, `unknown_action`, `toString`, `constructor`, `__proto__` and `hasOwnProperty`, nothing pairs. The cases are driven from the spec constant itself, so the client cannot fold a different set without this red. ## Acceptance notes - The positive control dropped the client's `delete` pairing; only the new pin went red. No earlier pin covered the `delete` pair. - The own-key guard has no red-capable control of its own: with the old lookup, a prototype key resolves to a function, which never equals an activity type string, so it could not pair either. The guard is defensive and the prototype-key cases pin the outcome, not the guard. ## Verification All readings at head `0e467929a3`, worktree on base `a1d0b1ee9e`. <details><summary>Package tests and type-check (plugin-detail)</summary> - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-detail/src/__tests__/recordTimelineStream-27055.test.tsx`: Test Files 1 passed (1), Tests 42 passed (42). - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-detail/`: Test Files 371 passed, 1 skipped (372); Tests 3328 passed, 18 skipped (3346); exit 0. - `pnpm --filter @object-ui/plugin-detail type-check` (both tsc legs): exit 0. </details> <details><summary>Positive control (ablation)</summary> - Mutation via `node scripts/ablation-replace.mjs` on `recordTimelineStream.ts`: anchor ` return RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES[` replaced so `delete` returns undefined; anchor 1 to 0, marker 0 to 1, blob `ca01922c2dbc` to `5837636a3f05`. - Run: Tests 1 failed, 41 passed (42); the failing case is the audit `delete` entry pairs with the activity `deleted` row. `VERDICT command-exit 1`. - Restore: blob after restore `ca01922c2dbc` equals the HEAD blob, `git diff HEAD` empty. - A first attempt with an anchor the replacement also contained was refused by the tool before any run (anchor count did not drop); it measured nothing. </details> <details><summary>Derived gates (dispatch-gates --commands, 35 families)</summary> - All 35 commands exit 0, including `pnpm --dir ui exec vitest run scripts/` (Test Files 170 passed, 2 skipped; Tests 4985 passed, 2 skipped). - `node scripts/pm/dispatch-gates.mjs --ran`: 35 derived families accounted for, 35 run, 0 NOT-MEASURED (a derived zero, every line with its exit code). - Not run locally: the repo-wide `pnpm lint` and the full CI farm, which CI owns. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgUR8Nu4M7tbtgES7q2 <!-- signed: domain:ui#6 · os-sales · session_01L6bhgUR8Nu4M7tbtgES7q2 --> Co-authored-by: Claude <noreply@anthropic.com>
1 parent b13fc74 commit 8a0516c

3 files changed

Lines changed: 55 additions & 8 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
---
4+
5+
refactor(plugin-detail): the record timeline stream pairs audit entries with activity rows by the spec's `RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The 动态 stream used to keep its own copy of the audit action to activity type pairs (`create`/`created`, `update`/`updated`, `delete`/`deleted`). It now reads `RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES` from `@objectstack/spec/api`, the same set the server's `timeline` count steps aside, so the badge and the drawn rows cannot fold different types. No behaviour change: the same three pairs fold, and any other audit action (`restore`, an unknown action) pairs with no activity row.

‎ui/packages/plugin-detail/src/__tests__/recordTimelineStream-27055.test.tsx‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { describe, it, expect, afterEach } from 'vitest';
2020
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';
2121
import { DiscussionContextProvider } from '@object-ui/react';
2222
import type { FeedItem } from '@object-ui/types';
23+
import { RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES } from '@objectstack/spec/api';
2324
import {
2425
auditEntriesToFeedItems,
2526
filterTimelineStream,
@@ -399,3 +400,34 @@ describe('one action, one row: an activity row carries its audit entry (objectst
399400
expect(container.querySelectorAll('[data-record-timeline-peek-row]')).toHaveLength(2);
400401
});
401402
});
403+
404+
describe("the stream folds exactly the spec's RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES, the set the server's count steps aside", () => {
405+
const at = (ms: number) => new Date(Date.UTC(2026, 10, 2, 9, 0, 0) + ms).toISOString();
406+
const row = (activityType: string) =>
407+
({ id: 'row', type: 'field_change', actor: 'Dev Admin', body: 'Activity', createdAt: at(0), activityType, actorId: 'u1' }) as FeedItem;
408+
const entry = (action: string) =>
409+
auditEntriesToFeedItems(
410+
[{ id: 1, action, created_at: at(100), user_id: 'u1', user_name: 'Dev Admin', changes: [{ field: 'company', label: 'Company', from: 'A', to: 'B' }] }],
411+
{ unknownUser: 'Unknown' },
412+
);
413+
const pairs = (action: string, activityType: string) =>
414+
mergeTimelineStream([row(activityType)], entry(action)).some((item) => fieldHistoryOf(item)?.id === 'audit:1');
415+
const folded = Object.entries(RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES);
416+
const foldedTypes = [...new Set(Object.values(RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES) as string[])];
417+
418+
it('the spec names the three write actions', () => {
419+
expect(folded.map(([action]) => action).sort()).toEqual(['create', 'delete', 'update']);
420+
});
421+
422+
it.each(folded)('the audit `%s` entry pairs with the activity `%s` row, and with no other folded type', (action, activityType) => {
423+
expect(pairs(action, activityType)).toBe(true);
424+
for (const other of foldedTypes.filter((t) => t !== activityType)) expect(pairs(action, other)).toBe(false);
425+
});
426+
427+
it.each(['restore', 'unknown_action', 'toString', 'constructor', '__proto__', 'hasOwnProperty'])(
428+
'an audit `%s` entry, outside the spec constant, pairs with no activity row',
429+
(action) => {
430+
for (const activityType of foldedTypes) expect(pairs(action, activityType)).toBe(false);
431+
},
432+
);
433+
});

‎ui/packages/plugin-detail/src/recordTimelineStream.ts‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
*/
2121

2222
import * as React from 'react';
23-
import { RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE } from '@objectstack/spec/api';
23+
import { RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES, RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE } from '@objectstack/spec/api';
2424
import type { FeedItem } from '@object-ui/types';
2525

2626
/**
@@ -160,12 +160,17 @@ const timeOf = (item: FeedItem): number => {
160160
*/
161161
export const TIMELINE_PAIR_WINDOW_MS = 5000;
162162

163-
/** The ledger's write actions and the activity type the same action writes. */
164-
const AUDIT_ACTION_TO_ACTIVITY_TYPE: Readonly<Record<string, string>> = {
165-
create: 'created',
166-
update: RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE,
167-
delete: 'deleted',
168-
};
163+
/**
164+
* The activity type the ledger write action `action` writes, or undefined for
165+
* an action the spec does not fold (`restore`, an unknown action, a prototype
166+
* key such as `toString`). The pairs are the spec's
167+
* `RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES`, the same set the server's
168+
* `timeline` count steps aside, so the badge and the drawn rows fold alike.
169+
*/
170+
function foldedActivityTypeOf(action: string | undefined): string | undefined {
171+
if (!action || !Object.prototype.hasOwnProperty.call(RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES, action)) return undefined;
172+
return RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES[action as keyof typeof RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES];
173+
}
169174

170175
/** One actor on both sides: the same user id, or, where neither names one, the same name. */
171176
function sameActor(activity: FeedItem, audit: FeedItem & AuditFeedFacts): boolean {
@@ -183,7 +188,7 @@ function sameActor(activity: FeedItem, audit: FeedItem & AuditFeedFacts): boolea
183188
* in ms, or -1.
184189
*/
185190
function pairGap(activity: FeedItem, audit: FeedItem & AuditFeedFacts): number {
186-
const activityType = audit.auditAction ? AUDIT_ACTION_TO_ACTIVITY_TYPE[audit.auditAction] : undefined;
191+
const activityType = foldedActivityTypeOf(audit.auditAction);
187192
if (!activityType || activity.activityType !== activityType || activity.parentId) return -1;
188193
if (!sameActor(activity, audit)) return -1;
189194
const gap = Math.abs(timeOf(activity) - timeOf(audit));

0 commit comments

Comments
 (0)