@n8n/frontend-module-inbox 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE.md +88 -0
  2. package/LICENSE_EE.md +27 -0
  3. package/README.md +61 -2
  4. package/biome.jsonc +4 -0
  5. package/eslint.config.mjs +4 -0
  6. package/package.json +72 -3
  7. package/src/__tests__/design-system-icons.test.ts +24 -0
  8. package/src/__tests__/setup.ts +4 -0
  9. package/src/components/InboxEmptyState.vue +86 -0
  10. package/src/components/InboxList.test.ts +289 -0
  11. package/src/components/InboxList.vue +195 -0
  12. package/src/components/InboxListItem.vue +208 -0
  13. package/src/components/InboxListSection.vue +168 -0
  14. package/src/composables/useReviewInboxSectionCollapse.test.ts +66 -0
  15. package/src/composables/useReviewInboxSectionCollapse.ts +42 -0
  16. package/src/inbox.api.ts +20 -0
  17. package/src/inbox.constants.ts +19 -0
  18. package/src/inbox.module.ts +55 -0
  19. package/src/inbox.routes.test.ts +140 -0
  20. package/src/inbox.routes.ts +49 -0
  21. package/src/inbox.store.test.ts +444 -0
  22. package/src/inbox.store.ts +305 -0
  23. package/src/index.ts +5 -0
  24. package/src/reviews/WorkflowReviewDetail.test.ts +647 -0
  25. package/src/reviews/WorkflowReviewDetail.vue +257 -0
  26. package/src/reviews/components/WorkflowReviewActivityFeed.test.ts +923 -0
  27. package/src/reviews/components/WorkflowReviewActivityFeed.vue +312 -0
  28. package/src/reviews/components/WorkflowReviewChangesList.test.ts +97 -0
  29. package/src/reviews/components/WorkflowReviewChangesList.vue +143 -0
  30. package/src/reviews/components/WorkflowReviewChangesSection.test.ts +331 -0
  31. package/src/reviews/components/WorkflowReviewChangesSection.vue +217 -0
  32. package/src/reviews/components/WorkflowReviewCommentComposer.test.ts +175 -0
  33. package/src/reviews/components/WorkflowReviewCommentComposer.vue +83 -0
  34. package/src/reviews/components/WorkflowReviewDecisionPopover.test.ts +288 -0
  35. package/src/reviews/components/WorkflowReviewDecisionPopover.vue +203 -0
  36. package/src/reviews/components/WorkflowReviewDetailMetadata.test.ts +206 -0
  37. package/src/reviews/components/WorkflowReviewDetailMetadata.vue +245 -0
  38. package/src/reviews/components/WorkflowReviewDetailTabs.decisionNote.test.ts +90 -0
  39. package/src/reviews/components/WorkflowReviewDetailTabs.test.ts +500 -0
  40. package/src/reviews/components/WorkflowReviewDetailTabs.vue +329 -0
  41. package/src/reviews/components/WorkflowReviewStatusDot.vue +67 -0
  42. package/src/reviews/components/_activity-card.scss +36 -0
  43. package/src/reviews/components/activity-entries/WorkflowReviewActivityActorAvatar.vue +50 -0
  44. package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.test.ts +103 -0
  45. package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.vue +116 -0
  46. package/src/reviews/components/activity-entries/WorkflowReviewActivityEventEntry.vue +361 -0
  47. package/src/reviews/components/activity-entries/WorkflowReviewActivityFallback.vue +49 -0
  48. package/src/reviews/components/activity-entries/WorkflowReviewActivityWorkflowLink.vue +36 -0
  49. package/src/reviews/components/activityEntryRegistry.ts +27 -0
  50. package/src/reviews/constants.ts +41 -0
  51. package/src/reviews/reviewActivity.store.test.ts +463 -0
  52. package/src/reviews/reviewActivity.store.ts +186 -0
  53. package/src/reviews/reviewDetail.store.test.ts +135 -0
  54. package/src/reviews/reviewDetail.store.ts +74 -0
  55. package/src/reviews/workflowReviewStatus.utils.ts +44 -0
  56. package/src/reviews/workflowReviews.api.ts +61 -0
  57. package/src/reviews/workflowReviews.utils.ts +18 -0
  58. package/src/self-healing/SelfHealingResultDetail.vue +19 -0
  59. package/src/views/InboxView.test.ts +568 -0
  60. package/src/views/InboxView.vue +260 -0
  61. package/stylelint.config.mjs +9 -0
  62. package/vite.config.ts +52 -0
