Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/record-timeline-folded-types-from-spec.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
},
);
});
21 changes: 13 additions & 8 deletions ui/packages/plugin-detail/src/recordTimelineStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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<Record<string, string>> = {
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 {
Expand All @@ -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));
Expand Down
Loading