@n8n/frontend-module-self-healing 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.
@@ -0,0 +1,490 @@
1
+ <script setup lang="ts">
2
+ import type { SaveSelfHealingConfigurationDto } from '@n8n/api-types';
3
+ import { useToast } from '@n8n/composables/useToast';
4
+ import {
5
+ N8nButton,
6
+ N8nAvatar,
7
+ N8nCombobox2,
8
+ N8nIcon,
9
+ N8nIconButton,
10
+ N8nSegmentControl,
11
+ N8nText,
12
+ N8nUserInfo,
13
+ N8nSettingsLayout,
14
+ N8nSettingsPageHeader,
15
+ N8nSettingsRow,
16
+ N8nSettingsRowGroup,
17
+ N8nSettingsSection,
18
+ useMessage,
19
+ type ComboboxItem,
20
+ type ComboboxValue,
21
+ type SegmentOption,
22
+ } from '@n8n/design-system';
23
+ import { useI18n } from '@n8n/i18n';
24
+ import { VIEWS } from '@n8n/frontend-constants/views';
25
+ import { useUsersStore } from '@n8n/stores/users.store';
26
+ import { computed, onMounted, ref, toRaw, watch } from 'vue';
27
+ import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router';
28
+
29
+ import { useSelfHealingStore } from '../self-healing.store';
30
+
31
+ const props = defineProps<{ projectId: string; configurationId: string }>();
32
+ const store = useSelfHealingStore();
33
+ const users = useUsersStore();
34
+ const i18n = useI18n();
35
+ const toast = useToast();
36
+ const message = useMessage();
37
+ const router = useRouter();
38
+
39
+ const pageRef = ref<HTMLElement | null>(null);
40
+ const loading = ref(true);
41
+ const saving = ref(false);
42
+ const loadError = ref(false);
43
+ const savedForm = ref<SaveSelfHealingConfigurationDto>();
44
+ const form = ref<SaveSelfHealingConfigurationDto>({
45
+ scope: 'all',
46
+ workflowIds: [],
47
+ reviewerIds: [],
48
+ enabled: true,
49
+ });
50
+
51
+ const isNew = computed(() => props.configurationId === 'new');
52
+ const choices = computed(() => store.options[props.projectId]);
53
+ const otherConfigurations = computed(() =>
54
+ (store.configurations[props.projectId] ?? []).filter(
55
+ ({ id }) => String(id) !== props.configurationId,
56
+ ),
57
+ );
58
+ const hasAllWorkflowsConfiguration = computed(() =>
59
+ otherConfigurations.value.some(({ scope }) => scope === 'all'),
60
+ );
61
+ const reservedWorkflows = computed(
62
+ () => new Set(otherConfigurations.value.flatMap(({ workflowIds }) => workflowIds)),
63
+ );
64
+
65
+ const scopeOptions = computed<Array<SegmentOption<'all' | 'selected'>>>(() => [
66
+ {
67
+ value: 'all',
68
+ label: i18n.baseText('selfHealing.config.allScope'),
69
+ disabled: hasAllWorkflowsConfiguration.value,
70
+ },
71
+ { value: 'selected', label: i18n.baseText('selfHealing.config.selected') },
72
+ ]);
73
+ const workflowPickerItems = computed<ComboboxItem[]>(() =>
74
+ (choices.value?.workflows ?? [])
75
+ .filter(({ id }) => !form.value.workflowIds.includes(id))
76
+ .map(({ id, name }) => ({
77
+ value: id,
78
+ label: name,
79
+ icon: 'workflow',
80
+ disabled: reservedWorkflows.value.has(id),
81
+ })),
82
+ );
83
+ const selectedWorkflows = computed(() =>
84
+ form.value.workflowIds.map((id) => ({
85
+ id,
86
+ name:
87
+ choices.value?.workflows.find((workflow) => workflow.id === id)?.name ??
88
+ i18n.baseText('selfHealing.config.unavailableWorkflow'),
89
+ })),
90
+ );
91
+ const reviewersById = computed(
92
+ () => new Map(choices.value?.reviewers.map((reviewer) => [reviewer.id, reviewer])),
93
+ );
94
+
95
+ function reviewerName(id: string) {
96
+ const reviewer = reviewersById.value.get(id);
97
+ if (!reviewer) return i18n.baseText('selfHealing.config.unavailableReviewer');
98
+
99
+ const name = [reviewer.firstName, reviewer.lastName].filter(Boolean).join(' ') || reviewer.email;
100
+ if (id === users.currentUser?.id) {
101
+ return i18n.baseText('selfHealing.config.you', { interpolate: { name } });
102
+ }
103
+
104
+ return name;
105
+ }
106
+
107
+ const selectedReviewers = computed(() =>
108
+ form.value.reviewerIds.map((id) => ({
109
+ id,
110
+ user: reviewersById.value.get(id),
111
+ })),
112
+ );
113
+
114
+ const reviewerPickerItems = computed(() =>
115
+ (choices.value?.reviewers ?? [])
116
+ .filter(({ id }) => !form.value.reviewerIds.includes(id))
117
+ .map((reviewer) => ({
118
+ value: reviewer.id,
119
+ label: reviewerName(reviewer.id),
120
+ keywords: [reviewer.email],
121
+ })),
122
+ );
123
+
124
+ function addWorkflow(value: ComboboxValue | ComboboxValue[] | undefined) {
125
+ if (typeof value === 'string' && value && !form.value.workflowIds.includes(value)) {
126
+ form.value.workflowIds.push(value);
127
+ }
128
+ }
129
+
130
+ function addReviewer(value: ComboboxValue | ComboboxValue[] | undefined) {
131
+ if (typeof value === 'string' && value && !form.value.reviewerIds.includes(value)) {
132
+ form.value.reviewerIds.push(value);
133
+ }
134
+ }
135
+
136
+ onMounted(() => pageRef.value?.scrollIntoView({ block: 'start' }));
137
+
138
+ const dirty = computed(
139
+ () => !!savedForm.value && JSON.stringify(form.value) !== JSON.stringify(savedForm.value),
140
+ );
141
+ const isFormValid = computed(() => {
142
+ if (form.value.reviewerIds.length === 0) {
143
+ return false;
144
+ }
145
+
146
+ if (form.value.scope === 'all') {
147
+ return !hasAllWorkflowsConfiguration.value;
148
+ }
149
+
150
+ return (
151
+ form.value.workflowIds.length > 0 &&
152
+ form.value.workflowIds.every((id) => !reservedWorkflows.value.has(id))
153
+ );
154
+ });
155
+
156
+ const title = computed(() =>
157
+ i18n.baseText(isNew.value ? 'selfHealing.config.new' : 'selfHealing.config.edit'),
158
+ );
159
+
160
+ let pendingLoad: symbol | undefined;
161
+
162
+ async function load() {
163
+ const request = Symbol();
164
+ pendingLoad = request;
165
+ loading.value = true;
166
+ loadError.value = false;
167
+ savedForm.value = undefined;
168
+
169
+ try {
170
+ await store.load(props.projectId);
171
+ if (pendingLoad !== request) return;
172
+
173
+ const configuration = store.configurations[props.projectId]?.find(
174
+ ({ id }) => String(id) === props.configurationId,
175
+ );
176
+ if (!isNew.value && !configuration) {
177
+ throw new Error(i18n.baseText('selfHealing.config.notFound'));
178
+ }
179
+
180
+ if (configuration) {
181
+ form.value = {
182
+ scope: configuration.scope,
183
+ workflowIds: [...configuration.workflowIds],
184
+ reviewerIds: [...configuration.reviewerIds],
185
+ enabled: configuration.enabled,
186
+ };
187
+ } else {
188
+ form.value = {
189
+ scope: hasAllWorkflowsConfiguration.value ? 'selected' : 'all',
190
+ workflowIds: [],
191
+ reviewerIds: users.currentUser ? [users.currentUser.id] : [],
192
+ enabled: true,
193
+ };
194
+ }
195
+
196
+ savedForm.value = structuredClone(toRaw(form.value));
197
+ } catch (error) {
198
+ if (pendingLoad !== request) return;
199
+
200
+ loadError.value = true;
201
+ toast.showError(error, i18n.baseText('selfHealing.config.loadError'));
202
+ } finally {
203
+ if (pendingLoad === request) {
204
+ loading.value = false;
205
+ }
206
+ }
207
+ }
208
+
209
+ watch(() => [props.projectId, props.configurationId], load, { immediate: true });
210
+
211
+ function discard() {
212
+ if (savedForm.value) {
213
+ form.value = structuredClone(toRaw(savedForm.value));
214
+ }
215
+ }
216
+
217
+ function back() {
218
+ void router.push({
219
+ name: VIEWS.PROJECT_SETTINGS,
220
+ params: { projectId: props.projectId },
221
+ hash: '#self-healing',
222
+ });
223
+ }
224
+
225
+ async function save() {
226
+ saving.value = true;
227
+
228
+ try {
229
+ const payload = structuredClone(toRaw(form.value));
230
+ if (payload.scope === 'all') {
231
+ payload.workflowIds = [];
232
+ }
233
+
234
+ const { scope, workflowIds, reviewerIds } = payload;
235
+ await store.save(
236
+ props.projectId,
237
+ isNew.value ? payload : { scope, workflowIds, reviewerIds },
238
+ isNew.value ? undefined : Number(props.configurationId),
239
+ );
240
+
241
+ form.value = payload;
242
+ savedForm.value = structuredClone(payload);
243
+ toast.showMessage({ title: i18n.baseText('selfHealing.config.saved'), type: 'success' });
244
+
245
+ if (isNew.value) back();
246
+ } catch (error) {
247
+ toast.showError(error, i18n.baseText('selfHealing.config.saveError'));
248
+ } finally {
249
+ saving.value = false;
250
+ }
251
+ }
252
+
253
+ async function confirmDiscardChanges() {
254
+ if (!dirty.value) {
255
+ return true;
256
+ }
257
+
258
+ const answer = await message.confirm(
259
+ i18n.baseText('selfHealing.config.unsavedMessage'),
260
+ i18n.baseText('selfHealing.config.unsavedTitle'),
261
+ {
262
+ confirmButtonText: i18n.baseText('selfHealing.config.discard'),
263
+ cancelButtonText: i18n.baseText('generic.cancel'),
264
+ type: 'warning',
265
+ },
266
+ );
267
+
268
+ return answer === 'confirm';
269
+ }
270
+
271
+ onBeforeRouteLeave(confirmDiscardChanges);
272
+ onBeforeRouteUpdate(async (to, from) => {
273
+ if (
274
+ to.params.projectId !== from.params.projectId ||
275
+ to.params.configurationId !== from.params.configurationId
276
+ ) {
277
+ return await confirmDiscardChanges();
278
+ }
279
+
280
+ return true;
281
+ });
282
+ </script>
283
+
284
+ <template>
285
+ <div ref="pageRef" :class="$style.page">
286
+ <div :class="$style.backBar">
287
+ <N8nButton variant="ghost" size="small" :class="$style.backButton" @click="back">
288
+ <template #icon><N8nIcon icon="arrow-left" /></template>
289
+ {{ i18n.baseText('selfHealing.config.back') }}
290
+ </N8nButton>
291
+ </div>
292
+ <N8nSettingsLayout :class="$style.layout" data-test-id="self-healing-config-page">
293
+ <N8nSettingsPageHeader
294
+ :title="title"
295
+ :description="i18n.baseText('selfHealing.config.description')"
296
+ :show-docs-link="false"
297
+ />
298
+ <p v-if="loading">{{ i18n.baseText('selfHealing.config.loading') }}</p>
299
+ <N8nButton v-else-if="loadError" variant="outline" @click="load">{{
300
+ i18n.baseText('selfHealing.config.retry')
301
+ }}</N8nButton>
302
+ <form v-else @submit.prevent="save">
303
+ <N8nSettingsSection :title="i18n.baseText('selfHealing.config.scope')">
304
+ <N8nSettingsRowGroup>
305
+ <N8nSettingsRow
306
+ :title="i18n.baseText('selfHealing.config.workflows')"
307
+ :description="i18n.baseText('selfHealing.config.scopeHelp')"
308
+ >
309
+ <template #action>
310
+ <N8nSegmentControl
311
+ v-model="form.scope"
312
+ :options="scopeOptions"
313
+ :disabled="saving"
314
+ :aria-label="i18n.baseText('selfHealing.config.scope')"
315
+ data-test-id="self-healing-scope"
316
+ />
317
+ </template>
318
+ </N8nSettingsRow>
319
+ </N8nSettingsRowGroup>
320
+ <N8nSettingsRowGroup v-if="form.scope === 'selected'">
321
+ <N8nSettingsRow layout="custom">
322
+ <N8nCombobox2
323
+ id="self-healing-workflows"
324
+ model-value=""
325
+ icon="search"
326
+ :class="$style.searchPicker"
327
+ :items="workflowPickerItems"
328
+ :disabled="saving"
329
+ :aria-label="i18n.baseText('selfHealing.config.workflows')"
330
+ :placeholder="i18n.baseText('selfHealing.config.chooseWorkflows')"
331
+ data-test-id="self-healing-workflows"
332
+ @update:model-value="addWorkflow"
333
+ @keydown.enter.prevent
334
+ >
335
+ <template #item-trailing="{ item }">
336
+ <N8nText v-if="item.disabled" size="small" color="text-light">{{
337
+ i18n.baseText('selfHealing.config.workflowReserved')
338
+ }}</N8nText>
339
+ </template>
340
+ </N8nCombobox2>
341
+ <N8nText
342
+ v-if="!selectedWorkflows.length"
343
+ size="small"
344
+ color="text-light"
345
+ :class="$style.hint"
346
+ >{{ i18n.baseText('selfHealing.config.noWorkflows') }}</N8nText
347
+ >
348
+ </N8nSettingsRow>
349
+ <N8nSettingsRow
350
+ v-for="workflow in selectedWorkflows"
351
+ :key="workflow.id"
352
+ :title="workflow.name"
353
+ show-visual
354
+ data-test-id="self-healing-selected-workflow"
355
+ >
356
+ <template #visual><N8nIcon icon="workflow" size="large" /></template>
357
+ <template #action>
358
+ <N8nIconButton
359
+ icon="x"
360
+ variant="ghost"
361
+ size="small"
362
+ :disabled="saving"
363
+ :aria-label="i18n.baseText('selfHealing.config.removeWorkflow')"
364
+ @click="form.workflowIds = form.workflowIds.filter((id) => id !== workflow.id)"
365
+ />
366
+ </template>
367
+ </N8nSettingsRow>
368
+ </N8nSettingsRowGroup>
369
+ </N8nSettingsSection>
370
+ <N8nSettingsSection
371
+ :title="i18n.baseText('selfHealing.config.reviewers')"
372
+ :description="i18n.baseText('selfHealing.config.reviewersHelp')"
373
+ >
374
+ <N8nSettingsRowGroup>
375
+ <N8nSettingsRow layout="custom">
376
+ <N8nCombobox2
377
+ id="self-healing-reviewers"
378
+ model-value=""
379
+ :items="reviewerPickerItems"
380
+ :disabled="saving"
381
+ :aria-label="i18n.baseText('selfHealing.config.reviewers')"
382
+ :placeholder="i18n.baseText('selfHealing.config.chooseReviewers')"
383
+ data-test-id="self-healing-reviewers"
384
+ @update:model-value="addReviewer"
385
+ @keydown.enter.prevent
386
+ >
387
+ <template #item-leading="{ item }">
388
+ <N8nAvatar
389
+ :first-name="reviewersById.get(item.value)?.firstName"
390
+ :last-name="reviewersById.get(item.value)?.lastName"
391
+ size="small"
392
+ />
393
+ </template>
394
+ </N8nCombobox2>
395
+ <N8nText
396
+ v-if="!form.reviewerIds.length"
397
+ size="small"
398
+ color="text-light"
399
+ :class="$style.hint"
400
+ >{{ i18n.baseText('selfHealing.config.noReviewers') }}</N8nText
401
+ >
402
+ </N8nSettingsRow>
403
+ <N8nSettingsRow
404
+ v-for="reviewer in selectedReviewers"
405
+ :key="reviewer.id"
406
+ data-test-id="self-healing-selected-reviewer"
407
+ >
408
+ <template #info>
409
+ <N8nUserInfo
410
+ v-if="reviewer.user"
411
+ v-bind="reviewer.user"
412
+ :is-current-user="reviewer.id === users.currentUser?.id"
413
+ />
414
+ <N8nText v-else>{{
415
+ i18n.baseText('selfHealing.config.unavailableReviewer')
416
+ }}</N8nText>
417
+ </template>
418
+ <template #action>
419
+ <N8nIconButton
420
+ icon="x"
421
+ variant="ghost"
422
+ size="small"
423
+ :disabled="saving"
424
+ :aria-label="i18n.baseText('selfHealing.config.removeReviewer')"
425
+ @click="form.reviewerIds = form.reviewerIds.filter((id) => id !== reviewer.id)"
426
+ />
427
+ </template>
428
+ </N8nSettingsRow>
429
+ </N8nSettingsRowGroup>
430
+ </N8nSettingsSection>
431
+ <div :class="$style.actions">
432
+ <N8nButton
433
+ type="button"
434
+ variant="outline"
435
+ :disabled="saving || (!isNew && !dirty)"
436
+ @click="isNew ? back() : discard()"
437
+ >{{ i18n.baseText(isNew ? 'generic.cancel' : 'selfHealing.config.discard') }}</N8nButton
438
+ >
439
+ <N8nButton
440
+ type="submit"
441
+ :loading="saving"
442
+ :disabled="!isFormValid || (!isNew && !dirty)"
443
+ data-test-id="self-healing-save"
444
+ >{{
445
+ i18n.baseText(isNew ? 'selfHealing.config.createAction' : 'selfHealing.config.save')
446
+ }}</N8nButton
447
+ >
448
+ </div>
449
+ </form>
450
+ </N8nSettingsLayout>
451
+ </div>
452
+ </template>
453
+
454
+ <style module lang="scss">
455
+ .page {
456
+ width: 100%;
457
+ }
458
+
459
+ .backBar {
460
+ position: sticky;
461
+ top: 0;
462
+ z-index: 1;
463
+ padding: var(--spacing--2xs) var(--spacing--lg) var(--spacing--sm);
464
+ background-color: var(--color--background--light-2);
465
+ }
466
+
467
+ .backButton {
468
+ margin-inline-start: calc(-1 * var(--spacing--xs));
469
+ }
470
+
471
+ .layout {
472
+ padding-top: var(--spacing--2xs);
473
+ }
474
+
475
+ .actions {
476
+ margin-top: var(--spacing--lg);
477
+ display: flex;
478
+ justify-content: flex-end;
479
+ gap: var(--spacing--2xs);
480
+ }
481
+
482
+ .searchPicker [data-icon='search'] {
483
+ color: var(--input--placeholder--color);
484
+ }
485
+
486
+ .hint {
487
+ display: block;
488
+ margin-top: var(--spacing--2xs);
489
+ }
490
+ </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,50 @@
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
+ // Resolve shared frontend packages from source, as the editor does.
45
+ // Exclude sibling modules to keep the module boundary intact.
46
+ alias: frontendAliases(packagesDir),
47
+ },
48
+ }),
49
+ vitestConfig,
50
+ );