@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.
@@ -1,4 +1,9 @@
1
- import type { InboxItem, ListInboxResponse } from '@n8n/api-types';
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([reviewItem('first')]))
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([reviewItem('first')], { hasMore: true, nextCursor: 'page-2' }))
104
+ .mockResolvedValueOnce(page([result('first')], { hasMore: true, nextCursor: 'page-2' }))
85
105
  .mockReturnValueOnce(pending.promise)
86
- .mockResolvedValueOnce(page([reviewItem('newest')]));
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([reviewItem('older')]));
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([reviewItem('first')], { ...partial, hasMore: true, nextCursor: 'partial-page' }),
122
+ page([result('first')], { ...partial, hasMore: true, nextCursor: 'partial-page' }),
103
123
  )
104
124
  .mockRejectedValueOnce(new Error('timeout'))
105
- .mockResolvedValueOnce(page([reviewItem('older')], partial));
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([reviewItem('closed')]),
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([reviewItem('open')]));
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([reviewItem('first')]));
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 = [reviewItem('open')];
151
- store.lists.closed.items = [reviewItem('closed')];
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: ['workflow_review'], failedSources: [] },
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(['workflow_review']);
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: ['workflow_review'] }))
169
- .mockResolvedValueOnce(page([reviewItem('recovered')]));
188
+ .mockResolvedValueOnce(page([], { disabledSources: ['self_healing_result'] }))
189
+ .mockResolvedValueOnce(page([result('recovered')]));
170
190
  await store.refreshListAndSummary();
171
- expect(store.disabledSources).toContain('workflow_review');
191
+ expect(store.disabledSources).toContain('self_healing_result');
172
192
  await store.refreshListAndSummary();
173
- expect(store.disabledSources).not.toContain('workflow_review');
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([reviewItem('first')], { ...partial, hasMore: true, nextCursor: 'partial-second' }),
234
+ page([result('first')], { ...partial, hasMore: true, nextCursor: 'partial-second' }),
183
235
  )
184
236
  .mockResolvedValueOnce(
185
- page([reviewItem('older')], { ...partial, hasMore: true, nextCursor: 'partial-third' }),
237
+ page([result('older')], { ...partial, hasMore: true, nextCursor: 'partial-third' }),
186
238
  )
187
239
  .mockRejectedValueOnce(new Error('timeout'))
188
240
  .mockResolvedValueOnce(
189
- page([reviewItem('recovered')], { hasMore: true, nextCursor: 'complete-second' }),
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([reviewItem('waiting')], { hasMore: true, nextCursor: 'waiting-2' }),
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
- function reviewItem(id: string): InboxItem {
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(2);
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
  });
@@ -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
- // A category can query only part of the source set.
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
- 'sourceSnapshot',
25
- 'targetSnapshot',
26
- 'workflowId',
27
- 'workflowName',
28
- 'sourceLabel',
29
- 'targetLabel',
30
- 'showFullscreenButton',
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-top: var(--spacing--5xs);
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
- height: 100%;
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
- {{ i18n.baseText('workflowReviews.detail.metadata.workflow') }}
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">