@n8n/frontend-module-inbox 0.2.4 → 0.3.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @n8n/frontend-module-inbox
2
2
 
3
- The shared Inbox lists workflow reviews and saved Assistant results. Review details, comments, and decisions live in `src/reviews`. The editor supplies only the workflow diff renderer through the SDK component registry. Backend services keep their existing access rules and actions.
3
+ The shared Inbox lists workflow reviews and saved Assistant results. Review details, comments, and decisions live in `src/reviews`. The editor supplies the workflow diff renderer and Assistant chat handoff through the SDK. Backend services keep their existing access rules and actions.
4
4
 
5
5
  Open has two groups: Waiting for review and Authored by you. Assistant results go
6
6
  in Waiting for review. Each group has its own cursor, Load more button, and retry.
@@ -9,7 +9,30 @@ Closed is a flat list with infinite scroll. Group collapse state persists per us
9
9
  Review decisions update the loaded rows and counts in place. Approving a selected
10
10
  review opens the Closed tab, as before.
11
11
 
12
- Self-healing detail UI remains gated by the disabled-by-default self-healing rollout. AST-1518 replaces the explicit unavailable detail state.
12
+ Assistant details live in `src/self-healing`. They show saved reports, workflow
13
+ changes, review activity, and usage. Result actions use the existing self-healing
14
+ API. Actions are available only while a result is open.
15
+ Fix ready shows Discard and close beside a Review menu: Apply and publish,
16
+ Continue with Assistant, and Continue with Editor.
17
+ Needs attention shows Discard and close beside a Continue in… menu with Continue
18
+ with Assistant and Continue with Editor. It has no publish action.
19
+ When a result has a suggestion, both menus show a note that tells what
20
+ continuing does.
21
+ Could not fix closes automatically and shows no actions.
22
+ Continuation applies a suggestion to the workflow draft, or dismisses a result
23
+ without one. It does not publish. The first successful action closes the result.
24
+ The frontend then updates the Inbox and opens the destination.
25
+ Chat uses the normal frontend Assistant handoff to create a private thread in the
26
+ reviewer's personal project. A reviewer can lack read access to the result's
27
+ project. The frontend loads the personal project before it closes the result.
28
+ If the project cannot load, the result stays open. The handoff sends the full
29
+ report and workflow attachment to start an Assistant run. If changes were
30
+ applied, the message says they are already in the draft.
31
+ Navigation failure does not reopen the result or undo saved changes.
32
+ After a lost response, the frontend refreshes the result and shows an error.
33
+ The user can retry the action if the result is still open.
34
+ A refresh does not navigate or retry publication.
35
+ Publishing remains a separate action. Self-healing remains off by default.
13
36
 
14
37
  Frontend feature module. Consumed from source by the editor-ui shell through
15
38
  `src/app/modules.manifest.ts`; there is no build step and no `dist`.
