@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,463 @@
1
+ import type {
2
+ ListWorkflowReviewActivityResponse,
3
+ WorkflowReviewActivityEntry,
4
+ } from '@n8n/api-types';
5
+ import { createPinia, setActivePinia } from 'pinia';
6
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
8
+
9
+ import * as workflowReviewsApi from './workflowReviews.api';
10
+ import { useReviewActivityStore } from './reviewActivity.store';
11
+
12
+ vi.mock('./workflowReviews.api');
13
+
14
+ function makeEntry(id: string): WorkflowReviewActivityEntry {
15
+ return {
16
+ id,
17
+ type: 'comment.created',
18
+ typeVersion: 1,
19
+ data: null,
20
+ createdBy: null,
21
+ createdAt: '2024-01-01T00:00:00.000Z',
22
+ messages: [
23
+ {
24
+ id: `msg-${id}`,
25
+ body: `comment ${id}`,
26
+ createdBy: null,
27
+ createdAt: '2024-01-01T00:00:00.000Z',
28
+ updatedAt: null,
29
+ deletedAt: null,
30
+ },
31
+ ],
32
+ };
33
+ }
34
+
35
+ function makePage(
36
+ ids: string[],
37
+ overrides: Partial<ListWorkflowReviewActivityResponse> = {},
38
+ ): ListWorkflowReviewActivityResponse {
39
+ return {
40
+ data: ids.map(makeEntry),
41
+ nextCursor: null,
42
+ hasMore: false,
43
+ ...overrides,
44
+ };
45
+ }
46
+
47
+ describe('useReviewActivityStore', () => {
48
+ beforeEach(() => {
49
+ setActivePinia(createPinia());
50
+ vi.resetAllMocks();
51
+ });
52
+
53
+ it('loads the first page for a review', async () => {
54
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(
55
+ makePage(['1', '2'], { nextCursor: 'cursor-1', hasMore: true }),
56
+ );
57
+ const store = useReviewActivityStore();
58
+
59
+ await store.fetchFeed('req-1');
60
+
61
+ expect(store.entries.map((entry) => entry.id)).toEqual(['1', '2']);
62
+ expect(store.hasMore).toBe(true);
63
+ expect(store.nextCursor).toBe('cursor-1');
64
+ expect(store.loading).toBe(false);
65
+ });
66
+
67
+ it('shows a saved comment after returning to its review and keeps the new draft', async () => {
68
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(makePage(['1']));
69
+ const pending = createDeferredPromise<WorkflowReviewActivityEntry>();
70
+ vi.mocked(workflowReviewsApi.createWorkflowReviewComment).mockReturnValue(pending.promise);
71
+ const store = useReviewActivityStore();
72
+ await store.fetchFeed('req-1');
73
+ const post = store.postComment('Sent comment');
74
+ await store.fetchFeed('req-2');
75
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(
76
+ makePage(['1', '10']),
77
+ );
78
+ await store.fetchFeed('req-1');
79
+ store.draft = 'Current draft';
80
+ pending.resolve(makeEntry('9'));
81
+ await expect(post).resolves.toBe(false);
82
+ expect(store.entries.map((entry) => entry.id)).toEqual(['1', '9', '10']);
83
+ expect(store.draft).toBe('Current draft');
84
+ });
85
+
86
+ it("never shows the previous review's comments while the new one loads", async () => {
87
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(makePage(['1']));
88
+ const store = useReviewActivityStore();
89
+ await store.fetchFeed('req-1');
90
+
91
+ const pending = store.fetchFeed('req-2');
92
+
93
+ expect(store.entries).toEqual([]);
94
+ expect(store.hasMore).toBe(false);
95
+ expect(store.nextCursor).toBeNull();
96
+ await pending;
97
+ });
98
+
99
+ it('ignores a stale feed response', async () => {
100
+ let resolveFirst!: (response: ListWorkflowReviewActivityResponse) => void;
101
+ const firstResponse = new Promise<ListWorkflowReviewActivityResponse>((resolve) => {
102
+ resolveFirst = resolve;
103
+ });
104
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity)
105
+ .mockImplementationOnce(async () => await firstResponse)
106
+ .mockResolvedValueOnce(makePage(['9']));
107
+ const store = useReviewActivityStore();
108
+
109
+ const firstFetch = store.fetchFeed('req-1');
110
+ await store.fetchFeed('req-2');
111
+ resolveFirst(makePage(['1']));
112
+ await firstFetch;
113
+
114
+ expect(store.entries.map((entry) => entry.id)).toEqual(['9']);
115
+ });
116
+
117
+ it('puts older entries above and a new comment at the bottom', async () => {
118
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(
119
+ makePage(['3', '4'], { nextCursor: 'cursor-1', hasMore: true }),
120
+ );
121
+ vi.mocked(workflowReviewsApi.createWorkflowReviewComment).mockResolvedValue(makeEntry('5'));
122
+ const store = useReviewActivityStore();
123
+ await store.fetchFeed('req-1');
124
+
125
+ await expect(store.postComment('hi')).resolves.toBe(true);
126
+
127
+ expect(store.entries.map((entry) => entry.id)).toEqual(['3', '4', '5']);
128
+
129
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(
130
+ makePage(['1', '2']),
131
+ );
132
+ await store.loadMore();
133
+
134
+ // Asserted explicitly: the mock answers the same page whatever it is asked for, so
135
+ // without this a `loadMore` that dropped the cursor would still look correct here.
136
+ expect(workflowReviewsApi.fetchWorkflowReviewActivity).toHaveBeenLastCalledWith(
137
+ expect.anything(),
138
+ 'req-1',
139
+ { limit: 25, cursor: 'cursor-1' },
140
+ );
141
+ expect(store.entries.map((entry) => entry.id)).toEqual(['1', '2', '3', '4', '5']);
142
+ expect(store.hasMore).toBe(false);
143
+ expect(store.nextCursor).toBeNull();
144
+ });
145
+
146
+ it('ignores a stale load-more response', async () => {
147
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(
148
+ makePage(['3'], { nextCursor: 'cursor-1', hasMore: true }),
149
+ );
150
+ const store = useReviewActivityStore();
151
+ await store.fetchFeed('req-1');
152
+
153
+ let resolveLoadMore!: (response: ListWorkflowReviewActivityResponse) => void;
154
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity)
155
+ .mockImplementationOnce(
156
+ async () =>
157
+ await new Promise<ListWorkflowReviewActivityResponse>((resolve) => {
158
+ resolveLoadMore = resolve;
159
+ }),
160
+ )
161
+ .mockResolvedValueOnce(makePage(['7']));
162
+
163
+ const pendingLoadMore = store.loadMore();
164
+ await store.fetchFeed('req-2');
165
+ resolveLoadMore(makePage(['1', '2']));
166
+ await pendingLoadMore;
167
+
168
+ expect(store.entries.map((entry) => entry.id)).toEqual(['7']);
169
+ });
170
+
171
+ it('keeps a comment posted while the first page was still in flight', async () => {
172
+ let resolveFeed!: (response: ListWorkflowReviewActivityResponse) => void;
173
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockImplementationOnce(
174
+ async () =>
175
+ await new Promise<ListWorkflowReviewActivityResponse>((resolve) => {
176
+ resolveFeed = resolve;
177
+ }),
178
+ );
179
+ vi.mocked(workflowReviewsApi.createWorkflowReviewComment).mockResolvedValue(makeEntry('5'));
180
+ const store = useReviewActivityStore();
181
+
182
+ const pendingFeed = store.fetchFeed('req-1');
183
+ await store.postComment('hi');
184
+ // The page was snapshotted server-side before the comment was written
185
+ resolveFeed(makePage(['3', '4']));
186
+ await pendingFeed;
187
+
188
+ expect(store.entries.map((entry) => entry.id)).toEqual(['3', '4', '5']);
189
+ });
190
+
191
+ it('does not duplicate a comment the refetched feed already returned', async () => {
192
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(makePage([]));
193
+ const store = useReviewActivityStore();
194
+ await store.fetchFeed('req-1');
195
+
196
+ let resolvePost!: (entry: WorkflowReviewActivityEntry) => void;
197
+ vi.mocked(workflowReviewsApi.createWorkflowReviewComment).mockImplementationOnce(
198
+ async () =>
199
+ await new Promise<WorkflowReviewActivityEntry>((resolve) => {
200
+ resolvePost = resolve;
201
+ }),
202
+ );
203
+
204
+ const pendingPost = store.postComment('hi');
205
+ await store.fetchFeed('req-2');
206
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(makePage(['5']));
207
+ await store.fetchFeed('req-1');
208
+ resolvePost(makeEntry('5'));
209
+ await pendingPost;
210
+
211
+ expect(store.entries.map((entry) => entry.id)).toEqual(['5']);
212
+ });
213
+
214
+ it('stops paging when an older page comes back empty', async () => {
215
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(
216
+ makePage(['3'], { nextCursor: 'cursor-1', hasMore: true }),
217
+ );
218
+ const store = useReviewActivityStore();
219
+ await store.fetchFeed('req-1');
220
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(
221
+ makePage([], { nextCursor: 'cursor-1', hasMore: true }),
222
+ );
223
+
224
+ await store.loadMore();
225
+
226
+ expect(store.hasMore).toBe(false);
227
+ expect(store.entries.map((entry) => entry.id)).toEqual(['3']);
228
+ });
229
+
230
+ it('rejects a post with no review selected instead of reporting success', async () => {
231
+ const store = useReviewActivityStore();
232
+
233
+ await expect(store.postComment('hi')).rejects.toThrow();
234
+ expect(workflowReviewsApi.createWorkflowReviewComment).not.toHaveBeenCalled();
235
+ });
236
+
237
+ it('does not ask for older entries when the feed is already complete', async () => {
238
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(makePage(['1']));
239
+ const store = useReviewActivityStore();
240
+ await store.fetchFeed('req-1');
241
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockClear();
242
+
243
+ await store.loadMore();
244
+
245
+ expect(workflowReviewsApi.fetchWorkflowReviewActivity).not.toHaveBeenCalled();
246
+ });
247
+
248
+ it('drops an in-flight feed response after a reset', async () => {
249
+ let resolveFeed!: (response: ListWorkflowReviewActivityResponse) => void;
250
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockImplementationOnce(
251
+ async () =>
252
+ await new Promise<ListWorkflowReviewActivityResponse>((resolve) => {
253
+ resolveFeed = resolve;
254
+ }),
255
+ );
256
+ const store = useReviewActivityStore();
257
+
258
+ const pending = store.fetchFeed('req-1');
259
+ store.draft = 'for req-1 only';
260
+ store.decisionNote = 'for req-1 only';
261
+ store.reset();
262
+ resolveFeed(makePage(['1']));
263
+ await pending;
264
+
265
+ expect(store.entries).toEqual([]);
266
+ expect(store.currentReviewId).toBeNull();
267
+ // Leaving the view must not carry a draft into whatever review is opened next.
268
+ expect(store.draft).toBe('');
269
+ expect(store.decisionNote).toBe('');
270
+ });
271
+
272
+ it('does not leave the next review stuck in a sending state', async () => {
273
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(makePage(['1']));
274
+ vi.mocked(workflowReviewsApi.createWorkflowReviewComment).mockReturnValue(
275
+ new Promise(() => {}),
276
+ );
277
+ const store = useReviewActivityStore();
278
+ await store.fetchFeed('req-1');
279
+
280
+ void store.postComment('hi');
281
+ expect(store.posting).toBe(true);
282
+
283
+ await store.fetchFeed('req-2');
284
+
285
+ expect(store.posting).toBe(false);
286
+ });
287
+
288
+ it('keeps send disabled for the post the viewer is still waiting on after leaving and returning', async () => {
289
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(makePage(['1']));
290
+ const resolvers: Array<(entry: WorkflowReviewActivityEntry) => void> = [];
291
+ vi.mocked(workflowReviewsApi.createWorkflowReviewComment).mockImplementation(
292
+ async () => await new Promise((resolve) => resolvers.push(resolve)),
293
+ );
294
+ const store = useReviewActivityStore();
295
+ await store.fetchFeed('req-1');
296
+ const first = store.postComment('hi');
297
+
298
+ await store.fetchFeed('req-2');
299
+ await store.fetchFeed('req-1');
300
+ const second = store.postComment('again');
301
+ expect(store.posting).toBe(true);
302
+
303
+ resolvers[0](makeEntry('9'));
304
+ await first;
305
+ expect(store.posting).toBe(true);
306
+
307
+ resolvers[1](makeEntry('10'));
308
+ await second;
309
+ expect(store.posting).toBe(false);
310
+ });
311
+
312
+ it('keeps the send button disabled when a failed page is retried mid-post', async () => {
313
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockRejectedValue(new Error('boom'));
314
+ let resolvePost: (entry: WorkflowReviewActivityEntry) => void = () => {};
315
+ vi.mocked(workflowReviewsApi.createWorkflowReviewComment).mockReturnValue(
316
+ new Promise((resolve) => {
317
+ resolvePost = resolve;
318
+ }),
319
+ );
320
+ const store = useReviewActivityStore();
321
+ await store.fetchFeed('req-1');
322
+ store.draft = 'half a thought';
323
+
324
+ const pending = store.postComment('hi');
325
+ expect(store.posting).toBe(true);
326
+
327
+ // Retrying refetches the same review.
328
+ await store.fetchFeed('req-1');
329
+ expect(store.posting).toBe(true);
330
+ expect(store.draft).toBe('half a thought');
331
+
332
+ resolvePost(makeEntry('9'));
333
+ await pending;
334
+ expect(store.posting).toBe(false);
335
+ });
336
+
337
+ it('keeps a comment posted onto a failed feed visible while that feed is retried', async () => {
338
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockRejectedValue(new Error('boom'));
339
+ vi.mocked(workflowReviewsApi.createWorkflowReviewComment).mockResolvedValue(makeEntry('9'));
340
+ const store = useReviewActivityStore();
341
+ await store.fetchFeed('req-1');
342
+ await store.postComment('hi');
343
+ expect(store.entries.map((entry) => entry.id)).toEqual(['9']);
344
+
345
+ await store.fetchFeed('req-1');
346
+
347
+ expect(store.entries.map((entry) => entry.id)).toEqual(['9']);
348
+ });
349
+
350
+ it('drops older pages a refetch did not return so the feed stays in order', async () => {
351
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(
352
+ makePage(['3', '4'], { nextCursor: 'cursor-1', hasMore: true }),
353
+ );
354
+ const store = useReviewActivityStore();
355
+ await store.fetchFeed('req-1');
356
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(
357
+ makePage(['1', '2']),
358
+ );
359
+ await store.loadMore();
360
+ expect(store.entries.map((entry) => entry.id)).toEqual(['1', '2', '3', '4']);
361
+
362
+ // Kept older pages would land after the newer one and invert the list.
363
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(
364
+ makePage(['3', '4'], { nextCursor: 'cursor-1', hasMore: true }),
365
+ );
366
+ await store.fetchFeed('req-1');
367
+
368
+ expect(store.entries.map((entry) => entry.id)).toEqual(['3', '4']);
369
+ });
370
+
371
+ it("does not drop a stale post's comment into the review the viewer moved to", async () => {
372
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(makePage(['1']));
373
+ let resolvePost: (entry: WorkflowReviewActivityEntry) => void = () => {};
374
+ vi.mocked(workflowReviewsApi.createWorkflowReviewComment).mockReturnValue(
375
+ new Promise((resolve) => {
376
+ resolvePost = resolve;
377
+ }),
378
+ );
379
+ const store = useReviewActivityStore();
380
+ await store.fetchFeed('req-1');
381
+ const pending = store.postComment('hi');
382
+
383
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(makePage(['2']));
384
+ await store.fetchFeed('req-2');
385
+ resolvePost(makeEntry('9'));
386
+
387
+ // Reported to the caller too: clearing a draft or switching tabs on the back of this
388
+ // post would hit the review the viewer is reading now.
389
+ await expect(pending).resolves.toBe(false);
390
+ expect(store.entries.map((entry) => entry.id)).toEqual(['2']);
391
+ });
392
+
393
+ it('drops the comment and decision drafts when the viewer moves to another review', async () => {
394
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(makePage(['1']));
395
+ const store = useReviewActivityStore();
396
+ await store.fetchFeed('req-1');
397
+ store.draft = 'for req-1 only';
398
+ store.decisionNote = 'for req-1 only';
399
+
400
+ await store.fetchFeed('req-2');
401
+
402
+ expect(store.draft).toBe('');
403
+ expect(store.decisionNote).toBe('');
404
+
405
+ // The composer is on screen at the same time, so dropping the note must leave the
406
+ // comment the viewer is writing alone.
407
+ store.draft = 'half a comment';
408
+ store.decisionNote = 'needs work';
409
+ store.clearDecisionNote('needs work');
410
+ expect(store.draft).toBe('half a comment');
411
+ });
412
+
413
+ // Both the decision and the comment paths clear the note after awaiting a request, by
414
+ // which time the viewer may have typed the next one.
415
+ it('keeps a note typed while the submitted one was still in flight', () => {
416
+ const store = useReviewActivityStore();
417
+ store.decisionNote = 'and one more thing';
418
+
419
+ store.clearDecisionNote('needs work');
420
+ expect(store.decisionNote).toBe('and one more thing');
421
+
422
+ store.clearDecisionNote('and one more thing');
423
+ expect(store.decisionNote).toBe('');
424
+ });
425
+
426
+ it('reports a failed comment to the composer, not as a feed error', async () => {
427
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(makePage(['1']));
428
+ vi.mocked(workflowReviewsApi.createWorkflowReviewComment).mockRejectedValue(new Error('nope'));
429
+ const store = useReviewActivityStore();
430
+ await store.fetchFeed('req-1');
431
+
432
+ await expect(store.postComment('hi')).rejects.toThrow('nope');
433
+
434
+ expect(store.error).toBeNull();
435
+ expect(store.posting).toBe(false);
436
+ expect(store.entries.map((entry) => entry.id)).toEqual(['1']);
437
+ });
438
+
439
+ it('shows a failed load in the feed rather than throwing', async () => {
440
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockRejectedValue(new Error('boom'));
441
+ const store = useReviewActivityStore();
442
+
443
+ await store.fetchFeed('req-1');
444
+
445
+ expect(store.error).toEqual(new Error('boom'));
446
+ expect(store.loading).toBe(false);
447
+ });
448
+
449
+ it('keeps a loaded feed when load-more fails', async () => {
450
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockResolvedValue(
451
+ makePage(['3'], { nextCursor: 'cursor-1', hasMore: true }),
452
+ );
453
+ const store = useReviewActivityStore();
454
+ await store.fetchFeed('req-1');
455
+ vi.mocked(workflowReviewsApi.fetchWorkflowReviewActivity).mockRejectedValue(new Error('boom'));
456
+
457
+ await store.loadMore();
458
+
459
+ expect(store.error).toEqual(new Error('boom'));
460
+ expect(store.entries.map((entry) => entry.id)).toEqual(['3']);
461
+ expect(store.loadingMore).toBe(false);
462
+ });
463
+ });
@@ -0,0 +1,186 @@
1
+ import type { WorkflowReviewActivityEntry } from '@n8n/api-types';
2
+ import { defineStore } from 'pinia';
3
+ import { ref } from 'vue';
4
+
5
+ import { useRootStore } from '@n8n/stores/useRootStore';
6
+
7
+ import { createWorkflowReviewComment, fetchWorkflowReviewActivity } from './workflowReviews.api';
8
+ import { toError } from './workflowReviews.utils';
9
+
10
+ const DEFAULT_LIMIT = 25;
11
+
12
+ /**
13
+ * The activity feed of one review. `entries` is ascending by id: the backend pages
14
+ * backwards, so `loadMore` prepends older pages. Posted comments merge by id.
15
+ */
16
+ export const useReviewActivityStore = defineStore('workflowReviewActivity', () => {
17
+ const rootStore = useRootStore();
18
+
19
+ const currentReviewId = ref<string | null>(null);
20
+ const entries = ref<WorkflowReviewActivityEntry[]>([]);
21
+ const nextCursor = ref<string | null>(null);
22
+ const hasMore = ref(false);
23
+ const loading = ref(false);
24
+ const loadingMore = ref(false);
25
+ const posting = ref(false);
26
+ const error = ref<Error | null>(null);
27
+ // Held here, not in the composer: switching to the Changes tab unmounts it, and a
28
+ // half-typed comment must survive that.
29
+ const draft = ref('');
30
+ // Held here, not in the popover: the popover is not keyed on the review, so a local ref
31
+ // would carry review A's note into review B. This copy goes with the feed when the
32
+ // selection changes.
33
+ const decisionNote = ref('');
34
+
35
+ let feedRequestSeq = 0;
36
+ let postSeq = 0;
37
+
38
+ async function fetchFeed(reviewId: string) {
39
+ const requestSeq = ++feedRequestSeq;
40
+ const switchedReview = currentReviewId.value !== reviewId;
41
+ currentReviewId.value = reviewId;
42
+ nextCursor.value = null;
43
+ hasMore.value = false;
44
+ loadingMore.value = false;
45
+ loading.value = true;
46
+ error.value = null;
47
+ // Clearing on a refetch would drop a comment the viewer just posted onto a feed whose
48
+ // first page failed, re-enable send mid-post, and discard what they are typing. On a
49
+ // switch it has to go synchronously, or the gap until the response arrives renders the
50
+ // previous review's feed.
51
+ if (switchedReview) {
52
+ postSeq++;
53
+ entries.value = [];
54
+ posting.value = false;
55
+ draft.value = '';
56
+ decisionNote.value = '';
57
+ }
58
+
59
+ try {
60
+ const response = await fetchWorkflowReviewActivity(rootStore.restApiContext, reviewId, {
61
+ limit: DEFAULT_LIMIT,
62
+ });
63
+ if (requestSeq !== feedRequestSeq) return;
64
+
65
+ // Merged, not assigned: a comment posted while this page was in flight is already
66
+ // appended, and the server snapshot predates it. Only what is newer than the page
67
+ // survives, since `entries` is ascending by id and an older page kept here would
68
+ // land after the newer one and invert the list.
69
+ const newestInPage = Number(response.data.at(-1)?.id ?? 0);
70
+ entries.value = [
71
+ ...response.data,
72
+ ...entries.value.filter((entry) => Number(entry.id) > newestInPage),
73
+ ];
74
+ nextCursor.value = response.nextCursor;
75
+ hasMore.value = response.hasMore;
76
+ } catch (e) {
77
+ if (requestSeq !== feedRequestSeq) return;
78
+ // Deliberately recorded rather than thrown: load failures belong in the feed's
79
+ // error row, not in a toast.
80
+ error.value = toError(e);
81
+ } finally {
82
+ if (requestSeq === feedRequestSeq) {
83
+ loading.value = false;
84
+ }
85
+ }
86
+ }
87
+
88
+ async function loadMore() {
89
+ const reviewId = currentReviewId.value;
90
+ const cursor = nextCursor.value;
91
+ if (!reviewId || !cursor || loading.value || loadingMore.value) return;
92
+
93
+ const requestSeq = ++feedRequestSeq;
94
+ loadingMore.value = true;
95
+ error.value = null;
96
+
97
+ try {
98
+ const response = await fetchWorkflowReviewActivity(rootStore.restApiContext, reviewId, {
99
+ limit: DEFAULT_LIMIT,
100
+ cursor,
101
+ });
102
+ if (requestSeq !== feedRequestSeq || currentReviewId.value !== reviewId) return;
103
+
104
+ entries.value = [...response.data, ...entries.value];
105
+ nextCursor.value = response.nextCursor;
106
+ // A page that returns nothing ends the walk: keeping `hasMore` would re-arm the
107
+ // sentinel on an unchanged list and intersect forever.
108
+ hasMore.value = response.hasMore && response.data.length > 0;
109
+ } catch (e) {
110
+ if (requestSeq !== feedRequestSeq || currentReviewId.value !== reviewId) return;
111
+ error.value = toError(e);
112
+ } finally {
113
+ if (requestSeq === feedRequestSeq) {
114
+ loadingMore.value = false;
115
+ }
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Resolves `false` after a selection change. The saved comment can still belong
121
+ * in the current feed, but the caller must not clear a new draft.
122
+ */
123
+ async function postComment(body: string): Promise<boolean> {
124
+ const reviewId = currentReviewId.value;
125
+ // Thrown, not swallowed: a silent return would read as success to the caller.
126
+ if (!reviewId) throw new Error('Cannot post a comment without a selected review');
127
+
128
+ const requestSeq = ++postSeq;
129
+ posting.value = true;
130
+ try {
131
+ const entry = await createWorkflowReviewComment(rootStore.restApiContext, reviewId, { body });
132
+ if (currentReviewId.value !== reviewId) return false;
133
+
134
+ // A feed refetch that raced this post may already carry the comment.
135
+ entries.value = [...entries.value.filter((existing) => existing.id !== entry.id), entry].sort(
136
+ (a, b) => Number(a.id) - Number(b.id),
137
+ );
138
+ return requestSeq === postSeq;
139
+ } finally {
140
+ // Only the newest post owns the flag: after A -> B -> A a stale post finishing would
141
+ // otherwise re-enable send while the post the user is waiting on is still in flight.
142
+ if (requestSeq === postSeq) posting.value = false;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Clears the decision note, unless the viewer typed something else since the submit
148
+ * `expected` came from — both callers await a request first.
149
+ */
150
+ function clearDecisionNote(expected: string) {
151
+ if (decisionNote.value.trim() === expected) decisionNote.value = '';
152
+ }
153
+
154
+ function reset() {
155
+ feedRequestSeq += 1;
156
+ postSeq++;
157
+ currentReviewId.value = null;
158
+ entries.value = [];
159
+ nextCursor.value = null;
160
+ hasMore.value = false;
161
+ loading.value = false;
162
+ loadingMore.value = false;
163
+ posting.value = false;
164
+ error.value = null;
165
+ draft.value = '';
166
+ decisionNote.value = '';
167
+ }
168
+
169
+ return {
170
+ currentReviewId,
171
+ entries,
172
+ nextCursor,
173
+ hasMore,
174
+ loading,
175
+ loadingMore,
176
+ posting,
177
+ error,
178
+ draft,
179
+ decisionNote,
180
+ fetchFeed,
181
+ loadMore,
182
+ postComment,
183
+ clearDecisionNote,
184
+ reset,
185
+ };
186
+ });