@n8n/frontend-module-inbox 0.0.0-stage → 0.1.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 (58) hide show
  1. package/LICENSE.md +88 -0
  2. package/README.md +59 -2
  3. package/biome.jsonc +4 -0
  4. package/eslint.config.mjs +4 -0
  5. package/package.json +62 -3
  6. package/src/__tests__/design-system-icons.test.ts +24 -0
  7. package/src/__tests__/setup.ts +4 -0
  8. package/src/components/InboxEmptyState.vue +86 -0
  9. package/src/components/InboxList.test.ts +229 -0
  10. package/src/components/InboxList.vue +195 -0
  11. package/src/components/InboxListItem.vue +134 -0
  12. package/src/components/InboxListSection.vue +168 -0
  13. package/src/composables/useReviewInboxSectionCollapse.test.ts +66 -0
  14. package/src/composables/useReviewInboxSectionCollapse.ts +42 -0
  15. package/src/inbox.api.ts +20 -0
  16. package/src/inbox.constants.ts +16 -0
  17. package/src/inbox.module.ts +50 -0
  18. package/src/inbox.routes.test.ts +111 -0
  19. package/src/inbox.routes.ts +30 -0
  20. package/src/inbox.store.test.ts +349 -0
  21. package/src/inbox.store.ts +305 -0
  22. package/src/index.ts +5 -0
  23. package/src/reviews/WorkflowReviewDetail.test.ts +647 -0
  24. package/src/reviews/WorkflowReviewDetail.vue +257 -0
  25. package/src/reviews/components/WorkflowReviewActivityFeed.test.ts +923 -0
  26. package/src/reviews/components/WorkflowReviewActivityFeed.vue +312 -0
  27. package/src/reviews/components/WorkflowReviewChangesSection.test.ts +327 -0
  28. package/src/reviews/components/WorkflowReviewChangesSection.vue +219 -0
  29. package/src/reviews/components/WorkflowReviewCommentComposer.test.ts +175 -0
  30. package/src/reviews/components/WorkflowReviewCommentComposer.vue +83 -0
  31. package/src/reviews/components/WorkflowReviewDecisionPopover.test.ts +288 -0
  32. package/src/reviews/components/WorkflowReviewDecisionPopover.vue +203 -0
  33. package/src/reviews/components/WorkflowReviewDetailMetadata.test.ts +206 -0
  34. package/src/reviews/components/WorkflowReviewDetailMetadata.vue +241 -0
  35. package/src/reviews/components/WorkflowReviewDetailTabs.decisionNote.test.ts +90 -0
  36. package/src/reviews/components/WorkflowReviewDetailTabs.test.ts +434 -0
  37. package/src/reviews/components/WorkflowReviewDetailTabs.vue +311 -0
  38. package/src/reviews/components/WorkflowReviewStatusDot.vue +67 -0
  39. package/src/reviews/components/_activity-card.scss +36 -0
  40. package/src/reviews/components/activity-entries/WorkflowReviewActivityActorAvatar.vue +50 -0
  41. package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.test.ts +103 -0
  42. package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.vue +116 -0
  43. package/src/reviews/components/activity-entries/WorkflowReviewActivityEventEntry.vue +361 -0
  44. package/src/reviews/components/activity-entries/WorkflowReviewActivityFallback.vue +49 -0
  45. package/src/reviews/components/activity-entries/WorkflowReviewActivityWorkflowLink.vue +36 -0
  46. package/src/reviews/components/activityEntryRegistry.ts +27 -0
  47. package/src/reviews/constants.ts +41 -0
  48. package/src/reviews/reviewActivity.store.test.ts +463 -0
  49. package/src/reviews/reviewActivity.store.ts +186 -0
  50. package/src/reviews/reviewDetail.store.test.ts +135 -0
  51. package/src/reviews/reviewDetail.store.ts +74 -0
  52. package/src/reviews/workflowReviewStatus.utils.ts +44 -0
  53. package/src/reviews/workflowReviews.api.ts +61 -0
  54. package/src/reviews/workflowReviews.utils.ts +18 -0
  55. package/src/views/InboxView.test.ts +525 -0
  56. package/src/views/InboxView.vue +251 -0
  57. package/stylelint.config.mjs +9 -0
  58. package/vite.config.ts +52 -0
