@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,260 @@
1
+ <script lang="ts" setup>
2
+ import type { InboxItem } from '@n8n/api-types';
3
+ import { useDocumentTitle } from '@n8n/composables/useDocumentTitle';
4
+ import { N8nResizeWrapper, useResizablePanel } from '@n8n/design-system';
5
+ import { VIEWS } from '@n8n/frontend-constants/views';
6
+ import { useI18n } from '@n8n/i18n';
7
+ import { useSettingsStore } from '@n8n/stores/settings.store';
8
+ import { storeToRefs } from 'pinia';
9
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
10
+ import { useRoute, useRouter } from 'vue-router';
11
+
12
+ import InboxEmptyState from '../components/InboxEmptyState.vue';
13
+ import InboxList from '../components/InboxList.vue';
14
+ import { INBOX_VIEW, type InboxItemChange } from '../inbox.constants';
15
+ import { inboxItemLocation, isInboxRoute, selectionFromRoute } from '../inbox.routes';
16
+ import { useInboxStore } from '../inbox.store';
17
+ import WorkflowReviewDetail from '../reviews/WorkflowReviewDetail.vue';
18
+ import SelfHealingResultDetail from '../self-healing/SelfHealingResultDetail.vue';
19
+
20
+ const store = useInboxStore();
21
+ const settingsStore = useSettingsStore();
22
+ const { activeTab, openCount, closedCount } = storeToRefs(store);
23
+ const sections = computed(() =>
24
+ store.activeSectionKeys.map((key) => ({ key, ...store.lists[key] })),
25
+ );
26
+ const route = useRoute();
27
+ const router = useRouter();
28
+ const i18n = useI18n();
29
+ const documentTitle = useDocumentTitle({ releaseChannel: settingsStore.settings.releaseChannel });
30
+ documentTitle.set(i18n.baseText('inbox.title'));
31
+
32
+ const contentRef = ref<HTMLElement | null>(null);
33
+ const sidebarResizer = useResizablePanel({
34
+ container: contentRef,
35
+ width: {
36
+ localStorageKey: 'N8N_WORKFLOW_REVIEW_SIDEBAR_WIDTH',
37
+ defaultSize: (size) => Math.min(Math.max(size * 0.25, 240), 400),
38
+ minSize: 240,
39
+ maxSize: (size) => Math.min(size * 0.5, 640),
40
+ snap: true,
41
+ },
42
+ });
43
+ const sidebarWidth = sidebarResizer.width;
44
+ const selection = computed(() => selectionFromRoute(route));
45
+ const selectedKey = computed(() =>
46
+ selection.value ? `${selection.value.type}:${selection.value.id}` : null,
47
+ );
48
+ const selectedListItem = computed(() =>
49
+ Object.values(store.lists)
50
+ .flatMap((list) => list.items)
51
+ .find((item) => `${item.type}:${item.id}` === selectedKey.value),
52
+ );
53
+ const selectedReviewItem = computed(() =>
54
+ selectedListItem.value?.type === 'workflow_review' ? selectedListItem.value : undefined,
55
+ );
56
+ const detailTab = computed(() => (route.query.tab === 'changes' ? 'changes' : 'activity'));
57
+ let isMounted = false;
58
+
59
+ function isOnInbox() {
60
+ return isInboxRoute(router.currentRoute.value);
61
+ }
62
+
63
+ function isActive() {
64
+ return isMounted && isOnInbox();
65
+ }
66
+
67
+ function isSelectedReview(id: string) {
68
+ const selected = selectionFromRoute(router.currentRoute.value);
69
+ return selected?.type === 'workflow_review' && selected.id === id;
70
+ }
71
+
72
+ // Reset on entry. A layout-swap copy can unmount after the next view has mounted.
73
+ store.reset();
74
+ store.activeTab = route.query.state === 'closed' ? 'closed' : 'open';
75
+
76
+ watch(
77
+ () => route.query.state,
78
+ (state) => {
79
+ if (isOnInbox()) void store.setActiveTab(state === 'closed' ? 'closed' : 'open');
80
+ },
81
+ );
82
+ watch(
83
+ [selection, () => store.disabledSources],
84
+ ([item, types]) => {
85
+ if (item && types.includes(item.type)) onClearSelection();
86
+ },
87
+ { immediate: true },
88
+ );
89
+
90
+ function onSelect(item: InboxItem) {
91
+ void router.replace(inboxItemLocation(item, route));
92
+ }
93
+ function onClearSelection() {
94
+ if (!isOnInbox()) return;
95
+ const query = { ...route.query };
96
+ for (const key of ['projectId', 'workflowId']) delete query[key];
97
+ void router.replace({ name: INBOX_VIEW, query });
98
+ }
99
+ function onActiveTabChange(tab: 'open' | 'closed') {
100
+ const query = { ...route.query };
101
+ if (tab === 'closed') query.state = tab;
102
+ else delete query.state;
103
+ void router.replace({ query });
104
+ }
105
+ function onDetailTabChange(tab: 'activity' | 'changes') {
106
+ if (!isOnInbox()) return;
107
+ const query = { ...route.query };
108
+ if (tab === 'changes') query.tab = tab;
109
+ else delete query.tab;
110
+ void router.replace({ query });
111
+ }
112
+ function onItemChange(change: InboxItemChange) {
113
+ if (!change.state && !isActive()) return;
114
+ // A successful decision still updates shared state after this view unmounts.
115
+ store.reconcileItemChange(change);
116
+ if (!isOnInbox()) return;
117
+ // Closed can load before the decision commits, so it can miss the review.
118
+ if (!change.state || (change.state === 'closed' && activeTab.value === 'closed')) {
119
+ void store.fetchActiveTab();
120
+ }
121
+ const currentRoute = router.currentRoute.value;
122
+ const selected = selectionFromRoute(currentRoute);
123
+ if (
124
+ change.state === 'closed' &&
125
+ activeTab.value !== 'closed' &&
126
+ selected?.type === change.type &&
127
+ selected.id === change.id
128
+ ) {
129
+ void router.replace({ query: { ...currentRoute.query, state: 'closed' } });
130
+ }
131
+ }
132
+
133
+ watch(
134
+ () => store.enabled,
135
+ (enabled) => {
136
+ if (!enabled && isMounted && isOnInbox()) void router.replace({ name: VIEWS.HOMEPAGE });
137
+ },
138
+ );
139
+ onMounted(() => {
140
+ isMounted = true;
141
+ void store.refreshListAndSummary();
142
+ });
143
+ onBeforeUnmount(() => {
144
+ isMounted = false;
145
+ });
146
+ </script>
147
+
148
+ <template>
149
+ <div :class="$style.layout" data-test-id="inbox-view">
150
+ <main ref="contentRef" :class="$style.content">
151
+ <N8nResizeWrapper
152
+ :class="$style.sidebarResizer"
153
+ :style="{ width: `${sidebarWidth}px` }"
154
+ :resizer="sidebarResizer"
155
+ :supported-directions="['right']"
156
+ >
157
+ <InboxList
158
+ :class="$style.sidebar"
159
+ :sections="sections"
160
+ :active-tab="activeTab"
161
+ :selected-key="selectedKey"
162
+ :open-count="openCount"
163
+ :closed-count="closedCount"
164
+ @select="onSelect"
165
+ @clear="onClearSelection"
166
+ @update:active-tab="onActiveTabChange"
167
+ @load-more="(section) => store.lists[section].loadMore()"
168
+ @retry="(section) => store.lists[section].retry()"
169
+ @refresh-section="(section) => store.lists[section].fetchList()"
170
+ @retry-active-tab="store.fetchActiveTab()"
171
+ />
172
+ </N8nResizeWrapper>
173
+ <div :class="$style.main">
174
+ <WorkflowReviewDetail
175
+ v-if="
176
+ selection?.type === 'workflow_review' && !store.disabledSources.includes(selection.type)
177
+ "
178
+ :review-id="selection.id"
179
+ :list-item="selectedReviewItem"
180
+ :tab="detailTab"
181
+ :on-item-change="onItemChange"
182
+ :is-active="isActive"
183
+ :is-selected="isSelectedReview"
184
+ @update:tab="onDetailTabChange"
185
+ >
186
+ <InboxEmptyState />
187
+ </WorkflowReviewDetail>
188
+ <template v-else>
189
+ <div :class="$style.columnTitle" />
190
+ <div :class="$style.mainBody">
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 />
199
+ </div>
200
+ </template>
201
+ </div>
202
+ </main>
203
+ </div>
204
+ </template>
205
+
206
+ <style lang="scss" module>
207
+ @use '@n8n/design-system/css/mixins/breakpoints';
208
+
209
+ .layout {
210
+ display: flex;
211
+ flex-direction: column;
212
+ width: 100%;
213
+ height: 100%;
214
+ padding-inline: var(--spacing--md);
215
+ box-sizing: border-box;
216
+ }
217
+
218
+ .content {
219
+ --review-callout--max-width: 34rem;
220
+ --review-activity--max-width: 45rem;
221
+ --review-tab-bar--height: var(--height--sm);
222
+ /* Keep the existing Reviews tab alignment at every root font size. */
223
+ --review-tab-bar--indicator-overhang: 15px;
224
+ --review-tab-bar--gap: calc(var(--spacing--sm) + var(--review-tab-bar--indicator-overhang));
225
+ display: flex;
226
+ width: 100%;
227
+ min-height: 0;
228
+ height: 100%;
229
+ overflow: hidden;
230
+ }
231
+ .sidebarResizer {
232
+ flex: 0 0 auto;
233
+ }
234
+ .main {
235
+ display: flex;
236
+ flex: 1;
237
+ flex-direction: column;
238
+ min-width: 0;
239
+ min-height: 0;
240
+ overflow: hidden;
241
+ padding: 0 0 var(--spacing--md) var(--spacing--md);
242
+ }
243
+ .sidebar,
244
+ .main {
245
+ padding-top: var(--spacing--lg);
246
+
247
+ @include breakpoints.breakpoint('sm-and-down') {
248
+ padding-top: var(--spacing--sm);
249
+ }
250
+ }
251
+ .columnTitle {
252
+ min-height: var(--spacing--2xl);
253
+ padding-bottom: var(--spacing--sm);
254
+ }
255
+ .mainBody {
256
+ flex: 1;
257
+ min-height: 0;
258
+ overflow: auto;
259
+ }
260
+ </style>
@@ -0,0 +1,9 @@
1
+ import { baseConfig } from '@n8n/stylelint-config/base';
2
+
3
+ export default {
4
+ ...baseConfig,
5
+ rules: {
6
+ ...baseConfig.rules,
7
+ '@n8n/css-var-naming': [true, { severity: 'error' }],
8
+ },
9
+ };
package/vite.config.ts ADDED
@@ -0,0 +1,52 @@
1
+ import vue from '@vitejs/plugin-vue';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { resolve } from 'node:path';
4
+ import icons from 'unplugin-icons/vite';
5
+ import svgLoader from 'vite-svg-loader';
6
+ import { defineConfig, mergeConfig } from 'vite';
7
+ import { frontendAliases } from '@n8n/frontend-vite-config';
8
+ import { vitestConfig } from '@n8n/vitest-config/frontend';
9
+
10
+ const packageDir = fileURLToPath(new URL('.', import.meta.url));
11
+ const packagesDir = resolve(packageDir, '..', '..', '..');
12
+
13
+ export default mergeConfig(
14
+ defineConfig({
15
+ // `@n8n/design-system`'s icon set reaches for two things Vite does not handle on its
16
+ // own: `~icons/lucide/*` virtual modules and `./custom/*.svg` single-file components.
17
+ // Consuming design-system from source makes both the consumer's problem — without
18
+ // `svgLoader` an `.svg` import returns a data-URI string, which Vue then renders as a
19
+ // tag name and jsdom rejects. Every module that renders a design-system component
20
+ // needs these two plugins. `src/__tests__/design-system-icons.test.ts` guards them.
21
+ plugins: [
22
+ vue(),
23
+ // Off, so a test never reaches the network for a missing collection. The collection
24
+ // comes from the `@iconify/json` devDependency instead.
25
+ icons({ compiler: 'vue3', autoInstall: false }),
26
+ svgLoader({
27
+ svgoConfig: {
28
+ plugins: [
29
+ {
30
+ name: 'preset-default',
31
+ params: {
32
+ overrides: {
33
+ // The icons rely on their ids, and on a viewBox to stay scalable.
34
+ cleanupIds: false,
35
+ removeViewBox: false,
36
+ },
37
+ },
38
+ },
39
+ ],
40
+ },
41
+ }),
42
+ ],
43
+ resolve: {
44
+ // The same platform mapping the editor-ui dev server uses, so a test resolves
45
+ // `@n8n/stores/...` from source rather than from a stale `dist` — the two disagreeing is
46
+ // what put 1,111 specifiers on the wrong side of the src/dist line. Sibling modules are
47
+ // deliberately absent: nothing here should make a cross-module import resolve.
48
+ alias: frontendAliases(packagesDir),
49
+ },
50
+ }),
51
+ vitestConfig,
52
+ );