@n8n/frontend-module-inbox 0.1.0 → 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.
@@ -5,6 +5,7 @@ import type {
5
5
  } from '@n8n/api-types';
6
6
  import { createTestingPinia } from '@pinia/testing';
7
7
  import { createComponentRenderer } from '@n8n/frontend-test-utils';
8
+ import userEvent from '@testing-library/user-event';
8
9
 
9
10
  import WorkflowReviewDetailTabs from './WorkflowReviewDetailTabs.vue';
10
11
 
@@ -13,7 +14,7 @@ vi.mock('./WorkflowReviewChangesSection.vue', () => ({
13
14
  name: 'WorkflowReviewChangesSection',
14
15
  props: ['workflow', 'state', 'decision'],
15
16
  template:
16
- '<div data-test-id="workflow-review-changes-section" :data-state="state" :data-decision="decision" />',
17
+ '<div data-test-id="workflow-review-changes-section" :data-workflow-id="workflow.workflowId" :data-state="state" :data-decision="decision" />',
17
18
  },
18
19
  }));
19
20
 
@@ -293,7 +294,7 @@ describe('WorkflowReviewDetailTabs', () => {
293
294
  });
294
295
 
295
296
  describe('changes tab', () => {
296
- it('renders one section per workflow', () => {
297
+ it('renders one item per workflow and opens only the first', () => {
297
298
  const { getAllByTestId } = renderComponent({
298
299
  props: {
299
300
  review: makeDetail({
@@ -307,7 +308,72 @@ describe('WorkflowReviewDetailTabs', () => {
307
308
  },
308
309
  });
309
310
 
310
- expect(getAllByTestId('workflow-review-changes-section')).toHaveLength(2);
311
+ expect(getAllByTestId('workflow-review-changes-item')).toHaveLength(2);
312
+ expect(getAllByTestId('workflow-review-changes-section')).toHaveLength(1);
313
+ });
314
+
315
+ it('keeps the open workflow when the user switches tabs', async () => {
316
+ const review = makeDetail({
317
+ workflows: [makeWorkflowDetail(), makeWorkflowDetail({ workflowId: 'wf-2' })],
318
+ });
319
+ const { getAllByTestId, getByTestId, rerender } = renderComponent({
320
+ props: { review, tab: 'changes', deciding: false },
321
+ });
322
+
323
+ await userEvent.click(getAllByTestId('workflow-review-changes-item-trigger')[1]);
324
+ await rerender({ review, tab: 'activity', deciding: false });
325
+ await rerender({ review, tab: 'changes', deciding: false });
326
+
327
+ expect(getByTestId('workflow-review-changes-section')).toHaveAttribute(
328
+ 'data-workflow-id',
329
+ 'wf-2',
330
+ );
331
+ });
332
+
333
+ it('opens the first workflow again when another review is selected', async () => {
334
+ const workflows = [makeWorkflowDetail(), makeWorkflowDetail({ workflowId: 'wf-2' })];
335
+ const { getAllByTestId, getByTestId, rerender } = renderComponent({
336
+ props: { review: makeDetail({ workflows }), tab: 'changes', deciding: false },
337
+ });
338
+
339
+ await userEvent.click(getAllByTestId('workflow-review-changes-item-trigger')[1]);
340
+ await rerender({
341
+ review: makeDetail({ id: 'req-2', workflows }),
342
+ tab: 'changes',
343
+ deciding: false,
344
+ });
345
+
346
+ expect(getByTestId('workflow-review-changes-section')).toHaveAttribute(
347
+ 'data-workflow-id',
348
+ 'wf-1',
349
+ );
350
+ });
351
+
352
+ // The detail lists only the workflows the viewer can read, so a refresh can drop one.
353
+ it('opens the first workflow when a refresh removes the open one', async () => {
354
+ const first = makeWorkflowDetail();
355
+ const third = makeWorkflowDetail({ workflowId: 'wf-3' });
356
+ const { getAllByTestId, findByTestId, rerender } = renderComponent({
357
+ props: {
358
+ review: makeDetail({
359
+ workflows: [first, makeWorkflowDetail({ workflowId: 'wf-2' }), third],
360
+ }),
361
+ tab: 'changes',
362
+ deciding: false,
363
+ },
364
+ });
365
+
366
+ await userEvent.click(getAllByTestId('workflow-review-changes-item-trigger')[1]);
367
+ await rerender({
368
+ review: makeDetail({ workflows: [first, third] }),
369
+ tab: 'changes',
370
+ deciding: false,
371
+ });
372
+
373
+ expect(await findByTestId('workflow-review-changes-section')).toHaveAttribute(
374
+ 'data-workflow-id',
375
+ 'wf-1',
376
+ );
311
377
  });
312
378
 
313
379
  // A closed review keeps its diff: the backend serves the baseline frozen at
@@ -2,12 +2,12 @@
2
2
  import type { WorkflowReviewInboxItem, WorkflowReviewRequestDetail } from '@n8n/api-types';
3
3
  import { N8nCallout, N8nTabs, N8nText } from '@n8n/design-system';
4
4
  import { useI18n } from '@n8n/i18n';
5
- import { computed, provide, useTemplateRef } from 'vue';
5
+ import { computed, provide, ref, useTemplateRef, watch } from 'vue';
6
6
 
7
7
  import { ReviewDetailScrollContainerKey, ReviewLinkedWorkflowsKey } from '../constants';
8
8
  import type { WorkflowReviewDecisionInput } from '../workflowReviews.api';
9
9
  import WorkflowReviewActivityFeed from './WorkflowReviewActivityFeed.vue';
10
- import WorkflowReviewChangesSection from './WorkflowReviewChangesSection.vue';
10
+ import WorkflowReviewChangesList from './WorkflowReviewChangesList.vue';
11
11
  import WorkflowReviewCommentComposer from './WorkflowReviewCommentComposer.vue';
12
12
  import WorkflowReviewDecisionPopover from './WorkflowReviewDecisionPopover.vue';
13
13
  import WorkflowReviewDetailMetadata from './WorkflowReviewDetailMetadata.vue';
@@ -33,6 +33,26 @@ const detail = computed<WorkflowReviewRequestDetail | null>(() =>
33
33
  'workflows' in props.review ? props.review : null,
34
34
  );
35
35
 
36
+ // Held here, not in the list: the detail body remounts on every tab switch.
37
+ const expandedWorkflowId = ref<string>();
38
+ // The review that `expandedWorkflowId` belongs to.
39
+ let expandedReviewId: string | undefined;
40
+
41
+ // Open the first workflow for each new review. A refetch keeps the choice while the
42
+ // open workflow is still listed. The detail lists only readable workflows, so it can drop one.
43
+ watch(
44
+ detail,
45
+ (value) => {
46
+ if (!value?.workflows.length) return;
47
+ const open = expandedWorkflowId.value;
48
+ const isListed = value.workflows.some(({ workflowId }) => workflowId === open);
49
+ if (value.id === expandedReviewId && (open === undefined || isListed)) return;
50
+ expandedReviewId = value.id;
51
+ expandedWorkflowId.value = value.workflows[0].workflowId;
52
+ },
53
+ { immediate: true },
54
+ );
55
+
36
56
  const viewerCanDecide = computed(() => detail.value?.viewerCanDecide ?? false);
37
57
  const viewerCanComment = computed(() => detail.value?.viewerCanComment ?? false);
38
58
 
@@ -190,15 +210,13 @@ const tabOptions = computed(() => [
190
210
  >
191
211
  {{ i18n.baseText('workflowReviews.changes.unavailable') }}
192
212
  </N8nCallout>
193
- <template v-else-if="detail.workflows.length > 0">
194
- <WorkflowReviewChangesSection
195
- v-for="workflow in detail.workflows"
196
- :key="workflow.workflowId"
197
- :workflow="workflow"
198
- :state="detail.state"
199
- :decision="detail.decision"
200
- />
201
- </template>
213
+ <WorkflowReviewChangesList
214
+ v-else-if="detail.workflows.length > 0"
215
+ v-model:expanded="expandedWorkflowId"
216
+ :workflows="detail.workflows"
217
+ :state="detail.state"
218
+ :decision="detail.decision"
219
+ />
202
220
  <!-- No rows left: the workflow was deleted, or the requester lost access to it. -->
203
221
  <N8nCallout
204
222
  v-else
@@ -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>
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  DecideWorkflowReviewRequestResponse,
3
+ InboxSelfHealingItem,
3
4
  InboxWorkflowReviewItem,
4
5
  WorkflowReviewRequestDetail,
5
6
  } from '@n8n/api-types';
@@ -54,6 +55,7 @@ const renderOptions = {
54
55
  <button data-test-id="retry-waiting" @click="$emit('retry', 'waiting')" />
55
56
  <button data-test-id="select-review" @click="$emit('select', { type: 'workflow_review', id: 'req-1' })" />
56
57
  <button data-test-id="select-other-review" @click="$emit('select', { type: 'workflow_review', id: 'req-2' })" />
58
+ <button data-test-id="select-result" @click="$emit('select', { type: 'self_healing_result', id: 'req-1', projectId: 'p1', workflowId: 'w1' })" />
57
59
  <button data-test-id="clear-review" @click="$emit('clear')" />
58
60
  <button data-test-id="select-closed-tab" @click="$emit('update:active-tab', 'closed')" />
59
61
  </div>`,
@@ -79,6 +81,10 @@ const renderOptions = {
79
81
  <slot v-if="!listItem" />
80
82
  </div>`,
81
83
  }),
84
+ SelfHealingResultDetail: {
85
+ props: ['selection'],
86
+ template: '<div data-test-id="result-detail" :data-id="selection.id" />',
87
+ },
82
88
  },
83
89
  },
84
90
  };
@@ -93,7 +99,7 @@ beforeEach(async () => {
93
99
  await router.isReady();
94
100
  mockedStore(useSettingsStore).settings.inbox = {
95
101
  enabled: true,
96
- availableTypes: ['workflow_review'],
102
+ availableTypes: ['workflow_review', 'self_healing_result'],
97
103
  failedTypes: [],
98
104
  };
99
105
  store = mockedStore(useInboxStore);
@@ -113,17 +119,18 @@ it('refreshes the list and summary on mount', async () => {
113
119
  it('keeps the loaded list when navigating between Inbox routes', async () => {
114
120
  const { getByTestId, queryByTestId } = renderRoutedComponent();
115
121
  await waitAllPromises();
116
- store.lists.waiting.items = [reviewItem(), { ...reviewItem(), id: 'req-2' }];
122
+ store.lists.waiting.items = [reviewItem(), resultItem()];
117
123
  store.lists.waiting.nextCursor = 'next-page';
118
- for (const button of ['select-review', 'select-other-review', 'clear-review']) {
124
+ for (const button of ['select-review', 'select-result', 'clear-review']) {
119
125
  getByTestId(button).click();
120
126
  await waitAllPromises();
121
127
  }
122
128
  expect(router.currentRoute.value.fullPath).toBe('/inbox');
123
129
  expect(queryByTestId('review-detail')).not.toBeInTheDocument();
130
+ expect(queryByTestId('result-detail')).not.toBeInTheDocument();
124
131
  expect(store.reset).toHaveBeenCalledOnce();
125
132
  expect(store.refreshListAndSummary).toHaveBeenCalledOnce();
126
- expect(store.lists.waiting.items).toEqual([reviewItem(), { ...reviewItem(), id: 'req-2' }]);
133
+ expect(store.lists.waiting.items).toEqual([reviewItem(), resultItem()]);
127
134
  expect(store.lists.waiting.nextCursor).toBe('next-page');
128
135
  });
129
136
 
@@ -135,8 +142,24 @@ it('passes the selected review and list fallback to its detail entry', async ()
135
142
  expect(getByTestId('review-detail')).toHaveAttribute('data-title', 'List review');
136
143
  });
137
144
 
138
- it('clears selection and keeps the current state and tab', async () => {
139
- await router.replace('/inbox/reviews/req-1?state=closed&tab=changes');
145
+ it('selects a result with the same ID without rendering the review entry', async () => {
146
+ await router.replace('/inbox/reviews/req-1');
147
+ const { getByTestId, queryByTestId } = renderComponent();
148
+ getByTestId('select-result').click();
149
+ await waitAllPromises();
150
+ expect(router.currentRoute.value.query).toEqual({
151
+ projectId: 'p1',
152
+ workflowId: 'w1',
153
+ });
154
+ expect(router.currentRoute.value.path).toBe('/inbox/assistant-results/req-1');
155
+ expect(getByTestId('result-detail')).toHaveAttribute('data-id', 'req-1');
156
+ expect(queryByTestId('review-detail')).not.toBeInTheDocument();
157
+ });
158
+
159
+ it('clears source context and keeps the current state and tab', async () => {
160
+ await router.replace(
161
+ '/inbox/assistant-results/r1?projectId=p1&workflowId=w1&state=closed&tab=changes',
162
+ );
140
163
  const { getByTestId } = renderComponent();
141
164
  getByTestId('clear-review').click();
142
165
  await waitAllPromises();
@@ -237,7 +260,7 @@ it('reloads Closed without replacing the URL when a review closes there', async
237
260
  expect(store.fetchActiveTab).toHaveBeenCalledOnce();
238
261
  });
239
262
 
240
- it.each(['select-other-review'])(
263
+ it.each(['select-other-review', 'select-result'])(
241
264
  'reconciles a late decision without following it after %s',
242
265
  async (target) => {
243
266
  await router.replace('/inbox/reviews/req-1');
@@ -280,6 +303,7 @@ it.each([
280
303
  ['/inbox/reviews/req-1', false],
281
304
  ['/inbox/reviews/req-1', true],
282
305
  ['/inbox/reviews/req-2', false],
306
+ ['/inbox/assistant-results/result-1?projectId=p1&workflowId=w1', false],
283
307
  ['/other', false],
284
308
  ])(
285
309
  'reconciles approval after leaving the page for %s (pending detail: %s)',
@@ -399,6 +423,22 @@ function reviewItem(): InboxWorkflowReviewItem {
399
423
  updatedAt: '2024-01-01T00:00:00.000Z',
400
424
  };
401
425
  }
426
+ function resultItem(): InboxSelfHealingItem {
427
+ return {
428
+ type: 'self_healing_result',
429
+ id: 'req-1',
430
+ projectId: 'p1',
431
+ workflowId: 'w1',
432
+ workflowName: 'Workflow',
433
+ state: 'open',
434
+ outcome: 'fix_ready',
435
+ summary: 'Fix ready',
436
+ createdAt: '2024-01-01T00:00:00.000Z',
437
+ updatedAt: '2024-01-01T00:00:00.000Z',
438
+ completedAt: '2024-01-01T00:00:00.000Z',
439
+ };
440
+ }
441
+
402
442
  it('routes group pagination and retry to the named slice', async () => {
403
443
  const loadAuthored = vi.spyOn(store.lists.authored, 'loadMore').mockResolvedValue(undefined);
404
444
  const retryWaiting = vi.spyOn(store.lists.waiting, 'retry').mockResolvedValue(undefined);
@@ -474,7 +514,7 @@ it('restarts a partial group after a page failure while retaining exact page ret
474
514
  const request = vi
475
515
  .fn()
476
516
  .mockResolvedValueOnce({
477
- data: [reviewItem()],
517
+ data: [resultItem()],
478
518
  nextCursor: 'partial-next',
479
519
  hasMore: true,
480
520
  partial: true,
@@ -484,7 +524,7 @@ it('restarts a partial group after a page failure while retaining exact page ret
484
524
  .mockRejectedValueOnce(new Error('Page failed'))
485
525
  .mockRejectedValueOnce(new Error('Page still failed'))
486
526
  .mockResolvedValueOnce({
487
- data: [reviewItem(), { ...reviewItem(), id: 'req-2' }],
527
+ data: [reviewItem(), resultItem()],
488
528
  nextCursor: null,
489
529
  hasMore: false,
490
530
  partial: false,
@@ -503,7 +543,10 @@ it('restarts a partial group after a page failure while retaining exact page ret
503
543
  getByTestId('refresh-waiting').click();
504
544
  await waitAllPromises();
505
545
  expect(request).toHaveBeenLastCalledWith();
506
- expect(waiting.items.map((item) => item.type)).toEqual(['workflow_review', 'workflow_review']);
546
+ expect(waiting.items.map((item) => item.type)).toEqual([
547
+ 'workflow_review',
548
+ 'self_healing_result',
549
+ ]);
507
550
  expect(waiting.partial).toBe(false);
508
551
  expect(waiting.error).toBeNull();
509
552
  expect(waiting.nextCursor).toBeNull();
@@ -15,6 +15,7 @@ import { INBOX_VIEW, type InboxItemChange } from '../inbox.constants';
15
15
  import { inboxItemLocation, isInboxRoute, selectionFromRoute } from '../inbox.routes';
16
16
  import { useInboxStore } from '../inbox.store';
17
17
  import WorkflowReviewDetail from '../reviews/WorkflowReviewDetail.vue';
18
+ import SelfHealingResultDetail from '../self-healing/SelfHealingResultDetail.vue';
18
19
 
19
20
  const store = useInboxStore();
20
21
  const settingsStore = useSettingsStore();
@@ -92,6 +93,7 @@ function onSelect(item: InboxItem) {
92
93
  function onClearSelection() {
93
94
  if (!isOnInbox()) return;
94
95
  const query = { ...route.query };
96
+ for (const key of ['projectId', 'workflowId']) delete query[key];
95
97
  void router.replace({ name: INBOX_VIEW, query });
96
98
  }
97
99
  function onActiveTabChange(tab: 'open' | 'closed') {
@@ -186,7 +188,14 @@ onBeforeUnmount(() => {
186
188
  <template v-else>
187
189
  <div :class="$style.columnTitle" />
188
190
  <div :class="$style.mainBody">
189
- <InboxEmptyState />
191
+ <SelfHealingResultDetail
192
+ v-if="
193
+ selection?.type === 'self_healing_result' &&
194
+ !store.disabledSources.includes(selection.type)
195
+ "
196
+ :selection="selection"
197
+ />
198
+ <InboxEmptyState v-else />
190
199
  </div>
191
200
  </template>
192
201
  </div>