@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,168 @@
1
+ <script setup lang="ts">
2
+ import type { InboxItem } from '@n8n/api-types';
3
+ import { N8nButton, N8nIcon, N8nLoading, N8nText } from '@n8n/design-system';
4
+ import { useI18n } from '@n8n/i18n';
5
+ import { useUsersStore } from '@n8n/stores/users.store';
6
+
7
+ import type { CollapsibleReviewInboxSection } from '../composables/useReviewInboxSectionCollapse';
8
+ import type { InboxSectionKey } from '../inbox.constants';
9
+ import InboxListItem from './InboxListItem.vue';
10
+
11
+ export type InboxListSection = {
12
+ key: InboxSectionKey;
13
+ items: InboxItem[];
14
+ loading: boolean;
15
+ loadingMore: boolean;
16
+ hasMore: boolean;
17
+ error: Error | null;
18
+ failedRequest: 'list' | 'loadMore' | null;
19
+ partial: boolean;
20
+ };
21
+
22
+ defineProps<{
23
+ section: InboxListSection;
24
+ collapsed: boolean;
25
+ selectedKey: string | null;
26
+ }>();
27
+ const emit = defineEmits<{
28
+ select: [item: InboxItem];
29
+ toggle: [key: CollapsibleReviewInboxSection];
30
+ loadMore: [];
31
+ retry: [];
32
+ refreshSection: [];
33
+ }>();
34
+ const i18n = useI18n();
35
+ const usersStore = useUsersStore();
36
+
37
+ function sectionTitle(key: 'waiting' | 'authored') {
38
+ return key === 'waiting' && usersStore.isAdminOrOwner
39
+ ? i18n.baseText('workflowReviews.sidebar.section.waiting.titleAdmin')
40
+ : i18n.baseText(`workflowReviews.sidebar.section.${key}.title`);
41
+ }
42
+ </script>
43
+
44
+ <template>
45
+ <div :class="$style.section" :data-section="section.key">
46
+ <button
47
+ v-if="section.key !== 'closed'"
48
+ :id="`inbox-section-header-${section.key}`"
49
+ type="button"
50
+ :class="$style.sectionHeader"
51
+ :aria-expanded="!collapsed"
52
+ :aria-controls="`inbox-section-${section.key}`"
53
+ data-test-id="inbox-section-header"
54
+ @click="emit('toggle', section.key)"
55
+ >
56
+ <N8nIcon
57
+ icon="chevron-down"
58
+ size="small"
59
+ :class="[$style.chevron, { [$style.chevronCollapsed]: collapsed }]"
60
+ />
61
+ <N8nText bold size="small" color="text-base">{{ sectionTitle(section.key) }}</N8nText>
62
+ </button>
63
+ <div
64
+ :id="`inbox-section-${section.key}`"
65
+ :class="$style.group"
66
+ role="list"
67
+ :aria-labelledby="
68
+ section.key !== 'closed' ? `inbox-section-header-${section.key}` : undefined
69
+ "
70
+ :aria-label="section.key === 'closed' ? i18n.baseText('inbox.tabs.closed') : undefined"
71
+ >
72
+ <template v-if="!collapsed">
73
+ <div v-for="item in section.items" :key="`${item.type}:${item.id}`" role="listitem">
74
+ <InboxListItem
75
+ :item="item"
76
+ :selected="selectedKey === `${item.type}:${item.id}`"
77
+ @select="emit('select', item)"
78
+ />
79
+ </div>
80
+ </template>
81
+ </div>
82
+ <template v-if="!collapsed">
83
+ <div v-if="section.loadingMore" :class="$style.loadingMore">
84
+ <N8nLoading :loading="true" :rows="1" />
85
+ </div>
86
+ <div v-if="section.partial" :class="$style.notice" role="status" data-test-id="inbox-partial">
87
+ <N8nText size="small">{{ i18n.baseText('inbox.partial') }}</N8nText>
88
+ <N8nButton
89
+ size="mini"
90
+ variant="outline"
91
+ :label="i18n.baseText('generic.retry')"
92
+ @click="emit('refreshSection')"
93
+ />
94
+ </div>
95
+ <div v-if="section.error" :class="$style.notice" role="alert" data-test-id="inbox-list-error">
96
+ <N8nText color="danger" size="small">{{ i18n.baseText('inbox.loadError') }}</N8nText>
97
+ <N8nButton
98
+ size="mini"
99
+ variant="outline"
100
+ :label="i18n.baseText('generic.retry')"
101
+ @click="emit('retry')"
102
+ />
103
+ </div>
104
+ <N8nButton
105
+ v-if="section.key !== 'closed' && section.hasMore"
106
+ :class="$style.loadMore"
107
+ size="small"
108
+ variant="outline"
109
+ :loading="section.loadingMore"
110
+ :disabled="section.loading"
111
+ :label="i18n.baseText('inbox.loadMore')"
112
+ data-test-id="inbox-load-more"
113
+ @click="emit('loadMore')"
114
+ />
115
+ </template>
116
+ </div>
117
+ </template>
118
+
119
+ <style module lang="scss">
120
+ .section,
121
+ .group {
122
+ display: flex;
123
+ flex-direction: column;
124
+ gap: var(--spacing--2xs);
125
+ min-width: 0;
126
+ }
127
+
128
+ .sectionHeader {
129
+ display: flex;
130
+ align-items: center;
131
+ gap: var(--spacing--3xs);
132
+ width: 100%;
133
+ padding: var(--spacing--3xs) var(--spacing--2xs);
134
+ border: none;
135
+ border-radius: var(--radius);
136
+ background: transparent;
137
+ cursor: pointer;
138
+ text-align: left;
139
+ }
140
+ .sectionHeader:hover {
141
+ background-color: var(--background--active);
142
+ }
143
+ .sectionHeader:focus-visible {
144
+ outline: var(--border-width) solid var(--focus--border-color);
145
+ }
146
+ .chevron {
147
+ transition: transform var(--duration--snappy) ease;
148
+ color: var(--color--text--tint-1);
149
+ }
150
+ .chevronCollapsed {
151
+ transform: rotate(-90deg);
152
+ }
153
+ .notice {
154
+ display: flex;
155
+ align-items: center;
156
+ justify-content: space-between;
157
+ gap: var(--spacing--2xs);
158
+ padding: 0 var(--spacing--2xs);
159
+ }
160
+
161
+ .loadMore {
162
+ align-self: center;
163
+ }
164
+
165
+ .loadingMore {
166
+ padding: var(--spacing--sm);
167
+ }
168
+ </style>
@@ -0,0 +1,66 @@
1
+ import { createPinia, setActivePinia } from 'pinia';
2
+ import { nextTick } from 'vue';
3
+
4
+ import { useUsersStore } from '@n8n/stores/users.store';
5
+ import { useReviewInboxSectionCollapse } from './useReviewInboxSectionCollapse';
6
+
7
+ describe('useReviewInboxSectionCollapse', () => {
8
+ beforeEach(() => {
9
+ localStorage.clear();
10
+ setActivePinia(createPinia());
11
+ });
12
+
13
+ it('starts with both sections expanded and writes nothing until toggled', () => {
14
+ useUsersStore().currentUserId = 'user-1';
15
+ const { isCollapsed } = useReviewInboxSectionCollapse();
16
+
17
+ expect(isCollapsed('waiting')).toBe(false);
18
+ expect(isCollapsed('authored')).toBe(false);
19
+ expect(localStorage.getItem('user-1_N8N_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS')).toBeNull();
20
+ });
21
+
22
+ it('restores the existing review section preference', () => {
23
+ useUsersStore().currentUserId = 'user-1';
24
+ localStorage.setItem(
25
+ 'user-1_N8N_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS',
26
+ JSON.stringify({ waiting: true, authored: false }),
27
+ );
28
+ const { isCollapsed } = useReviewInboxSectionCollapse();
29
+ expect(isCollapsed('waiting')).toBe(true);
30
+ expect(isCollapsed('authored')).toBe(false);
31
+ });
32
+
33
+ it('persists a collapsed section for the current user', async () => {
34
+ useUsersStore().currentUserId = 'user-1';
35
+ const { isCollapsed, toggleSection } = useReviewInboxSectionCollapse();
36
+
37
+ toggleSection('authored');
38
+ await nextTick();
39
+
40
+ expect(isCollapsed('authored')).toBe(true);
41
+ expect(isCollapsed('waiting')).toBe(false);
42
+ expect(localStorage.getItem('user-1_N8N_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS')).toBe(
43
+ JSON.stringify({ waiting: false, authored: true }),
44
+ );
45
+ });
46
+
47
+ it('keeps the choice independent between users', () => {
48
+ useUsersStore().currentUserId = 'user-1';
49
+ useReviewInboxSectionCollapse().toggleSection('waiting');
50
+
51
+ setActivePinia(createPinia());
52
+ useUsersStore().currentUserId = 'user-2';
53
+
54
+ expect(useReviewInboxSectionCollapse().isCollapsed('waiting')).toBe(false);
55
+ });
56
+
57
+ it('falls back to a non-persistent ref when there is no current user', async () => {
58
+ const { isCollapsed, toggleSection } = useReviewInboxSectionCollapse();
59
+
60
+ toggleSection('waiting');
61
+ await nextTick();
62
+
63
+ expect(isCollapsed('waiting')).toBe(true);
64
+ expect(localStorage.getItem('_N8N_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS')).toBeNull();
65
+ });
66
+ });
@@ -0,0 +1,42 @@
1
+ import { useLocalStorage } from '@vueuse/core';
2
+ import { ref } from 'vue';
3
+
4
+ import { useUsersStore } from '@n8n/stores/users.store';
5
+
6
+ /** Only the open tab is sectioned, so only those two sections can collapse. */
7
+ export type CollapsibleReviewInboxSection = 'waiting' | 'authored';
8
+
9
+ export type CollapsedReviewInboxSections = Record<CollapsibleReviewInboxSection, boolean>;
10
+
11
+ const DEFAULT_COLLAPSED: CollapsedReviewInboxSections = { waiting: false, authored: false };
12
+
13
+ /**
14
+ * Which inbox sections are collapsed, persisted per user. Collapsing only hides
15
+ * rows — the store keeps the loaded items and their cursors, so re-expanding
16
+ * never refetches.
17
+ */
18
+ export const useReviewInboxSectionCollapse = () => {
19
+ const usersStore = useUsersStore();
20
+ const userId = usersStore.currentUserId;
21
+
22
+ const collapsedSections = userId
23
+ ? useLocalStorage<CollapsedReviewInboxSections>(
24
+ `${userId}_N8N_WORKFLOW_REVIEW_INBOX_COLLAPSED_SECTIONS`,
25
+ { ...DEFAULT_COLLAPSED },
26
+ { writeDefaults: false },
27
+ )
28
+ : ref<CollapsedReviewInboxSections>({ ...DEFAULT_COLLAPSED });
29
+
30
+ function isCollapsed(section: CollapsibleReviewInboxSection): boolean {
31
+ return collapsedSections.value[section] ?? false;
32
+ }
33
+
34
+ function toggleSection(section: CollapsibleReviewInboxSection): void {
35
+ collapsedSections.value = {
36
+ ...collapsedSections.value,
37
+ [section]: !isCollapsed(section),
38
+ };
39
+ }
40
+
41
+ return { collapsedSections, isCollapsed, toggleSection };
42
+ };
@@ -0,0 +1,20 @@
1
+ import type {
2
+ GetInboxSummaryResponse,
3
+ InboxCategory,
4
+ InboxState,
5
+ ListInboxResponse,
6
+ } from '@n8n/api-types';
7
+ import { makeRestApiRequest, type IRestApiContext } from '@n8n/rest-api-client';
8
+
9
+ export async function fetchInbox(
10
+ context: IRestApiContext,
11
+ query: { state: InboxState; category?: InboxCategory; limit: number; cursor?: string },
12
+ ): Promise<ListInboxResponse> {
13
+ return await makeRestApiRequest(context, 'GET', '/inbox', query);
14
+ }
15
+
16
+ export async function fetchInboxSummary(
17
+ context: IRestApiContext,
18
+ ): Promise<GetInboxSummaryResponse> {
19
+ return await makeRestApiRequest(context, 'GET', '/inbox/summary');
20
+ }
@@ -0,0 +1,19 @@
1
+ import type { InboxSourceType, InboxState, WorkflowReviewRequestDecision } from '@n8n/api-types';
2
+
3
+ export const INBOX_VIEW = 'Inbox';
4
+ export const INBOX_ASSISTANT_RESULT_VIEW = 'InboxAssistantResult';
5
+ export const INBOX_PAGE_LIMIT = 15;
6
+
7
+ export type InboxSectionKey = 'waiting' | 'authored' | 'closed';
8
+
9
+ export type InboxSelection =
10
+ | { type: 'workflow_review'; id: string }
11
+ | { type: 'self_healing_result'; id: string; projectId: string; workflowId: string };
12
+
13
+ export type InboxItemChange = {
14
+ type: InboxSourceType;
15
+ id: string;
16
+ state?: InboxState;
17
+ decision?: WorkflowReviewRequestDecision;
18
+ updatedAt?: string;
19
+ };
@@ -0,0 +1,55 @@
1
+ import { defineFrontendModule } from '@n8n/frontend-module-sdk';
2
+ import type { RouteRecordSingleView } from 'vue-router';
3
+
4
+ const inboxView = {
5
+ component: async () => await import('./views/InboxView.vue'),
6
+
7
+ async beforeEnter() {
8
+ const { useSettingsStore } = await import('@n8n/stores/settings.store');
9
+
10
+ return useSettingsStore().settings.inbox?.enabled === true || '/';
11
+ },
12
+
13
+ meta: { layout: 'default', middleware: ['authenticated', 'custom'] },
14
+ } satisfies Pick<RouteRecordSingleView, 'component' | 'beforeEnter' | 'meta'>;
15
+
16
+ export const InboxModule = defineFrontendModule({
17
+ id: 'inbox',
18
+ name: 'Inbox',
19
+ description: 'Workflow reviews and saved Assistant results.',
20
+ icon: 'inbox',
21
+ routes: [
22
+ // Keep legacy /reviews links working after the move to Inbox.
23
+ {
24
+ path: '/reviews/:reviewRequestId?',
25
+ redirect: (to) => {
26
+ const reviewId = to.params.reviewRequestId;
27
+
28
+ if (typeof reviewId === 'string' && reviewId) {
29
+ return {
30
+ name: 'WorkflowReviewRequestsView',
31
+ params: { reviewId },
32
+ query: to.query,
33
+ };
34
+ }
35
+
36
+ return { name: 'Inbox', query: to.query };
37
+ },
38
+ },
39
+ {
40
+ ...inboxView,
41
+ path: '/inbox',
42
+ name: 'Inbox',
43
+ },
44
+ {
45
+ ...inboxView,
46
+ path: '/inbox/reviews/:reviewId',
47
+ name: 'WorkflowReviewRequestsView',
48
+ },
49
+ {
50
+ ...inboxView,
51
+ path: '/inbox/assistant-results/:resultId',
52
+ name: 'InboxAssistantResult',
53
+ },
54
+ ],
55
+ });
@@ -0,0 +1,140 @@
1
+ import { createMemoryHistory, createRouter } from 'vue-router';
2
+
3
+ import { InboxModule } from './inbox.module';
4
+ import { inboxItemLocation, isInboxRoute, selectionFromRoute } from './inbox.routes';
5
+
6
+ const settingsStore = vi.hoisted(() => ({ settings: { inbox: { enabled: true } } }));
7
+ vi.mock('@n8n/stores/settings.store', () => ({ useSettingsStore: () => settingsStore }));
8
+
9
+ beforeEach(() => {
10
+ settingsStore.settings.inbox.enabled = true;
11
+ });
12
+
13
+ function createInboxRouter() {
14
+ const routes = (InboxModule.routes ?? []).map((route) =>
15
+ 'component' in route ? { ...route, component: { template: '<div />' } } : route,
16
+ );
17
+ return createRouter({ history: createMemoryHistory(), routes });
18
+ }
19
+
20
+ const result = {
21
+ type: 'self_healing_result',
22
+ id: 'result-1',
23
+ state: 'open',
24
+ projectId: 'project',
25
+ workflowId: 'workflow',
26
+ workflowName: 'Workflow',
27
+ summary: 'Summary',
28
+ outcome: 'fix_ready',
29
+ createdAt: '',
30
+ updatedAt: '',
31
+ completedAt: '',
32
+ } as const;
33
+
34
+ it('redirects to home when Inbox is disabled', async () => {
35
+ settingsStore.settings.inbox.enabled = false;
36
+ const router = createInboxRouter();
37
+ router.addRoute({ path: '/', name: 'home', component: { template: '<div />' } });
38
+ await router.push('/inbox');
39
+ expect(router.currentRoute.value).toMatchObject({ path: '/', name: 'home' });
40
+ });
41
+
42
+ it('redirects an old review link and preserves filters and the detail tab', async () => {
43
+ const router = createInboxRouter();
44
+ await router.push('/reviews/review-1?state=closed&tab=changes&filter=a&filter=b');
45
+ expect(router.currentRoute.value.path).toBe('/inbox/reviews/review-1');
46
+ expect(router.currentRoute.value.query).toEqual({
47
+ state: 'closed',
48
+ tab: 'changes',
49
+ filter: ['a', 'b'],
50
+ });
51
+ });
52
+
53
+ it('redirects the old review list without selecting an item', async () => {
54
+ const router = createInboxRouter();
55
+ await router.push('/reviews?state=closed');
56
+ expect(router.currentRoute.value.fullPath).toBe('/inbox?state=closed');
57
+ expect(selectionFromRoute(router.currentRoute.value)).toBeNull();
58
+ });
59
+
60
+ it.each([
61
+ ['/inbox/reviews/review-1', { type: 'workflow_review', id: 'review-1' }],
62
+ [
63
+ '/inbox/assistant-results/result-1?projectId=project&workflowId=workflow',
64
+ {
65
+ type: result.type,
66
+ id: result.id,
67
+ projectId: result.projectId,
68
+ workflowId: result.workflowId,
69
+ },
70
+ ],
71
+ ])('selects the item when opening %s directly', async (path, selection) => {
72
+ const router = createInboxRouter();
73
+ await router.push(path);
74
+ expect(selectionFromRoute(router.currentRoute.value)).toEqual(selection);
75
+ });
76
+
77
+ it.each(['', '?projectId=project', '?workflowId=workflow'])(
78
+ 'requires Assistant scope: %s',
79
+ async (query) => {
80
+ const router = createInboxRouter();
81
+ await router.push(`/inbox/assistant-results/result-1${query}`);
82
+ expect(selectionFromRoute(router.currentRoute.value)).toBeNull();
83
+ },
84
+ );
85
+
86
+ it.each([
87
+ ['Inbox', true],
88
+ ['WorkflowReviewRequestsView', true],
89
+ ['InboxAssistantResult', true],
90
+ ['Workflow', false],
91
+ [undefined, false],
92
+ ])('recognizes the Inbox route %s: %s', (name, expected) => {
93
+ expect(isInboxRoute({ name })).toBe(expected);
94
+ });
95
+
96
+ it('keeps the tab only when selecting the same item', async () => {
97
+ const router = createInboxRouter();
98
+ await router.push(
99
+ '/inbox/assistant-results/result-1?projectId=project&workflowId=workflow&state=closed&tab=changes',
100
+ );
101
+ expect(router.resolve(inboxItemLocation(result, router.currentRoute.value))).toMatchObject({
102
+ path: '/inbox/assistant-results/result-1',
103
+ query: { projectId: 'project', workflowId: 'workflow', state: 'closed', tab: 'changes' },
104
+ });
105
+ expect(
106
+ router.resolve(inboxItemLocation({ ...result, id: 'result-2' }, router.currentRoute.value))
107
+ .query,
108
+ ).toEqual({
109
+ projectId: 'project',
110
+ workflowId: 'workflow',
111
+ state: 'closed',
112
+ });
113
+ await router.push('/inbox/reviews/result-1?state=closed&tab=changes');
114
+ expect(router.resolve(inboxItemLocation(result, router.currentRoute.value)).query).toEqual({
115
+ projectId: 'project',
116
+ workflowId: 'workflow',
117
+ state: 'closed',
118
+ });
119
+ });
120
+
121
+ it('restores selection and tabs when navigating back and forward', async () => {
122
+ const router = createInboxRouter();
123
+ await router.push('/inbox');
124
+ await router.push('/inbox/reviews/review-1?tab=changes');
125
+ await router.push(inboxItemLocation(result, router.currentRoute.value));
126
+ router.back();
127
+ await vi.waitFor(() =>
128
+ expect(router.currentRoute.value.fullPath).toBe('/inbox/reviews/review-1?tab=changes'),
129
+ );
130
+ expect(selectionFromRoute(router.currentRoute.value)).toEqual({
131
+ type: 'workflow_review',
132
+ id: 'review-1',
133
+ });
134
+ router.forward();
135
+ await vi.waitFor(() =>
136
+ expect(router.currentRoute.value.path).toBe('/inbox/assistant-results/result-1'),
137
+ );
138
+ expect(selectionFromRoute(router.currentRoute.value)?.id).toBe(result.id);
139
+ expect(router.currentRoute.value.query.tab).toBeUndefined();
140
+ });
@@ -0,0 +1,49 @@
1
+ import type { InboxItem } from '@n8n/api-types';
2
+ import type { LocationQuery, RouteLocationNormalizedLoaded, RouteLocationRaw } from 'vue-router';
3
+
4
+ import { INBOX_ASSISTANT_RESULT_VIEW, INBOX_VIEW, type InboxSelection } from './inbox.constants';
5
+ import { WORKFLOW_REVIEW_REQUESTS_VIEW } from './reviews/constants';
6
+
7
+ type InboxRoute = Pick<RouteLocationNormalizedLoaded, 'name' | 'params' | 'query'>;
8
+
9
+ function stringParam(value: LocationQuery[string]): string | undefined {
10
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
11
+ }
12
+
13
+ export function isInboxRoute(route: Pick<InboxRoute, 'name'>): boolean {
14
+ return (
15
+ route.name === INBOX_VIEW ||
16
+ route.name === WORKFLOW_REVIEW_REQUESTS_VIEW ||
17
+ route.name === INBOX_ASSISTANT_RESULT_VIEW
18
+ );
19
+ }
20
+
21
+ export function selectionFromRoute({ name, params, query }: InboxRoute): InboxSelection | null {
22
+ if (name === WORKFLOW_REVIEW_REQUESTS_VIEW) {
23
+ const id = stringParam(params.reviewId);
24
+ return id ? { type: 'workflow_review', id } : null;
25
+ }
26
+ if (name !== INBOX_ASSISTANT_RESULT_VIEW) return null;
27
+ const id = stringParam(params.resultId);
28
+ if (!id) return null;
29
+ const projectId = stringParam(query.projectId);
30
+ const workflowId = stringParam(query.workflowId);
31
+ if (projectId && workflowId) {
32
+ return { type: 'self_healing_result', id, projectId, workflowId };
33
+ }
34
+ return null;
35
+ }
36
+
37
+ export function inboxItemLocation(item: InboxItem, route?: InboxRoute): RouteLocationRaw {
38
+ const next: LocationQuery = { ...route?.query };
39
+ const selected = route ? selectionFromRoute(route) : null;
40
+ if (selected?.type !== item.type || selected.id !== item.id) delete next.tab;
41
+ delete next.projectId;
42
+ delete next.workflowId;
43
+ if (item.type === 'self_healing_result') {
44
+ next.projectId = item.projectId;
45
+ next.workflowId = item.workflowId;
46
+ return { name: INBOX_ASSISTANT_RESULT_VIEW, params: { resultId: item.id }, query: next };
47
+ }
48
+ return { name: WORKFLOW_REVIEW_REQUESTS_VIEW, params: { reviewId: item.id }, query: next };
49
+ }