@@ -0,0 +1,923 @@
1
+ import type { WorkflowReviewActivityEntry } from '@n8n/api-types';
2
+ import { createTestingPinia } from '@pinia/testing';
3
+ import { createComponentRenderer, mockedStore } from '@n8n/frontend-test-utils';
4
+ import { computed, nextTick, ref } from 'vue';
5
+
6
+ import {
7
+ ReviewDetailScrollContainerKey,
8
+ ReviewLinkedWorkflowsKey,
9
+ type ReviewLinkedWorkflowContext,
10
+ } from '../constants';
11
+ import { useReviewActivityStore } from '../reviewActivity.store';
12
+ import WorkflowReviewActivityFeed from './WorkflowReviewActivityFeed.vue';
13
+
14
+ /**
15
+ * The scroll anchoring arithmetic is deliberately untested: jsdom has no layout, so
16
+ * `getBoundingClientRect()` is all zeroes and a test would have to stub those getters and then
17
+ * re-compute the production arithmetic from its own constants. Do not backfill a fake one.
18
+ * Which of the two behaviours a changed list picks — hold the reading position, or jump to the
19
+ * newest entry — is testable, and is what the reload cases below assert.
20
+ */
21
+ const observer = vi.hoisted(() => ({
22
+ observe: vi.fn(),
23
+ onIntersect: () => {},
24
+ }));
25
+
26
+ vi.mock('@n8n/composables/useIntersectionObserver', () => ({
27
+ useIntersectionObserver: (options: { onIntersect: () => void }) => {
28
+ observer.onIntersect = options.onIntersect;
29
+ return { observe: observer.observe, disconnect: vi.fn(), observer: { value: null } };
30
+ },
31
+ }));
32
+
33
+ // jsdom has no ResizeObserver, so the test calls the composer's resize callback itself.
34
+ const resize = vi.hoisted(() => ({ onResize: (_entries: ResizeObserverEntry[]) => {} }));
35
+
36
+ vi.mock('@vueuse/core', async (importOriginal) => ({
37
+ ...(await importOriginal<typeof import('@vueuse/core')>()),
38
+ useResizeObserver: (_target: unknown, onResize: (entries: ResizeObserverEntry[]) => void) => {
39
+ resize.onResize = onResize;
40
+ },
41
+ }));
42
+
43
+ function resizeComposer(height: number) {
44
+ resize.onResize([{ borderBoxSize: [{ blockSize: height }] } as unknown as ResizeObserverEntry]);
45
+ }
46
+
47
+ const renderComponent = createComponentRenderer(WorkflowReviewActivityFeed);
48
+
49
+ /** Renders the feed with a stand-in for the detail pane's scroll area, and returns that area. */
50
+ function renderInScrollContainer(options: Parameters<typeof renderComponent>[0] = {}) {
51
+ const container = document.createElement('div');
52
+ renderComponent({
53
+ ...options,
54
+ global: { provide: { [ReviewDetailScrollContainerKey as symbol]: ref(container) } },
55
+ });
56
+ return container;
57
+ }
58
+
59
+ type CommentEntry = Extract<WorkflowReviewActivityEntry, { type: 'comment.created' }>;
60
+
61
+ function makeComment(overrides: Partial<CommentEntry> = {}): WorkflowReviewActivityEntry {
62
+ return {
63
+ id: '1',
64
+ type: 'comment.created',
65
+ typeVersion: 1,
66
+ data: null,
67
+ createdBy: null,
68
+ createdAt: '2024-01-01T00:00:00.000Z',
69
+ messages: [
70
+ {
71
+ id: 'msg-1',
72
+ body: 'Looks good',
73
+ createdBy: null,
74
+ createdAt: '2024-01-01T00:00:00.000Z',
75
+ updatedAt: null,
76
+ deletedAt: null,
77
+ },
78
+ ],
79
+ ...overrides,
80
+ };
81
+ }
82
+
83
+ function isEntering(element: HTMLElement) {
84
+ return element.classList.contains('itemEntering');
85
+ }
86
+
87
+ /** Every non-comment entry shares these fields; `type` and `data` are per row. */
88
+ const systemEntry = {
89
+ id: '1',
90
+ typeVersion: 1,
91
+ createdBy: null,
92
+ createdAt: '2024-01-01T00:00:00.000Z',
93
+ };
94
+
95
+ describe('WorkflowReviewActivityFeed', () => {
96
+ let store: ReturnType<typeof mockedStore<typeof useReviewActivityStore>>;
97
+
98
+ beforeEach(() => {
99
+ createTestingPinia();
100
+ observer.observe.mockReset();
101
+ store = mockedStore(useReviewActivityStore);
102
+ store.currentReviewId = 'req-1';
103
+ store.entries = [];
104
+ store.loading = false;
105
+ store.loadingMore = false;
106
+ store.hasMore = false;
107
+ store.nextCursor = null;
108
+ store.error = null;
109
+ });
110
+
111
+ it('shows one entry per activity in the feed', async () => {
112
+ store.entries = Array.from({ length: 3 }, (_, index) => makeComment({ id: String(index) }));
113
+
114
+ const { queryAllByTestId } = renderComponent();
115
+ await nextTick();
116
+
117
+ expect(queryAllByTestId('workflow-review-activity-entry')).toHaveLength(3);
118
+ });
119
+
120
+ it('shows a loading state while the first page is in flight', () => {
121
+ store.loading = true;
122
+
123
+ const { container, queryByTestId } = renderComponent();
124
+
125
+ expect(container.querySelector('.n8n-loading')).toBeInTheDocument();
126
+ expect(queryByTestId('workflow-review-activity-empty')).not.toBeInTheDocument();
127
+ });
128
+
129
+ it('shows an error row when the first page failed', () => {
130
+ store.error = new Error('boom');
131
+
132
+ const { getByTestId, queryByTestId } = renderComponent();
133
+
134
+ expect(getByTestId('workflow-review-activity-error')).toHaveTextContent(
135
+ "Couldn't load activity",
136
+ );
137
+ expect(queryByTestId('workflow-review-activity-empty')).not.toBeInTheDocument();
138
+ });
139
+
140
+ it('reloads the feed when the viewer retries after a failure', () => {
141
+ store.error = new Error('boom');
142
+
143
+ const { getByTestId } = renderComponent();
144
+ getByTestId('workflow-review-activity-retry').click();
145
+
146
+ // `loadMore` is a no-op here: with no cursor it returns before requesting anything
147
+ expect(store.fetchFeed).toHaveBeenCalledWith('req-1');
148
+ });
149
+
150
+ it('still reaches the earlier activity after posting onto a feed that failed to load', () => {
151
+ store.error = new Error('boom');
152
+ store.entries = [makeComment()];
153
+
154
+ const { getByTestId } = renderComponent();
155
+ getByTestId('workflow-review-activity-load-more-retry').click();
156
+
157
+ expect(store.fetchFeed).toHaveBeenCalledWith('req-1');
158
+ expect(store.loadMore).not.toHaveBeenCalled();
159
+ });
160
+
161
+ it('shows progress while refetching a feed that already has entries', async () => {
162
+ store.entries = [makeComment()];
163
+ store.loading = true;
164
+
165
+ const { container } = renderComponent();
166
+ await nextTick();
167
+
168
+ expect(container.querySelector('.n8n-loading')).toBeInTheDocument();
169
+ });
170
+
171
+ it('keeps a loaded feed and offers a retry when load-more failed', async () => {
172
+ store.entries = [makeComment()];
173
+ store.hasMore = true;
174
+ // A load-more failure always has a cursor to resume from.
175
+ store.nextCursor = 'cursor-1';
176
+ store.error = new Error('boom');
177
+
178
+ const { getAllByTestId, getByTestId, queryByTestId } = renderComponent();
179
+ await nextTick();
180
+
181
+ expect(getAllByTestId('workflow-review-activity-entry')).toHaveLength(1);
182
+ expect(queryByTestId('workflow-review-activity-error')).not.toBeInTheDocument();
183
+
184
+ getByTestId('workflow-review-activity-load-more-retry').click();
185
+
186
+ expect(store.loadMore).toHaveBeenCalled();
187
+
188
+ // Paging must survive a failure: the sentinel is withheld while the error shows,
189
+ // so it has to come back — and be observed again — once the error clears.
190
+ observer.observe.mockClear();
191
+ store.error = null;
192
+ await nextTick();
193
+ await nextTick();
194
+
195
+ expect(observer.observe).toHaveBeenCalledWith(
196
+ getByTestId('workflow-review-activity-load-more-sentinel'),
197
+ );
198
+ });
199
+
200
+ it('shows a placeholder for an activity type this frontend does not know', async () => {
201
+ store.entries = [makeComment({ typeVersion: 2 })];
202
+
203
+ const { getByTestId } = renderComponent();
204
+ await nextTick();
205
+
206
+ expect(getByTestId('workflow-review-activity-unknown')).toHaveTextContent(
207
+ "This activity entry can't be displayed.",
208
+ );
209
+ });
210
+
211
+ it('loads older entries when the viewer scrolls to the top of the feed', async () => {
212
+ store.entries = [makeComment()];
213
+ store.hasMore = true;
214
+
215
+ const { getByTestId } = renderComponent();
216
+ await nextTick();
217
+ await nextTick();
218
+
219
+ expect(observer.observe).toHaveBeenCalledWith(
220
+ getByTestId('workflow-review-activity-load-more-sentinel'),
221
+ );
222
+
223
+ observer.onIntersect();
224
+
225
+ expect(store.loadMore).toHaveBeenCalledTimes(1);
226
+ });
227
+
228
+ it('stops loading once the oldest entry is on screen', async () => {
229
+ store.entries = [makeComment()];
230
+ store.hasMore = false;
231
+
232
+ renderComponent();
233
+ await nextTick();
234
+ await nextTick();
235
+
236
+ expect(observer.observe).not.toHaveBeenCalled();
237
+ });
238
+
239
+ /**
240
+ * A reload keeps only the entries newer than the page it fetched, so its first entry
241
+ * differs without anything having been prepended. `scrollHeight` is stubbed because jsdom
242
+ * has no layout; the assertion is on the feed landing at the bottom, not on any arithmetic.
243
+ */
244
+ async function reloadFeed(container: HTMLElement) {
245
+ Object.defineProperty(container, 'scrollHeight', { value: 500, configurable: true });
246
+ container.scrollTop = 120;
247
+
248
+ store.entries = [makeComment({ id: '2' }), makeComment({ id: '3' })];
249
+ await nextTick();
250
+
251
+ return container;
252
+ }
253
+
254
+ it('shows the entry a decision just added to a feed the viewer had paged back through', async () => {
255
+ store.entries = [makeComment({ id: '1' }), makeComment({ id: '2' })];
256
+
257
+ const container = renderInScrollContainer();
258
+ await nextTick();
259
+
260
+ expect((await reloadFeed(container)).scrollTop).toBe(500);
261
+ });
262
+
263
+ // `loadMore` returns early while another page is in flight, leaving behind the anchor it
264
+ // captured — which by the next reload points at an entry no longer on screen.
265
+ it('shows the newest entry after a reload that followed a load-more that never ran', async () => {
266
+ store.entries = [makeComment({ id: '1' }), makeComment({ id: '2' })];
267
+ store.hasMore = true;
268
+ store.loadingMore = true;
269
+
270
+ const container = renderInScrollContainer();
271
+ await nextTick();
272
+ observer.onIntersect();
273
+
274
+ expect((await reloadFeed(container)).scrollTop).toBe(500);
275
+ });
276
+
277
+ it('does not request the same older page twice', async () => {
278
+ store.entries = [makeComment()];
279
+ store.hasMore = true;
280
+ store.loadingMore = true;
281
+
282
+ renderComponent();
283
+ await nextTick();
284
+ await nextTick();
285
+
286
+ expect(observer.observe).not.toHaveBeenCalled();
287
+ });
288
+
289
+ // One element for all states, so the input keeps its focus and draft.
290
+ it('keeps the same composer after the entries through loading and a failed first page', async () => {
291
+ store.loading = true;
292
+ const { getByTestId } = renderComponent({
293
+ slots: { composer: '<textarea data-test-id="feed-composer" />' },
294
+ });
295
+ const composer = getByTestId('feed-composer');
296
+
297
+ store.loading = false;
298
+ store.error = new Error('boom');
299
+ await nextTick();
300
+ expect(getByTestId('feed-composer')).toBe(composer);
301
+
302
+ store.error = null;
303
+ store.entries = [makeComment({ id: '1' })];
304
+ await nextTick();
305
+
306
+ const lastEntry = getByTestId('workflow-review-activity-entry');
307
+ expect(getByTestId('feed-composer')).toBe(composer);
308
+ expect(composer.closest('[role="list"]')).toBeNull();
309
+ expect(lastEntry.compareDocumentPosition(composer)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
310
+ });
311
+
312
+ it('follows a growing composer only when the feed is at the bottom', async () => {
313
+ store.entries = [makeComment({ id: '1' })];
314
+ const container = renderInScrollContainer({ slots: { composer: '<textarea />' } });
315
+ await nextTick();
316
+
317
+ Object.defineProperty(container, 'clientHeight', { value: 300, configurable: true });
318
+ Object.defineProperty(container, 'scrollHeight', { value: 524, configurable: true });
319
+
320
+ // A restored draft sizes the input after the feed scrolled to the bottom.
321
+ container.scrollTop = 184;
322
+ resizeComposer(40);
323
+ expect(container.scrollTop).toBe(524);
324
+
325
+ // Scrolled up to older entries.
326
+ container.scrollTop = 50;
327
+ resizeComposer(64);
328
+ expect(container.scrollTop).toBe(50);
329
+ });
330
+
331
+ it('fades in entries newer than the last one shown, also after a refetch', async () => {
332
+ store.entries = [makeComment({ id: '1' }), makeComment({ id: '2' })];
333
+ const { getAllByTestId } = renderComponent();
334
+ await nextTick();
335
+
336
+ store.entries = [...store.entries, makeComment({ id: '3' })];
337
+ await nextTick();
338
+
339
+ expect(getAllByTestId('workflow-review-activity-entry').map(isEntering)).toEqual([
340
+ false,
341
+ false,
342
+ true,
343
+ ]);
344
+
345
+ // A refetch returns only the newest page, so the first entry changes too.
346
+ store.entries = [makeComment({ id: '2' }), makeComment({ id: '3' }), makeComment({ id: '4' })];
347
+ await nextTick();
348
+
349
+ expect(getAllByTestId('workflow-review-activity-entry').map(isEntering)).toEqual([
350
+ false,
351
+ false,
352
+ true,
353
+ ]);
354
+ });
355
+
356
+ it('does not fade in the first entries or an older page', async () => {
357
+ store.loading = true;
358
+ const { getAllByTestId } = renderComponent();
359
+
360
+ store.loading = false;
361
+ store.entries = [makeComment({ id: '2' })];
362
+ await nextTick();
363
+ store.entries = [makeComment({ id: '1' }), ...store.entries];
364
+ await nextTick();
365
+
366
+ expect(getAllByTestId('workflow-review-activity-entry').map(isEntering)).toEqual([
367
+ false,
368
+ false,
369
+ ]);
370
+ });
371
+
372
+ describe('review lifecycle entries', () => {
373
+ it.each<[string, WorkflowReviewActivityEntry, { testId: string; text: string; note?: string }]>(
374
+ [
375
+ [
376
+ 'the review being submitted',
377
+ {
378
+ ...systemEntry,
379
+ type: 'review.opened',
380
+ data: { workflowVersions: [{ workflowId: 'wf-1', workflowVersionId: 'version-1' }] },
381
+ },
382
+ { testId: 'workflow-review-activity-opened', text: 'Review submitted' },
383
+ ],
384
+ [
385
+ 'requested changes with the note the reviewer left',
386
+ {
387
+ ...systemEntry,
388
+ type: 'review.changes_requested',
389
+ data: {
390
+ workflowVersions: [{ workflowId: 'wf-1', workflowVersionId: 'version-1' }],
391
+ note: 'Please add retries',
392
+ },
393
+ },
394
+ {
395
+ testId: 'workflow-review-activity-changes-requested',
396
+ text: 'Requested changes',
397
+ note: 'Please add retries',
398
+ },
399
+ ],
400
+ [
401
+ 'an approval with the note the reviewer left',
402
+ {
403
+ ...systemEntry,
404
+ type: 'review.approved',
405
+ data: {
406
+ workflowVersions: [{ workflowId: 'wf-1', workflowVersionId: 'version-1' }],
407
+ note: 'Ship it',
408
+ },
409
+ },
410
+ {
411
+ testId: 'workflow-review-activity-approved',
412
+ text: 'Approved submission',
413
+ note: 'Ship it',
414
+ },
415
+ ],
416
+ [
417
+ 'an approval given without a note',
418
+ {
419
+ ...systemEntry,
420
+ type: 'review.approved',
421
+ data: {
422
+ workflowVersions: [{ workflowId: 'wf-1', workflowVersionId: 'version-1' }],
423
+ note: null,
424
+ },
425
+ },
426
+ { testId: 'workflow-review-activity-approved', text: 'Approved submission' },
427
+ ],
428
+ [
429
+ 'an approval whose stored payload can no longer be read',
430
+ { ...systemEntry, type: 'review.approved', data: null },
431
+ { testId: 'workflow-review-activity-approved', text: 'Approved submission' },
432
+ ],
433
+ [
434
+ 'the reviewed version being updated',
435
+ {
436
+ ...systemEntry,
437
+ type: 'review.version_updated',
438
+ data: {
439
+ workflowId: 'wf-1',
440
+ fromWorkflowVersionId: 'version-1',
441
+ toWorkflowVersionId: 'version-2',
442
+ },
443
+ },
444
+ {
445
+ testId: 'workflow-review-activity-version-updated',
446
+ text: 'New workflow version submitted',
447
+ },
448
+ ],
449
+ [
450
+ 'a workflow archived by a user',
451
+ {
452
+ ...systemEntry,
453
+ type: 'workflow.archived',
454
+ data: { workflowId: 'wf-1', actorKind: 'user' },
455
+ },
456
+ {
457
+ testId: 'workflow-review-activity-workflow-archived',
458
+ text: 'Archived the workflow',
459
+ },
460
+ ],
461
+ [
462
+ 'a workflow archived by the system',
463
+ {
464
+ ...systemEntry,
465
+ type: 'workflow.archived',
466
+ data: { workflowId: 'wf-1', actorKind: 'system' },
467
+ },
468
+ {
469
+ testId: 'workflow-review-activity-workflow-archived',
470
+ text: 'The workflow was archived by the system',
471
+ },
472
+ ],
473
+ [
474
+ 'a workflow deleted by a user',
475
+ {
476
+ ...systemEntry,
477
+ type: 'workflow.deleted',
478
+ data: { workflowId: 'wf-1', actorKind: 'user' },
479
+ },
480
+ {
481
+ testId: 'workflow-review-activity-workflow-deleted',
482
+ text: 'Deleted the workflow',
483
+ },
484
+ ],
485
+ [
486
+ 'a workflow deleted by the system',
487
+ {
488
+ ...systemEntry,
489
+ type: 'workflow.deleted',
490
+ data: { workflowId: 'wf-1', actorKind: 'system' },
491
+ },
492
+ {
493
+ testId: 'workflow-review-activity-workflow-deleted',
494
+ text: 'The workflow was deleted by the system',
495
+ },
496
+ ],
497
+ [
498
+ 'a workflow moved by a user',
499
+ {
500
+ ...systemEntry,
501
+ type: 'workflow.moved',
502
+ data: { workflowId: 'wf-1', actorKind: 'user' },
503
+ },
504
+ {
505
+ testId: 'workflow-review-activity-workflow-moved',
506
+ text: 'Moved the workflow to another project',
507
+ },
508
+ ],
509
+ ],
510
+ )('shows %s', async (_label, entry, expected) => {
511
+ store.entries = [entry];
512
+
513
+ const { getByTestId, queryByTestId } = renderComponent();
514
+ await nextTick();
515
+
516
+ expect(getByTestId(expected.testId)).toHaveTextContent(expected.text);
517
+ if (expected.note) {
518
+ expect(getByTestId('workflow-review-activity-note')).toHaveTextContent(expected.note);
519
+ } else {
520
+ expect(queryByTestId('workflow-review-activity-note')).not.toBeInTheDocument();
521
+ }
522
+ });
523
+
524
+ // The only entry whose payload the sentence cannot do without: the close reason *is* the
525
+ // sentence, so there is nothing left to render.
526
+ it('shows a placeholder for a closed review that no longer says why', async () => {
527
+ store.entries = [{ ...systemEntry, type: 'review.closed', data: null }];
528
+
529
+ const { getByTestId, queryByTestId } = renderComponent();
530
+ await nextTick();
531
+
532
+ expect(getByTestId('workflow-review-activity-unknown')).toHaveTextContent(
533
+ "This activity entry can't be displayed.",
534
+ );
535
+ expect(queryByTestId('workflow-review-activity-closed')).not.toBeInTheDocument();
536
+ });
537
+
538
+ // Losing the note must not cost the actor and the verb too, which are known from the type.
539
+ it('keeps a change request whose note can no longer be read', async () => {
540
+ store.entries = [{ ...systemEntry, type: 'review.changes_requested', data: null }];
541
+
542
+ const { getByTestId, queryByTestId } = renderComponent();
543
+ await nextTick();
544
+
545
+ expect(getByTestId('workflow-review-activity-changes-requested')).toHaveTextContent(
546
+ 'Requested changes',
547
+ );
548
+ expect(queryByTestId('workflow-review-activity-note')).not.toBeInTheDocument();
549
+ expect(queryByTestId('workflow-review-activity-unknown')).not.toBeInTheDocument();
550
+ });
551
+
552
+ // Only a downgrade can produce a stored type outside the union; the feed must still
553
+ // hold its place rather than crash or drop it.
554
+ it('shows a placeholder for an entry type no renderer covers', async () => {
555
+ store.entries = [
556
+ {
557
+ ...systemEntry,
558
+ type: 'review.something_newer',
559
+ data: null,
560
+ } as unknown as WorkflowReviewActivityEntry,
561
+ ];
562
+
563
+ const { getByTestId } = renderComponent();
564
+ await nextTick();
565
+
566
+ expect(getByTestId('workflow-review-activity-unknown')).toBeInTheDocument();
567
+ });
568
+
569
+ // The payload names the actor kind, and without it the entry must not guess: it
570
+ // degrades to the actorless sentence rather than crediting the wrong kind of actor.
571
+ it('renders a cause entry whose payload cannot be read as a system sentence', async () => {
572
+ store.entries = [{ ...systemEntry, type: 'workflow.archived', data: null }];
573
+
574
+ const { getByTestId, queryByTestId } = renderComponent();
575
+ await nextTick();
576
+
577
+ expect(getByTestId('workflow-review-activity-workflow-archived')).toHaveTextContent(
578
+ 'The workflow was archived by the system',
579
+ );
580
+ expect(queryByTestId('workflow-review-activity-actor')).not.toBeInTheDocument();
581
+ });
582
+
583
+ it('names the workflow in cause entries when the detail surface provides its name', async () => {
584
+ store.entries = [
585
+ {
586
+ ...systemEntry,
587
+ id: '1',
588
+ type: 'workflow.archived',
589
+ data: { workflowId: 'wf-1', actorKind: 'user' },
590
+ },
591
+ {
592
+ ...systemEntry,
593
+ id: '2',
594
+ type: 'workflow.moved',
595
+ data: { workflowId: 'wf-1', actorKind: 'system' },
596
+ },
597
+ {
598
+ ...systemEntry,
599
+ id: '3',
600
+ type: 'review.version_updated',
601
+ data: {
602
+ workflowId: 'wf-1',
603
+ fromWorkflowVersionId: 'version-1',
604
+ toWorkflowVersionId: 'version-2',
605
+ },
606
+ },
607
+ ];
608
+
609
+ const { getByTestId } = renderWithLinkedWorkflows({});
610
+ await nextTick();
611
+
612
+ expect(getByTestId('workflow-review-activity-workflow-archived')).toHaveTextContent(
613
+ 'Archived Payment handler',
614
+ );
615
+ expect(getByTestId('workflow-review-activity-workflow-moved')).toHaveTextContent(
616
+ 'Payment handler was moved to another project by the system',
617
+ );
618
+ expect(getByTestId('workflow-review-activity-version-updated')).toHaveTextContent(
619
+ 'New version submitted for Payment handler',
620
+ );
621
+ });
622
+
623
+ it('links every mentioned workflow to its canvas', async () => {
624
+ store.entries = [
625
+ {
626
+ ...systemEntry,
627
+ id: '1',
628
+ type: 'workflow.archived',
629
+ data: { workflowId: 'wf-1', actorKind: 'user' },
630
+ },
631
+ {
632
+ ...systemEntry,
633
+ id: '2',
634
+ type: 'workflow.published',
635
+ data: { workflowId: 'wf-1', workflowVersionId: '234df342-5f2b-4e5a-9be6-79ee7bb1c02f' },
636
+ },
637
+ ];
638
+
639
+ const { getAllByTestId } = renderComponent({
640
+ global: {
641
+ provide: {
642
+ [ReviewLinkedWorkflowsKey as symbol]: computed(
643
+ () =>
644
+ new Map([
645
+ [
646
+ 'wf-1',
647
+ {
648
+ workflowName: 'Payment handler',
649
+ pinnedVersionId: null,
650
+ pinnedVersionName: null,
651
+ },
652
+ ],
653
+ ]),
654
+ ),
655
+ },
656
+ },
657
+ });
658
+ await nextTick();
659
+
660
+ const links = getAllByTestId('workflow-review-activity-workflow-link');
661
+ expect(links).toHaveLength(2);
662
+ expect(links[0]).toHaveTextContent('Payment handler');
663
+ });
664
+
665
+ it('names the user who archived the workflow, but no one for a system archive', async () => {
666
+ store.entries = [
667
+ {
668
+ ...systemEntry,
669
+ id: '1',
670
+ type: 'workflow.archived',
671
+ data: { workflowId: 'wf-1', actorKind: 'user' },
672
+ createdBy: {
673
+ id: 'user-1',
674
+ email: 'ada@example.com',
675
+ firstName: 'Ada',
676
+ lastName: 'Lovelace',
677
+ },
678
+ },
679
+ {
680
+ ...systemEntry,
681
+ id: '2',
682
+ type: 'workflow.archived',
683
+ data: { workflowId: 'wf-1', actorKind: 'system' },
684
+ },
685
+ ];
686
+
687
+ const { getAllByTestId } = renderComponent();
688
+ await nextTick();
689
+
690
+ const actors = getAllByTestId('workflow-review-activity-actor');
691
+ expect(actors).toHaveLength(1);
692
+ expect(actors[0]).toHaveTextContent('Ada Lovelace');
693
+ // The system entry fills the avatar column with an icon instead.
694
+ expect(getAllByTestId('workflow-review-activity-system-icon')).toHaveLength(1);
695
+ });
696
+
697
+ // `actorKind: 'user'` with a null author is a deleted user, still a person: named as
698
+ // one, and with a person silhouette where their avatar would be — not an empty circle.
699
+ it('names a deleted user as the actor of their archive', async () => {
700
+ store.entries = [
701
+ {
702
+ ...systemEntry,
703
+ type: 'workflow.archived',
704
+ data: { workflowId: 'wf-1', actorKind: 'user' },
705
+ createdBy: null,
706
+ },
707
+ ];
708
+
709
+ const { getByTestId } = renderComponent();
710
+ await nextTick();
711
+
712
+ expect(getByTestId('workflow-review-activity-actor')).toHaveTextContent('Deleted user');
713
+ expect(getByTestId('workflow-review-activity-deleted-actor')).toBeInTheDocument();
714
+ });
715
+
716
+ const renderWithLinkedWorkflows = (linked: Partial<ReviewLinkedWorkflowContext>) =>
717
+ renderComponent({
718
+ global: {
719
+ provide: {
720
+ [ReviewLinkedWorkflowsKey as symbol]: computed(
721
+ () =>
722
+ new Map([
723
+ [
724
+ 'wf-1',
725
+ {
726
+ workflowName: 'Payment handler',
727
+ pinnedVersionId: null,
728
+ pinnedVersionName: null,
729
+ ...linked,
730
+ },
731
+ ],
732
+ ]),
733
+ ),
734
+ },
735
+ },
736
+ });
737
+
738
+ it('names the publisher on a published entry', async () => {
739
+ store.entries = [
740
+ {
741
+ ...systemEntry,
742
+ type: 'workflow.published',
743
+ data: { workflowId: 'wf-1', workflowVersionId: 'version-1' },
744
+ createdBy: {
745
+ id: 'user-1',
746
+ email: 'ada@example.com',
747
+ firstName: 'Ada',
748
+ lastName: 'Lovelace',
749
+ },
750
+ },
751
+ ];
752
+
753
+ const { getByTestId } = renderWithLinkedWorkflows({});
754
+ await nextTick();
755
+
756
+ expect(getByTestId('workflow-review-activity-actor')).toHaveTextContent('Ada Lovelace');
757
+ });
758
+
759
+ it('names the published workflow when the detail surface provides its name', async () => {
760
+ store.entries = [
761
+ {
762
+ ...systemEntry,
763
+ type: 'workflow.published',
764
+ data: { workflowId: 'wf-1', workflowVersionId: '234df342-5f2b-4e5a-9be6-79ee7bb1c02f' },
765
+ },
766
+ ];
767
+
768
+ const { getByTestId } = renderWithLinkedWorkflows({});
769
+ await nextTick();
770
+
771
+ expect(getByTestId('workflow-review-activity-workflow-published')).toHaveTextContent(
772
+ 'Published Payment handler Version 234df342',
773
+ );
774
+ });
775
+
776
+ // the user-given version name wins over the label generated from the id.
777
+ it('shows the published version by its user-given name when it has one', async () => {
778
+ store.entries = [
779
+ {
780
+ ...systemEntry,
781
+ type: 'workflow.published',
782
+ data: { workflowId: 'wf-1', workflowVersionId: '234df342-5f2b-4e5a-9be6-79ee7bb1c02f' },
783
+ },
784
+ ];
785
+
786
+ const { getByTestId } = renderWithLinkedWorkflows({
787
+ pinnedVersionId: '234df342-5f2b-4e5a-9be6-79ee7bb1c02f',
788
+ pinnedVersionName: 'Release candidate',
789
+ });
790
+ await nextTick();
791
+
792
+ expect(getByTestId('workflow-review-activity-workflow-published')).toHaveTextContent(
793
+ 'Published Payment handler Release candidate',
794
+ );
795
+ });
796
+
797
+ it('does not use the pinned version name for a different published version', async () => {
798
+ store.entries = [
799
+ {
800
+ ...systemEntry,
801
+ type: 'workflow.published',
802
+ data: { workflowId: 'wf-1', workflowVersionId: '234df342-5f2b-4e5a-9be6-79ee7bb1c02f' },
803
+ },
804
+ ];
805
+
806
+ const { getByTestId } = renderWithLinkedWorkflows({
807
+ pinnedVersionId: 'a-different-version-id',
808
+ pinnedVersionName: 'Release candidate',
809
+ });
810
+ await nextTick();
811
+
812
+ expect(getByTestId('workflow-review-activity-workflow-published')).toHaveTextContent(
813
+ 'Published Payment handler Version 234df342',
814
+ );
815
+ });
816
+
817
+ it('shows a placeholder for a published entry whose payload cannot be read', async () => {
818
+ store.entries = [{ ...systemEntry, type: 'workflow.published', data: null }];
819
+
820
+ const { getByTestId, queryByTestId } = renderComponent();
821
+ await nextTick();
822
+
823
+ expect(getByTestId('workflow-review-activity-unknown')).toBeInTheDocument();
824
+ expect(queryByTestId('workflow-review-activity-workflow-published')).not.toBeInTheDocument();
825
+ });
826
+
827
+ // The workflow can be deleted after publishing, so the feed must still say what was
828
+ // published even though there is nothing left to link to.
829
+ it('names a published version as an unknown workflow when its workflow has since been deleted', async () => {
830
+ store.entries = [
831
+ {
832
+ ...systemEntry,
833
+ type: 'workflow.published',
834
+ data: { workflowId: 'wf-1', workflowVersionId: '234df342-5f2b-4e5a-9be6-79ee7bb1c02f' },
835
+ },
836
+ ];
837
+
838
+ const { getByTestId, queryByTestId } = renderComponent();
839
+ await nextTick();
840
+
841
+ expect(getByTestId('workflow-review-activity-workflow-published')).toHaveTextContent(
842
+ 'Published unknown workflow Version 234df342',
843
+ );
844
+ expect(queryByTestId('workflow-review-activity-workflow-link')).not.toBeInTheDocument();
845
+ });
846
+
847
+ it('names the reviewer who acted', async () => {
848
+ store.entries = [
849
+ {
850
+ ...systemEntry,
851
+ type: 'review.approved',
852
+ data: {
853
+ workflowVersions: [{ workflowId: 'wf-1', workflowVersionId: 'version-1' }],
854
+ note: null,
855
+ },
856
+ createdBy: {
857
+ id: 'user-1',
858
+ email: 'ada@example.com',
859
+ firstName: 'Ada',
860
+ lastName: 'Lovelace',
861
+ },
862
+ },
863
+ ];
864
+
865
+ const { getByTestId } = renderComponent();
866
+ await nextTick();
867
+
868
+ expect(getByTestId('workflow-review-activity-actor')).toHaveTextContent('Ada Lovelace');
869
+ });
870
+
871
+ // The actor is missing for two different reasons, and a deleted approver must not
872
+ // read as a system event.
873
+ it('names a deleted reviewer as the actor of their decision', async () => {
874
+ store.entries = [
875
+ {
876
+ ...systemEntry,
877
+ type: 'review.approved',
878
+ data: {
879
+ workflowVersions: [{ workflowId: 'wf-1', workflowVersionId: 'version-1' }],
880
+ note: null,
881
+ },
882
+ },
883
+ ];
884
+
885
+ const { getByTestId } = renderComponent();
886
+ await nextTick();
887
+
888
+ expect(getByTestId('workflow-review-activity-actor')).toHaveTextContent('Deleted user');
889
+ });
890
+
891
+ // A lifecycle close concludes the feed, so it renders as a callout, with the
892
+ // entry's (relative, live) timestamp slotted into the middle of the sentence.
893
+ it('renders a lifecycle close as a titled callout without an actor', async () => {
894
+ store.entries = [
895
+ { ...systemEntry, type: 'review.closed', data: { reason: 'no-reviewable-workflows' } },
896
+ ];
897
+
898
+ const { getByTestId, queryByTestId } = renderComponent();
899
+ await nextTick();
900
+
901
+ const callout = getByTestId('workflow-review-activity-closed');
902
+ expect(callout).toHaveTextContent('Submission closed');
903
+ expect(callout).toHaveTextContent(
904
+ /The review was closed automatically .+ because there is nothing left to review\./,
905
+ );
906
+ expect(queryByTestId('workflow-review-activity-actor')).not.toBeInTheDocument();
907
+ });
908
+
909
+ it('renders footer content as one more timeline item after the entries', async () => {
910
+ store.entries = [makeComment({ id: '1' })];
911
+
912
+ const { getByTestId } = renderComponent({
913
+ slots: { footer: '<div data-test-id="feed-footer-content" />' },
914
+ });
915
+ await nextTick();
916
+
917
+ const footer = getByTestId('feed-footer-content');
918
+ const lastEntry = getByTestId('workflow-review-activity-entry');
919
+ expect(footer.closest('[role="listitem"]')).not.toBeNull();
920
+ expect(lastEntry.compareDocumentPosition(footer)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
921
+ });
922
+ });
923
+ });