@n8n/frontend-module-inbox 0.2.3 → 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.
@@ -0,0 +1,430 @@
1
+ <script setup lang="ts">
2
+ import type { SelfHealingResultDetail } from '@n8n/api-types';
3
+ import {
4
+ N8nButton,
5
+ N8nCallout,
6
+ N8nDropdownMenu,
7
+ N8nHeading,
8
+ N8nIcon,
9
+ N8nMarkdown,
10
+ N8nTabs,
11
+ N8nText,
12
+ type DropdownMenuItemProps,
13
+ } from '@n8n/design-system';
14
+ import { componentRegistry } from '@n8n/frontend-module-sdk';
15
+ import { useI18n } from '@n8n/i18n';
16
+ import { computed } from 'vue';
17
+
18
+ import SelfHealingResultActivity from './SelfHealingResultActivity.vue';
19
+ import SelfHealingResultMetadata from './SelfHealingResultMetadata.vue';
20
+ import type { SelfHealingPublishAccess, SelfHealingResultAction } from './selfHealingResults.api';
21
+
22
+ type DetailTab = 'activity' | 'changes';
23
+ type MenuAction = Exclude<SelfHealingResultAction, 'dismiss'>;
24
+
25
+ const props = defineProps<{
26
+ detail: SelfHealingResultDetail;
27
+ workflowName: string;
28
+ tab: DetailTab;
29
+ pendingAction: SelfHealingResultAction | null;
30
+ publishAccess: SelfHealingPublishAccess;
31
+ canChat: boolean;
32
+ }>();
33
+ const emit = defineEmits<{
34
+ action: [action: SelfHealingResultAction];
35
+ 'update:tab': [tab: DetailTab];
36
+ }>();
37
+
38
+ const i18n = useI18n();
39
+ const reportOptions = {
40
+ markdown: { html: false, linkify: true, breaks: true },
41
+ linkAttributes: { attrs: { target: '_blank', rel: 'noopener noreferrer' } },
42
+ tasklists: { enabled: false },
43
+ youtube: {},
44
+ };
45
+ const WorkflowDiffView = computed(() => componentRegistry.get('workflow-diff'));
46
+ const isOpen = computed(() => props.detail.reviewState === 'open');
47
+ const isFixReady = computed(() => props.detail.outcome === 'fix_ready');
48
+ const hasSuggestion = computed(() => props.detail.suggestion !== null);
49
+ const activeTab = computed(() =>
50
+ props.tab === 'changes' && hasSuggestion.value ? 'changes' : 'activity',
51
+ );
52
+ const pending = computed(() => props.pendingAction !== null);
53
+
54
+ const outcomeLabel = computed(() => {
55
+ switch (props.detail.outcome) {
56
+ case 'fix_ready':
57
+ return i18n.baseText('inbox.outcome.fixReady');
58
+ case 'needs_you':
59
+ return i18n.baseText('inbox.outcome.needsAttention');
60
+ case 'could_not_fix':
61
+ return i18n.baseText('inbox.outcome.couldNotFix');
62
+ }
63
+ });
64
+
65
+ const statusLabel = computed(() => {
66
+ const state = props.detail.reviewState;
67
+ if (state === 'open' || state === 'closed') return outcomeLabel.value;
68
+ return i18n.baseText(`inbox.selfHealing.state.${state}`);
69
+ });
70
+
71
+ const statusClass = computed(() => {
72
+ if (!isOpen.value) return 'closed';
73
+ return isFixReady.value ? 'fixReady' : 'needsAttention';
74
+ });
75
+
76
+ const tabOptions = computed(() => [
77
+ { label: i18n.baseText('inbox.selfHealing.tabs.activity'), value: 'activity' },
78
+ ...(hasSuggestion.value
79
+ ? [{ label: i18n.baseText('inbox.selfHealing.tabs.changes'), value: 'changes' }]
80
+ : []),
81
+ ]);
82
+
83
+ const menuItems = computed<Array<DropdownMenuItemProps<MenuAction>>>(() => {
84
+ const continuation: Array<DropdownMenuItemProps<MenuAction>> = [
85
+ {
86
+ id: 'chat',
87
+ label: i18n.baseText('inbox.selfHealing.action.continueWithAssistant'),
88
+ icon: { type: 'icon', value: 'message-circle-plus' },
89
+ disabled: !props.canChat,
90
+ divided: isFixReady.value,
91
+ },
92
+ {
93
+ id: 'editor',
94
+ label: i18n.baseText('inbox.selfHealing.action.continueWithEditor'),
95
+ icon: { type: 'icon', value: 'square-pen' },
96
+ },
97
+ ];
98
+ if (!isFixReady.value) return continuation;
99
+
100
+ return [
101
+ {
102
+ id: 'approve-and-publish',
103
+ label: i18n.baseText('inbox.selfHealing.action.applyAndPublish'),
104
+ icon: { type: 'icon', value: 'check' },
105
+ disabled: props.publishAccess !== 'allowed',
106
+ },
107
+ ...continuation,
108
+ ];
109
+ });
110
+
111
+ const menuNotes = computed(() => [
112
+ ...(hasSuggestion.value ? [i18n.baseText('inbox.selfHealing.action.continueSavesDraft')] : []),
113
+ ...(isFixReady.value && props.publishAccess === 'denied'
114
+ ? [i18n.baseText('inbox.selfHealing.action.publishUnavailable')]
115
+ : []),
116
+ ...(props.canChat ? [] : [i18n.baseText('inbox.selfHealing.action.chatUnavailable')]),
117
+ ]);
118
+
119
+ function onTabChange(tab: string) {
120
+ if (tab === 'activity' || tab === 'changes') emit('update:tab', tab);
121
+ }
122
+ </script>
123
+
124
+ <template>
125
+ <section :class="$style.detail" data-test-id="self-healing-result-content">
126
+ <div :class="$style.columnTitle">
127
+ <span :class="[$style.statusDot, $style[statusClass]]" role="img" :aria-label="statusLabel" />
128
+ <N8nHeading bold tag="h2" size="xlarge" data-test-id="self-healing-result-title">
129
+ {{ detail.summary }}
130
+ </N8nHeading>
131
+ </div>
132
+ <slot name="notice" />
133
+ <div :class="$style.container">
134
+ <div :class="$style.tabRow">
135
+ <N8nTabs
136
+ :model-value="activeTab"
137
+ :options="tabOptions"
138
+ variant="modern"
139
+ data-test-id="self-healing-result-tabs"
140
+ @update:model-value="onTabChange"
141
+ />
142
+ <div v-if="isOpen" :class="$style.actions">
143
+ <N8nButton
144
+ variant="ghost"
145
+ size="small"
146
+ :label="i18n.baseText('inbox.selfHealing.action.discardAndClose')"
147
+ :disabled="pending"
148
+ :loading="pendingAction === 'dismiss'"
149
+ data-test-id="self-healing-discard"
150
+ @click="emit('action', 'dismiss')"
151
+ />
152
+ <N8nDropdownMenu
153
+ :items="menuItems"
154
+ placement="bottom-end"
155
+ :disabled="pending"
156
+ content-test-id="self-healing-review-menu"
157
+ @select="emit('action', $event)"
158
+ >
159
+ <template #trigger>
160
+ <N8nButton
161
+ size="small"
162
+ :disabled="pending"
163
+ :loading="pending && pendingAction !== 'dismiss'"
164
+ data-test-id="self-healing-review-trigger"
165
+ >
166
+ {{
167
+ i18n.baseText(
168
+ isFixReady
169
+ ? 'inbox.selfHealing.action.review'
170
+ : 'inbox.selfHealing.action.continueIn',
171
+ )
172
+ }}
173
+ <N8nIcon icon="chevron-down" />
174
+ </N8nButton>
175
+ </template>
176
+ <template v-if="menuNotes.length" #footer>
177
+ <div :class="$style.menuNotes">
178
+ <N8nText v-for="note in menuNotes" :key="note" size="small" color="text-light">
179
+ {{ note }}
180
+ </N8nText>
181
+ </div>
182
+ </template>
183
+ </N8nDropdownMenu>
184
+ </div>
185
+ </div>
186
+ <div :class="$style.detailBody">
187
+ <div
188
+ v-if="activeTab === 'activity'"
189
+ :class="$style.activityPanel"
190
+ data-test-id="self-healing-activity-panel"
191
+ >
192
+ <div :class="$style.reportCard">
193
+ <N8nText tag="h3" bold color="text-light" size="medium">
194
+ {{ i18n.baseText('inbox.selfHealing.report') }}
195
+ </N8nText>
196
+ <N8nCallout
197
+ v-if="detail.outcome === 'could_not_fix'"
198
+ theme="danger"
199
+ data-test-id="self-healing-outcome-notice"
200
+ >
201
+ <strong :class="$style.noticeTitle">{{ outcomeLabel }}</strong>
202
+ {{ i18n.baseText('inbox.selfHealing.couldNotFix.body') }}
203
+ </N8nCallout>
204
+ <N8nMarkdown
205
+ :content="detail.report"
206
+ :options="reportOptions"
207
+ :class="$style.report"
208
+ data-test-id="self-healing-report"
209
+ />
210
+ </div>
211
+ <SelfHealingResultActivity :detail="detail" />
212
+ <N8nCallout
213
+ v-if="detail.reviewState === 'outdated'"
214
+ theme="warning"
215
+ data-test-id="self-healing-outdated-notice"
216
+ >
217
+ <strong :class="$style.noticeTitle">
218
+ {{ i18n.baseText('inbox.selfHealing.state.outdated') }}
219
+ </strong>
220
+ {{ i18n.baseText('inbox.selfHealing.state.outdated.body') }}
221
+ </N8nCallout>
222
+ <N8nCallout
223
+ v-if="isOpen && detail.outcome === 'needs_you'"
224
+ theme="warning"
225
+ data-test-id="self-healing-outcome-notice"
226
+ >
227
+ <strong :class="$style.noticeTitle">
228
+ {{ i18n.baseText('inbox.selfHealing.needsAttention.title') }}
229
+ </strong>
230
+ {{ i18n.baseText('inbox.selfHealing.needsAttention.body') }}
231
+ </N8nCallout>
232
+ </div>
233
+ <div v-else :class="$style.changesPanel" data-test-id="self-healing-changes-panel">
234
+ <div v-if="WorkflowDiffView && detail.suggestion" :class="$style.diff">
235
+ <component
236
+ :is="WorkflowDiffView"
237
+ :source-snapshot="detail.suggestion.payload.original"
238
+ :target-snapshot="detail.suggestion.payload.proposed"
239
+ :workflow-id="detail.workflowId"
240
+ :workflow-name="workflowName"
241
+ :source-label="i18n.baseText('inbox.selfHealing.changes.original')"
242
+ :target-label="i18n.baseText('inbox.selfHealing.changes.proposed')"
243
+ show-fullscreen-button
244
+ />
245
+ </div>
246
+ <N8nCallout v-else theme="warning">
247
+ {{ i18n.baseText('inbox.selfHealing.changes.unavailable') }}
248
+ </N8nCallout>
249
+ </div>
250
+ <SelfHealingResultMetadata
251
+ :detail="detail"
252
+ :workflow-name="workflowName"
253
+ :status-label="statusLabel"
254
+ />
255
+ </div>
256
+ </div>
257
+ </section>
258
+ </template>
259
+
260
+ <style module lang="scss">
261
+ .detail,
262
+ .container {
263
+ display: flex;
264
+ flex: 1;
265
+ flex-direction: column;
266
+ min-height: 0;
267
+ min-width: 0;
268
+ }
269
+
270
+ .container {
271
+ container-name: assistant-detail;
272
+ container-type: inline-size;
273
+ }
274
+
275
+ .columnTitle {
276
+ display: flex;
277
+ align-items: center;
278
+ gap: var(--spacing--2xs);
279
+ min-height: var(--spacing--2xl);
280
+ padding-bottom: var(--spacing--sm);
281
+ overflow-wrap: anywhere;
282
+ }
283
+
284
+ .statusDot {
285
+ flex-shrink: 0;
286
+ width: var(--font-size--3xs);
287
+ height: var(--font-size--3xs);
288
+ border-radius: 50%;
289
+ }
290
+
291
+ .fixReady {
292
+ background-color: var(--color--blue-500);
293
+ }
294
+
295
+ .needsAttention {
296
+ background-color: var(--color--yellow-500);
297
+ }
298
+
299
+ .closed {
300
+ background-color: var(--color--neutral-500);
301
+ }
302
+
303
+ /* Match the tab row of workflow reviews. */
304
+ .tabRow {
305
+ display: flex;
306
+ align-items: center;
307
+ justify-content: space-between;
308
+ gap: var(--spacing--sm);
309
+ padding-bottom: var(--review-tab-bar--indicator-overhang);
310
+ border-bottom: var(--border);
311
+
312
+ > :global(.n8n-tabs) {
313
+ transform: translateY(var(--spacing--sm));
314
+ }
315
+ }
316
+
317
+ .actions {
318
+ display: flex;
319
+ flex-shrink: 0;
320
+ align-items: center;
321
+ gap: var(--spacing--2xs);
322
+ }
323
+
324
+ .menuNotes {
325
+ display: flex;
326
+ flex-direction: column;
327
+ gap: var(--spacing--4xs);
328
+ max-width: 16rem;
329
+ padding: 0 var(--spacing--xs) var(--spacing--xs);
330
+ }
331
+
332
+ .detailBody {
333
+ display: flex;
334
+ flex: 1;
335
+ gap: var(--spacing--sm);
336
+ min-height: 0;
337
+ overflow: auto;
338
+ padding-top: var(--spacing--sm);
339
+ }
340
+
341
+ .activityPanel {
342
+ display: flex;
343
+ flex: 1;
344
+ flex-direction: column;
345
+ gap: var(--spacing--sm);
346
+ min-width: 0;
347
+ max-width: var(--review-activity--max-width, 45rem);
348
+ margin-inline-end: auto;
349
+ }
350
+
351
+ .reportCard {
352
+ display: flex;
353
+ flex-direction: column;
354
+ gap: var(--spacing--2xs);
355
+ padding: var(--spacing--xs) var(--spacing--sm);
356
+ border: var(--border);
357
+ border-radius: var(--radius--2xs);
358
+ }
359
+
360
+ .noticeTitle {
361
+ display: block;
362
+ }
363
+
364
+ /* The global Markdown rules size paragraphs from this root. */
365
+ .report:global(.n8n-markdown) {
366
+ --markdown--spacing: var(--spacing--4xs);
367
+ --n8n-markdown--base-font-size: var(--font-size--sm);
368
+
369
+ overflow-wrap: anywhere;
370
+ min-width: 0;
371
+ font-size: var(--font-size--sm);
372
+ line-height: var(--line-height--xl);
373
+
374
+ :is(h1, h2, h3, h4, h5, h6) {
375
+ margin-top: calc(var(--markdown--spacing) * 2);
376
+ font-size: var(--font-size--sm);
377
+ line-height: inherit;
378
+
379
+ &:first-child {
380
+ margin-top: 0;
381
+ }
382
+ }
383
+ }
384
+
385
+ .changesPanel {
386
+ display: flex;
387
+ flex-direction: column;
388
+ flex: 1;
389
+ min-width: 0;
390
+ min-height: 0;
391
+ }
392
+
393
+ .diff {
394
+ flex: 1;
395
+ min-height: 0;
396
+ border: var(--border);
397
+ border-radius: var(--radius--2xs);
398
+ overflow: hidden;
399
+ }
400
+
401
+ /* Match the detail breakpoint of workflow reviews. Container queries cannot use CSS variables. */
402
+ @container assistant-detail (max-width: 44rem) {
403
+ .tabRow {
404
+ flex-wrap: wrap;
405
+ }
406
+
407
+ /* A full row above the tabs keeps the active-tab indicator on the row border. */
408
+ .actions {
409
+ order: -1;
410
+ width: 100%;
411
+ justify-content: flex-end;
412
+ }
413
+
414
+ .detailBody {
415
+ flex-direction: column;
416
+ }
417
+
418
+ .activityPanel {
419
+ flex: 0 0 auto;
420
+ max-width: none;
421
+ margin-inline-end: 0;
422
+ }
423
+
424
+ .changesPanel {
425
+ flex: 1 0 auto;
426
+ /* Keep the canvas usable above the metadata when the detail columns stack. */
427
+ min-height: calc(var(--spacing--5xl) + var(--spacing--4xl));
428
+ }
429
+ }
430
+ </style>