@n8n/frontend-module-inbox 0.1.0 → 0.2.1
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.
- package/LICENSE_EE.md +27 -0
- package/README.md +5 -3
- package/package.json +22 -12
- package/src/components/InboxEmptyState.vue +1 -1
- package/src/components/InboxList.test.ts +76 -16
- package/src/components/InboxListItem.vue +81 -7
- package/src/components/InboxListSection.vue +8 -8
- package/src/inbox.constants.ts +4 -1
- package/src/inbox.module.ts +6 -1
- package/src/inbox.routes.test.ts +55 -26
- package/src/inbox.routes.ts +22 -3
- package/src/inbox.store.test.ts +123 -28
- package/src/inbox.store.ts +3 -3
- package/src/reviews/components/WorkflowReviewChangesList.test.ts +97 -0
- package/src/reviews/components/WorkflowReviewChangesList.vue +143 -0
- package/src/reviews/components/WorkflowReviewChangesSection.test.ts +13 -9
- package/src/reviews/components/WorkflowReviewChangesSection.vue +5 -7
- package/src/reviews/components/WorkflowReviewDetailMetadata.vue +5 -1
- package/src/reviews/components/WorkflowReviewDetailTabs.test.ts +69 -3
- package/src/reviews/components/WorkflowReviewDetailTabs.vue +29 -11
- package/src/self-healing/SelfHealingResultDetail.vue +19 -0
- package/src/views/InboxView.test.ts +53 -10
- package/src/views/InboxView.vue +10 -1
package/src/inbox.store.test.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
InboxItem,
|
|
3
|
+
InboxSelfHealingItem,
|
|
4
|
+
InboxWorkflowReviewItem,
|
|
5
|
+
ListInboxResponse,
|
|
6
|
+
} from '@n8n/api-types';
|
|
2
7
|
import { ResponseError } from '@n8n/rest-api-client';
|
|
3
8
|
import { useSettingsStore } from '@n8n/stores/settings.store';
|
|
4
9
|
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
|
|
@@ -11,6 +16,21 @@ vi.mock('./inbox.api');
|
|
|
11
16
|
const waitingOrClosedRequest = vi.fn<typeof api.fetchInbox>();
|
|
12
17
|
const authoredRequest = vi.fn<typeof api.fetchInbox>();
|
|
13
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
|
+
}
|
|
14
34
|
function page(
|
|
15
35
|
data: InboxItem[] = [],
|
|
16
36
|
overrides: Partial<ListInboxResponse> = {},
|
|
@@ -28,7 +48,7 @@ function page(
|
|
|
28
48
|
function enableInbox() {
|
|
29
49
|
useSettingsStore().settings.inbox = {
|
|
30
50
|
enabled: true,
|
|
31
|
-
availableTypes: ['workflow_review'],
|
|
51
|
+
availableTypes: ['workflow_review', 'self_healing_result'],
|
|
32
52
|
failedTypes: [],
|
|
33
53
|
};
|
|
34
54
|
}
|
|
@@ -65,7 +85,7 @@ describe('Inbox list requests', () => {
|
|
|
65
85
|
const pending = createDeferredPromise<ListInboxResponse>();
|
|
66
86
|
const request = vi
|
|
67
87
|
.fn()
|
|
68
|
-
.mockResolvedValueOnce(page([
|
|
88
|
+
.mockResolvedValueOnce(page([result('first')]))
|
|
69
89
|
.mockReturnValueOnce(pending.promise);
|
|
70
90
|
const slice = createInboxListSlice(request, vi.fn());
|
|
71
91
|
await slice.fetchList();
|
|
@@ -81,14 +101,14 @@ describe('Inbox list requests', () => {
|
|
|
81
101
|
const pending = createDeferredPromise<ListInboxResponse>();
|
|
82
102
|
const request = vi
|
|
83
103
|
.fn()
|
|
84
|
-
.mockResolvedValueOnce(page([
|
|
104
|
+
.mockResolvedValueOnce(page([result('first')], { hasMore: true, nextCursor: 'page-2' }))
|
|
85
105
|
.mockReturnValueOnce(pending.promise)
|
|
86
|
-
.mockResolvedValueOnce(page([
|
|
106
|
+
.mockResolvedValueOnce(page([result('newest')]));
|
|
87
107
|
const slice = createInboxListSlice(request, vi.fn());
|
|
88
108
|
await slice.fetchList();
|
|
89
109
|
const loadMore = slice.loadMore();
|
|
90
110
|
await slice.fetchList();
|
|
91
|
-
pending.resolve(page([
|
|
111
|
+
pending.resolve(page([result('older')]));
|
|
92
112
|
await loadMore;
|
|
93
113
|
expect(slice.items.map((item) => item.id)).toEqual(['newest']);
|
|
94
114
|
expect(slice.loadingMore).toBe(false);
|
|
@@ -99,10 +119,10 @@ describe('Inbox list requests', () => {
|
|
|
99
119
|
const request = vi
|
|
100
120
|
.fn()
|
|
101
121
|
.mockResolvedValueOnce(
|
|
102
|
-
page([
|
|
122
|
+
page([result('first')], { ...partial, hasMore: true, nextCursor: 'partial-page' }),
|
|
103
123
|
)
|
|
104
124
|
.mockRejectedValueOnce(new Error('timeout'))
|
|
105
|
-
.mockResolvedValueOnce(page([
|
|
125
|
+
.mockResolvedValueOnce(page([result('older')], partial));
|
|
106
126
|
const slice = createInboxListSlice(request, vi.fn());
|
|
107
127
|
await slice.fetchList();
|
|
108
128
|
await slice.loadMore();
|
|
@@ -119,12 +139,12 @@ describe('shared Inbox state', () => {
|
|
|
119
139
|
it('keeps requests for Open and Closed independent', async () => {
|
|
120
140
|
const open = createDeferredPromise<ListInboxResponse>();
|
|
121
141
|
waitingOrClosedRequest.mockImplementation(async (_context, query) =>
|
|
122
|
-
query.state === 'open' ? await open.promise : page([
|
|
142
|
+
query.state === 'open' ? await open.promise : page([result('closed')]),
|
|
123
143
|
);
|
|
124
144
|
const store = useInboxStore();
|
|
125
145
|
const request = store.refreshListAndSummary();
|
|
126
146
|
await store.setActiveTab('closed');
|
|
127
|
-
open.resolve(page([
|
|
147
|
+
open.resolve(page([result('open')]));
|
|
128
148
|
await request;
|
|
129
149
|
expect(store.activeTab).toBe('closed');
|
|
130
150
|
expect(store.lists.closed.items[0].id).toBe('closed');
|
|
@@ -132,7 +152,7 @@ describe('shared Inbox state', () => {
|
|
|
132
152
|
});
|
|
133
153
|
|
|
134
154
|
it('treats a failed count as unknown while the list remains usable', async () => {
|
|
135
|
-
waitingOrClosedRequest.mockResolvedValue(page([
|
|
155
|
+
waitingOrClosedRequest.mockResolvedValue(page([result('first')]));
|
|
136
156
|
vi.mocked(api.fetchInboxSummary).mockResolvedValue({
|
|
137
157
|
counts: null,
|
|
138
158
|
partial: true,
|
|
@@ -147,46 +167,78 @@ describe('shared Inbox state', () => {
|
|
|
147
167
|
|
|
148
168
|
it('clears disabled-source rows from both tabs even when all reads fail', async () => {
|
|
149
169
|
const store = useInboxStore();
|
|
150
|
-
store.lists.waiting.items = [
|
|
151
|
-
store.lists.closed.items = [
|
|
170
|
+
store.lists.waiting.items = [result('open')];
|
|
171
|
+
store.lists.closed.items = [result('closed')];
|
|
152
172
|
waitingOrClosedRequest.mockRejectedValue(
|
|
153
173
|
new ResponseError('unavailable', {
|
|
154
174
|
httpStatusCode: 503,
|
|
155
|
-
meta: { disabledSources: ['
|
|
175
|
+
meta: { disabledSources: ['self_healing_result'], failedSources: ['workflow_review'] },
|
|
156
176
|
}),
|
|
157
177
|
);
|
|
158
178
|
await store.refreshListAndSummary();
|
|
159
179
|
expect(store.lists.waiting.items).toEqual([]);
|
|
160
180
|
expect(store.lists.closed.items).toEqual([]);
|
|
161
|
-
expect(store.disabledSources).toEqual(['
|
|
181
|
+
expect(store.disabledSources).toEqual(['self_healing_result']);
|
|
162
182
|
});
|
|
163
183
|
});
|
|
164
184
|
|
|
165
185
|
it('allows a source to recover on a fresh page without a settings reload', async () => {
|
|
166
186
|
const store = useInboxStore();
|
|
167
187
|
waitingOrClosedRequest
|
|
168
|
-
.mockResolvedValueOnce(page([], { disabledSources: ['
|
|
169
|
-
.mockResolvedValueOnce(page([
|
|
188
|
+
.mockResolvedValueOnce(page([], { disabledSources: ['self_healing_result'] }))
|
|
189
|
+
.mockResolvedValueOnce(page([result('recovered')]));
|
|
170
190
|
await store.refreshListAndSummary();
|
|
171
|
-
expect(store.disabledSources).toContain('
|
|
191
|
+
expect(store.disabledSources).toContain('self_healing_result');
|
|
172
192
|
await store.refreshListAndSummary();
|
|
173
|
-
expect(store.disabledSources).not.toContain('
|
|
193
|
+
expect(store.disabledSources).not.toContain('self_healing_result');
|
|
174
194
|
expect(store.lists.waiting.items[0].id).toBe('recovered');
|
|
175
195
|
});
|
|
176
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
|
+
|
|
177
229
|
it('starts a new cursor chain to recover a failed source after paging', async () => {
|
|
178
230
|
const partial = { partial: true, failedSources: ['workflow_review' as const] };
|
|
179
231
|
const request = vi
|
|
180
232
|
.fn()
|
|
181
233
|
.mockResolvedValueOnce(
|
|
182
|
-
page([
|
|
234
|
+
page([result('first')], { ...partial, hasMore: true, nextCursor: 'partial-second' }),
|
|
183
235
|
)
|
|
184
236
|
.mockResolvedValueOnce(
|
|
185
|
-
page([
|
|
237
|
+
page([result('older')], { ...partial, hasMore: true, nextCursor: 'partial-third' }),
|
|
186
238
|
)
|
|
187
239
|
.mockRejectedValueOnce(new Error('timeout'))
|
|
188
240
|
.mockResolvedValueOnce(
|
|
189
|
-
page([
|
|
241
|
+
page([result('recovered')], { hasMore: true, nextCursor: 'complete-second' }),
|
|
190
242
|
);
|
|
191
243
|
const slice = createInboxListSlice(request, vi.fn());
|
|
192
244
|
await slice.fetchList();
|
|
@@ -201,6 +253,31 @@ it('starts a new cursor chain to recover a failed source after paging', async ()
|
|
|
201
253
|
expect(slice.partial).toBe(false);
|
|
202
254
|
});
|
|
203
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
|
+
|
|
204
281
|
it('requests the two Open categories and omits category for Closed', async () => {
|
|
205
282
|
const store = useInboxStore();
|
|
206
283
|
await store.refreshListAndSummary();
|
|
@@ -227,7 +304,7 @@ it('requests the two Open categories and omits category for Closed', async () =>
|
|
|
227
304
|
|
|
228
305
|
it('keeps each Open group cursor and retry independent', async () => {
|
|
229
306
|
waitingOrClosedRequest.mockResolvedValueOnce(
|
|
230
|
-
page([
|
|
307
|
+
page([result('waiting')], { hasMore: true, nextCursor: 'waiting-2' }),
|
|
231
308
|
);
|
|
232
309
|
authoredRequest
|
|
233
310
|
.mockResolvedValueOnce(
|
|
@@ -268,7 +345,25 @@ it('requires both groups to be successfully empty before showing the Open empty
|
|
|
268
345
|
expect(store.isEmpty).toBe(true);
|
|
269
346
|
});
|
|
270
347
|
|
|
271
|
-
|
|
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 {
|
|
272
367
|
return {
|
|
273
368
|
type: 'workflow_review',
|
|
274
369
|
id,
|
|
@@ -288,7 +383,7 @@ function reviewItem(id: string): InboxItem {
|
|
|
288
383
|
|
|
289
384
|
it('updates a review decision without discarding loaded pages', async () => {
|
|
290
385
|
const store = useInboxStore();
|
|
291
|
-
store.lists.waiting.items = [reviewItem('first'), reviewItem('older')];
|
|
386
|
+
store.lists.waiting.items = [reviewItem('first'), reviewItem('older'), result('assistant')];
|
|
292
387
|
store.lists.waiting.nextCursor = 'next-page';
|
|
293
388
|
store.reconcileItemChange({
|
|
294
389
|
type: 'workflow_review',
|
|
@@ -297,7 +392,7 @@ it('updates a review decision without discarding loaded pages', async () => {
|
|
|
297
392
|
decision: 'changes_requested',
|
|
298
393
|
updatedAt: '2026-01-02T00:00:00.000Z',
|
|
299
394
|
});
|
|
300
|
-
expect(store.lists.waiting.items).toHaveLength(
|
|
395
|
+
expect(store.lists.waiting.items).toHaveLength(3);
|
|
301
396
|
expect(store.lists.waiting.items[1]).toMatchObject({ decision: 'changes_requested' });
|
|
302
397
|
expect(store.lists.waiting.nextCursor).toBe('next-page');
|
|
303
398
|
expect(api.fetchInbox).not.toHaveBeenCalled();
|
|
@@ -307,14 +402,14 @@ it('removes an approved review from Open and updates the counts', async () => {
|
|
|
307
402
|
const store = useInboxStore();
|
|
308
403
|
store.openCount = 2;
|
|
309
404
|
store.closedCount = 1;
|
|
310
|
-
store.lists.waiting.items = [reviewItem('review')];
|
|
405
|
+
store.lists.waiting.items = [reviewItem('review'), result('review')];
|
|
311
406
|
store.reconcileItemChange({
|
|
312
407
|
type: 'workflow_review',
|
|
313
408
|
id: 'review',
|
|
314
409
|
state: 'closed',
|
|
315
410
|
decision: 'approved',
|
|
316
411
|
});
|
|
317
|
-
expect(store.lists.waiting.items).toEqual([]);
|
|
412
|
+
expect(store.lists.waiting.items).toEqual([result('review')]);
|
|
318
413
|
expect(store.openCount).toBe(1);
|
|
319
414
|
expect(store.closedCount).toBe(2);
|
|
320
415
|
});
|
package/src/inbox.store.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { fetchInbox, fetchInboxSummary } from './inbox.api';
|
|
|
15
15
|
import { INBOX_PAGE_LIMIT, type InboxItemChange, type InboxSectionKey } from './inbox.constants';
|
|
16
16
|
import { toError } from './reviews/workflowReviews.utils';
|
|
17
17
|
|
|
18
|
-
const sourceTypes: InboxSourceType[] = ['workflow_review'];
|
|
18
|
+
const sourceTypes: InboxSourceType[] = ['workflow_review', 'self_healing_result'];
|
|
19
19
|
|
|
20
20
|
type ListState = {
|
|
21
21
|
items: InboxItem[];
|
|
@@ -30,7 +30,7 @@ type ListState = {
|
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
function isSourceType(value: unknown): value is InboxSourceType {
|
|
33
|
-
return value === 'workflow_review';
|
|
33
|
+
return value === 'workflow_review' || value === 'self_healing_result';
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function disabledTypesFromError(error: unknown): InboxSourceType[] {
|
|
@@ -189,7 +189,7 @@ export const useInboxStore = defineStore('inbox', () => {
|
|
|
189
189
|
removeDisabledSources,
|
|
190
190
|
reconcileSources,
|
|
191
191
|
),
|
|
192
|
-
//
|
|
192
|
+
// Authored does not query Assistant results, so absence says nothing about that source.
|
|
193
193
|
authored: createInboxListSlice(requestPage('open', 'authored'), removeDisabledSources, (page) =>
|
|
194
194
|
reconcileSources(page, true),
|
|
195
195
|
),
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
WorkflowReviewRequestWorkflowDetail,
|
|
3
|
+
WorkflowReviewVersionSnapshot,
|
|
4
|
+
} from '@n8n/api-types';
|
|
5
|
+
import { createTestingPinia } from '@pinia/testing';
|
|
6
|
+
import userEvent from '@testing-library/user-event';
|
|
7
|
+
import { createComponentRenderer } from '@n8n/frontend-test-utils';
|
|
8
|
+
|
|
9
|
+
import WorkflowReviewChangesList from './WorkflowReviewChangesList.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-workflow-id="workflow.workflowId" />',
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const renderComponent = createComponentRenderer(WorkflowReviewChangesList);
|
|
21
|
+
|
|
22
|
+
function makeSnapshot(
|
|
23
|
+
overrides: Partial<WorkflowReviewVersionSnapshot> = {},
|
|
24
|
+
): WorkflowReviewVersionSnapshot {
|
|
25
|
+
return {
|
|
26
|
+
versionId: 'version-1',
|
|
27
|
+
name: null,
|
|
28
|
+
nodes: [],
|
|
29
|
+
connections: {},
|
|
30
|
+
nodeGroups: [],
|
|
31
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeWorkflowDetail(
|
|
37
|
+
overrides: Partial<WorkflowReviewRequestWorkflowDetail> = {},
|
|
38
|
+
): WorkflowReviewRequestWorkflowDetail {
|
|
39
|
+
return {
|
|
40
|
+
workflowId: 'wf-1',
|
|
41
|
+
workflowName: 'My workflow',
|
|
42
|
+
workflowVersionId: 'version-1',
|
|
43
|
+
pinnedVersion: makeSnapshot(),
|
|
44
|
+
publishedVersionId: null,
|
|
45
|
+
baselineVersion: null,
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('WorkflowReviewChangesList', () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
createTestingPinia();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('opens one workflow at a time and closes it on a second click', async () => {
|
|
56
|
+
const { getAllByTestId, queryAllByTestId } = renderComponent({
|
|
57
|
+
props: {
|
|
58
|
+
workflows: [
|
|
59
|
+
makeWorkflowDetail(),
|
|
60
|
+
makeWorkflowDetail({ workflowId: 'wf-2', workflowName: 'Other workflow' }),
|
|
61
|
+
],
|
|
62
|
+
state: 'open',
|
|
63
|
+
decision: 'pending',
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
const [firstTrigger, secondTrigger] = getAllByTestId('workflow-review-changes-item-trigger');
|
|
67
|
+
|
|
68
|
+
await userEvent.click(firstTrigger);
|
|
69
|
+
await userEvent.click(secondTrigger);
|
|
70
|
+
|
|
71
|
+
const sections = getAllByTestId('workflow-review-changes-section');
|
|
72
|
+
expect(sections).toHaveLength(1);
|
|
73
|
+
expect(sections[0]).toHaveAttribute('data-workflow-id', 'wf-2');
|
|
74
|
+
|
|
75
|
+
await userEvent.click(secondTrigger);
|
|
76
|
+
|
|
77
|
+
expect(queryAllByTestId('workflow-review-changes-section')).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('shows the version next to the workflow name, and only the name when the version is missing', () => {
|
|
81
|
+
const { getAllByTestId } = renderComponent({
|
|
82
|
+
props: {
|
|
83
|
+
workflows: [
|
|
84
|
+
makeWorkflowDetail({ pinnedVersion: makeSnapshot({ name: 'Fix retries' }) }),
|
|
85
|
+
makeWorkflowDetail({ workflowId: 'wf-2', workflowName: 'Pruned', pinnedVersion: null }),
|
|
86
|
+
],
|
|
87
|
+
state: 'open',
|
|
88
|
+
decision: 'pending',
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const [withVersion, withoutVersion] = getAllByTestId('workflow-review-changes-item-trigger');
|
|
92
|
+
|
|
93
|
+
expect(withVersion).toHaveTextContent('My workflow');
|
|
94
|
+
expect(withVersion).toHaveTextContent('Fix retries');
|
|
95
|
+
expect(withoutVersion).toHaveTextContent(/^Pruned$/);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
WorkflowReviewRequestDecision,
|
|
4
|
+
WorkflowReviewRequestState,
|
|
5
|
+
WorkflowReviewRequestWorkflowDetail,
|
|
6
|
+
} from '@n8n/api-types';
|
|
7
|
+
import { N8nIcon, N8nText } from '@n8n/design-system';
|
|
8
|
+
import {
|
|
9
|
+
AccordionContent,
|
|
10
|
+
AccordionHeader,
|
|
11
|
+
AccordionItem,
|
|
12
|
+
AccordionRoot,
|
|
13
|
+
AccordionTrigger,
|
|
14
|
+
} from 'reka-ui';
|
|
15
|
+
|
|
16
|
+
import { getVersionLabel } from '@n8n/composables/useWorkflowVersionLabel';
|
|
17
|
+
import WorkflowReviewChangesSection from './WorkflowReviewChangesSection.vue';
|
|
18
|
+
|
|
19
|
+
defineProps<{
|
|
20
|
+
workflows: WorkflowReviewRequestWorkflowDetail[];
|
|
21
|
+
state: WorkflowReviewRequestState;
|
|
22
|
+
decision: WorkflowReviewRequestDecision;
|
|
23
|
+
}>();
|
|
24
|
+
|
|
25
|
+
/** The open workflow's id. The parent owns it, so it survives a tab switch. */
|
|
26
|
+
const expanded = defineModel<string | undefined>('expanded');
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<template>
|
|
30
|
+
<!-- Closed items unmount, so only the open workflow pays for its diff canvases. -->
|
|
31
|
+
<AccordionRoot v-model="expanded" type="single" collapsible unmount-on-hide :class="$style.list">
|
|
32
|
+
<AccordionItem
|
|
33
|
+
v-for="workflow in workflows"
|
|
34
|
+
:key="workflow.workflowId"
|
|
35
|
+
:value="workflow.workflowId"
|
|
36
|
+
:class="$style.item"
|
|
37
|
+
data-test-id="workflow-review-changes-item"
|
|
38
|
+
>
|
|
39
|
+
<AccordionHeader :class="$style.header">
|
|
40
|
+
<AccordionTrigger
|
|
41
|
+
:class="$style.trigger"
|
|
42
|
+
data-test-id="workflow-review-changes-item-trigger"
|
|
43
|
+
>
|
|
44
|
+
<N8nIcon icon="chevron-down" size="small" :class="$style.chevron" />
|
|
45
|
+
<N8nText bold size="medium" color="text-dark" :class="$style.name">
|
|
46
|
+
{{ workflow.workflowName }}
|
|
47
|
+
</N8nText>
|
|
48
|
+
<N8nText
|
|
49
|
+
v-if="workflow.pinnedVersion"
|
|
50
|
+
size="small"
|
|
51
|
+
color="text-light"
|
|
52
|
+
:class="$style.version"
|
|
53
|
+
data-test-id="workflow-review-changes-item-version"
|
|
54
|
+
>
|
|
55
|
+
{{ getVersionLabel({ workflowHistory: workflow.pinnedVersion }) }}
|
|
56
|
+
</N8nText>
|
|
57
|
+
</AccordionTrigger>
|
|
58
|
+
</AccordionHeader>
|
|
59
|
+
<AccordionContent :class="$style.content">
|
|
60
|
+
<WorkflowReviewChangesSection :workflow="workflow" :state="state" :decision="decision" />
|
|
61
|
+
</AccordionContent>
|
|
62
|
+
</AccordionItem>
|
|
63
|
+
</AccordionRoot>
|
|
64
|
+
</template>
|
|
65
|
+
|
|
66
|
+
<style module lang="scss">
|
|
67
|
+
.list {
|
|
68
|
+
display: flex;
|
|
69
|
+
flex-direction: column;
|
|
70
|
+
gap: var(--spacing--2xs);
|
|
71
|
+
/* A definite height, so the diff canvas can size itself with percentages. */
|
|
72
|
+
height: 100%;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.item {
|
|
76
|
+
display: flex;
|
|
77
|
+
flex-direction: column;
|
|
78
|
+
flex-shrink: 0;
|
|
79
|
+
border: var(--border);
|
|
80
|
+
border-radius: var(--radius--2xs);
|
|
81
|
+
overflow: hidden;
|
|
82
|
+
|
|
83
|
+
/* Only a diff takes the free height. A callout keeps its natural size. */
|
|
84
|
+
&[data-state='open']:has([data-review-diff]) {
|
|
85
|
+
flex: 1 0 auto;
|
|
86
|
+
min-height: var(--review-diff--min-height, 28rem);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.header {
|
|
91
|
+
margin: 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.trigger {
|
|
95
|
+
display: flex;
|
|
96
|
+
align-items: center;
|
|
97
|
+
gap: var(--spacing--2xs);
|
|
98
|
+
width: 100%;
|
|
99
|
+
padding: var(--spacing--xs) var(--spacing--sm);
|
|
100
|
+
border: none;
|
|
101
|
+
background: var(--color--background--light-3);
|
|
102
|
+
text-align: left;
|
|
103
|
+
cursor: pointer;
|
|
104
|
+
|
|
105
|
+
&:focus-visible {
|
|
106
|
+
outline: var(--focus--border-width) solid var(--focus--outline-color);
|
|
107
|
+
outline-offset: calc(-1 * var(--focus--border-width));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
&[data-state='open'] {
|
|
111
|
+
border-bottom: var(--border);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.chevron {
|
|
116
|
+
flex-shrink: 0;
|
|
117
|
+
color: var(--color--text--tint-1);
|
|
118
|
+
transition: transform 0.15s ease;
|
|
119
|
+
|
|
120
|
+
[data-state='closed'] > & {
|
|
121
|
+
transform: rotate(-90deg);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.name,
|
|
126
|
+
.version {
|
|
127
|
+
overflow: hidden;
|
|
128
|
+
text-overflow: ellipsis;
|
|
129
|
+
white-space: nowrap;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.version {
|
|
133
|
+
flex-shrink: 0;
|
|
134
|
+
max-width: 40%;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.content {
|
|
138
|
+
display: flex;
|
|
139
|
+
flex-direction: column;
|
|
140
|
+
flex: 1;
|
|
141
|
+
min-height: 0;
|
|
142
|
+
}
|
|
143
|
+
</style>
|
|
@@ -20,15 +20,17 @@ const { diffViewProps } = vi.hoisted(() => ({
|
|
|
20
20
|
|
|
21
21
|
const DiffView = defineComponent({
|
|
22
22
|
name: 'WorkflowDiffView',
|
|
23
|
-
props:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
23
|
+
props: {
|
|
24
|
+
sourceSnapshot: null,
|
|
25
|
+
targetSnapshot: null,
|
|
26
|
+
workflowId: String,
|
|
27
|
+
workflowName: String,
|
|
28
|
+
sourceLabel: String,
|
|
29
|
+
targetLabel: String,
|
|
30
|
+
showFullscreenButton: Boolean,
|
|
31
|
+
canvasIdPrefix: String,
|
|
32
|
+
hideTitle: Boolean,
|
|
33
|
+
},
|
|
32
34
|
setup(props) {
|
|
33
35
|
diffViewProps.push(props as SlotWorkflowDiffProps);
|
|
34
36
|
},
|
|
@@ -227,6 +229,8 @@ describe('WorkflowReviewChangesSection', () => {
|
|
|
227
229
|
|
|
228
230
|
expect(diffViewProps[0].workflowId).toBe('wf-1');
|
|
229
231
|
expect(diffViewProps[0].workflowName).toBe('Payment handler');
|
|
232
|
+
expect(diffViewProps[0].canvasIdPrefix).toBe('wf-1');
|
|
233
|
+
expect(diffViewProps[0].hideTitle).toBe(true);
|
|
230
234
|
expect(sourceSnapshot?.versionId).toBe('0f123890-0000-0000-0000-000000000000');
|
|
231
235
|
expect(sourceSnapshot?.nodes.map((node) => node.id)).toEqual(['node-removed', 'node-1']);
|
|
232
236
|
|
|
@@ -129,7 +129,7 @@ const targetLabel = computed(() =>
|
|
|
129
129
|
>
|
|
130
130
|
{{ noChangesText }}
|
|
131
131
|
</N8nCallout>
|
|
132
|
-
<div v-else :class="$style.diff" data-test-id="workflow-review-changes-diff">
|
|
132
|
+
<div v-else :class="$style.diff" data-review-diff data-test-id="workflow-review-changes-diff">
|
|
133
133
|
<component
|
|
134
134
|
:is="WorkflowDiffView"
|
|
135
135
|
:source-snapshot="workflow.baselineVersion ?? undefined"
|
|
@@ -138,7 +138,9 @@ const targetLabel = computed(() =>
|
|
|
138
138
|
:target-snapshot="workflow.pinnedVersion ?? undefined"
|
|
139
139
|
:source-label="sourceLabel"
|
|
140
140
|
:target-label="targetLabel"
|
|
141
|
+
:canvas-id-prefix="workflow.workflowId"
|
|
141
142
|
show-fullscreen-button
|
|
143
|
+
hide-title
|
|
142
144
|
>
|
|
143
145
|
<!-- Only when a baseline exists: with no prior published version there is
|
|
144
146
|
no publish status to represent. -->
|
|
@@ -164,7 +166,7 @@ const targetLabel = computed(() =>
|
|
|
164
166
|
<style module lang="scss">
|
|
165
167
|
.callout {
|
|
166
168
|
max-width: var(--review-callout--max-width, 34rem);
|
|
167
|
-
margin
|
|
169
|
+
margin: var(--spacing--xs);
|
|
168
170
|
}
|
|
169
171
|
|
|
170
172
|
.versionBadge {
|
|
@@ -209,11 +211,7 @@ const targetLabel = computed(() =>
|
|
|
209
211
|
}
|
|
210
212
|
|
|
211
213
|
.diff {
|
|
212
|
-
|
|
214
|
+
flex: 1;
|
|
213
215
|
min-height: 0;
|
|
214
|
-
border: var(--border);
|
|
215
|
-
border-radius: var(--radius--2xs);
|
|
216
|
-
overflow: hidden;
|
|
217
|
-
margin-top: var(--spacing--5xs);
|
|
218
216
|
}
|
|
219
217
|
</style>
|
|
@@ -117,7 +117,11 @@ const statusSummary = computed(() =>
|
|
|
117
117
|
>
|
|
118
118
|
<template #header>
|
|
119
119
|
<N8nText bold color="text-light" size="medium">
|
|
120
|
-
{{
|
|
120
|
+
{{
|
|
121
|
+
i18n.baseText('workflowReviews.detail.metadata.workflow', {
|
|
122
|
+
adjustToNumber: detail.workflows.length,
|
|
123
|
+
})
|
|
124
|
+
}}
|
|
121
125
|
</N8nText>
|
|
122
126
|
</template>
|
|
123
127
|
<div :class="$style.workflows">
|