package/package.json CHANGED
@@ -1,25 +1,25 @@
1
1
  {
2
2
  "name": "@n8n/frontend-module-inbox",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "exports": {
7
7
  ".": "./src/index.ts"
8
8
  },
9
9
  "dependencies": {
10
- "@n8n/api-types": "1.45.3",
11
- "@n8n/composables": "1.35.3",
12
- "@n8n/design-system": "2.44.4",
13
- "@n8n/frontend-module-sdk": "0.15.4",
14
- "@n8n/i18n": "2.45.3",
15
- "@n8n/rest-api-client": "2.45.3",
16
- "@n8n/stores": "2.45.3",
17
- "n8n-workflow": "2.45.1",
10
+ "@n8n/api-types": "1.46.0",
11
+ "@n8n/composables": "1.36.0",
12
+ "@n8n/design-system": "2.45.0",
13
+ "@n8n/frontend-module-sdk": "0.16.0",
14
+ "@n8n/i18n": "2.46.0",
15
+ "@n8n/rest-api-client": "2.46.0",
16
+ "@n8n/stores": "2.46.0",
17
+ "n8n-workflow": "2.46.0",
18
18
  "pinia": "^2.2.4",
19
19
  "reka-ui": "^2.5.0",
20
20
  "vue": "^3.5.13",
21
21
  "vue-router": "^4.5.0",
22
- "@n8n/frontend-constants": "0.11.0",
22
+ "@n8n/frontend-constants": "0.12.0",
23
23
  "@vueuse/core": "^14.3.0",
24
24
  "lodash": "4.18.1",
25
25
  "vue-i18n": "^11.4.4"
@@ -40,14 +40,14 @@
40
40
  "stylelint": "^16.23.0",
41
41
  "typescript": "6.0.2",
42
42
  "unplugin-icons": "^23.0.1",
43
- "vite": "^8.0.2",
43
+ "vite": "^8.1.6",
44
44
  "vite-svg-loader": "5.1.0",
45
45
  "vitest": "5.0.1",
46
46
  "vue-tsc": "^2.2.8",
47
47
  "@pinia/testing": "^0.1.6",
48
48
  "@testing-library/user-event": "^14.6.1",
49
49
  "@types/lodash": "4.17.17",
50
- "@n8n/utils": "1.52.0"
50
+ "@n8n/utils": "1.53.0"
51
51
  },
52
52
  "license": "SEE LICENSE IN LICENSE.md",
53
53
  "homepage": "https://n8n.io",
@@ -1,4 +1,8 @@
1
- import type { InboxSelfHealingItem, InboxWorkflowReviewItem } from '@n8n/api-types';
1
+ import type {
2
+ InboxPromotionReviewItem,
3
+ InboxSelfHealingItem,
4
+ InboxWorkflowReviewItem,
5
+ } from '@n8n/api-types';
2
6
  import { createComponentRenderer } from '@n8n/frontend-test-utils';
3
7
  import { useUsersStore } from '@n8n/stores/users.store';
4
8
  import userEvent from '@testing-library/user-event';
@@ -247,6 +251,54 @@ it.each([
247
251
  },
248
252
  );
249
253
 
254
+ it('renders promotion review rows with the branch as fallback title and the GitLab problem', () => {
255
+ const promotion = (props: Partial<InboxPromotionReviewItem>): InboxPromotionReviewItem => ({
256
+ type: 'promotion_review',
257
+ id: 'promotion',
258
+ state: 'closed',
259
+ reviewState: 'merged',
260
+ reviewType: 'gitlab',
261
+ title: 'Promote the thing',
262
+ webUrl: null,
263
+ branchName: 'n8n-promotion/1',
264
+ environmentName: null,
265
+ requester: null,
266
+ approver: null,
267
+ remoteError: null,
268
+ headCommitSha: null,
269
+ runCommitSha: null,
270
+ hasConflicts: false,
271
+ createdAt: '2026-01-01T00:00:00.000Z',
272
+ updatedAt: '2026-01-01T00:00:00.000Z',
273
+ ...props,
274
+ });
275
+ const { getByRole } = renderComponent({
276
+ props: {
277
+ sections: [
278
+ section('waiting', {
279
+ items: [
280
+ promotion({}),
281
+ promotion({
282
+ id: 'unanswered',
283
+ state: 'open',
284
+ reviewState: 'open',
285
+ title: null,
286
+ branchName: 'n8n-promotion/2',
287
+ remoteError: 'unreachable',
288
+ }),
289
+ ],
290
+ }),
291
+ ],
292
+ },
293
+ });
294
+ const [merged, unanswered] = within(getByRole('list')).getAllByRole('button');
295
+ expect(within(merged).getByText('Promote the thing')).toBeInTheDocument();
296
+ expect(within(merged).getByText('Promotion')).toBeInTheDocument();
297
+ expect(within(merged).getByRole('img', { name: 'Merged' }).className).toContain('merged');
298
+ expect(within(unanswered).getByText('n8n-promotion/2')).toBeInTheDocument();
299
+ expect(within(unanswered).getByText('GitLab unreachable')).toBeInTheDocument();
300
+ });
301
+
250
302
  it('separates partial-source recovery from a failed page retry', () => {
251
303
  const { getByTestId, emitted } = renderComponent({
252
304
  props: { sections: [section('waiting', { partial: true, error: new Error('Page failed') })] },
@@ -154,20 +154,15 @@ function onListBackgroundClick() {
154
154
  }
155
155
 
156
156
  .header {
157
- position: relative;
158
157
  display: flex;
159
- align-items: center;
160
- height: var(--review-tab-bar--height, var(--height--sm));
161
- padding-right: var(--spacing--md);
162
- margin-bottom: var(--review-tab-bar--gap);
158
+ align-items: flex-end;
159
+ margin: 0 var(--spacing--md) var(--spacing--sm) 0;
160
+ /* The divider follows the bottom of the tabs, so the tab badges can change their height. */
161
+ border-bottom: var(--border);
163
162
 
164
- &::after {
165
- content: '';
166
- position: absolute;
167
- left: 0;
168
- right: var(--spacing--md);
169
- bottom: calc(-1 * var(--review-tab-bar--indicator-overhang) - var(--border-width));
170
- border-bottom: var(--border-width) solid var(--border-color);
163
+ > :global(.n8n-tabs) {
164
+ /* The active-tab indicator covers the divider, as in the detail tab bars. */
165
+ margin-bottom: calc(-1 * var(--border-width));
171
166
  }
172
167
  }
173
168
 
@@ -1,12 +1,12 @@
1
1
  <script setup lang="ts">
2
- import type { InboxItem, InboxSelfHealingItem } from '@n8n/api-types';
2
+ import type { InboxItem, InboxSelfHealingItem, PromotionReviewType } from '@n8n/api-types';
3
3
  import {
4
4
  N8nAssistantAvatar,
5
5
  N8nBadge,
6
6
  N8nCard,
7
- N8nIcon,
8
7
  N8nText,
9
8
  N8nTimeAgo,
9
+ type IconName,
10
10
  } from '@n8n/design-system';
11
11
  import { useI18n } from '@n8n/i18n';
12
12
  import { useRootStore } from '@n8n/stores/useRootStore';
@@ -18,6 +18,20 @@ const emit = defineEmits<{ select: [] }>();
18
18
  const i18n = useI18n();
19
19
  const rootStore = useRootStore();
20
20
 
21
+ // A promotion review shows the icon of its Git host. A new host needs an icon here.
22
+ const reviewTypeIcon = { gitlab: 'gitlab' } satisfies Record<PromotionReviewType, IconName>;
23
+
24
+ function itemTitle(item: InboxItem) {
25
+ switch (item.type) {
26
+ case 'workflow_review':
27
+ return item.title;
28
+ case 'self_healing_result':
29
+ return item.summary;
30
+ case 'promotion_review':
31
+ return item.title ?? item.branchName ?? i18n.baseText('inbox.promotionReview.untitled');
32
+ }
33
+ }
34
+
21
35
  function outcomeLabel(item: InboxSelfHealingItem) {
22
36
  switch (item.outcome) {
23
37
  case 'fix_ready':
@@ -57,13 +71,20 @@ function outcomeLabel(item: InboxSelfHealingItem) {
57
71
  $style.cardTitle,
58
72
  { [$style.assistantTitle]: item.type === 'self_healing_result' },
59
73
  ]"
60
- >{{ item.type === 'workflow_review' ? item.title : item.summary }}</N8nText
74
+ >{{ itemTitle(item) }}</N8nText
61
75
  >
62
76
  <WorkflowReviewStatusDot
63
77
  v-if="item.type === 'workflow_review'"
64
78
  :state="item.state"
65
79
  :decision="item.decision"
66
80
  />
81
+ <span
82
+ v-else-if="item.type === 'promotion_review'"
83
+ :class="[$style.statusDot, $style[item.reviewState]]"
84
+ role="img"
85
+ :aria-label="i18n.baseText(`inbox.promotionReview.state.${item.reviewState}`)"
86
+ data-test-id="inbox-promotion-status"
87
+ />
67
88
  <span
68
89
  v-else
69
90
  :class="[
@@ -80,12 +101,28 @@ function outcomeLabel(item: InboxSelfHealingItem) {
80
101
  />
81
102
  </div>
82
103
  <div :class="$style.meta">
83
- <N8nBadge v-if="item.workflowName" variant="outline" :class="$style.workflowBadge">
84
- <span :class="$style.workflowBadgeText" :title="item.workflowName"
85
- ><N8nIcon icon="workflow" size="small" /><span>{{ item.workflowName }}</span></span
86
- >
87
- </N8nBadge>
104
+ <N8nBadge
105
+ v-if="item.type === 'promotion_review'"
106
+ variant="outline"
107
+ :leading-icon="reviewTypeIcon[item.reviewType]"
108
+ :class="$style.workflowBadge"
109
+ >{{ i18n.baseText('inbox.promotionReview.badge') }}</N8nBadge
110
+ >
111
+ <N8nBadge
112
+ v-else-if="item.workflowName"
113
+ variant="outline"
114
+ leading-icon="workflow"
115
+ :class="$style.workflowBadge"
116
+ :title="item.workflowName"
117
+ >{{ item.workflowName }}</N8nBadge
118
+ >
88
119
  <div :class="$style.metaActions">
120
+ <N8nText
121
+ v-if="item.type === 'promotion_review' && item.remoteError"
122
+ size="xsmall"
123
+ color="warning"
124
+ >{{ i18n.baseText(`inbox.promotionReview.remoteError.${item.remoteError}`) }}</N8nText
125
+ >
89
126
  <N8nText :class="$style.time" size="xsmall" color="text-light">
90
127
  <N8nTimeAgo :date="item.updatedAt" :locale="rootStore.defaultLocale" />
91
128
  </N8nText>
@@ -165,20 +202,6 @@ function outcomeLabel(item: InboxSelfHealingItem) {
165
202
  max-width: 100%;
166
203
  }
167
204
  }
168
- .workflowBadgeText {
169
- display: inline-flex;
170
- align-items: center;
171
- gap: var(--spacing--3xs);
172
- max-width: 100%;
173
- min-width: 0;
174
- line-height: calc(var(--font-size--sm) + var(--border-width));
175
- > span {
176
- overflow: hidden;
177
- text-overflow: ellipsis;
178
- white-space: nowrap;
179
- min-width: 0;
180
- }
181
- }
182
205
  .metaActions {
183
206
  display: flex;
184
207
  align-items: center;
@@ -205,4 +228,13 @@ function outcomeLabel(item: InboxSelfHealingItem) {
205
228
  .closed {
206
229
  background-color: var(--color--neutral-500);
207
230
  }
231
+ .open {
232
+ background-color: var(--color--blue-500);
233
+ }
234
+ .changes_requested {
235
+ background-color: var(--color--yellow-500);
236
+ }
237
+ .merged {
238
+ background-color: var(--color--green-500);
239
+ }
208
240
  </style>
@@ -2,13 +2,15 @@ import type { InboxSourceType, InboxState, WorkflowReviewRequestDecision } from
2
2
 
3
3
  export const INBOX_VIEW = 'Inbox';
4
4
  export const INBOX_ASSISTANT_RESULT_VIEW = 'InboxAssistantResult';
5
+ export const INBOX_PROMOTION_REVIEW_VIEW = 'InboxPromotionReview';
5
6
  export const INBOX_PAGE_LIMIT = 15;
6
7
 
7
8
  export type InboxSectionKey = 'waiting' | 'authored' | 'closed';
8
9
 
9
10
  export type InboxSelection =
10
11
  | { type: 'workflow_review'; id: string }
11
- | { type: 'self_healing_result'; id: string; projectId: string; workflowId: string };
12
+ | { type: 'self_healing_result'; id: string; projectId: string; workflowId: string }
13
+ | { type: 'promotion_review'; id: string };
12
14
 
13
15
  export type InboxItemChange = {
14
16
  type: InboxSourceType;
@@ -16,7 +16,7 @@ const inboxView = {
16
16
  export const InboxModule = defineFrontendModule({
17
17
  id: 'inbox',
18
18
  name: 'Inbox',
19
- description: 'Workflow reviews and saved Assistant results.',
19
+ description: 'Workflow reviews, promotion reviews, and saved Assistant results.',
20
20
  icon: 'inbox',
21
21
  routes: [
22
22
  // Keep legacy /reviews links working after the move to Inbox.
@@ -51,5 +51,10 @@ export const InboxModule = defineFrontendModule({
51
51
  path: '/inbox/assistant-results/:resultId',
52
52
  name: 'InboxAssistantResult',
53
53
  },
54
+ {
55
+ ...inboxView,
56
+ path: '/inbox/promotion-reviews/:reviewId',
57
+ name: 'InboxPromotionReview',
58
+ },
54
59
  ],
55
60
  });
@@ -59,6 +59,7 @@ it('redirects the old review list without selecting an item', async () => {
59
59
 
60
60
  it.each([
61
61
  ['/inbox/reviews/review-1', { type: 'workflow_review', id: 'review-1' }],
62
+ ['/inbox/promotion-reviews/review-1', { type: 'promotion_review', id: 'review-1' }],
62
63
  [
63
64
  '/inbox/assistant-results/result-1?projectId=project&workflowId=workflow',
64
65
  {
@@ -87,12 +88,37 @@ it.each([
87
88
  ['Inbox', true],
88
89
  ['WorkflowReviewRequestsView', true],
89
90
  ['InboxAssistantResult', true],
91
+ ['InboxPromotionReview', true],
90
92
  ['Workflow', false],
91
93
  [undefined, false],
92
94
  ])('recognizes the Inbox route %s: %s', (name, expected) => {
93
95
  expect(isInboxRoute({ name })).toBe(expected);
94
96
  });
95
97
 
98
+ it('links a promotion review row to its detail route', () => {
99
+ const router = createInboxRouter();
100
+ const location = inboxItemLocation({
101
+ type: 'promotion_review',
102
+ id: 'review-1',
103
+ state: 'open',
104
+ reviewState: 'open',
105
+ reviewType: 'gitlab',
106
+ title: null,
107
+ webUrl: null,
108
+ branchName: null,
109
+ environmentName: null,
110
+ requester: null,
111
+ approver: null,
112
+ remoteError: null,
113
+ headCommitSha: null,
114
+ runCommitSha: null,
115
+ hasConflicts: false,
116
+ createdAt: '',
117
+ updatedAt: '',
118
+ });
119
+ expect(router.resolve(location).path).toBe('/inbox/promotion-reviews/review-1');
120
+ });
121
+
96
122
  it('keeps the tab only when selecting the same item', async () => {
97
123
  const router = createInboxRouter();
98
124
  await router.push(
@@ -1,7 +1,12 @@
1
1
  import type { InboxItem } from '@n8n/api-types';
2
2
  import type { LocationQuery, RouteLocationNormalizedLoaded, RouteLocationRaw } from 'vue-router';
3
3
 
4
- import { INBOX_ASSISTANT_RESULT_VIEW, INBOX_VIEW, type InboxSelection } from './inbox.constants';
4
+ import {
5
+ INBOX_ASSISTANT_RESULT_VIEW,
6
+ INBOX_PROMOTION_REVIEW_VIEW,
7
+ INBOX_VIEW,
8
+ type InboxSelection,
9
+ } from './inbox.constants';
5
10
  import { WORKFLOW_REVIEW_REQUESTS_VIEW } from './reviews/constants';
6
11
 
7
12
  type InboxRoute = Pick<RouteLocationNormalizedLoaded, 'name' | 'params' | 'query'>;
@@ -14,7 +19,8 @@ export function isInboxRoute(route: Pick<InboxRoute, 'name'>): boolean {
14
19
  return (
15
20
  route.name === INBOX_VIEW ||
16
21
  route.name === WORKFLOW_REVIEW_REQUESTS_VIEW ||
17
- route.name === INBOX_ASSISTANT_RESULT_VIEW
22
+ route.name === INBOX_ASSISTANT_RESULT_VIEW ||
23
+ route.name === INBOX_PROMOTION_REVIEW_VIEW
18
24
  );
19
25
  }
20
26
 
@@ -23,6 +29,10 @@ export function selectionFromRoute({ name, params, query }: InboxRoute): InboxSe
23
29
  const id = stringParam(params.reviewId);
24
30
  return id ? { type: 'workflow_review', id } : null;
25
31
  }
32
+ if (name === INBOX_PROMOTION_REVIEW_VIEW) {
33
+ const id = stringParam(params.reviewId);
34
+ return id ? { type: 'promotion_review', id } : null;
35
+ }
26
36
  if (name !== INBOX_ASSISTANT_RESULT_VIEW) return null;
27
37
  const id = stringParam(params.resultId);
28
38
  if (!id) return null;
@@ -45,5 +55,8 @@ export function inboxItemLocation(item: InboxItem, route?: InboxRoute): RouteLoc
45
55
  next.workflowId = item.workflowId;
46
56
  return { name: INBOX_ASSISTANT_RESULT_VIEW, params: { resultId: item.id }, query: next };
47
57
  }
58
+ if (item.type === 'promotion_review') {
59
+ return { name: INBOX_PROMOTION_REVIEW_VIEW, params: { reviewId: item.id }, query: next };
60
+ }
48
61
  return { name: WORKFLOW_REVIEW_REQUESTS_VIEW, params: { reviewId: item.id }, query: next };
49
62
  }
@@ -15,7 +15,11 @@ 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', 'self_healing_result'];
18
+ const sourceTypes: InboxSourceType[] = [
19
+ 'workflow_review',
20
+ 'self_healing_result',
21
+ 'promotion_review',
22
+ ];
19
23
 
20
24
  type ListState = {
21
25
  items: InboxItem[];
@@ -30,7 +34,9 @@ type ListState = {
30
34
  };
31
35
 
32
36
  function isSourceType(value: unknown): value is InboxSourceType {
33
- return value === 'workflow_review' || value === 'self_healing_result';
37
+ return (
38
+ value === 'workflow_review' || value === 'self_healing_result' || value === 'promotion_review'
39
+ );
34
40
  }
35
41
 
36
42
  function disabledTypesFromError(error: unknown): InboxSourceType[] {