@n8n/frontend-module-inbox 0.0.0-stage → 0.1.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 (58) hide show
  1. package/LICENSE.md +88 -0
  2. package/README.md +59 -2
  3. package/biome.jsonc +4 -0
  4. package/eslint.config.mjs +4 -0
  5. package/package.json +62 -3
  6. package/src/__tests__/design-system-icons.test.ts +24 -0
  7. package/src/__tests__/setup.ts +4 -0
  8. package/src/components/InboxEmptyState.vue +86 -0
  9. package/src/components/InboxList.test.ts +229 -0
  10. package/src/components/InboxList.vue +195 -0
  11. package/src/components/InboxListItem.vue +134 -0
  12. package/src/components/InboxListSection.vue +168 -0
  13. package/src/composables/useReviewInboxSectionCollapse.test.ts +66 -0
  14. package/src/composables/useReviewInboxSectionCollapse.ts +42 -0
  15. package/src/inbox.api.ts +20 -0
  16. package/src/inbox.constants.ts +16 -0
  17. package/src/inbox.module.ts +50 -0
  18. package/src/inbox.routes.test.ts +111 -0
  19. package/src/inbox.routes.ts +30 -0
  20. package/src/inbox.store.test.ts +349 -0
  21. package/src/inbox.store.ts +305 -0
  22. package/src/index.ts +5 -0
  23. package/src/reviews/WorkflowReviewDetail.test.ts +647 -0
  24. package/src/reviews/WorkflowReviewDetail.vue +257 -0
  25. package/src/reviews/components/WorkflowReviewActivityFeed.test.ts +923 -0
  26. package/src/reviews/components/WorkflowReviewActivityFeed.vue +312 -0
  27. package/src/reviews/components/WorkflowReviewChangesSection.test.ts +327 -0
  28. package/src/reviews/components/WorkflowReviewChangesSection.vue +219 -0
  29. package/src/reviews/components/WorkflowReviewCommentComposer.test.ts +175 -0
  30. package/src/reviews/components/WorkflowReviewCommentComposer.vue +83 -0
  31. package/src/reviews/components/WorkflowReviewDecisionPopover.test.ts +288 -0
  32. package/src/reviews/components/WorkflowReviewDecisionPopover.vue +203 -0
  33. package/src/reviews/components/WorkflowReviewDetailMetadata.test.ts +206 -0
  34. package/src/reviews/components/WorkflowReviewDetailMetadata.vue +241 -0
  35. package/src/reviews/components/WorkflowReviewDetailTabs.decisionNote.test.ts +90 -0
  36. package/src/reviews/components/WorkflowReviewDetailTabs.test.ts +434 -0
  37. package/src/reviews/components/WorkflowReviewDetailTabs.vue +311 -0
  38. package/src/reviews/components/WorkflowReviewStatusDot.vue +67 -0
  39. package/src/reviews/components/_activity-card.scss +36 -0
  40. package/src/reviews/components/activity-entries/WorkflowReviewActivityActorAvatar.vue +50 -0
  41. package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.test.ts +103 -0
  42. package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.vue +116 -0
  43. package/src/reviews/components/activity-entries/WorkflowReviewActivityEventEntry.vue +361 -0
  44. package/src/reviews/components/activity-entries/WorkflowReviewActivityFallback.vue +49 -0
  45. package/src/reviews/components/activity-entries/WorkflowReviewActivityWorkflowLink.vue +36 -0
  46. package/src/reviews/components/activityEntryRegistry.ts +27 -0
  47. package/src/reviews/constants.ts +41 -0
  48. package/src/reviews/reviewActivity.store.test.ts +463 -0
  49. package/src/reviews/reviewActivity.store.ts +186 -0
  50. package/src/reviews/reviewDetail.store.test.ts +135 -0
  51. package/src/reviews/reviewDetail.store.ts +74 -0
  52. package/src/reviews/workflowReviewStatus.utils.ts +44 -0
  53. package/src/reviews/workflowReviews.api.ts +61 -0
  54. package/src/reviews/workflowReviews.utils.ts +18 -0
  55. package/src/views/InboxView.test.ts +525 -0
  56. package/src/views/InboxView.vue +251 -0
  57. package/stylelint.config.mjs +9 -0
  58. package/vite.config.ts +52 -0
