@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,135 @@
1
+ import type {
2
+ DecideWorkflowReviewRequestResponse,
3
+ WorkflowReviewRequestDetail,
4
+ } from '@n8n/api-types';
5
+ import { ResponseError } from '@n8n/rest-api-client';
6
+ import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
7
+
8
+ import { useReviewDetailStore } from './reviewDetail.store';
9
+ import * as api from './workflowReviews.api';
10
+
11
+ vi.mock('./workflowReviews.api');
12
+
13
+ function detail(id: string): WorkflowReviewRequestDetail {
14
+ return {
15
+ id,
16
+ state: 'open',
17
+ decision: 'pending',
18
+ createdAt: '2026-01-01T00:00:00.000Z',
19
+ updatedAt: '2026-01-01T00:00:00.000Z',
20
+ projectId: 'project',
21
+ title: 'Review',
22
+ requester: null,
23
+ authors: [],
24
+ reviewers: [],
25
+ description: null,
26
+ workflows: [],
27
+ viewerCanDecide: true,
28
+ viewerCanComment: true,
29
+ viewerDecisionIneligibilityReason: null,
30
+ };
31
+ }
32
+
33
+ beforeEach(() => vi.resetAllMocks());
34
+
35
+ it('ignores detail responses from an earlier selection', async () => {
36
+ const first = createDeferredPromise<WorkflowReviewRequestDetail>();
37
+ vi.mocked(api.fetchWorkflowReviewRequestDetail)
38
+ .mockReturnValueOnce(first.promise)
39
+ .mockResolvedValueOnce(detail('second'));
40
+ const store = useReviewDetailStore();
41
+ const pending = store.fetchDetail('first');
42
+ await store.fetchDetail('second');
43
+ first.resolve(detail('first'));
44
+ await pending;
45
+ expect(store.detail?.id).toBe('second');
46
+ expect(store.detailLoading).toBe(false);
47
+ });
48
+
49
+ it('keeps cached detail when a refetch reports that the review does not exist', async () => {
50
+ vi.mocked(api.fetchWorkflowReviewRequestDetail)
51
+ .mockResolvedValueOnce(detail('review'))
52
+ .mockRejectedValueOnce(new ResponseError('unavailable', { httpStatusCode: 404 }));
53
+ const store = useReviewDetailStore();
54
+ await store.fetchDetail('review');
55
+ await store.fetchDetail('review');
56
+ expect(store.detail?.id).toBe('review');
57
+ expect(store.detailNotFound).toBe(true);
58
+ });
59
+
60
+ it('reports an access error without marking the review as missing', async () => {
61
+ const error = new ResponseError('forbidden', { httpStatusCode: 403 });
62
+ vi.mocked(api.fetchWorkflowReviewRequestDetail)
63
+ .mockResolvedValueOnce(detail('review'))
64
+ .mockRejectedValueOnce(error);
65
+ const store = useReviewDetailStore();
66
+ await store.fetchDetail('review');
67
+ await expect(store.fetchDetail('review')).rejects.toBe(error);
68
+ expect(store.detail?.id).toBe('review');
69
+ expect(store.detailNotFound).toBe(false);
70
+ });
71
+
72
+ it('does not restore a cleared selection when its request finishes', async () => {
73
+ const request = createDeferredPromise<WorkflowReviewRequestDetail>();
74
+ vi.mocked(api.fetchWorkflowReviewRequestDetail).mockReturnValueOnce(request.promise);
75
+ const store = useReviewDetailStore();
76
+ const pending = store.fetchDetail('review');
77
+ store.clearDetail();
78
+ request.resolve(detail('review'));
79
+ await pending;
80
+ expect(store.detail).toBeNull();
81
+ expect(store.detailLoading).toBe(false);
82
+ });
83
+
84
+ it('applies a completed decision after the viewer returns to the same review', async () => {
85
+ vi.mocked(api.fetchWorkflowReviewRequestDetail).mockImplementation(async (_context, id) =>
86
+ detail(id),
87
+ );
88
+ const response = createDeferredPromise<DecideWorkflowReviewRequestResponse>();
89
+ vi.mocked(api.decideWorkflowReviewRequest).mockReturnValue(response.promise);
90
+ const store = useReviewDetailStore();
91
+ await store.fetchDetail('review');
92
+ const decision = store.decideOnReview('review', { decision: 'approved' });
93
+ store.clearDetail();
94
+ await store.fetchDetail('other');
95
+ store.clearDetail();
96
+ await store.fetchDetail('review');
97
+ response.resolve({
98
+ id: 'review',
99
+ state: 'closed',
100
+ decision: 'approved',
101
+ workflowVersionId: null,
102
+ createdAt: '2026-01-01T00:00:00.000Z',
103
+ updatedAt: '2026-01-01T00:00:00.000Z',
104
+ });
105
+ await decision;
106
+ expect(store.detail?.state).toBe('closed');
107
+ expect(store.detail?.decision).toBe('approved');
108
+ });
109
+
110
+ it('keeps a different review request when an earlier decision finishes', async () => {
111
+ const request = createDeferredPromise<WorkflowReviewRequestDetail>();
112
+ const response = createDeferredPromise<DecideWorkflowReviewRequestResponse>();
113
+ vi.mocked(api.fetchWorkflowReviewRequestDetail)
114
+ .mockResolvedValueOnce(detail('review'))
115
+ .mockReturnValueOnce(request.promise);
116
+ vi.mocked(api.decideWorkflowReviewRequest).mockReturnValueOnce(response.promise);
117
+ const store = useReviewDetailStore();
118
+ await store.fetchDetail('review');
119
+ const decision = store.decideOnReview('review', { decision: 'approved' });
120
+ store.clearDetail();
121
+ const pending = store.fetchDetail('other');
122
+ response.resolve({
123
+ ...detail('review'),
124
+ state: 'closed',
125
+ decision: 'approved',
126
+ workflowVersionId: null,
127
+ });
128
+ await decision;
129
+ expect(store.detailLoading).toBe(true);
130
+ request.resolve(detail('other'));
131
+ await pending;
132
+ expect(store.detail?.state).toBe('open');
133
+ expect(store.detail?.id).toBe('other');
134
+ expect(store.detailLoading).toBe(false);
135
+ });
@@ -0,0 +1,74 @@
1
+ import type { WorkflowReviewRequestDetail } from '@n8n/api-types';
2
+ import { ResponseError } from '@n8n/rest-api-client';
3
+ import { useRootStore } from '@n8n/stores/useRootStore';
4
+ import { defineStore } from 'pinia';
5
+ import { ref } from 'vue';
6
+
7
+ import {
8
+ decideWorkflowReviewRequest,
9
+ fetchWorkflowReviewRequestDetail,
10
+ type WorkflowReviewDecisionInput,
11
+ } from './workflowReviews.api';
12
+
13
+ export const useReviewDetailStore = defineStore('workflowReviewDetail', () => {
14
+ const rootStore = useRootStore();
15
+ const detail = ref<WorkflowReviewRequestDetail | null>(null);
16
+ const detailLoading = ref(false);
17
+ const detailNotFound = ref(false);
18
+ const deciding = ref(false);
19
+ const selectionRevision = ref(0);
20
+ let requestSeq = 0;
21
+
22
+ async function fetchDetail(id: string) {
23
+ const seq = ++requestSeq;
24
+ if (detail.value?.id !== id) {
25
+ detail.value = null;
26
+ detailLoading.value = true;
27
+ detailNotFound.value = false;
28
+ }
29
+ try {
30
+ const response = await fetchWorkflowReviewRequestDetail(rootStore.restApiContext, id);
31
+ if (seq !== requestSeq) return;
32
+ detail.value = response;
33
+ detailNotFound.value = false;
34
+ } catch (error) {
35
+ if (seq !== requestSeq) return;
36
+ if (error instanceof ResponseError && error.httpStatusCode === 404) {
37
+ detailNotFound.value = true;
38
+ return;
39
+ }
40
+ throw error;
41
+ } finally {
42
+ if (seq === requestSeq) detailLoading.value = false;
43
+ }
44
+ }
45
+
46
+ async function decideOnReview(id: string, input: WorkflowReviewDecisionInput) {
47
+ const response = await decideWorkflowReviewRequest(rootStore.restApiContext, id, input);
48
+ if (detail.value?.id === id) {
49
+ detail.value.decision = response.decision;
50
+ detail.value.state = response.state;
51
+ detail.value.updatedAt = response.updatedAt;
52
+ }
53
+ return response;
54
+ }
55
+
56
+ function clearDetail() {
57
+ requestSeq++;
58
+ selectionRevision.value++;
59
+ detail.value = null;
60
+ detailLoading.value = false;
61
+ detailNotFound.value = false;
62
+ }
63
+
64
+ return {
65
+ detail,
66
+ detailLoading,
67
+ detailNotFound,
68
+ deciding,
69
+ selectionRevision,
70
+ fetchDetail,
71
+ decideOnReview,
72
+ clearDetail,
73
+ };
74
+ });
@@ -0,0 +1,44 @@
1
+ import type { WorkflowReviewRequestDecision, WorkflowReviewRequestState } from '@n8n/api-types';
2
+ import type { BaseTextKey, useI18n } from '@n8n/i18n';
3
+
4
+ export type WorkflowReviewStatusDisplay = {
5
+ /** The state half alone, for surfaces that compose the two halves themselves. */
6
+ stateLabel: string;
7
+ /** The decision half alone, for surfaces where the state is implied. */
8
+ decisionLabel: string;
9
+ /** Color class of {@link WorkflowReviewStatusDot}. */
10
+ colorClass: 'pending' | 'changesRequested' | 'approved' | 'closed';
11
+ };
12
+
13
+ /**
14
+ * The one mapping from review state/decision to what users see — card badge,
15
+ * detail status card, and status dot all render from here so their labels and
16
+ * colors cannot drift apart. A closed review that never got a decision reads
17
+ * `No decision`, not `Waiting for review`: nobody is waiting anymore.
18
+ */
19
+ export function getWorkflowReviewStatusDisplay(
20
+ i18n: ReturnType<typeof useI18n>,
21
+ state: WorkflowReviewRequestState,
22
+ decision: WorkflowReviewRequestDecision,
23
+ ): WorkflowReviewStatusDisplay {
24
+ const decisionKey: BaseTextKey =
25
+ state === 'closed' && decision === 'pending'
26
+ ? 'workflowReviews.decision.noDecision'
27
+ : (`workflowReviews.decision.${decision}` as BaseTextKey);
28
+
29
+ const decisionLabel = i18n.baseText(decisionKey);
30
+ const stateLabel = i18n.baseText(`workflowReviews.status.${state}` as BaseTextKey);
31
+
32
+ const colorClass =
33
+ state === 'open'
34
+ ? decision === 'changes_requested'
35
+ ? 'changesRequested'
36
+ : decision === 'approved'
37
+ ? 'approved'
38
+ : 'pending'
39
+ : decision === 'approved'
40
+ ? 'approved'
41
+ : 'closed';
42
+
43
+ return { stateLabel, decisionLabel, colorClass };
44
+ }
@@ -0,0 +1,61 @@
1
+ import type {
2
+ DecideWorkflowReviewRequestDto,
3
+ DecideWorkflowReviewRequestResponse,
4
+ ListWorkflowReviewActivityResponse,
5
+ WorkflowReviewActivityEntry,
6
+ WorkflowReviewRequestDetail,
7
+ } from '@n8n/api-types';
8
+ import { makeRestApiRequest, type IRestApiContext } from '@n8n/rest-api-client';
9
+
10
+ /** What a reviewer submits with a decision; `pending` is the initial state, never an input. */
11
+ export type WorkflowReviewDecisionInput = Pick<DecideWorkflowReviewRequestDto, 'decision' | 'note'>;
12
+
13
+ export async function decideWorkflowReviewRequest(
14
+ context: IRestApiContext,
15
+ workflowReviewRequestId: string,
16
+ payload: DecideWorkflowReviewRequestDto,
17
+ ): Promise<DecideWorkflowReviewRequestResponse> {
18
+ return await makeRestApiRequest<DecideWorkflowReviewRequestResponse>(
19
+ context,
20
+ 'POST',
21
+ `/workflow-review-requests/${encodeURIComponent(workflowReviewRequestId)}/decision`,
22
+ { ...payload },
23
+ );
24
+ }
25
+
26
+ export async function fetchWorkflowReviewRequestDetail(
27
+ context: IRestApiContext,
28
+ workflowReviewRequestId: string,
29
+ ): Promise<WorkflowReviewRequestDetail> {
30
+ return await makeRestApiRequest(
31
+ context,
32
+ 'GET',
33
+ `/workflow-review-requests/${encodeURIComponent(workflowReviewRequestId)}`,
34
+ );
35
+ }
36
+
37
+ export async function fetchWorkflowReviewActivity(
38
+ context: IRestApiContext,
39
+ workflowReviewRequestId: string,
40
+ params: { limit?: number; cursor?: string },
41
+ ): Promise<ListWorkflowReviewActivityResponse> {
42
+ return await makeRestApiRequest(
43
+ context,
44
+ 'GET',
45
+ `/workflow-review-requests/${encodeURIComponent(workflowReviewRequestId)}/activity`,
46
+ params,
47
+ );
48
+ }
49
+
50
+ export async function createWorkflowReviewComment(
51
+ context: IRestApiContext,
52
+ workflowReviewRequestId: string,
53
+ payload: { body: string },
54
+ ): Promise<WorkflowReviewActivityEntry> {
55
+ return await makeRestApiRequest(
56
+ context,
57
+ 'POST',
58
+ `/workflow-review-requests/${encodeURIComponent(workflowReviewRequestId)}/comments`,
59
+ { ...payload },
60
+ );
61
+ }
@@ -0,0 +1,18 @@
1
+ export function toError(error: unknown): Error {
2
+ return error instanceof Error ? error : new Error(String(error));
3
+ }
4
+
5
+ type UserNameParts = {
6
+ firstName?: string | null;
7
+ lastName?: string | null;
8
+ email?: string | null;
9
+ };
10
+
11
+ export function formatUserDisplayName(user: UserNameParts): string {
12
+ return [user.firstName, user.lastName].filter(Boolean).join(' ') || user.email || '';
13
+ }
14
+
15
+ /** Names whoever produced an activity entry. The fallback is passed in to keep i18n out of here. */
16
+ export function formatActorName(actor: UserNameParts | null, fallback: string): string {
17
+ return actor ? formatUserDisplayName(actor) : fallback;
18
+ }
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ import { N8nEmptyState } from '@n8n/design-system';
3
+ import { useI18n } from '@n8n/i18n';
4
+
5
+ import type { InboxSelection } from '../inbox.constants';
6
+
7
+ // AST-1518 replaces this rollout-gated integration point with the report and actions.
8
+ defineProps<{ selection: Extract<InboxSelection, { type: 'self_healing_result' }> }>();
9
+ const i18n = useI18n();
10
+ </script>
11
+
12
+ <template>
13
+ <N8nEmptyState
14
+ :heading="i18n.baseText('inbox.resultUnavailable.title')"
15
+ :description="i18n.baseText('inbox.resultUnavailable.body')"
16
+ data-test-id="inbox-self-healing-detail"
17
+ :data-result-id="selection.id"
18
+ />
19
+ </template>