From 0e467929a3e3b01e5c947294bf8270198fe2b451 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 13:18:59 +0000 Subject: [PATCH] refactor(plugin-detail): pair timeline audit entries by the spec's folded activity types The record timeline stream kept a local audit action to activity type map that restated RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES from @objectstack/spec/api. Read the spec constant instead (own-key lookup, so an action outside it, including a prototype key, never pairs), and pin that the stream pairs exactly the entries the constant names. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L6bhgUR8Nu4M7tbtgES7q2 --- .../record-timeline-folded-types-from-spec.md | 10 ++++++ .../recordTimelineStream-27055.test.tsx | 32 +++++++++++++++++++ .../plugin-detail/src/recordTimelineStream.ts | 21 +++++++----- 3 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 .changeset/record-timeline-folded-types-from-spec.md diff --git a/.changeset/record-timeline-folded-types-from-spec.md b/.changeset/record-timeline-folded-types-from-spec.md new file mode 100644 index 00000000000..340708f92c9 --- /dev/null +++ b/.changeset/record-timeline-folded-types-from-spec.md @@ -0,0 +1,10 @@ +--- +'@object-ui/plugin-detail': patch +--- + +refactor(plugin-detail): the record timeline stream pairs audit entries with activity rows by the spec's `RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES` + +Clause-②: no +Metadata-protocol: none + +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. 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 17f0b2c6f66..b350a7fcb2d 100644 --- a/ui/packages/plugin-detail/src/__tests__/recordTimelineStream-27055.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/recordTimelineStream-27055.test.tsx @@ -20,6 +20,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { render, screen, cleanup, fireEvent, within } from '@testing-library/react'; import { DiscussionContextProvider } from '@object-ui/react'; import type { FeedItem } from '@object-ui/types'; +import { RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES } from '@objectstack/spec/api'; import { auditEntriesToFeedItems, filterTimelineStream, @@ -399,3 +400,34 @@ describe('one action, one row: an activity row carries its audit entry (objectst expect(container.querySelectorAll('[data-record-timeline-peek-row]')).toHaveLength(2); }); }); + +describe("the stream folds exactly the spec's RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES, the set the server's count steps aside", () => { + const at = (ms: number) => new Date(Date.UTC(2026, 10, 2, 9, 0, 0) + ms).toISOString(); + const row = (activityType: string) => + ({ id: 'row', type: 'field_change', actor: 'Dev Admin', body: 'Activity', createdAt: at(0), activityType, actorId: 'u1' }) as FeedItem; + const entry = (action: string) => + auditEntriesToFeedItems( + [{ id: 1, action, created_at: at(100), user_id: 'u1', user_name: 'Dev Admin', changes: [{ field: 'company', label: 'Company', from: 'A', to: 'B' }] }], + { unknownUser: 'Unknown' }, + ); + const pairs = (action: string, activityType: string) => + mergeTimelineStream([row(activityType)], entry(action)).some((item) => fieldHistoryOf(item)?.id === 'audit:1'); + const folded = Object.entries(RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES); + const foldedTypes = [...new Set(Object.values(RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES) as string[])]; + + it('the spec names the three write actions', () => { + expect(folded.map(([action]) => action).sort()).toEqual(['create', 'delete', 'update']); + }); + + it.each(folded)('the audit `%s` entry pairs with the activity `%s` row, and with no other folded type', (action, activityType) => { + expect(pairs(action, activityType)).toBe(true); + for (const other of foldedTypes.filter((t) => t !== activityType)) expect(pairs(action, other)).toBe(false); + }); + + it.each(['restore', 'unknown_action', 'toString', 'constructor', '__proto__', 'hasOwnProperty'])( + 'an audit `%s` entry, outside the spec constant, pairs with no activity row', + (action) => { + for (const activityType of foldedTypes) expect(pairs(action, activityType)).toBe(false); + }, + ); +}); diff --git a/ui/packages/plugin-detail/src/recordTimelineStream.ts b/ui/packages/plugin-detail/src/recordTimelineStream.ts index a406f0a324b..ca01922c2db 100644 --- a/ui/packages/plugin-detail/src/recordTimelineStream.ts +++ b/ui/packages/plugin-detail/src/recordTimelineStream.ts @@ -20,7 +20,7 @@ */ import * as React from 'react'; -import { RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE } from '@objectstack/spec/api'; +import { RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES, RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE } from '@objectstack/spec/api'; import type { FeedItem } from '@object-ui/types'; /** @@ -160,12 +160,17 @@ const timeOf = (item: FeedItem): number => { */ export const TIMELINE_PAIR_WINDOW_MS = 5000; -/** The ledger's write actions and the activity type the same action writes. */ -const AUDIT_ACTION_TO_ACTIVITY_TYPE: Readonly> = { - create: 'created', - update: RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE, - delete: 'deleted', -}; +/** + * The activity type the ledger write action `action` writes, or undefined for + * an action the spec does not fold (`restore`, an unknown action, a prototype + * key such as `toString`). The pairs are the spec's + * `RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES`, the same set the server's + * `timeline` count steps aside, so the badge and the drawn rows fold alike. + */ +function foldedActivityTypeOf(action: string | undefined): string | undefined { + if (!action || !Object.prototype.hasOwnProperty.call(RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES, action)) return undefined; + return RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES[action as keyof typeof RECORD_TIMELINE_FOLDED_ACTIVITY_TYPES]; +} /** One actor on both sides: the same user id, or, where neither names one, the same name. */ function sameActor(activity: FeedItem, audit: FeedItem & AuditFeedFacts): boolean { @@ -183,7 +188,7 @@ function sameActor(activity: FeedItem, audit: FeedItem & AuditFeedFacts): boolea * in ms, or -1. */ function pairGap(activity: FeedItem, audit: FeedItem & AuditFeedFacts): number { - const activityType = audit.auditAction ? AUDIT_ACTION_TO_ACTIVITY_TYPE[audit.auditAction] : undefined; + const activityType = foldedActivityTypeOf(audit.auditAction); if (!activityType || activity.activityType !== activityType || activity.parentId) return -1; if (!sameActor(activity, audit)) return -1; const gap = Math.abs(timeOf(activity) - timeOf(audit));