Skip to content

Commit ff3f83b

Browse files
fix(ui): one record action reads as one timeline row with its field changes; every timeline peek row names its actor (#30342)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBVmFoQ6NLiPf5VfPaWXaynQ)_ Fixes #30122 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** 记录动态里,一个操作同时写下的「活动记录」和「审计记录」现在合成一行:这一行保留谁做的和做了什么(「更新了 线索…:公司 · Dev Admin」),下面直接带上改前→改后的值;看不到审计日志的成员看到的也是同一行,只是没有改前改后值。右栏动态预览的每一行也显示操作人了。 - **为什么改:** 卡片的期望是「每行都说清是谁;一个操作对所有人都只是一行,有权限的人在这一行上看到字段差异」。实测:流内紧凑行早已显示操作人,缺的只是右栏预览行;重复两行是真的(新建一条线索,管理员看到「创建了…」和一长串初始值两行)。 - **风险与回滚:** 风险低:只改 plugin-detail 的前端合并与显示,不改数据、接口、spec,`ACTIVITY_TYPE_TO_FEED_TYPE` 未动,裁决 C(「变更」只放审计条目)不变。配对规则:同一记录、动作对应(create↔created、update↔updated、delete↔deleted)、同一操作人、时间相差不超过 5 秒,就近配对;配不上的照旧各自一行。回滚即撤销本 PR。注意:页签上的「动态 N」数字来自服务端计数,新建一条有字段历史的记录后仍可能比显示的行数多 1,要等另一张卡改 spec 与 REST。 - **先看:** `ui/packages/plugin-detail/src/recordTimelineStream.ts`(`pairActions` 与 `mergeTimelineStream`)、`ui/packages/plugin-detail/src/RecordActivityTimeline.tsx`(合并行的显示与预览行的操作人)、`ui/packages/plugin-detail/src/__tests__/recordTimelineStream-27055.test.tsx`(新 pin)。 - **两分钟验证:** `pnpm --dir ui exec vitest run packages/plugin-detail/src/__tests__/recordTimelineStream-27055.test.tsx`,预期全部通过;或 `pnpm dev:crm` 后新建一条线索、改公司名,打开动态页签:「全部」里只有两行(创建了、更新了),每行下面有改前→改后。 - **需要你做的:** 无,席位落地。 ## What changes - **One action, one row.** `mergeTimelineStream` (`recordTimelineStream.ts`) now pairs an activity row with the audit entry the same action wrote, and draws them as ONE row: the activity row, carrying the audit entry as `fieldHistory` (a package-local hand-off type, `TimelineStreamItem`). The row keeps its actor and summary; `RecordActivityTimeline` draws the audit entry's before and after values on a second line of the same row (`data-stream-change-diff`). - **The pairing rule** (`pairActions`, `pairGap`): same record (both sources are read for this one record); matching action kind, audit `create` / `update` / `delete` to activity `created` / `updated` / `deleted`; same actor (the audit's `user_id` equals the activity's `actor_id`; where neither names a user, the same display name); stamps no more than `TIMELINE_PAIR_WINDOW_MS` = 5000 ms apart. Candidates pair nearest in time first, each row and each entry once. Measured on a live CRM boot: activity `timestamp` and audit `created_at` of one create, and of one update, were 1 ms apart, with the same user id; plugin-audit writes both in one call. `auditEntriesToFeedItems` carries the ledger's `action` and `user_id` onto its items for this; the host already spread `user_id` into its enriched entries, so `RecordDetailView` is unchanged. - **Unpaired rows draw as before**: an audit entry with no activity row is its own row, and with field changes present an unpaired `updated` activity row still steps aside (the old blanket rule, now applied only to rows that did not pair). - **Chips (ruling C on #30074 unchanged).** 全部 and 活动 show the paired row with its diff; 变更, and the panel's `changes_only`, show each audit entry on its own (`fieldHistoryEntriesOf`). A reader without audit access gets no field changes from the host, so nothing pairs and the activity row draws alone, as before. - **Actor.** Measured first: the stream's compact rows already name the actor on `main` (and did on `987ff33b88`); full comment rows do too. The one face without a visible actor was the 动态 panel's peek row (actor only in the hover title). It now shows the actor beside the time (`data-record-timeline-peek-actor`). - Changeset `.changeset/30122-timeline-one-row-per-action.md` (`@object-ui/plugin-detail` patch); plugin-detail README says one action is one row. ## Tests All readings on head `0b041cec`, the final commit. - **Pairing pins** (new top-level describe in `recordTimelineStream-27055.test.tsx`): create pairs into one row and 变更 still shows the audit entry alone; update pairs; unpaired audit and unpaired activity rows stay two rows; different actors do not pair; one past the window does not pair and the window's edge does; a different action kind does not pair; two entries for one row pair nearest first; the system on both sides pairs by name. - **Renderer pins:** an audit reader sees ONE compact row with the summary, the actor and the diff line; a reader without the audit log sees the same one row with no diff line; 变更 shows the audit diff on its own row; every peek row names its actor. - The ruling C pins from #30294 are unchanged and green (the four chips, activity under 活动 with history on and off, panel `changes_only`). - **Ablation** via `scripts/ablation-replace.mjs` on `recordTimelineStream.ts`, anchor `const pairs = pairActions(activity, changes);` replaced by an empty map (the subject resolves to `src` through the `ui/vitest.config.mts` aliases, no dist leg): anchor 1 to 0, blob `a406f0a324ba` to `b2e7fb7be1e8`; 6 tests red, exactly the six pins that need a pair (create, update, window edge, nearest first, system by name, the audit reader's one row); the unpaired, different-actor, non-audit-reader, 变更 and peek pins green. Restored: blob equals HEAD `a406f0a324ba`, `git diff HEAD` empty. - **Live** (example CRM, `objectstack dev --seed-admin --fresh`, Vite console, zh-CN). As admin: create a lead, then change its company. 全部 draws two rows: 「更新了 线索 "Live after Lead":公司 · Dev Admin · 刚刚」 with 「公司 Pair Co → Pair Holdings」 under it, and 「创建了 线索 "Live after Lead" · Dev Admin · 刚刚」 with the initial values under it; 变更 shows the two audit entries on their own; the peek rows read summary, actor, time. As a member granted `crm_sales_user` plus a scratch set reading `sys_activity` and `sys_comment` (their `sys_audit_log` read answers 403 `PERMISSION_DENIED`): 全部 draws the same two rows with no diff, peek rows name the actor. Screenshots are kept in the project files, not committed. <details><summary>Test readings</summary> - Targeted, the three timeline test files: Test Files 3 passed (3), Tests 80 passed (80) after the nearest-first fix (the first run caught a greedy-order pairing: 1 red, fixed in `0b041cec`). - `@object-ui/plugin-detail` whole package plus `RecordDetailView.recordPageTiers-26678.test.tsx`: Test Files 370 passed, 1 skipped (371); Tests 3303 passed, 18 skipped (3321); lock VERDICT command-exit 0. - Type-check: `@object-ui/plugin-detail` exit 0; `@object-ui/app-shell` exit 0 after a full build (both `tsc --noEmit` and `tsc -p tsconfig.test.json`). - Ablation run: Test Files 1 failed, 2 passed (3); Tests 6 failed, 74 passed (80). </details> ## Acceptance notes - **The 动态 N badge can still read one more than the rows drawn.** The tab badge, the panel's 「查看全部 N 条」 and the peek total read the server's `timeline` count (`packages/rest/src/record-page-routes.ts`, `answerTimeline`: comments + audit writes + drawn activity, minus `updated` rows when any field change exists), whose formula the spec spells as an upper bound (`packages/spec/src/api/record-page.zod.ts`, the `RecordPageTimelineRequestSchema` table and `RECORD_TIMELINE_UPDATE_ACTIVITY_TYPE`). Live: one create plus one update read 「动态 3」 for the admin over two drawn rows (the member, who counts no audit, read 「动态 2」). Counts the client makes itself (the stream's own header count and the count probe where the host has no total) now count one per action. Options for the follow-up card: (A) let `created` and `deleted` step aside too, beside `updated`, when any field change is counted, in the spec table, its `.describe()` text and `answerTimeline`; (B) count only activity rows when both sources are on and ignore audit writes, dropping unpaired audit entries from the count; (C) leave the upper bound. The seat files it as its own card. - **A reader without audit access still sees an empty 变更 chip** on a history-on object: the host's `historyEnabled` reads `enable.trackHistory` and whether `sys_audit_log` is registered, not whether this reader may read it (live: the member's 变更 was empty). Not changed here. - Window choice: 5 s is far above the 1 ms measured and allows a driver that keeps whole seconds; two different actions by the same user on the same record within 5 s of each other still pair correctly because pairing also needs the matching kind and goes nearest first. - No other open PR names `recordTimelineStream` or `RecordActivityTimeline` (search at PR time: 0 and 0). ## Verification Gates from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, derived at `0b041cec` (35 commands). <details><summary>Gate readings</summary> - 35 of 35 run serially after a full build, each exit code captured before any pipe: all 35 exit 0. `--ran`: "35 derived famil(ies) accounted for — 35 run, 0 NOT-MEASURED (a DERIVED zero — all 35 recorded an exit code and none of them is 3)". - `node scripts/check-sdui-manifest.mjs` exit 0; `pnpm --dir ui exec vitest run scripts/`: Test Files 170 passed, 2 skipped (172); Tests 4985 passed, 2 skipped. - `pnpm --dir ui run check:new-line-citations`: 0 new citations, exit 0. - `check-platform-checklist` not owed: nothing renamed or removed (new module members only; none added to the package entry). - `main` moved 1 commit after the merge base by PR time; it touches no path in this diff and `git merge-tree` is clean. CI runs the merge ref. - Not run locally, CI's: Build Core, Type Check UI and the workspace type-check lanes, Test Core, Test UI (whole app-shell), the repo-wide lint. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzic59DvGAbu7U78pZt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 31d23c3 commit ff3f83b

5 files changed

Lines changed: 278 additions & 23 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
---
4+
5+
fix(record page): one action reads as one timeline row, and every row of the timeline panel names who acted
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **One row per action.** When one action wrote both an activity row and an audit-log entry (same record, matching kind: create and created, update and updated, delete and deleted; same actor; stamped within `TIMELINE_PAIR_WINDOW_MS`, 5 seconds), the record page's timeline draws it once: the activity row keeps its actor and summary and shows the field changes, before and after, on the same row. A reader who cannot read the audit log sees the same single row without them. The Changes chip, and the panel's `changes_only`, still show each audit entry on its own. Rows that pair with nothing draw as before.
11+
- **The 动态 panel's peek rows name the actor**, beside the time.
12+
- The record page's 动态 count still comes from the server's `timeline` count, which the spec defines as an upper bound: for a record created with field history on it can still read one more than the rows drawn.

‎ui/packages/plugin-detail/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -819,7 +819,9 @@ newest entries and 「查看全部 N 条 →」, which opens the stream in full
819819
All / Activity / Discussion chips, a Changes chip while the record shows its
820820
field history (the host's `RecordFieldHistoryContext`; it holds the audit's
821821
field changes with their before and after values, and every activity event,
822-
`created` and `updated` included, sits under Activity), and paging
822+
`created` and `updated` included, sits under Activity; one action that wrote
823+
both an activity row and an audit entry is one row, its before and after values
824+
on it), and paging
823825
(`?related=timeline`) — then each related list in the reader's arrangement, then Approvals and
824826
Attachments. The reader's ⊟ (`rightColumn`) takes the right column out of the
825827
two-column tiers; there is no tabbed tier.

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

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@ import { useDetailTranslation } from './useDetailTranslation';
4444
import { foldFeedItems, type ActivityTimelineEntry } from './renderers/recordActivityFeed';
4545
import {
4646
TIMELINE_STREAM_FILTERS,
47+
fieldHistoryEntriesOf,
48+
fieldHistoryOf,
4749
filterTimelineStream,
48-
isFieldHistoryEntry,
4950
isSystemStreamEntry,
5051
type TimelineStreamFilter,
5152
} from './recordTimelineStream';
@@ -288,7 +289,7 @@ function filterItems(items: FeedItem[], mode: FeedFilterMode): FeedItem[] {
288289
// The stream's 变更 rule (objectstack#30074, ruling C): the audit's field
289290
// changes only, so the panel and the stream agree on what a change is.
290291
case 'changes_only':
291-
return items.filter(isFieldHistoryEntry);
292+
return fieldHistoryEntriesOf(items);
292293
case 'tasks_only':
293294
return items.filter((i) => i.type === 'task');
294295
default:
@@ -583,15 +584,21 @@ export const RecordActivityTimeline: React.FC<RecordActivityTimelineProps> = ({
583584
// same function when expanded (objectstack#25959).
584585
// objectstack#27055 — a system entry of the stream: ONE muted line, the
585586
// change itself, then who and when. 「状态 新建 → 已联系 · Dev Admin · 昨天」.
587+
const changesText = (item: FeedItem): string =>
588+
(item.fieldChanges ?? [])
589+
.map((change) => {
590+
const label = change.fieldLabel || labelOfField(change.field);
591+
const from = change.oldDisplayValue ?? (change.oldValue == null ? '' : String(change.oldValue));
592+
const to = change.newDisplayValue ?? (change.newValue == null ? '' : String(change.newValue));
593+
return `${label} ${from || '—'} → ${to || '—'}`;
594+
})
595+
.join('; ');
596+
// An activity row paired with its audit entry (objectstack#30122) reads as
597+
// its own summary; the before and after values sit on the same row.
586598
const systemLineText = (item: FeedItem): string => {
587-
const changes = (item.fieldChanges ?? []).map((change) => {
588-
const label = change.fieldLabel || labelOfField(change.field);
589-
const from = change.oldDisplayValue ?? (change.oldValue == null ? '' : String(change.oldValue));
590-
const to = change.newDisplayValue ?? (change.newValue == null ? '' : String(change.newValue));
591-
return `${label} ${from || '—'} → ${to || '—'}`;
592-
});
593-
return changes.length > 0
594-
? changes.join('; ')
599+
const changes = fieldHistoryOf(item) ? '' : changesText(item);
600+
return changes
601+
? changes
595602
: item.body && item.body.trim()
596603
? item.body
597604
: item.changedFields && item.changedFields.length > 0
@@ -603,6 +610,7 @@ export const RecordActivityTimeline: React.FC<RecordActivityTimelineProps> = ({
603610
const renderSystemLine = (item: FeedItem): React.ReactNode => {
604611
const Icon = FEED_TYPE_ICONS[item.type] || Zap;
605612
const what = systemLineText(item);
613+
const paired = fieldHistoryOf(item);
606614
return (
607615
<div key={item.id} className="flex items-start gap-2 py-0.5 text-xs text-muted-foreground" data-stream-system-row="">
608616
<span className="z-10 flex h-8 w-8 shrink-0 items-center justify-center">
@@ -614,6 +622,11 @@ export const RecordActivityTimeline: React.FC<RecordActivityTimelineProps> = ({
614622
<span>{item.actor}</span>
615623
<span aria-hidden>{' · '}</span>
616624
<span>{formatTimestamp(item.createdAt, t, displayLocale)}</span>
625+
{paired && (
626+
<span className="block text-foreground/70" data-stream-change-diff="">
627+
{changesText(paired)}
628+
</span>
629+
)}
617630
</p>
618631
</div>
619632
);
@@ -877,6 +890,10 @@ export const RecordActivityTimeline: React.FC<RecordActivityTimelineProps> = ({
877890
>
878891
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
879892
<span className="min-w-0 flex-1 truncate">{what}</span>
893+
{/* Every row says who (objectstack#30122). */}
894+
<span className="max-w-[40%] shrink-0 truncate text-[11.5px] text-muted-foreground" data-record-timeline-peek-actor="">
895+
{item.actor}
896+
</span>
880897
<span className="shrink-0 text-[11.5px] text-muted-foreground tabular-nums">{when}</span>
881898
</div>
882899
);

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

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import {
2626
mergeTimelineStream,
2727
RecordFieldChangesContext,
2828
RecordFieldHistoryContext,
29+
TIMELINE_PAIR_WINDOW_MS,
30+
fieldHistoryOf,
2931
} from '../recordTimelineStream';
3032
import { RecordActivityTimeline } from '../RecordActivityTimeline';
3133
import { RecordActivityRenderer } from '../renderers/record-activity';
@@ -246,3 +248,108 @@ describe('an activity row sits under 活动 whatever the history flag; 变更 ho
246248
expect(screen.queryByText('Logged a call')).toBeNull();
247249
});
248250
});
251+
252+
describe('one action, one row: an activity row carries its audit entry (objectstack#30122)', () => {
253+
const at = (ms: number) => new Date(Date.UTC(2026, 10, 1, 9, 0, 0) + ms).toISOString();
254+
const activityRow = (id: string, activityType: string, ms: number, actorId: string | null = 'u1', actor = 'Dev Admin') =>
255+
({ id, type: 'field_change', actor, body: `Activity ${id}`, createdAt: at(ms), activityType, actorId }) as FeedItem;
256+
const auditOf = (id: number, action: string, ms: number, user_id: string | null = 'u1', user_name = 'Dev Admin') =>
257+
auditEntriesToFeedItems(
258+
[{ id, action, created_at: at(ms), user_id, user_name, changes: [{ field: 'company', label: 'Company', from: 'Old Co', to: `New Co ${id}` }] }],
259+
{ unknownUser: 'Unknown' },
260+
);
261+
262+
it('a create: the activity `created` row and the audit `create` entry are one row, the diff on it', () => {
263+
const stream = mergeTimelineStream([activityRow('a1', 'created', 0)], auditOf(1, 'create', 200));
264+
expect(stream.map((i) => i.id)).toEqual(['a1']);
265+
expect(fieldHistoryOf(stream[0])?.id).toBe('audit:1');
266+
// Ruling C: 变更 still shows the audit entry on its own; 活动 the activity row.
267+
expect(filterTimelineStream(stream, 'changes').map((i) => i.id)).toEqual(['audit:1']);
268+
expect(filterTimelineStream(stream, 'activity').map((i) => i.id)).toEqual(['a1']);
269+
});
270+
271+
it('an update: the activity `updated` row and the audit `update` entry are one row', () => {
272+
const stream = mergeTimelineStream([activityRow('a2', 'updated', 1000)], auditOf(2, 'update', 1000));
273+
expect(stream.map((i) => i.id)).toEqual(['a2']);
274+
expect(fieldHistoryOf(stream[0])?.fieldChanges?.[0]).toMatchObject({ field: 'company', newDisplayValue: 'New Co 2' });
275+
});
276+
277+
it('unpaired rows draw as before: an audit entry with no activity row, an activity row with no audit entry', () => {
278+
const stream = mergeTimelineStream([activityRow('a3', 'created', 0)], auditOf(3, 'update', 60_000));
279+
expect(stream.map((i) => i.id)).toEqual(['audit:3', 'a3']);
280+
expect(stream.every((i) => fieldHistoryOf(i) === undefined)).toBe(true);
281+
});
282+
283+
it('different actors do not pair', () => {
284+
const stream = mergeTimelineStream([activityRow('a4', 'created', 0, 'u1')], auditOf(4, 'create', 100, 'u2', 'Other'));
285+
expect(stream.map((i) => i.id).sort()).toEqual(['a4', 'audit:4']);
286+
});
287+
288+
it(`outside the ${TIMELINE_PAIR_WINDOW_MS} ms window does not pair; at its edge it does`, () => {
289+
const outside = mergeTimelineStream([activityRow('a5', 'created', 0)], auditOf(5, 'create', TIMELINE_PAIR_WINDOW_MS + 1));
290+
expect(outside.map((i) => i.id).sort()).toEqual(['a5', 'audit:5']);
291+
const edge = mergeTimelineStream([activityRow('a6', 'created', 0)], auditOf(6, 'create', TIMELINE_PAIR_WINDOW_MS));
292+
expect(edge.map((i) => i.id)).toEqual(['a6']);
293+
});
294+
295+
it('a different action kind does not pair, and a row pairs once (nearest in time)', () => {
296+
const kinds = mergeTimelineStream([activityRow('a7', 'created', 0)], auditOf(7, 'delete', 0));
297+
expect(kinds.map((i) => i.id).sort()).toEqual(['a7', 'audit:7']);
298+
const twice = mergeTimelineStream(
299+
[activityRow('a8', 'created', 0)],
300+
[...auditOf(8, 'create', 1500), ...auditOf(9, 'create', 300)],
301+
);
302+
expect(twice.map((i) => i.id).sort()).toEqual(['a8', 'audit:8']);
303+
expect(fieldHistoryOf(twice.find((i) => i.id === 'a8')!)?.id).toBe('audit:9');
304+
});
305+
306+
it('the system on both sides (no user id) pairs by name', () => {
307+
const stream = mergeTimelineStream([activityRow('a10', 'updated', 0, null, 'System')], auditOf(10, 'update', 50, null, 'System'));
308+
expect(stream.map((i) => i.id)).toEqual(['a10']);
309+
});
310+
311+
const page = (fieldChanges: FeedItem[] | null) => (
312+
<DefaultRecordPageContext.Provider value>
313+
<DiscussionContextProvider items={[activityRow('r1', 'created', 0)] as any[]} loading={false}>
314+
<RecordFieldChangesContext.Provider value={fieldChanges}>
315+
<RecordActivityRenderer schema={{ type: 'record:activity', showCompleted: true } as any} />
316+
</RecordFieldChangesContext.Provider>
317+
</DiscussionContextProvider>
318+
</DefaultRecordPageContext.Provider>
319+
);
320+
321+
it('an audit reader sees ONE row: the summary, who, and the diff on it', () => {
322+
const { container } = render(page(auditOf(11, 'create', 100)));
323+
const rows = container.querySelectorAll('[data-stream-system-row]');
324+
expect(rows).toHaveLength(1);
325+
expect(rows[0].querySelector('[data-stream-change]')?.textContent).toBe('Activity r1');
326+
expect(rows[0].textContent).toContain('Dev Admin');
327+
expect(rows[0].querySelector('[data-stream-change-diff]')?.textContent).toBe('Company Old Co → New Co 11');
328+
});
329+
330+
it('a reader without the audit log sees the same ONE row, without a diff', () => {
331+
const { container } = render(page(null));
332+
const rows = container.querySelectorAll('[data-stream-system-row]');
333+
expect(rows).toHaveLength(1);
334+
expect(rows[0].querySelector('[data-stream-change]')?.textContent).toBe('Activity r1');
335+
expect(rows[0].querySelector('[data-stream-change-diff]')).toBeNull();
336+
});
337+
338+
it('变更 shows the audit diff on its own row (ruling C)', () => {
339+
const { container } = render(page(auditOf(12, 'create', 100)));
340+
fireEvent.click(container.querySelector('[data-stream-filter="changes"]')!);
341+
const rows = container.querySelectorAll('[data-stream-system-row]');
342+
expect(rows).toHaveLength(1);
343+
expect(rows[0].querySelector('[data-stream-change]')?.textContent).toBe('Company Old Co → New Co 12');
344+
expect(rows[0].querySelector('[data-stream-change-diff]')).toBeNull();
345+
});
346+
347+
it('every peek row says who', () => {
348+
const { container } = render(
349+
<RecordActivityTimeline items={[comment, activityRow('p1', 'created', 0)]} variant="peek" />,
350+
);
351+
const actors = Array.from(container.querySelectorAll('[data-record-timeline-peek-actor]')).map((e) => e.textContent);
352+
expect(actors).toEqual(expect.arrayContaining(['Ada', 'Dev Admin']));
353+
expect(container.querySelectorAll('[data-record-timeline-peek-row]')).toHaveLength(2);
354+
});
355+
});

0 commit comments

Comments
 (0)