@@ -0,0 +1,195 @@
1
+ <script setup lang="ts">
2
+ import type { InboxItem, InboxState } from '@n8n/api-types';
3
+ import { N8nButton, N8nHeading, N8nLoading, N8nTabs, N8nText } from '@n8n/design-system';
4
+ import { useI18n } from '@n8n/i18n';
5
+ import { useIntersectionObserver } from '@vueuse/core';
6
+ import { computed, useTemplateRef } from 'vue';
7
+
8
+ import { useReviewInboxSectionCollapse } from '../composables/useReviewInboxSectionCollapse';
9
+ import type { InboxSectionKey } from '../inbox.constants';
10
+ import InboxListSection from './InboxListSection.vue';
11
+ import type { InboxListSection as ListSection } from './InboxListSection.vue';
12
+
13
+ const props = defineProps<{
14
+ sections: ListSection[];
15
+ activeTab: InboxState;
16
+ selectedKey: string | null;
17
+ openCount: number | null;
18
+ closedCount: number | null;
19
+ }>();
20
+ const emit = defineEmits<{
21
+ select: [item: InboxItem];
22
+ clear: [];
23
+ 'update:active-tab': [state: InboxState];
24
+ loadMore: [section: InboxSectionKey];
25
+ retry: [section: InboxSectionKey];
26
+ retryActiveTab: [];
27
+ refreshSection: [section: InboxSectionKey];
28
+ }>();
29
+ const i18n = useI18n();
30
+ const { isCollapsed, toggleSection } = useReviewInboxSectionCollapse();
31
+ const loading = computed(() => props.sections.some((section) => section.loading));
32
+ const hasUsableRows = computed(() => props.sections.some((section) => section.items.length > 0));
33
+ const showInitialLoadError = computed(
34
+ () =>
35
+ !hasUsableRows.value &&
36
+ props.sections.some((section) => section.failedRequest === 'list') &&
37
+ !props.sections.some((section) => section.partial),
38
+ );
39
+ const visibleSections = computed(() =>
40
+ props.sections.filter(
41
+ (section) =>
42
+ !loading.value &&
43
+ !showInitialLoadError.value &&
44
+ (section.error || section.partial || section.items.length > 0 || section.hasMore),
45
+ ),
46
+ );
47
+ const listRef = useTemplateRef<HTMLElement>('list');
48
+ const loadMoreSentinel = useTemplateRef<HTMLElement>('loadMoreSentinel');
49
+ const canAutoLoad = computed(() => {
50
+ const closed = props.sections.find((section) => section.key === 'closed');
51
+ // A failed page waits for Retry. A visible sentinel would otherwise retry in a loop.
52
+ return (
53
+ props.activeTab === 'closed' &&
54
+ closed?.hasMore &&
55
+ !loading.value &&
56
+ !closed.loadingMore &&
57
+ !closed.error
58
+ );
59
+ });
60
+ useIntersectionObserver(
61
+ loadMoreSentinel,
62
+ ([entry]) => {
63
+ if (entry?.isIntersecting && canAutoLoad.value) emit('loadMore', 'closed');
64
+ },
65
+ { root: listRef, threshold: 0.01 },
66
+ );
67
+ const tabs = computed(() => [
68
+ {
69
+ value: 'open',
70
+ label: i18n.baseText('inbox.tabs.open'),
71
+ tag: props.openCount === null ? undefined : String(props.openCount),
72
+ },
73
+ {
74
+ value: 'closed',
75
+ label: i18n.baseText('inbox.tabs.closed'),
76
+ tag: props.closedCount === null ? undefined : String(props.closedCount),
77
+ },
78
+ ]);
79
+ function onTabChange(value: string | number | boolean) {
80
+ if (value === 'open' || value === 'closed') emit('update:active-tab', value);
81
+ }
82
+
83
+ function onListBackgroundClick() {
84
+ if (props.selectedKey) emit('clear');
85
+ }
86
+ </script>
87
+
88
+ <template>
89
+ <aside :class="$style.sidebar" data-test-id="inbox-list">
90
+ <div :class="$style.title">
91
+ <N8nHeading bold tag="h2" size="xlarge">{{ i18n.baseText('inbox.title') }}</N8nHeading>
92
+ </div>
93
+ <div :class="$style.header">
94
+ <N8nTabs
95
+ :model-value="activeTab"
96
+ :options="tabs"
97
+ variant="modern"
98
+ data-test-id="inbox-tabs"
99
+ @update:model-value="onTabChange"
100
+ />
101
+ </div>
102
+ <div ref="list" :class="$style.list" @click.self="onListBackgroundClick">
103
+ <N8nLoading v-if="loading" :loading="true" :rows="3" data-test-id="inbox-list-skeleton" />
104
+ <div
105
+ v-else-if="showInitialLoadError"
106
+ :class="$style.sectionError"
107
+ role="alert"
108
+ data-test-id="inbox-list-error"
109
+ >
110
+ <N8nText color="danger" size="small">{{ i18n.baseText('inbox.loadError') }}</N8nText>
111
+ <N8nButton
112
+ variant="outline"
113
+ size="mini"
114
+ :label="i18n.baseText('generic.retry')"
115
+ @click="emit('retryActiveTab')"
116
+ />
117
+ </div>
118
+ <InboxListSection
119
+ v-for="section in visibleSections"
120
+ :key="section.key"
121
+ :section="section"
122
+ :collapsed="section.key !== 'closed' && isCollapsed(section.key)"
123
+ :selected-key="selectedKey"
124
+ @select="emit('select', $event)"
125
+ @toggle="toggleSection"
126
+ @load-more="emit('loadMore', section.key)"
127
+ @retry="emit('retry', section.key)"
128
+ @refresh-section="emit('refreshSection', section.key)"
129
+ />
130
+ <div v-if="canAutoLoad" ref="loadMoreSentinel" :class="$style.sentinel" />
131
+ <div
132
+ v-else-if="activeTab === 'closed' && !loading && !showInitialLoadError"
133
+ :class="$style.sentinel"
134
+ />
135
+ </div>
136
+ </aside>
137
+ </template>
138
+
139
+ <style module lang="scss">
140
+ .sidebar {
141
+ display: flex;
142
+ flex-direction: column;
143
+ width: 100%;
144
+ min-width: 0;
145
+ height: 100%;
146
+ border-right: var(--border-width) solid var(--border-color);
147
+ }
148
+
149
+ .title {
150
+ display: flex;
151
+ align-items: center;
152
+ min-height: var(--spacing--2xl);
153
+ padding: 0 var(--spacing--md) var(--spacing--sm) 0;
154
+ }
155
+
156
+ .header {
157
+ position: relative;
158
+ 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);
163
+
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);
171
+ }
172
+ }
173
+
174
+ .list {
175
+ display: flex;
176
+ flex: 1;
177
+ flex-direction: column;
178
+ gap: var(--spacing--sm);
179
+ min-width: 0;
180
+ overflow-y: auto;
181
+ padding: 0 var(--spacing--md) var(--spacing--md) 0;
182
+ }
183
+
184
+ .sectionError {
185
+ display: flex;
186
+ align-items: center;
187
+ justify-content: space-between;
188
+ gap: var(--spacing--2xs);
189
+ padding: 0 var(--spacing--2xs);
190
+ }
191
+
192
+ .sentinel {
193
+ height: var(--border-width);
194
+ }
195
+ </style>
@@ -0,0 +1,134 @@
1
+ <script setup lang="ts">
2
+ import type { InboxItem } from '@n8n/api-types';
3
+ import { N8nBadge, N8nCard, N8nIcon, N8nText, N8nTimeAgo } from '@n8n/design-system';
4
+ import { useRootStore } from '@n8n/stores/useRootStore';
5
+
6
+ import WorkflowReviewStatusDot from '../reviews/components/WorkflowReviewStatusDot.vue';
7
+
8
+ defineProps<{ item: InboxItem; selected: boolean }>();
9
+ const emit = defineEmits<{ select: [] }>();
10
+ const rootStore = useRootStore();
11
+ </script>
12
+
13
+ <template>
14
+ <N8nCard
15
+ :class="[$style.card, { [$style.selected]: selected }]"
16
+ role="option"
17
+ tabindex="0"
18
+ :aria-selected="selected"
19
+ :data-source="item.type"
20
+ data-test-id="inbox-row"
21
+ @click="emit('select')"
22
+ @keydown.enter.prevent="emit('select')"
23
+ @keydown.space.prevent="emit('select')"
24
+ >
25
+ <div :class="$style.cardContent">
26
+ <div :class="$style.cardHeader">
27
+ <N8nText bold tag="h3" :class="$style.cardTitle">{{ item.title }}</N8nText>
28
+ <WorkflowReviewStatusDot :state="item.state" :decision="item.decision" />
29
+ </div>
30
+ <div :class="$style.meta">
31
+ <N8nBadge v-if="item.workflowName" variant="outline" :class="$style.workflowBadge">
32
+ <span :class="$style.workflowBadgeText" :title="item.workflowName"
33
+ ><N8nIcon icon="workflow" size="small" /><span>{{ item.workflowName }}</span></span
34
+ >
35
+ </N8nBadge>
36
+ <div :class="$style.metaActions">
37
+ <N8nText :class="$style.time" size="xsmall" color="text-light">
38
+ <N8nTimeAgo :date="item.createdAt" :locale="rootStore.defaultLocale" />
39
+ </N8nText>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ </N8nCard>
44
+ </template>
45
+
46
+ <style module lang="scss">
47
+ .card {
48
+ cursor: pointer;
49
+ padding: var(--spacing--xs);
50
+ align-items: stretch;
51
+ border: var(--border-width) solid var(--border-color);
52
+ }
53
+
54
+ .card:hover:not(.selected) {
55
+ background-color: var(--background--hover);
56
+ border-color: transparent;
57
+ }
58
+
59
+ .selected {
60
+ background-color: var(--background--active);
61
+ border-color: transparent;
62
+ }
63
+ .card:focus-visible {
64
+ border-color: var(--focus--border-color);
65
+ }
66
+ .cardContent {
67
+ display: flex;
68
+ flex-direction: column;
69
+ align-items: flex-start;
70
+ gap: var(--spacing--2xs);
71
+ min-width: 0;
72
+ width: 100%;
73
+ }
74
+ .cardHeader {
75
+ display: flex;
76
+ align-items: center;
77
+ justify-content: space-between;
78
+ gap: var(--spacing--2xs);
79
+ width: 100%;
80
+ min-width: 0;
81
+ }
82
+ .cardTitle {
83
+ overflow: hidden;
84
+ text-overflow: ellipsis;
85
+ white-space: nowrap;
86
+ min-width: 0;
87
+ font-size: var(--font-size--sm);
88
+ }
89
+
90
+ .meta {
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: space-between;
94
+ gap: var(--spacing--sm);
95
+ width: 100%;
96
+ min-width: 0;
97
+ }
98
+ .workflowBadge {
99
+ flex: 0 1 auto;
100
+ min-width: 0;
101
+ border: var(--border);
102
+ border-radius: var(--radius);
103
+ padding: var(--spacing--4xs) var(--spacing--2xs);
104
+ color: var(--color--text);
105
+ > span {
106
+ max-width: 100%;
107
+ }
108
+ }
109
+ .workflowBadgeText {
110
+ display: inline-flex;
111
+ align-items: center;
112
+ gap: var(--spacing--3xs);
113
+ max-width: 100%;
114
+ min-width: 0;
115
+ line-height: calc(var(--font-size--sm) + var(--border-width));
116
+ > span {
117
+ overflow: hidden;
118
+ text-overflow: ellipsis;
119
+ white-space: nowrap;
120
+ min-width: 0;
121
+ }
122
+ }
123
+ .metaActions {
124
+ display: flex;
125
+ align-items: center;
126
+ gap: var(--spacing--sm);
127
+ margin-left: auto;
128
+ flex-shrink: 0;
129
+ }
130
+
131
+ .time {
132
+ white-space: nowrap;
133
+ }
134
+ </style>
@@ -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="listbox"
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
+ <InboxListItem
74
+ v-for="item in section.items"
75
+ :key="`${item.type}:${item.id}`"
76
+ :item="item"
77
+ :selected="selectedKey === `${item.type}:${item.id}`"
78
+ @select="emit('select', item)"
79
+ />
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,16 @@
1
+ import type { InboxSourceType, InboxState, WorkflowReviewRequestDecision } from '@n8n/api-types';
2
+
3
+ export const INBOX_VIEW = 'Inbox';
4
+ export const INBOX_PAGE_LIMIT = 15;
5
+
6
+ export type InboxSectionKey = 'waiting' | 'authored' | 'closed';
7
+
8
+ export type InboxSelection = { type: 'workflow_review'; id: string };
9
+
10
+ export type InboxItemChange = {
11
+ type: InboxSourceType;
12
+ id: string;
13
+ state?: InboxState;
14
+ decision?: WorkflowReviewRequestDecision;
15
+ updatedAt?: string;
16
+ };
@@ -0,0 +1,50 @@
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: 'The shared Inbox for workflow reviews.',
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
+ });