@@ -0,0 +1,434 @@
1
+ import type {
2
+ WorkflowReviewInboxItem,
3
+ WorkflowReviewRequestDetail,
4
+ WorkflowReviewRequestWorkflowDetail,
5
+ } from '@n8n/api-types';
6
+ import { createTestingPinia } from '@pinia/testing';
7
+ import { createComponentRenderer } from '@n8n/frontend-test-utils';
8
+
9
+ import WorkflowReviewDetailTabs from './WorkflowReviewDetailTabs.vue';
10
+
11
+ vi.mock('./WorkflowReviewChangesSection.vue', () => ({
12
+ default: {
13
+ name: 'WorkflowReviewChangesSection',
14
+ props: ['workflow', 'state', 'decision'],
15
+ template:
16
+ '<div data-test-id="workflow-review-changes-section" :data-state="state" :data-decision="decision" />',
17
+ },
18
+ }));
19
+
20
+ vi.mock('./WorkflowReviewActivityFeed.vue', async () => {
21
+ const { computed, inject } = await import('vue');
22
+ const { ReviewLinkedWorkflowsKey } = await import('../constants');
23
+ return {
24
+ default: {
25
+ name: 'WorkflowReviewActivityFeed',
26
+ setup() {
27
+ const linkedWorkflows = inject(
28
+ ReviewLinkedWorkflowsKey,
29
+ computed(() => new Map()),
30
+ );
31
+ return {
32
+ linkedWorkflowsJson: computed(() =>
33
+ JSON.stringify(Object.fromEntries(linkedWorkflows.value)),
34
+ ),
35
+ };
36
+ },
37
+ template:
38
+ '<div data-test-id="workflow-review-activity-feed" :data-linked-workflows="linkedWorkflowsJson"><slot name="header" /><slot name="footer" /><slot name="composer" /></div>',
39
+ },
40
+ };
41
+ });
42
+
43
+ vi.mock('./WorkflowReviewCommentComposer.vue', () => ({
44
+ default: {
45
+ name: 'WorkflowReviewCommentComposer',
46
+ props: ['canComment'],
47
+ template:
48
+ '<div data-test-id="workflow-review-comment-composer" :data-can-comment="canComment" />',
49
+ },
50
+ }));
51
+
52
+ vi.mock('./WorkflowReviewDetailMetadata.vue', () => ({
53
+ default: {
54
+ name: 'WorkflowReviewDetailMetadata',
55
+ template: '<aside data-test-id="workflow-review-detail-metadata" />',
56
+ },
57
+ }));
58
+
59
+ // The real popover cannot open in jsdom (Reka UI), so expose what this component passes
60
+ // down as attributes and let a button stand in for the comment it reports back.
61
+ vi.mock('./WorkflowReviewDecisionPopover.vue', () => ({
62
+ default: {
63
+ name: 'WorkflowReviewDecisionPopover',
64
+ props: ['deciding', 'viewerCanDecide', 'viewerCanComment', 'ineligibilityHint'],
65
+ template: `
66
+ <div
67
+ data-test-id="workflow-review-decision-popover"
68
+ :data-can-decide="viewerCanDecide"
69
+ :data-ineligibility-hint="ineligibilityHint"
70
+ >
71
+ <button data-test-id="emit-comment-posted" @click="$emit('comment-posted')" />
72
+ </div>`,
73
+ },
74
+ }));
75
+
76
+ const renderComponent = createComponentRenderer(WorkflowReviewDetailTabs);
77
+
78
+ function makeInboxItem(overrides: Partial<WorkflowReviewInboxItem> = {}): WorkflowReviewInboxItem {
79
+ return {
80
+ id: 'req-1',
81
+ projectId: 'proj-1',
82
+ title: 'Needs review',
83
+ workflowName: 'My workflow',
84
+ workflowVersionId: null,
85
+ requester: null,
86
+ authors: [],
87
+ reviewers: [],
88
+ decision: 'pending',
89
+ state: 'open',
90
+ createdAt: '2024-01-01T00:00:00.000Z',
91
+ updatedAt: '2024-01-01T00:00:00.000Z',
92
+ ...overrides,
93
+ };
94
+ }
95
+
96
+ function makeWorkflowDetail(
97
+ overrides: Partial<WorkflowReviewRequestWorkflowDetail> = {},
98
+ ): WorkflowReviewRequestWorkflowDetail {
99
+ return {
100
+ workflowId: 'wf-1',
101
+ workflowName: 'My workflow',
102
+ workflowVersionId: 'version-1',
103
+ pinnedVersion: null,
104
+ publishedVersionId: null,
105
+ baselineVersion: null,
106
+ ...overrides,
107
+ };
108
+ }
109
+
110
+ function makeDetail(
111
+ overrides: Partial<WorkflowReviewRequestDetail> = {},
112
+ ): WorkflowReviewRequestDetail {
113
+ return {
114
+ ...makeInboxItem(),
115
+ description: null,
116
+ workflows: [makeWorkflowDetail()],
117
+ viewerCanDecide: true,
118
+ viewerDecisionIneligibilityReason: null,
119
+ viewerCanComment: true,
120
+ ...overrides,
121
+ };
122
+ }
123
+
124
+ describe('WorkflowReviewDetailTabs', () => {
125
+ beforeEach(() => {
126
+ createTestingPinia();
127
+ });
128
+
129
+ it('lets the viewer switch between the Activity and Changes tabs', async () => {
130
+ const { getByText, emitted } = renderComponent({
131
+ props: { review: makeDetail(), tab: 'activity', deciding: false },
132
+ });
133
+
134
+ getByText('Changes').click();
135
+
136
+ expect(emitted('update:tab')).toEqual([['changes']]);
137
+ });
138
+
139
+ describe('activity tab', () => {
140
+ it('renders the description when present', () => {
141
+ const { getByTestId, queryByTestId } = renderComponent({
142
+ props: {
143
+ review: makeDetail({ description: 'Adds retry logic' }),
144
+ tab: 'activity',
145
+ deciding: false,
146
+ },
147
+ });
148
+
149
+ expect(getByTestId('workflow-review-description')).toHaveTextContent('Adds retry logic');
150
+ expect(queryByTestId('workflow-review-no-description')).not.toBeInTheDocument();
151
+ });
152
+
153
+ it('renders a fallback when the review has no description', () => {
154
+ const { getByTestId } = renderComponent({
155
+ props: { review: makeDetail(), tab: 'activity', deciding: false },
156
+ });
157
+
158
+ expect(getByTestId('workflow-review-no-description')).toBeInTheDocument();
159
+ });
160
+
161
+ // Both go inside the feed, so they scroll with the entries.
162
+ it('places the description and the composer inside the feed', () => {
163
+ const { getByTestId } = renderComponent({
164
+ props: {
165
+ review: makeDetail({ description: 'Adds retry logic' }),
166
+ tab: 'activity',
167
+ deciding: false,
168
+ },
169
+ });
170
+
171
+ const feed = getByTestId('workflow-review-activity-feed');
172
+ const description = getByTestId('workflow-review-description');
173
+ const composer = getByTestId('workflow-review-comment-composer');
174
+
175
+ expect(feed).toContainElement(description);
176
+ expect(feed).toContainElement(composer);
177
+ });
178
+
179
+ it.each([
180
+ ['lets a viewer who may comment use the composer', true],
181
+ ['locks the composer for a viewer who may not', false],
182
+ ])('%s', (_label, viewerCanComment) => {
183
+ const { getByTestId } = renderComponent({
184
+ props: { review: makeDetail({ viewerCanComment }), tab: 'activity', deciding: false },
185
+ });
186
+
187
+ expect(getByTestId('workflow-review-comment-composer')).toHaveAttribute(
188
+ 'data-can-comment',
189
+ String(viewerCanComment),
190
+ );
191
+ });
192
+
193
+ it('defaults the composer to read-only on a review whose detail never loaded', () => {
194
+ const { getByTestId } = renderComponent({
195
+ props: { review: makeInboxItem(), tab: 'activity', deciding: false },
196
+ });
197
+
198
+ expect(getByTestId('workflow-review-comment-composer')).toHaveAttribute(
199
+ 'data-can-comment',
200
+ 'false',
201
+ );
202
+ });
203
+
204
+ it('provides the linked workflows to the feed', () => {
205
+ const { getByTestId } = renderComponent({
206
+ props: { review: makeDetail(), tab: 'activity', deciding: false },
207
+ });
208
+
209
+ expect(getByTestId('workflow-review-activity-feed')).toHaveAttribute(
210
+ 'data-linked-workflows',
211
+ JSON.stringify({
212
+ 'wf-1': {
213
+ workflowName: 'My workflow',
214
+ pinnedVersionId: 'version-1',
215
+ pinnedVersionName: null,
216
+ },
217
+ }),
218
+ );
219
+ });
220
+
221
+ describe('closed-state callout', () => {
222
+ it('celebrates a review that was approved and published', () => {
223
+ const { getByTestId } = renderComponent({
224
+ props: {
225
+ review: makeDetail({
226
+ state: 'closed',
227
+ decision: 'approved',
228
+ workflows: [makeWorkflowDetail({ publishedVersionId: 'version-1' })],
229
+ }),
230
+ tab: 'activity',
231
+ deciding: false,
232
+ },
233
+ });
234
+
235
+ const callout = getByTestId('workflow-review-closed-callout');
236
+ expect(callout).toHaveTextContent('Submission closed');
237
+ expect(callout).toHaveTextContent(
238
+ 'The review was approved and the workflow was published.',
239
+ );
240
+ });
241
+
242
+ it('shows no callout while the review is open', () => {
243
+ const { queryByTestId } = renderComponent({
244
+ props: { review: makeDetail(), tab: 'activity', deciding: false },
245
+ });
246
+
247
+ expect(queryByTestId('workflow-review-closed-callout')).not.toBeInTheDocument();
248
+ });
249
+
250
+ it('shows no callout when the approved version is not the published one', () => {
251
+ const { queryByTestId } = renderComponent({
252
+ props: {
253
+ review: makeDetail({
254
+ state: 'closed',
255
+ decision: 'approved',
256
+ workflows: [makeWorkflowDetail({ publishedVersionId: 'other-version' })],
257
+ }),
258
+ tab: 'activity',
259
+ deciding: false,
260
+ },
261
+ });
262
+
263
+ expect(queryByTestId('workflow-review-closed-callout')).not.toBeInTheDocument();
264
+ });
265
+
266
+ // A lifecycle close is already summarized by its `review.closed` feed entry,
267
+ // which renders as a callout; a second one here would say the same thing twice.
268
+ it('adds no summary for a review closed without an approval', () => {
269
+ const { queryByTestId } = renderComponent({
270
+ props: {
271
+ review: makeDetail({ state: 'closed', decision: 'changes_requested' }),
272
+ tab: 'activity',
273
+ deciding: false,
274
+ },
275
+ });
276
+
277
+ expect(queryByTestId('workflow-review-closed-callout')).not.toBeInTheDocument();
278
+ });
279
+ });
280
+
281
+ it('does not render the composer on closed reviews', () => {
282
+ const { getByTestId, queryByTestId } = renderComponent({
283
+ props: {
284
+ review: makeDetail({ state: 'closed', decision: 'approved' }),
285
+ tab: 'activity',
286
+ deciding: false,
287
+ },
288
+ });
289
+
290
+ expect(getByTestId('workflow-review-activity-feed')).toBeInTheDocument();
291
+ expect(queryByTestId('workflow-review-comment-composer')).not.toBeInTheDocument();
292
+ });
293
+ });
294
+
295
+ describe('changes tab', () => {
296
+ it('renders one section per workflow', () => {
297
+ const { getAllByTestId } = renderComponent({
298
+ props: {
299
+ review: makeDetail({
300
+ workflows: [
301
+ makeWorkflowDetail(),
302
+ makeWorkflowDetail({ workflowId: 'wf-2', workflowName: 'Other workflow' }),
303
+ ],
304
+ }),
305
+ tab: 'changes',
306
+ deciding: false,
307
+ },
308
+ });
309
+
310
+ expect(getAllByTestId('workflow-review-changes-section')).toHaveLength(2);
311
+ });
312
+
313
+ // A closed review keeps its diff: the backend serves the baseline frozen at
314
+ // approval, and the section needs the lifecycle to phrase the sides.
315
+ it('renders the diff for a closed review and forwards its lifecycle', () => {
316
+ const { getByTestId } = renderComponent({
317
+ props: {
318
+ review: makeDetail({ state: 'closed', decision: 'approved' }),
319
+ tab: 'changes',
320
+ deciding: false,
321
+ },
322
+ });
323
+
324
+ const section = getByTestId('workflow-review-changes-section');
325
+ expect(section).toHaveAttribute('data-state', 'closed');
326
+ expect(section).toHaveAttribute('data-decision', 'approved');
327
+ });
328
+
329
+ it('shows an unavailable state when the detail has no workflows left', () => {
330
+ const { getByTestId } = renderComponent({
331
+ props: {
332
+ review: makeDetail({ state: 'closed', workflows: [] }),
333
+ tab: 'changes',
334
+ deciding: false,
335
+ },
336
+ });
337
+
338
+ expect(getByTestId('workflow-review-changes-workflow-unavailable')).toBeInTheDocument();
339
+ });
340
+
341
+ it('shows an error state when the detail fetch failed', () => {
342
+ const { getByTestId, queryByTestId } = renderComponent({
343
+ props: { review: makeInboxItem(), tab: 'changes', deciding: false },
344
+ });
345
+
346
+ expect(getByTestId('workflow-review-changes-unavailable')).toBeInTheDocument();
347
+ expect(queryByTestId('workflow-review-changes-workflow-unavailable')).not.toBeInTheDocument();
348
+ });
349
+ });
350
+
351
+ describe('decision actions', () => {
352
+ // The trigger lives outside the tab panel, so a comment posted from the Changes tab
353
+ // would otherwise succeed with nothing to show for it.
354
+ it('switches to the activity tab once a comment was posted from the popover', () => {
355
+ const { getByTestId, emitted } = renderComponent({
356
+ props: { review: makeDetail(), tab: 'changes', deciding: false },
357
+ });
358
+
359
+ getByTestId('emit-comment-posted').click();
360
+
361
+ expect(emitted('update:tab')).toEqual([['activity']]);
362
+ });
363
+
364
+ it('offers no decision on a closed review', () => {
365
+ const { queryByTestId } = renderComponent({
366
+ props: {
367
+ review: makeDetail({ state: 'closed', decision: 'approved' }),
368
+ tab: 'activity',
369
+ deciding: false,
370
+ },
371
+ });
372
+
373
+ expect(queryByTestId('workflow-review-decision-popover')).not.toBeInTheDocument();
374
+ });
375
+
376
+ it('offers no decision on a review whose detail payload never loaded', () => {
377
+ const { queryByTestId } = renderComponent({
378
+ props: { review: makeInboxItem(), tab: 'activity', deciding: false },
379
+ });
380
+
381
+ expect(queryByTestId('workflow-review-decision-popover')).not.toBeInTheDocument();
382
+ });
383
+
384
+ it('leaves the decision actions open with no hint when the viewer can decide', () => {
385
+ const { getByTestId } = renderComponent({
386
+ props: { review: makeDetail(), tab: 'activity', deciding: false },
387
+ });
388
+
389
+ const popover = getByTestId('workflow-review-decision-popover');
390
+ expect(popover).toHaveAttribute('data-can-decide', 'true');
391
+ expect(popover).toHaveAttribute('data-ineligibility-hint', '');
392
+ });
393
+
394
+ it('says the viewer contributed a version when that is why they cannot decide', () => {
395
+ const { getByTestId } = renderComponent({
396
+ props: {
397
+ review: makeDetail({
398
+ viewerCanDecide: false,
399
+ viewerDecisionIneligibilityReason: 'author',
400
+ }),
401
+ tab: 'activity',
402
+ deciding: false,
403
+ },
404
+ });
405
+
406
+ const popover = getByTestId('workflow-review-decision-popover');
407
+ expect(popover).toHaveAttribute('data-can-decide', 'false');
408
+ expect(popover).toHaveAttribute(
409
+ 'data-ineligibility-hint',
410
+ 'You contributed a version to this review.',
411
+ );
412
+ });
413
+
414
+ it('falls back to the generic permission hint for any other reason', () => {
415
+ const { getByTestId } = renderComponent({
416
+ props: {
417
+ review: makeDetail({
418
+ viewerCanDecide: false,
419
+ viewerDecisionIneligibilityReason: 'missing_reviewer_permission',
420
+ }),
421
+ tab: 'activity',
422
+ deciding: false,
423
+ },
424
+ });
425
+
426
+ const popover = getByTestId('workflow-review-decision-popover');
427
+ expect(popover).toHaveAttribute('data-can-decide', 'false');
428
+ expect(popover).toHaveAttribute(
429
+ 'data-ineligibility-hint',
430
+ 'Missing permissions to perform this action',
431
+ );
432
+ });
433
+ });
434
+ });