@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,444 @@
1
+ import type {
2
+ InboxItem,
3
+ InboxSelfHealingItem,
4
+ InboxWorkflowReviewItem,
5
+ ListInboxResponse,
6
+ } from '@n8n/api-types';
7
+ import { ResponseError } from '@n8n/rest-api-client';
8
+ import { useSettingsStore } from '@n8n/stores/settings.store';
9
+ import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
10
+
11
+ import * as api from './inbox.api';
12
+ import { createInboxListSlice, useInboxStore } from './inbox.store';
13
+
14
+ vi.mock('./inbox.api');
15
+
16
+ const waitingOrClosedRequest = vi.fn<typeof api.fetchInbox>();
17
+ const authoredRequest = vi.fn<typeof api.fetchInbox>();
18
+
19
+ function result(id: string): InboxSelfHealingItem {
20
+ return {
21
+ id,
22
+ type: 'self_healing_result',
23
+ state: 'open',
24
+ projectId: 'project',
25
+ workflowId: 'workflow',
26
+ workflowName: 'Workflow',
27
+ summary: 'Review the proposed fix',
28
+ outcome: 'fix_ready',
29
+ createdAt: '2026-01-01T00:00:00.000Z',
30
+ updatedAt: '2026-01-01T00:00:00.000Z',
31
+ completedAt: '2026-01-01T00:00:00.000Z',
32
+ };
33
+ }
34
+ function page(
35
+ data: InboxItem[] = [],
36
+ overrides: Partial<ListInboxResponse> = {},
37
+ ): ListInboxResponse {
38
+ return {
39
+ data,
40
+ nextCursor: null,
41
+ hasMore: false,
42
+ partial: false,
43
+ failedSources: [],
44
+ disabledSources: [],
45
+ ...overrides,
46
+ };
47
+ }
48
+ function enableInbox() {
49
+ useSettingsStore().settings.inbox = {
50
+ enabled: true,
51
+ availableTypes: ['workflow_review', 'self_healing_result'],
52
+ failedTypes: [],
53
+ };
54
+ }
55
+
56
+ beforeEach(() => {
57
+ vi.resetAllMocks();
58
+ enableInbox();
59
+ vi.mocked(api.fetchInboxSummary).mockResolvedValue({
60
+ counts: { open: 2, closed: 1 },
61
+ partial: false,
62
+ failedSources: [],
63
+ disabledSources: [],
64
+ });
65
+ waitingOrClosedRequest.mockResolvedValue(page());
66
+ authoredRequest.mockResolvedValue(page());
67
+ vi.mocked(api.fetchInbox).mockImplementation(async (context, query) =>
68
+ query.category === 'authored'
69
+ ? await authoredRequest(context, query)
70
+ : await waitingOrClosedRequest(context, query),
71
+ );
72
+ });
73
+
74
+ it('keeps known counts when a later summary request fails', async () => {
75
+ const store = useInboxStore();
76
+ await store.fetchSummary();
77
+ vi.mocked(api.fetchInboxSummary).mockRejectedValueOnce(new Error('Summary unavailable'));
78
+ await store.fetchSummary();
79
+ expect(store.openCount).toBe(2);
80
+ expect(store.closedCount).toBe(1);
81
+ });
82
+
83
+ describe('Inbox list requests', () => {
84
+ it('restarts from the first page and exposes a failed reload', async () => {
85
+ const pending = createDeferredPromise<ListInboxResponse>();
86
+ const request = vi
87
+ .fn()
88
+ .mockResolvedValueOnce(page([result('first')]))
89
+ .mockReturnValueOnce(pending.promise);
90
+ const slice = createInboxListSlice(request, vi.fn());
91
+ await slice.fetchList();
92
+ const refresh = slice.fetchList();
93
+ expect(slice.items).toEqual([]);
94
+ pending.reject(new Error('temporarily unavailable'));
95
+ await refresh;
96
+ expect(slice.items).toEqual([]);
97
+ expect(slice.error).toBeInstanceOf(Error);
98
+ });
99
+
100
+ it('ignores an old load-more response after refreshing from the first page', async () => {
101
+ const pending = createDeferredPromise<ListInboxResponse>();
102
+ const request = vi
103
+ .fn()
104
+ .mockResolvedValueOnce(page([result('first')], { hasMore: true, nextCursor: 'page-2' }))
105
+ .mockReturnValueOnce(pending.promise)
106
+ .mockResolvedValueOnce(page([result('newest')]));
107
+ const slice = createInboxListSlice(request, vi.fn());
108
+ await slice.fetchList();
109
+ const loadMore = slice.loadMore();
110
+ await slice.fetchList();
111
+ pending.resolve(page([result('older')]));
112
+ await loadMore;
113
+ expect(slice.items.map((item) => item.id)).toEqual(['newest']);
114
+ expect(slice.loadingMore).toBe(false);
115
+ });
116
+
117
+ it('continues a partial cursor chain and retries a failed page without losing its rows', async () => {
118
+ const partial = { partial: true, failedSources: ['workflow_review' as const] };
119
+ const request = vi
120
+ .fn()
121
+ .mockResolvedValueOnce(
122
+ page([result('first')], { ...partial, hasMore: true, nextCursor: 'partial-page' }),
123
+ )
124
+ .mockRejectedValueOnce(new Error('timeout'))
125
+ .mockResolvedValueOnce(page([result('older')], partial));
126
+ const slice = createInboxListSlice(request, vi.fn());
127
+ await slice.fetchList();
128
+ await slice.loadMore();
129
+ expect(slice.nextCursor).toBe('partial-page');
130
+ expect(slice.items).toHaveLength(1);
131
+ await slice.retry();
132
+ expect(request).toHaveBeenLastCalledWith('partial-page');
133
+ expect(slice.items).toHaveLength(2);
134
+ expect(slice.partial).toBe(true);
135
+ });
136
+ });
137
+
138
+ describe('shared Inbox state', () => {
139
+ it('keeps requests for Open and Closed independent', async () => {
140
+ const open = createDeferredPromise<ListInboxResponse>();
141
+ waitingOrClosedRequest.mockImplementation(async (_context, query) =>
142
+ query.state === 'open' ? await open.promise : page([result('closed')]),
143
+ );
144
+ const store = useInboxStore();
145
+ const request = store.refreshListAndSummary();
146
+ await store.setActiveTab('closed');
147
+ open.resolve(page([result('open')]));
148
+ await request;
149
+ expect(store.activeTab).toBe('closed');
150
+ expect(store.lists.closed.items[0].id).toBe('closed');
151
+ expect(store.lists.waiting.items[0].id).toBe('open');
152
+ });
153
+
154
+ it('treats a failed count as unknown while the list remains usable', async () => {
155
+ waitingOrClosedRequest.mockResolvedValue(page([result('first')]));
156
+ vi.mocked(api.fetchInboxSummary).mockResolvedValue({
157
+ counts: null,
158
+ partial: true,
159
+ failedSources: ['workflow_review'],
160
+ disabledSources: [],
161
+ });
162
+ const store = useInboxStore();
163
+ await store.refreshListAndSummary();
164
+ expect(store.lists.waiting.items).toHaveLength(1);
165
+ expect(store.openCount).toBeNull();
166
+ });
167
+
168
+ it('clears disabled-source rows from both tabs even when all reads fail', async () => {
169
+ const store = useInboxStore();
170
+ store.lists.waiting.items = [result('open')];
171
+ store.lists.closed.items = [result('closed')];
172
+ waitingOrClosedRequest.mockRejectedValue(
173
+ new ResponseError('unavailable', {
174
+ httpStatusCode: 503,
175
+ meta: { disabledSources: ['self_healing_result'], failedSources: ['workflow_review'] },
176
+ }),
177
+ );
178
+ await store.refreshListAndSummary();
179
+ expect(store.lists.waiting.items).toEqual([]);
180
+ expect(store.lists.closed.items).toEqual([]);
181
+ expect(store.disabledSources).toEqual(['self_healing_result']);
182
+ });
183
+ });
184
+
185
+ it('allows a source to recover on a fresh page without a settings reload', async () => {
186
+ const store = useInboxStore();
187
+ waitingOrClosedRequest
188
+ .mockResolvedValueOnce(page([], { disabledSources: ['self_healing_result'] }))
189
+ .mockResolvedValueOnce(page([result('recovered')]));
190
+ await store.refreshListAndSummary();
191
+ expect(store.disabledSources).toContain('self_healing_result');
192
+ await store.refreshListAndSummary();
193
+ expect(store.disabledSources).not.toContain('self_healing_result');
194
+ expect(store.lists.waiting.items[0].id).toBe('recovered');
195
+ });
196
+
197
+ it('allows a source first seen on a later page when settings were stale', async () => {
198
+ useSettingsStore().settings.inbox = {
199
+ enabled: true,
200
+ availableTypes: ['self_healing_result'],
201
+ failedTypes: [],
202
+ };
203
+ const review: InboxItem = {
204
+ type: 'workflow_review',
205
+ id: 'review',
206
+ projectId: 'project',
207
+ title: 'Review',
208
+ workflowName: 'Workflow',
209
+ requester: null,
210
+ authors: [],
211
+ reviewers: [],
212
+ state: 'open',
213
+ decision: 'pending',
214
+ workflowVersionId: null,
215
+ createdAt: '2026-01-01T00:00:00.000Z',
216
+ updatedAt: '2026-01-01T00:00:00.000Z',
217
+ };
218
+ waitingOrClosedRequest
219
+ .mockResolvedValueOnce(page([result('newer')], { hasMore: true, nextCursor: 'next' }))
220
+ .mockResolvedValueOnce(page([review]));
221
+ const store = useInboxStore();
222
+ await store.refreshListAndSummary();
223
+ expect(store.disabledSources).toContain('workflow_review');
224
+ await store.lists.waiting.loadMore();
225
+ expect(store.disabledSources).not.toContain('workflow_review');
226
+ expect(store.lists.waiting.items.at(-1)?.id).toBe('review');
227
+ });
228
+
229
+ it('starts a new cursor chain to recover a failed source after paging', async () => {
230
+ const partial = { partial: true, failedSources: ['workflow_review' as const] };
231
+ const request = vi
232
+ .fn()
233
+ .mockResolvedValueOnce(
234
+ page([result('first')], { ...partial, hasMore: true, nextCursor: 'partial-second' }),
235
+ )
236
+ .mockResolvedValueOnce(
237
+ page([result('older')], { ...partial, hasMore: true, nextCursor: 'partial-third' }),
238
+ )
239
+ .mockRejectedValueOnce(new Error('timeout'))
240
+ .mockResolvedValueOnce(
241
+ page([result('recovered')], { hasMore: true, nextCursor: 'complete-second' }),
242
+ );
243
+ const slice = createInboxListSlice(request, vi.fn());
244
+ await slice.fetchList();
245
+ await slice.loadMore();
246
+ await slice.fetchList();
247
+ expect(slice.items).toEqual([]);
248
+ expect(slice.nextCursor).toBeNull();
249
+ await slice.retry();
250
+ expect(request).toHaveBeenLastCalledWith();
251
+ expect(slice.items.map((item) => item.id)).toEqual(['recovered']);
252
+ expect(slice.nextCursor).toBe('complete-second');
253
+ expect(slice.partial).toBe(false);
254
+ });
255
+
256
+ it('continues healthy-source pagination when a summary disables another source', async () => {
257
+ waitingOrClosedRequest
258
+ .mockResolvedValueOnce(page([result('first')], { hasMore: true, nextCursor: 'second' }))
259
+ .mockResolvedValueOnce(page([result('older')], { hasMore: true, nextCursor: 'third' }))
260
+ .mockResolvedValueOnce(page([result('oldest')]));
261
+ const store = useInboxStore();
262
+ await store.refreshListAndSummary();
263
+ await store.lists.waiting.loadMore();
264
+ vi.mocked(api.fetchInboxSummary).mockResolvedValue({
265
+ counts: { open: 3, closed: 0 },
266
+ partial: false,
267
+ failedSources: [],
268
+ disabledSources: ['workflow_review'],
269
+ });
270
+ await store.fetchSummary();
271
+ expect(store.lists.waiting.items.map((item) => item.id)).toEqual(['first', 'older']);
272
+ expect(store.lists.waiting.nextCursor).toBe('third');
273
+ await store.lists.waiting.loadMore();
274
+ expect(waitingOrClosedRequest).toHaveBeenLastCalledWith(
275
+ expect.anything(),
276
+ expect.objectContaining({ cursor: 'third' }),
277
+ );
278
+ expect(store.lists.waiting.items.map((item) => item.id)).toEqual(['first', 'older', 'oldest']);
279
+ });
280
+
281
+ it('requests the two Open categories and omits category for Closed', async () => {
282
+ const store = useInboxStore();
283
+ await store.refreshListAndSummary();
284
+ expect(api.fetchInbox).toHaveBeenCalledWith(expect.anything(), {
285
+ state: 'open',
286
+ category: 'waiting',
287
+ limit: 15,
288
+ cursor: undefined,
289
+ });
290
+ expect(api.fetchInbox).toHaveBeenCalledWith(expect.anything(), {
291
+ state: 'open',
292
+ category: 'authored',
293
+ limit: 15,
294
+ cursor: undefined,
295
+ });
296
+ await store.setActiveTab('closed');
297
+ expect(api.fetchInbox).toHaveBeenLastCalledWith(expect.anything(), {
298
+ state: 'closed',
299
+ category: undefined,
300
+ limit: 15,
301
+ cursor: undefined,
302
+ });
303
+ });
304
+
305
+ it('keeps each Open group cursor and retry independent', async () => {
306
+ waitingOrClosedRequest.mockResolvedValueOnce(
307
+ page([result('waiting')], { hasMore: true, nextCursor: 'waiting-2' }),
308
+ );
309
+ authoredRequest
310
+ .mockResolvedValueOnce(
311
+ page([reviewItem('authored')], { hasMore: true, nextCursor: 'authored-2' }),
312
+ )
313
+ .mockRejectedValueOnce(new Error('timeout'))
314
+ .mockResolvedValueOnce(page([reviewItem('older')]));
315
+ const store = useInboxStore();
316
+ await store.refreshListAndSummary();
317
+ await store.lists.authored.loadMore();
318
+ expect(store.lists.waiting.error).toBeNull();
319
+ expect(store.lists.waiting.nextCursor).toBe('waiting-2');
320
+ expect(store.lists.authored.nextCursor).toBe('authored-2');
321
+ await store.lists.authored.retry();
322
+ expect(authoredRequest).toHaveBeenLastCalledWith(
323
+ expect.anything(),
324
+ expect.objectContaining({ category: 'authored', cursor: 'authored-2' }),
325
+ );
326
+ expect(waitingOrClosedRequest).toHaveBeenCalledOnce();
327
+ expect(store.lists.authored.items.map((item) => item.id)).toEqual(['authored', 'older']);
328
+ });
329
+
330
+ it('requires both groups to be successfully empty before showing the Open empty state', async () => {
331
+ const store = useInboxStore();
332
+ expect(store.isEmpty).toBe(false);
333
+ authoredRequest.mockRejectedValueOnce(new Error('timeout'));
334
+ await store.refreshListAndSummary();
335
+ expect(store.lists.waiting.hasLoaded).toBe(true);
336
+ expect(store.initialLoadFailed).toBe(true);
337
+ expect(store.isEmpty).toBe(false);
338
+ authoredRequest.mockResolvedValueOnce(
339
+ page([], { partial: true, failedSources: ['workflow_review'] }),
340
+ );
341
+ await store.lists.authored.retry();
342
+ expect(store.partial).toBe(true);
343
+ expect(store.isEmpty).toBe(false);
344
+ await store.lists.authored.fetchList();
345
+ expect(store.isEmpty).toBe(true);
346
+ });
347
+
348
+ it('does not infer Assistant availability from the Authored response', async () => {
349
+ useSettingsStore().settings.inbox = {
350
+ enabled: true,
351
+ availableTypes: ['workflow_review'],
352
+ failedTypes: [],
353
+ };
354
+ const authored = createDeferredPromise<ListInboxResponse>();
355
+ waitingOrClosedRequest.mockResolvedValueOnce(page([result('assistant')]));
356
+ authoredRequest.mockReturnValueOnce(authored.promise);
357
+ const store = useInboxStore();
358
+ const refresh = store.refreshListAndSummary();
359
+ await vi.waitFor(() => expect(store.disabledSources).not.toContain('self_healing_result'));
360
+ authored.resolve(page());
361
+ await refresh;
362
+ expect(store.disabledSources).not.toContain('self_healing_result');
363
+ expect(store.lists.waiting.items[0].id).toBe('assistant');
364
+ });
365
+
366
+ function reviewItem(id: string): InboxWorkflowReviewItem {
367
+ return {
368
+ type: 'workflow_review',
369
+ id,
370
+ projectId: 'project',
371
+ title: 'Review',
372
+ workflowName: 'Workflow',
373
+ requester: null,
374
+ authors: [],
375
+ reviewers: [],
376
+ state: 'open',
377
+ decision: 'pending',
378
+ workflowVersionId: null,
379
+ createdAt: '2026-01-01T00:00:00.000Z',
380
+ updatedAt: '2026-01-01T00:00:00.000Z',
381
+ };
382
+ }
383
+
384
+ it('updates a review decision without discarding loaded pages', async () => {
385
+ const store = useInboxStore();
386
+ store.lists.waiting.items = [reviewItem('first'), reviewItem('older'), result('assistant')];
387
+ store.lists.waiting.nextCursor = 'next-page';
388
+ store.reconcileItemChange({
389
+ type: 'workflow_review',
390
+ id: 'older',
391
+ state: 'open',
392
+ decision: 'changes_requested',
393
+ updatedAt: '2026-01-02T00:00:00.000Z',
394
+ });
395
+ expect(store.lists.waiting.items).toHaveLength(3);
396
+ expect(store.lists.waiting.items[1]).toMatchObject({ decision: 'changes_requested' });
397
+ expect(store.lists.waiting.nextCursor).toBe('next-page');
398
+ expect(api.fetchInbox).not.toHaveBeenCalled();
399
+ });
400
+
401
+ it('removes an approved review from Open and updates the counts', async () => {
402
+ const store = useInboxStore();
403
+ store.openCount = 2;
404
+ store.closedCount = 1;
405
+ store.lists.waiting.items = [reviewItem('review'), result('review')];
406
+ store.reconcileItemChange({
407
+ type: 'workflow_review',
408
+ id: 'review',
409
+ state: 'closed',
410
+ decision: 'approved',
411
+ });
412
+ expect(store.lists.waiting.items).toEqual([result('review')]);
413
+ expect(store.openCount).toBe(1);
414
+ expect(store.closedCount).toBe(2);
415
+ });
416
+
417
+ it('keeps the counts when a late approval arrives after a refresh already closed the review', async () => {
418
+ const store = useInboxStore();
419
+ store.openCount = 1;
420
+ store.closedCount = 2;
421
+ store.lists.closed.items = [{ ...reviewItem('review'), state: 'closed', decision: 'approved' }];
422
+ store.reconcileItemChange({ type: 'workflow_review', id: 'review', state: 'closed' });
423
+ expect(store.openCount).toBe(1);
424
+ expect(store.closedCount).toBe(2);
425
+ expect(api.fetchInboxSummary).not.toHaveBeenCalled();
426
+ });
427
+
428
+ it('loads the counts when an approved review is not in a loaded page', async () => {
429
+ vi.mocked(api.fetchInboxSummary).mockResolvedValueOnce({
430
+ counts: { open: 1, closed: 2 },
431
+ partial: false,
432
+ failedSources: [],
433
+ disabledSources: [],
434
+ });
435
+ const store = useInboxStore();
436
+ store.openCount = 4;
437
+ store.closedCount = 7;
438
+ store.reconcileItemChange({ type: 'workflow_review', id: 'review', state: 'closed' });
439
+ await vi.waitFor(() => {
440
+ expect(api.fetchInboxSummary).toHaveBeenCalledOnce();
441
+ expect(store.openCount).toBe(1);
442
+ expect(store.closedCount).toBe(2);
443
+ });
444
+ });