@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.
- package/LICENSE.md +88 -0
- package/LICENSE_EE.md +27 -0
- package/README.md +5 -2
- package/biome.jsonc +4 -0
- package/eslint.config.mjs +4 -0
- package/package.json +64 -3
- package/src/__tests__/design-system-icons.test.ts +24 -0
- package/src/__tests__/setup.ts +4 -0
- package/src/components/ProjectSelfHealingSection.test.ts +119 -0
- package/src/components/ProjectSelfHealingSection.vue +298 -0
- package/src/index.ts +2 -0
- package/src/self-healing.api.ts +48 -0
- package/src/self-healing.store.test.ts +130 -0
- package/src/self-healing.store.ts +75 -0
- package/src/views/SelfHealingConfigView.test.ts +436 -0
- package/src/views/SelfHealingConfigView.vue +490 -0
- package/stylelint.config.mjs +9 -0
- package/vite.config.ts +50 -0
|
@@ -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>
|
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
|
+
);
|