mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Evaluations frontend (no-changelog) (#15550)
Co-authored-by: Yiorgis Gozadinos <yiorgis@n8n.io> Co-authored-by: JP van Oosten <jp@n8n.io> Co-authored-by: Giulio Andreini <g.andreini@gmail.com> Co-authored-by: Michael Kret <michael.k@radency.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import type { TestRunRecord } from '@/api/evaluation.ee';
|
||||
import { computed, watchEffect } from 'vue';
|
||||
import { Line } from 'vue-chartjs';
|
||||
import { useMetricsChart } from '../composables/useMetricsChart';
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import MetricsChart from '@/components/TestDefinition/ListRuns/MetricsChart.vue';
|
||||
import TestRunsTable from '@/components/TestDefinition/ListRuns/TestRunsTable.vue';
|
||||
import type { TestRunRecord } from '@/api/evaluation.ee';
|
||||
import MetricsChart from '@/components/Evaluations.ee/ListRuns/MetricsChart.vue';
|
||||
import TestRunsTable from '@/components/Evaluations.ee/ListRuns/TestRunsTable.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
|
||||
@@ -10,7 +10,7 @@ import { useRouter } from 'vue-router';
|
||||
|
||||
const props = defineProps<{
|
||||
runs: Array<TestRunRecord & { index: number }>;
|
||||
testId: string;
|
||||
workflowId: string;
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
@@ -42,7 +42,7 @@ const metricColumns = computed(() =>
|
||||
const columns = computed(() => [
|
||||
{
|
||||
prop: 'id',
|
||||
label: locale.baseText('testDefinition.listRuns.runNumber'),
|
||||
label: locale.baseText('evaluation.listRuns.runNumber'),
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{
|
||||
@@ -59,7 +59,7 @@ const columns = computed(() => [
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: locale.baseText('testDefinition.listRuns.status'),
|
||||
label: locale.baseText('evaluation.listRuns.status'),
|
||||
sortable: true,
|
||||
},
|
||||
...metricColumns.value,
|
||||
@@ -67,8 +67,8 @@ const columns = computed(() => [
|
||||
|
||||
const handleRowClick = (row: TestRunRecord) => {
|
||||
void router.push({
|
||||
name: VIEWS.TEST_DEFINITION_RUNS_DETAIL,
|
||||
params: { testId: row.testDefinitionId, runId: row.id },
|
||||
name: VIEWS.EVALUATION_RUNS_DETAIL,
|
||||
params: { runId: row.id },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import type { TestRunRecord } from '@/api/evaluation.ee';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import { computed } from 'vue';
|
||||
import type { TestTableColumn } from '../shared/TestTableBase.vue';
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
import TestTableBase from '../shared/TestTableBase.vue';
|
||||
import { statusDictionary } from '../shared/statusDictionary';
|
||||
import { getErrorBaseKey } from '@/components/Evaluations.ee/shared/errorCodes';
|
||||
const emit = defineEmits<{
|
||||
rowClick: [run: TestRunRecord & { index: number }];
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
runs: Array<TestRunRecord & { index: number }>;
|
||||
columns: Array<TestTableColumn<TestRunRecord & { index: number }>>;
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
const styledColumns = computed(() => {
|
||||
return props.columns.map((column) => {
|
||||
if (column.prop === 'id') {
|
||||
return {
|
||||
...column,
|
||||
width: 100,
|
||||
};
|
||||
}
|
||||
|
||||
if (column.prop === 'runAt') {
|
||||
return {
|
||||
...column,
|
||||
width: 150,
|
||||
};
|
||||
}
|
||||
|
||||
return column;
|
||||
});
|
||||
});
|
||||
|
||||
// Combine test run statuses and finalResult to get the final status
|
||||
const runSummaries = computed(() => {
|
||||
return props.runs.map(({ status, finalResult, errorDetails, ...run }) => {
|
||||
if (status === 'completed' && finalResult && ['error', 'warning'].includes(finalResult)) {
|
||||
status = 'warning';
|
||||
}
|
||||
return {
|
||||
...run,
|
||||
status,
|
||||
finalResult,
|
||||
errorDetails: errorDetails as Record<string, string | number> | undefined,
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading" color="text-base">
|
||||
{{ locale.baseText('evaluation.listRuns.pastRuns.total', { adjustToNumber: runs.length }) }}
|
||||
({{ runs.length }})
|
||||
</N8nHeading>
|
||||
|
||||
<TestTableBase
|
||||
:data="runSummaries"
|
||||
:columns="styledColumns"
|
||||
:default-sort="{ prop: 'runAt', order: 'descending' }"
|
||||
@row-click="(row) => (row.status !== 'error' ? emit('rowClick', row) : undefined)"
|
||||
>
|
||||
<template #id="{ row }">#{{ row.index }} </template>
|
||||
<template #status="{ row }">
|
||||
<div
|
||||
style="display: inline-flex; gap: 12px; text-transform: capitalize; align-items: center"
|
||||
>
|
||||
<N8nText v-if="row.status === 'running'" color="secondary">
|
||||
<AnimatedSpinner />
|
||||
</N8nText>
|
||||
<N8nIcon
|
||||
v-else
|
||||
:icon="statusDictionary[row.status].icon"
|
||||
:color="statusDictionary[row.status].color"
|
||||
/>
|
||||
<template v-if="row.status === 'warning'">
|
||||
<N8nText color="warning" :class="[$style.alertText, $style.warningText]">
|
||||
{{ locale.baseText(`evaluation.runDetail.error.partialCasesFailed`) }}
|
||||
</N8nText>
|
||||
</template>
|
||||
<template v-else-if="row.status === 'error'">
|
||||
<N8nTooltip placement="top" :show-after="300">
|
||||
<template #content>
|
||||
<i18n-t :keypath="`${getErrorBaseKey(row.errorCode)}`">
|
||||
<template
|
||||
v-if="
|
||||
locale.exists(`${getErrorBaseKey(row.errorCode)}.description` as BaseTextKey)
|
||||
"
|
||||
#description
|
||||
>
|
||||
{{
|
||||
locale.baseText(
|
||||
`${getErrorBaseKey(row.errorCode)}.description` as BaseTextKey,
|
||||
) && '. '
|
||||
}}
|
||||
{{
|
||||
locale.baseText(
|
||||
`${getErrorBaseKey(row.errorCode)}.description` as BaseTextKey,
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
|
||||
<N8nText :class="[$style.alertText, $style.errorText]">
|
||||
<i18n-t :keypath="`${getErrorBaseKey(row.errorCode)}`">
|
||||
<template
|
||||
v-if="
|
||||
locale.exists(`${getErrorBaseKey(row.errorCode)}.description` as BaseTextKey)
|
||||
"
|
||||
#description
|
||||
>
|
||||
<p :class="$style.grayText">
|
||||
{{
|
||||
locale.baseText(
|
||||
`${getErrorBaseKey(row.errorCode)}.description` as BaseTextKey,
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</N8nText>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ row.status }}
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</TestTableBase>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.grayText {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.alertText {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
max-width: 100%;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
line-height: 1.25;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.alertText::first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.warningText {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: var(--color-text-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { COMMUNITY_PLUS_ENROLLMENT_MODAL } from '@/constants';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const goToUpgrade = async () => {
|
||||
uiStore.openModalWithData({
|
||||
name: COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||
data: {
|
||||
customHeading: undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n8n-action-box
|
||||
data-test-id="evaluations-unlicensed"
|
||||
:heading="i18n.baseText('evaluations.paywall.title')"
|
||||
:description="i18n.baseText('evaluations.paywall.description')"
|
||||
:button-text="i18n.baseText('evaluations.paywall.cta')"
|
||||
@click="goToUpgrade"
|
||||
></n8n-action-box>
|
||||
</template>
|
||||
@@ -0,0 +1,325 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { N8nText, N8nButton, N8nCallout } from '@n8n/design-system';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useEvaluationStore } from '@/stores/evaluation.store.ee';
|
||||
import { PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants';
|
||||
import StepHeader from '../shared/StepHeader.vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useUsageStore } from '@/stores/usage.store';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
|
||||
defineEmits<{
|
||||
runTest: [];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const locale = useI18n();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const evaluationStore = useEvaluationStore();
|
||||
const usageStore = useUsageStore();
|
||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||
|
||||
const hasRuns = computed(() => {
|
||||
return evaluationStore.testRunsByWorkflowId[workflowsStore.workflow.id]?.length > 0;
|
||||
});
|
||||
|
||||
const evaluationsAvailable = computed(() => {
|
||||
return (
|
||||
usageStore.workflowsWithEvaluationsLimit === -1 ||
|
||||
usageStore.workflowsWithEvaluationsCount < usageStore.workflowsWithEvaluationsLimit
|
||||
);
|
||||
});
|
||||
|
||||
const evaluationsQuotaExceeded = computed(() => {
|
||||
return (
|
||||
usageStore.workflowsWithEvaluationsLimit !== -1 &&
|
||||
usageStore.workflowsWithEvaluationsCount >= usageStore.workflowsWithEvaluationsLimit &&
|
||||
!hasRuns.value
|
||||
);
|
||||
});
|
||||
|
||||
const activeStepIndex = ref(0);
|
||||
|
||||
// Calculate the initial active step based on the workflow state
|
||||
const initializeActiveStep = () => {
|
||||
if (evaluationsQuotaExceeded.value) {
|
||||
activeStepIndex.value = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
evaluationStore.evaluationTriggerExists &&
|
||||
evaluationStore.evaluationSetOutputsNodeExist &&
|
||||
evaluationStore.evaluationSetMetricsNodeExist
|
||||
) {
|
||||
activeStepIndex.value = 3;
|
||||
} else if (
|
||||
evaluationStore.evaluationTriggerExists &&
|
||||
evaluationStore.evaluationSetOutputsNodeExist
|
||||
) {
|
||||
activeStepIndex.value = 2;
|
||||
} else if (evaluationStore.evaluationTriggerExists) {
|
||||
activeStepIndex.value = 1;
|
||||
} else {
|
||||
activeStepIndex.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Run initialization on component mount
|
||||
initializeActiveStep();
|
||||
|
||||
const toggleStep = (index: number) => {
|
||||
activeStepIndex.value = index;
|
||||
};
|
||||
|
||||
function navigateToWorkflow(
|
||||
action?: 'addEvaluationTrigger' | 'addEvaluationNode' | 'executeEvaluation',
|
||||
) {
|
||||
const routeWorkflowId =
|
||||
workflowsStore.workflow.id === PLACEHOLDER_EMPTY_WORKFLOW_ID
|
||||
? 'new'
|
||||
: workflowsStore.workflow.id;
|
||||
|
||||
void router.push({
|
||||
name: VIEWS.WORKFLOW,
|
||||
params: { name: routeWorkflowId },
|
||||
query: action ? { action } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function onSeePlans() {
|
||||
void pageRedirectionHelper.goToUpgrade('evaluations', 'upgrade-evaluations');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container" data-test-id="evaluation-setup-wizard">
|
||||
<div :class="$style.steps">
|
||||
<!-- Step 1 -->
|
||||
<div :class="[$style.step, $style.completed]">
|
||||
<StepHeader
|
||||
:step-number="1"
|
||||
:title="locale.baseText('evaluations.setupWizard.step1.title')"
|
||||
:is-completed="evaluationStore.evaluationTriggerExists"
|
||||
:is-active="activeStepIndex === 0"
|
||||
@click="toggleStep(0)"
|
||||
/>
|
||||
<div v-if="activeStepIndex === 0" :class="$style.stepContent">
|
||||
<ul :class="$style.bulletPoints">
|
||||
<li>
|
||||
<N8nText size="small" color="text-base">
|
||||
{{ locale.baseText('evaluations.setupWizard.step1.item1') }}
|
||||
</N8nText>
|
||||
</li>
|
||||
<li>
|
||||
<N8nText size="small" color="text-base">
|
||||
{{ locale.baseText('evaluations.setupWizard.step1.item2') }}
|
||||
</N8nText>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div :class="$style.actionButton">
|
||||
<N8nButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
@click="navigateToWorkflow('addEvaluationTrigger')"
|
||||
>
|
||||
{{ locale.baseText('evaluations.setupWizard.step1.button') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div :class="[$style.step, activeStepIndex === 1 ? $style.active : '']">
|
||||
<StepHeader
|
||||
:step-number="2"
|
||||
:title="locale.baseText('evaluations.setupWizard.step2.title')"
|
||||
:is-completed="evaluationStore.evaluationSetOutputsNodeExist"
|
||||
:is-active="activeStepIndex === 1"
|
||||
@click="toggleStep(1)"
|
||||
/>
|
||||
<div v-if="activeStepIndex === 1" :class="$style.stepContent">
|
||||
<ul :class="$style.bulletPoints">
|
||||
<li>
|
||||
<N8nText size="small" color="text-base">
|
||||
{{ locale.baseText('evaluations.setupWizard.step2.item1') }}
|
||||
</N8nText>
|
||||
</li>
|
||||
</ul>
|
||||
<div :class="$style.actionButton">
|
||||
<N8nButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
@click="navigateToWorkflow('addEvaluationNode')"
|
||||
>
|
||||
{{ locale.baseText('evaluations.setupWizard.step2.button') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div :class="$style.step">
|
||||
<StepHeader
|
||||
:step-number="3"
|
||||
:title="locale.baseText('evaluations.setupWizard.step3.title')"
|
||||
:is-completed="evaluationStore.evaluationSetMetricsNodeExist"
|
||||
:is-active="activeStepIndex === 2"
|
||||
:is-optional="true"
|
||||
@click="toggleStep(2)"
|
||||
/>
|
||||
<div v-if="activeStepIndex === 2" :class="$style.stepContent">
|
||||
<ul v-if="!evaluationsQuotaExceeded" :class="$style.bulletPoints">
|
||||
<li>
|
||||
<N8nText size="small" color="text-base">
|
||||
{{ locale.baseText('evaluations.setupWizard.step3.item1') }}
|
||||
</N8nText>
|
||||
</li>
|
||||
<li>
|
||||
<N8nText size="small" color="text-base">
|
||||
{{ locale.baseText('evaluations.setupWizard.step3.item2') }}
|
||||
</N8nText>
|
||||
</li>
|
||||
</ul>
|
||||
<N8nCallout v-else theme="warning" iconless>
|
||||
{{ locale.baseText('evaluations.setupWizard.limitReached') }}
|
||||
</N8nCallout>
|
||||
<div :class="$style.actionButton">
|
||||
<N8nButton
|
||||
v-if="!evaluationsQuotaExceeded"
|
||||
size="small"
|
||||
type="secondary"
|
||||
@click="navigateToWorkflow('addEvaluationNode')"
|
||||
>
|
||||
{{ locale.baseText('evaluations.setupWizard.step3.button') }}
|
||||
</N8nButton>
|
||||
<N8nButton v-else size="small" @click="onSeePlans">
|
||||
{{ locale.baseText('generic.seePlans') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
size="small"
|
||||
text
|
||||
style="color: var(--color-text-light)"
|
||||
@click="toggleStep(3)"
|
||||
>
|
||||
{{ locale.baseText('evaluations.setupWizard.step3.skip') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<div
|
||||
v-if="usageStore.workflowsWithEvaluationsLimit !== -1 && evaluationsAvailable"
|
||||
:class="$style.quotaNote"
|
||||
>
|
||||
<N8nText size="xsmall" color="text-base">
|
||||
<i18n-t keypath="evaluations.setupWizard.step3.notice">
|
||||
<template #link>
|
||||
<a style="text-decoration: underline; color: inherit" @click="onSeePlans"
|
||||
>{{ locale.baseText('evaluations.setupWizard.step3.notice.link') }}
|
||||
</a>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4 -->
|
||||
<div :class="$style.step">
|
||||
<StepHeader
|
||||
:step-number="4"
|
||||
:title="locale.baseText('evaluations.setupWizard.step4.title')"
|
||||
:is-completed="false"
|
||||
:is-active="activeStepIndex === 3"
|
||||
@click="toggleStep(3)"
|
||||
>
|
||||
<div :class="[$style.actionButton, $style.actionButtonInline]">
|
||||
<N8nButton
|
||||
v-if="evaluationStore.evaluationSetMetricsNodeExist && !evaluationsQuotaExceeded"
|
||||
size="medium"
|
||||
type="secondary"
|
||||
:disabled="
|
||||
!evaluationStore.evaluationTriggerExists ||
|
||||
!evaluationStore.evaluationSetOutputsNodeExist
|
||||
"
|
||||
@click="$emit('runTest')"
|
||||
>
|
||||
{{ locale.baseText('evaluations.setupWizard.step4.button') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
v-else
|
||||
size="medium"
|
||||
type="secondary"
|
||||
:disabled="
|
||||
!evaluationStore.evaluationTriggerExists ||
|
||||
!evaluationStore.evaluationSetOutputsNodeExist
|
||||
"
|
||||
@click="navigateToWorkflow('executeEvaluation')"
|
||||
>
|
||||
{{ locale.baseText('evaluations.setupWizard.step4.altButton') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</StepHeader>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
background-color: var(--color-background-light);
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
|
||||
.step {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stepContent {
|
||||
padding: 0 0 0 calc(var(--spacing-xs) + 28px);
|
||||
animation: slideDown 0.2s ease;
|
||||
}
|
||||
|
||||
.bulletPoints {
|
||||
padding-left: var(--spacing-s);
|
||||
|
||||
li {
|
||||
margin-bottom: var(--spacing-3xs);
|
||||
}
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
margin-top: var(--spacing-s);
|
||||
display: flex;
|
||||
gap: var(--spacing-s);
|
||||
|
||||
button {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
}
|
||||
|
||||
.actionButtonInline {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.quotaNote {
|
||||
margin-top: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChartData, ChartOptions } from 'chart.js';
|
||||
import type { TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import type { TestRunRecord } from '@/api/evaluation.ee';
|
||||
import dateFormat from 'dateformat';
|
||||
import { useCssVar } from '@vueuse/core';
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { N8nText, N8nBadge } from '@n8n/design-system';
|
||||
import StepIndicator from './StepIndicator.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
defineProps<{
|
||||
stepNumber: number;
|
||||
title: string;
|
||||
isCompleted: boolean;
|
||||
isActive: boolean;
|
||||
isOptional?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
|
||||
const handleClick = (event: Event) => {
|
||||
// Only emit click event if the click isn't on a button or interactive element
|
||||
if (
|
||||
!(event.target as HTMLElement).closest('button') &&
|
||||
!(event.target as HTMLElement).closest('a') &&
|
||||
!(event.target as HTMLElement).closest('input') &&
|
||||
!(event.target as HTMLElement).closest('select')
|
||||
) {
|
||||
emit('click');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.stepHeader" @click="handleClick">
|
||||
<StepIndicator :step-number="stepNumber" :is-completed="isCompleted" :is-active="isActive" />
|
||||
<!-- Use slot if provided, otherwise use title prop -->
|
||||
<div :class="$style.titleSlot">
|
||||
<slot>
|
||||
<N8nText
|
||||
size="medium"
|
||||
:color="isActive || isCompleted ? 'text-dark' : 'text-light'"
|
||||
tag="span"
|
||||
bold
|
||||
>
|
||||
{{ title }}
|
||||
</N8nText>
|
||||
</slot>
|
||||
</div>
|
||||
<N8nBadge
|
||||
v-if="isOptional"
|
||||
style="background-color: var(--color-background-base); border: none"
|
||||
>
|
||||
{{ locale.baseText('evaluations.setupWizard.stepHeader.optional') }}
|
||||
</N8nBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.stepHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { N8nIcon } from '@n8n/design-system';
|
||||
|
||||
defineProps<{
|
||||
stepNumber: number;
|
||||
isCompleted: boolean;
|
||||
isActive?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
$style.stepIndicator,
|
||||
isCompleted && $style.completed,
|
||||
isActive && $style.active,
|
||||
!isActive && !isCompleted && $style.inactive,
|
||||
]"
|
||||
>
|
||||
<template v-if="isCompleted">
|
||||
<N8nIcon icon="check" size="xsmall" />
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ stepNumber }}
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.stepIndicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--color-text-light);
|
||||
color: var(--color-text-light);
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--font-size-2xs);
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.active {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
&.completed {
|
||||
background-color: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
color: var(--prim-color-white);
|
||||
}
|
||||
|
||||
&.inactive {
|
||||
color: var(--color-text-light);
|
||||
border-color: var(--color-text-base);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import N8nTooltip from '@n8n/design-system/components/N8nTooltip';
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
import type { TestTableColumn } from '@/components/TestDefinition/shared/TestTableBase.vue';
|
||||
import type { TestTableColumn } from '@/components/Evaluations.ee/shared/TestTableBase.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
@@ -23,15 +23,19 @@ function hasError(row: unknown): row is WithError {
|
||||
|
||||
const errorTooltipMap: Record<string, BaseTextKey> = {
|
||||
// Test case errors
|
||||
MOCKED_NODE_DOES_NOT_EXIST: 'testDefinition.runDetail.error.mockedNodeMissing',
|
||||
FAILED_TO_EXECUTE_EVALUATION_WORKFLOW: 'testDefinition.runDetail.error.evaluationFailed',
|
||||
FAILED_TO_EXECUTE_WORKFLOW: 'testDefinition.runDetail.error.executionFailed',
|
||||
TRIGGER_NO_LONGER_EXISTS: 'testDefinition.runDetail.error.triggerNoLongerExists',
|
||||
INVALID_METRICS: 'testDefinition.runDetail.error.invalidMetrics',
|
||||
MOCKED_NODE_NOT_FOUND: 'evaluation.runDetail.error.mockedNodeMissing',
|
||||
FAILED_TO_EXECUTE_WORKFLOW: 'evaluation.runDetail.error.executionFailed',
|
||||
INVALID_METRICS: 'evaluation.runDetail.error.invalidMetrics',
|
||||
|
||||
// Test run errors
|
||||
PAST_EXECUTIONS_NOT_FOUND: 'testDefinition.listRuns.error.noPastExecutions',
|
||||
EVALUATION_WORKFLOW_NOT_FOUND: 'testDefinition.listRuns.error.evaluationWorkflowNotFound',
|
||||
TEST_CASES_NOT_FOUND: 'evaluation.listRuns.error.testCasesNotFound',
|
||||
EVALUATION_TRIGGER_NOT_FOUND: 'evaluation.listRuns.error.evaluationTriggerNotFound',
|
||||
EVALUATION_TRIGGER_NOT_CONFIGURED: 'evaluation.listRuns.error.evaluationTriggerNotConfigured',
|
||||
SET_OUTPUTS_NODE_NOT_FOUND: 'evaluation.listRuns.error.setOutputsNodeNotFound',
|
||||
SET_OUTPUTS_NODE_NOT_CONFIGURED: 'evaluation.listRuns.error.setOutputsNodeNotConfigured',
|
||||
SET_METRICS_NODE_NOT_FOUND: 'evaluation.listRuns.error.setMetricsNodeNotFound',
|
||||
SET_METRICS_NODE_NOT_CONFIGURED: 'evaluation.listRuns.error.setMetricsNodeNotConfigured',
|
||||
CANT_FETCH_TEST_CASES: 'evaluation.listRuns.error.cantFetchTestCases',
|
||||
};
|
||||
|
||||
// FIXME: move status logic to a parent component
|
||||
@@ -47,14 +51,14 @@ const statusThemeMap: Record<string, string> = {
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
new: locale.baseText('testDefinition.listRuns.status.new'),
|
||||
running: locale.baseText('testDefinition.listRuns.status.running'),
|
||||
evaluation_running: locale.baseText('testDefinition.listRuns.status.evaluating'),
|
||||
completed: locale.baseText('testDefinition.listRuns.status.completed'),
|
||||
error: locale.baseText('testDefinition.listRuns.status.error'),
|
||||
success: locale.baseText('testDefinition.listRuns.status.success'),
|
||||
warning: locale.baseText('testDefinition.listRuns.status.warning'),
|
||||
cancelled: locale.baseText('testDefinition.listRuns.status.cancelled'),
|
||||
new: locale.baseText('evaluation.listRuns.status.new'),
|
||||
running: locale.baseText('evaluation.listRuns.status.running'),
|
||||
evaluation_running: locale.baseText('evaluation.listRuns.status.evaluating'),
|
||||
completed: locale.baseText('evaluation.listRuns.status.completed'),
|
||||
error: locale.baseText('evaluation.listRuns.status.error'),
|
||||
success: locale.baseText('evaluation.listRuns.status.success'),
|
||||
warning: locale.baseText('evaluation.listRuns.status.warning'),
|
||||
cancelled: locale.baseText('evaluation.listRuns.status.cancelled'),
|
||||
};
|
||||
|
||||
function getErrorTooltip(column: TestTableColumn<T>, row: T): string | undefined {
|
||||
@@ -112,7 +112,9 @@ defineSlots<{
|
||||
:data="localData"
|
||||
:border="true"
|
||||
:cell-class-name="$style.customCell"
|
||||
:row-class-name="$style.customRow"
|
||||
:row-class-name="
|
||||
({ row }) => (row?.status === 'error' ? $style.customDisabledRow : $style.customRow)
|
||||
"
|
||||
scrollbar-always-on
|
||||
@selection-change="handleSelectionChange"
|
||||
@header-dragend="handleColumnResize"
|
||||
@@ -184,6 +186,11 @@ defineSlots<{
|
||||
--color-table-row-hover-background: var(--color-background-light);
|
||||
}
|
||||
|
||||
.customDisabledRow {
|
||||
cursor: default;
|
||||
--color-table-row-hover-background: var(--color-background-light);
|
||||
}
|
||||
|
||||
.customHeaderCell {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
|
||||
const TEST_CASE_EXECUTION_ERROR_CODE = {
|
||||
MOCKED_NODE_NOT_FOUND: 'MOCKED_NODE_NOT_FOUND',
|
||||
FAILED_TO_EXECUTE_WORKFLOW: 'FAILED_TO_EXECUTE_WORKFLOW',
|
||||
INVALID_METRICS: 'INVALID_METRICS',
|
||||
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
|
||||
NO_METRICS_COLLECTED: 'NO_METRICS_COLLECTED',
|
||||
} as const;
|
||||
|
||||
export type TestCaseExecutionErrorCodes =
|
||||
(typeof TEST_CASE_EXECUTION_ERROR_CODE)[keyof typeof TEST_CASE_EXECUTION_ERROR_CODE];
|
||||
|
||||
const TEST_RUN_ERROR_CODES = {
|
||||
TEST_CASES_NOT_FOUND: 'TEST_CASES_NOT_FOUND',
|
||||
INTERRUPTED: 'INTERRUPTED',
|
||||
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
|
||||
EVALUATION_TRIGGER_NOT_FOUND: 'EVALUATION_TRIGGER_NOT_FOUND',
|
||||
EVALUATION_TRIGGER_NOT_CONFIGURED: 'EVALUATION_TRIGGER_NOT_CONFIGURED',
|
||||
EVALUATION_TRIGGER_DISABLED: 'EVALUATION_TRIGGER_DISABLED',
|
||||
SET_OUTPUTS_NODE_NOT_FOUND: 'SET_OUTPUTS_NODE_NOT_FOUND',
|
||||
SET_OUTPUTS_NODE_NOT_CONFIGURED: 'SET_OUTPUTS_NODE_NOT_CONFIGURED',
|
||||
SET_METRICS_NODE_NOT_FOUND: 'SET_METRICS_NODE_NOT_FOUND',
|
||||
SET_METRICS_NODE_NOT_CONFIGURED: 'SET_METRICS_NODE_NOT_CONFIGURED',
|
||||
CANT_FETCH_TEST_CASES: 'CANT_FETCH_TEST_CASES',
|
||||
PARTIAL_CASES_FAILED: 'PARTIAL_CASES_FAILED',
|
||||
} as const;
|
||||
|
||||
export type TestRunErrorCode = (typeof TEST_RUN_ERROR_CODES)[keyof typeof TEST_RUN_ERROR_CODES];
|
||||
const testCaseErrorDictionary: Partial<Record<TestCaseExecutionErrorCodes, BaseTextKey>> = {
|
||||
MOCKED_NODE_NOT_FOUND: 'evaluation.runDetail.error.mockedNodeMissing',
|
||||
FAILED_TO_EXECUTE_WORKFLOW: 'evaluation.runDetail.error.executionFailed',
|
||||
INVALID_METRICS: 'evaluation.runDetail.error.invalidMetrics',
|
||||
UNKNOWN_ERROR: 'evaluation.runDetail.error.unknownError',
|
||||
NO_METRICS_COLLECTED: 'evaluation.runDetail.error.noMetricsCollected',
|
||||
} as const;
|
||||
|
||||
const testRunErrorDictionary: Partial<Record<TestRunErrorCode, BaseTextKey>> = {
|
||||
TEST_CASES_NOT_FOUND: 'evaluation.listRuns.error.testCasesNotFound',
|
||||
INTERRUPTED: 'evaluation.listRuns.error.executionInterrupted',
|
||||
UNKNOWN_ERROR: 'evaluation.listRuns.error.unknownError',
|
||||
EVALUATION_TRIGGER_NOT_FOUND: 'evaluation.listRuns.error.evaluationTriggerNotFound',
|
||||
EVALUATION_TRIGGER_NOT_CONFIGURED: 'evaluation.listRuns.error.evaluationTriggerNotConfigured',
|
||||
EVALUATION_TRIGGER_DISABLED: 'evaluation.listRuns.error.evaluationTriggerDisabled',
|
||||
SET_OUTPUTS_NODE_NOT_FOUND: 'evaluation.listRuns.error.setOutputsNodeNotFound',
|
||||
SET_OUTPUTS_NODE_NOT_CONFIGURED: 'evaluation.listRuns.error.setOutputsNodeNotConfigured',
|
||||
SET_METRICS_NODE_NOT_FOUND: 'evaluation.listRuns.error.setMetricsNodeNotFound',
|
||||
SET_METRICS_NODE_NOT_CONFIGURED: 'evaluation.listRuns.error.setMetricsNodeNotConfigured',
|
||||
CANT_FETCH_TEST_CASES: 'evaluation.listRuns.error.cantFetchTestCases',
|
||||
PARTIAL_CASES_FAILED: 'evaluation.runDetail.error.partialCasesFailed',
|
||||
} as const;
|
||||
|
||||
export const getErrorBaseKey = (errorCode?: string): string => {
|
||||
return (
|
||||
testCaseErrorDictionary[errorCode as TestCaseExecutionErrorCodes] ??
|
||||
testRunErrorDictionary[errorCode as TestRunErrorCode] ??
|
||||
''
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { TestRunRecord } from '@/api/evaluation.ee';
|
||||
import type { IconColor } from '@n8n/design-system/types/icon';
|
||||
|
||||
export const statusDictionary: Record<TestRunRecord['status'], { icon: string; color: IconColor }> =
|
||||
{
|
||||
new: {
|
||||
icon: 'status-new',
|
||||
color: 'foreground-xdark',
|
||||
},
|
||||
running: {
|
||||
icon: 'spinner',
|
||||
color: 'secondary',
|
||||
},
|
||||
completed: {
|
||||
icon: 'status-completed',
|
||||
color: 'success',
|
||||
},
|
||||
error: {
|
||||
icon: 'exclamation-triangle',
|
||||
color: 'danger',
|
||||
},
|
||||
cancelled: {
|
||||
icon: 'status-canceled',
|
||||
color: 'foreground-xdark',
|
||||
},
|
||||
warning: {
|
||||
icon: 'status-warning',
|
||||
color: 'warning',
|
||||
},
|
||||
success: {
|
||||
icon: 'status-completed',
|
||||
color: 'success',
|
||||
},
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useMetricsChart } from '../composables/useMetricsChart';
|
||||
import type { TestRunRecord as Record } from '@/api/testDefinition.ee';
|
||||
import type { TestRunRecord as Record } from '@/api/evaluation.ee';
|
||||
|
||||
type TestRunRecord = Record & { index: number };
|
||||
describe('useMetricsChart', () => {
|
||||
const mockRuns: TestRunRecord[] = [
|
||||
{
|
||||
id: '1',
|
||||
testDefinitionId: 'test1',
|
||||
workflowId: 'workflow1',
|
||||
status: 'completed',
|
||||
createdAt: '2025-01-06T10:00:00Z',
|
||||
updatedAt: '2025-01-06T10:00:00Z',
|
||||
@@ -18,7 +18,7 @@ describe('useMetricsChart', () => {
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
testDefinitionId: 'test1',
|
||||
workflowId: 'workflow1',
|
||||
status: 'completed',
|
||||
createdAt: '2025-01-06T10:00:00Z',
|
||||
updatedAt: '2025-01-06T10:00:00Z',
|
||||
@@ -49,12 +49,7 @@ const githubButtonHidden = useLocalStorage(LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON
|
||||
// This is used to determine which tab to show when the route changes
|
||||
// TODO: It might be easier to manage this in the router config, by passing meta information to the routes
|
||||
// This would allow us to specify it just once on the root route, and then have the tabs be determined for children
|
||||
const testDefinitionRoutes: VIEWS[] = [
|
||||
VIEWS.TEST_DEFINITION,
|
||||
VIEWS.TEST_DEFINITION_EDIT,
|
||||
VIEWS.TEST_DEFINITION_RUNS_DETAIL,
|
||||
VIEWS.TEST_DEFINITION_RUNS_COMPARE,
|
||||
];
|
||||
const evaluationRoutes: VIEWS[] = [VIEWS.EVALUATION_EDIT, VIEWS.EVALUATION_RUNS_DETAIL];
|
||||
|
||||
const workflowRoutes: VIEWS[] = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
|
||||
|
||||
@@ -71,7 +66,7 @@ const tabBarItems = computed(() => {
|
||||
|
||||
if (posthogStore.isFeatureEnabled(WORKFLOW_EVALUATION_EXPERIMENT)) {
|
||||
items.push({
|
||||
value: MAIN_HEADER_TABS.TEST_DEFINITION,
|
||||
value: MAIN_HEADER_TABS.EVALUATION,
|
||||
label: locale.baseText('generic.tests'),
|
||||
});
|
||||
}
|
||||
@@ -126,14 +121,14 @@ onMounted(async () => {
|
||||
function isViewRoute(name: unknown): name is VIEWS {
|
||||
return (
|
||||
typeof name === 'string' &&
|
||||
[testDefinitionRoutes, workflowRoutes, executionRoutes].flat().includes(name as VIEWS)
|
||||
[evaluationRoutes, workflowRoutes, executionRoutes].flat().includes(name as VIEWS)
|
||||
);
|
||||
}
|
||||
|
||||
function syncTabsWithRoute(to: RouteLocation, from?: RouteLocation): void {
|
||||
// Map route types to their corresponding tab in the header
|
||||
const routeTabMapping = [
|
||||
{ routes: testDefinitionRoutes, tab: MAIN_HEADER_TABS.TEST_DEFINITION },
|
||||
{ routes: evaluationRoutes, tab: MAIN_HEADER_TABS.EVALUATION },
|
||||
{ routes: executionRoutes, tab: MAIN_HEADER_TABS.EXECUTIONS },
|
||||
{ routes: workflowRoutes, tab: MAIN_HEADER_TABS.WORKFLOW },
|
||||
];
|
||||
@@ -172,9 +167,8 @@ function onTabSelected(tab: MAIN_HEADER_TABS, event: MouseEvent) {
|
||||
void navigateToExecutionsView(openInNewTab);
|
||||
break;
|
||||
|
||||
case MAIN_HEADER_TABS.TEST_DEFINITION:
|
||||
activeHeaderTab.value = MAIN_HEADER_TABS.TEST_DEFINITION;
|
||||
void router.push({ name: VIEWS.TEST_DEFINITION });
|
||||
case MAIN_HEADER_TABS.EVALUATION:
|
||||
void navigateToEvaluationsView(openInNewTab);
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -230,6 +224,25 @@ async function navigateToExecutionsView(openInNewTab: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
async function navigateToEvaluationsView(openInNewTab: boolean) {
|
||||
const routeWorkflowId =
|
||||
workflowId.value === PLACEHOLDER_EMPTY_WORKFLOW_ID ? 'new' : workflowId.value;
|
||||
const routeToNavigateTo: RouteLocationRaw = {
|
||||
name: VIEWS.EVALUATION_EDIT,
|
||||
params: { name: routeWorkflowId },
|
||||
};
|
||||
|
||||
if (openInNewTab) {
|
||||
const { href } = router.resolve(routeToNavigateTo);
|
||||
window.open(href, '_blank');
|
||||
} else if (route.name !== routeToNavigateTo.name) {
|
||||
dirtyState.value = uiStore.stateIsDirty;
|
||||
workflowToReturnTo.value = workflowId.value;
|
||||
activeHeaderTab.value = MAIN_HEADER_TABS.EXECUTIONS;
|
||||
await router.push(routeToNavigateTo);
|
||||
}
|
||||
}
|
||||
|
||||
function hideGithubButton() {
|
||||
githubButtonHidden.value = true;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
REGULAR_NODE_CREATOR_VIEW,
|
||||
TRIGGER_NODE_CREATOR_VIEW,
|
||||
AI_UNCATEGORIZED_CATEGORY,
|
||||
AI_EVALUATION,
|
||||
} from '@/constants';
|
||||
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||
@@ -126,6 +127,7 @@ watch(
|
||||
[AI_NODE_CREATOR_VIEW]: AIView,
|
||||
[AI_OTHERS_NODE_CREATOR_VIEW]: AINodesView,
|
||||
[AI_UNCATEGORIZED_CATEGORY]: AINodesView,
|
||||
[AI_EVALUATION]: AINodesView,
|
||||
};
|
||||
|
||||
const itemKey = selectedView;
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
AI_CATEGORY_TOOLS,
|
||||
AI_SUBCATEGORY,
|
||||
CUSTOM_API_CALL_KEY,
|
||||
EVALUATION_TRIGGER,
|
||||
HTTP_REQUEST_NODE_TYPE,
|
||||
} from '@/constants';
|
||||
import { memoize, startCase } from 'lodash-es';
|
||||
@@ -20,7 +19,7 @@ import { i18n } from '@/plugins/i18n';
|
||||
|
||||
import { getCredentialOnlyNodeType } from '@/utils/credentialOnlyNodes';
|
||||
import { formatTriggerActionName } from '../utils';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
import { useEvaluationStore } from '@/stores/evaluation.store.ee';
|
||||
|
||||
const PLACEHOLDER_RECOMMENDED_ACTION_KEY = 'placeholder_recommended';
|
||||
|
||||
@@ -332,15 +331,10 @@ export function useActionsGenerator() {
|
||||
nodeTypes: INodeTypeDescription[],
|
||||
httpOnlyCredentials: ICredentialType[],
|
||||
) {
|
||||
const posthogStore = usePostHog();
|
||||
|
||||
const isEvaluationVariantEnabled = posthogStore.isVariantEnabled(
|
||||
EVALUATION_TRIGGER.name,
|
||||
EVALUATION_TRIGGER.variant,
|
||||
);
|
||||
const evaluationStore = useEvaluationStore();
|
||||
|
||||
const visibleNodeTypes = nodeTypes.filter((node) => {
|
||||
if (isEvaluationVariantEnabled) {
|
||||
if (evaluationStore.isEvaluationEnabled) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -404,7 +404,7 @@ describe('useActionsGenerator', () => {
|
||||
});
|
||||
|
||||
it('should not return evaluation or evaluation trigger node if variant is not enabled', () => {
|
||||
vi.spyOn(posthogStore, 'isVariantEnabled').mockReturnValue(false);
|
||||
vi.spyOn(posthogStore, 'isFeatureEnabled').mockReturnValue(false);
|
||||
|
||||
const node: INodeTypeDescription = {
|
||||
...baseV2NodeWoProps,
|
||||
|
||||
@@ -57,20 +57,17 @@ import {
|
||||
AI_CODE_TOOL_LANGCHAIN_NODE_TYPE,
|
||||
AI_WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
||||
HUMAN_IN_THE_LOOP_CATEGORY,
|
||||
EVALUATION_TRIGGER,
|
||||
} from '@/constants';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import type { SimplifiedNodeType } from '@/Interface';
|
||||
import type { INodeTypeDescription, Themed } from 'n8n-workflow';
|
||||
import type { INodeTypeDescription, NodeConnectionType, Themed } from 'n8n-workflow';
|
||||
import { EVALUATION_TRIGGER_NODE_TYPE, NodeConnectionTypes } from 'n8n-workflow';
|
||||
import type { NodeConnectionType } from 'n8n-workflow';
|
||||
import { useTemplatesStore } from '@/stores/templates.store';
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
import { camelCase } from 'lodash-es';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
|
||||
import { useEvaluationStore } from '@/stores/evaluation.store.ee';
|
||||
export interface NodeViewItemSection {
|
||||
key: string;
|
||||
title: string;
|
||||
@@ -169,14 +166,10 @@ export function AIView(_nodes: SimplifiedNodeType[]): NodeView {
|
||||
const i18n = useI18n();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const templatesStore = useTemplatesStore();
|
||||
const posthogStore = usePostHog();
|
||||
const evaluationStore = useEvaluationStore();
|
||||
const isEvaluationEnabled = evaluationStore.isEvaluationEnabled;
|
||||
|
||||
const isEvaluationVariantEnabled = posthogStore.isVariantEnabled(
|
||||
EVALUATION_TRIGGER.name,
|
||||
EVALUATION_TRIGGER.variant,
|
||||
);
|
||||
|
||||
const evaluationNode = getEvaluationNode(nodeTypesStore, isEvaluationVariantEnabled);
|
||||
const evaluationNode = getEvaluationNode(nodeTypesStore, isEvaluationEnabled);
|
||||
|
||||
const chainNodes = getAiNodesBySubcategory(nodeTypesStore.allLatestNodeTypes, AI_CATEGORY_CHAINS);
|
||||
const agentNodes = getAiNodesBySubcategory(nodeTypesStore.allLatestNodeTypes, AI_CATEGORY_AGENTS);
|
||||
@@ -368,13 +361,10 @@ export function AINodesView(_nodes: SimplifiedNodeType[]): NodeView {
|
||||
|
||||
export function TriggerView() {
|
||||
const i18n = useI18n();
|
||||
const posthogStore = usePostHog();
|
||||
const isEvaluationVariantEnabled = posthogStore.isVariantEnabled(
|
||||
EVALUATION_TRIGGER.name,
|
||||
EVALUATION_TRIGGER.variant,
|
||||
);
|
||||
const evaluationStore = useEvaluationStore();
|
||||
const isEvaluationEnabled = evaluationStore.isEvaluationEnabled;
|
||||
|
||||
const evaluationTriggerNode = isEvaluationVariantEnabled
|
||||
const evaluationTriggerNode = isEvaluationEnabled
|
||||
? {
|
||||
key: EVALUATION_TRIGGER_NODE_TYPE,
|
||||
type: 'node',
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.blockArrow">
|
||||
<div :class="$style.stalk"></div>
|
||||
<div :class="$style.arrowHead"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.blockArrow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stalk {
|
||||
min-height: 14px;
|
||||
width: 2px;
|
||||
background-color: var(--color-foreground-xdark);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.arrowHead {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
|
||||
border-top: 10px solid var(--color-foreground-xdark);
|
||||
}
|
||||
</style>
|
||||
@@ -1,84 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { EditableField } from '../types';
|
||||
|
||||
interface Props {
|
||||
modelValue: EditableField<string>;
|
||||
startEditing: (field: 'description') => void;
|
||||
saveChanges: (field: 'description') => void;
|
||||
handleKeydown: (e: KeyboardEvent, field: 'description') => void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
defineEmits<{ 'update:modelValue': [value: EditableField<string>] }>();
|
||||
|
||||
const locale = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.description">
|
||||
<template v-if="!modelValue.isEditing">
|
||||
<span :class="$style.descriptionText" @click="startEditing('description')">
|
||||
<n8n-icon
|
||||
v-if="modelValue.value.length === 0"
|
||||
:class="$style.icon"
|
||||
icon="plus"
|
||||
color="text-light"
|
||||
size="medium"
|
||||
/>
|
||||
<N8nText size="medium">
|
||||
{{ modelValue.value.length > 0 ? modelValue.value : 'Add a description' }}
|
||||
</N8nText>
|
||||
</span>
|
||||
<n8n-icon-button
|
||||
:class="$style.editInputButton"
|
||||
icon="pen"
|
||||
type="tertiary"
|
||||
@click="startEditing('description')"
|
||||
/>
|
||||
</template>
|
||||
<N8nInput
|
||||
v-else
|
||||
ref="descriptionInput"
|
||||
data-test-id="evaluation-description-input"
|
||||
:model-value="modelValue.tempValue"
|
||||
type="textarea"
|
||||
:placeholder="locale.baseText('testDefinition.edit.descriptionPlaceholder')"
|
||||
@update:model-value="$emit('update:modelValue', { ...modelValue, tempValue: $event })"
|
||||
@blur="() => saveChanges('description')"
|
||||
@keydown="(e: KeyboardEvent) => handleKeydown(e, 'description')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.description {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-light);
|
||||
font-size: var(--font-size-s);
|
||||
|
||||
&:hover {
|
||||
.editInputButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.descriptionText {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.icon {
|
||||
margin-right: var(--spacing-2xs);
|
||||
}
|
||||
}
|
||||
|
||||
.editInputButton {
|
||||
--button-font-color: var(--prim-gray-490);
|
||||
opacity: 0;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,219 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { type Modifier, detectOverflow } from '@popperjs/core';
|
||||
import { N8nInfoTip, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||
import { computed, ref, useCssModule } from 'vue';
|
||||
|
||||
interface EvaluationStep {
|
||||
title?: string;
|
||||
warning?: boolean;
|
||||
expanded?: boolean;
|
||||
description?: string;
|
||||
issues?: Array<{ field: string; message: string }>;
|
||||
showIssues?: boolean;
|
||||
tooltip: string;
|
||||
externalTooltip?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<EvaluationStep>(), {
|
||||
description: '',
|
||||
warning: false,
|
||||
expanded: false,
|
||||
issues: () => [],
|
||||
showIssues: true,
|
||||
title: '',
|
||||
});
|
||||
|
||||
const locale = useI18n();
|
||||
const isExpanded = ref(props.expanded);
|
||||
const $style = useCssModule();
|
||||
|
||||
const hasIssues = computed(() => props.issues.length > 0);
|
||||
|
||||
const containerClass = computed(() => {
|
||||
return {
|
||||
[$style.evaluationStep]: true,
|
||||
[$style['has-issues']]: true,
|
||||
};
|
||||
});
|
||||
|
||||
const toggleExpand = () => (isExpanded.value = !isExpanded.value);
|
||||
|
||||
const renderIssues = computed(() => props.showIssues && props.issues.length);
|
||||
const issuesList = computed(() => props.issues.map((issue) => issue.message).join(', '));
|
||||
|
||||
/**
|
||||
* @see https://popper.js.org/docs/v2/modifiers/#custom-modifiers
|
||||
*/
|
||||
const resizeModifier: Modifier<'resize', {}> = {
|
||||
name: 'resize',
|
||||
enabled: true,
|
||||
phase: 'beforeWrite',
|
||||
requires: ['preventOverflow'],
|
||||
fn({ state }) {
|
||||
const overflow = detectOverflow(state);
|
||||
const MARGIN_RIGHT = 15;
|
||||
|
||||
const maxWidth = state.rects.popper.width - overflow.right - MARGIN_RIGHT;
|
||||
|
||||
state.styles.popper.width = `${maxWidth}px`;
|
||||
},
|
||||
};
|
||||
|
||||
const popperModifiers = [
|
||||
resizeModifier,
|
||||
{ name: 'preventOverflow', options: { boundary: 'document' } },
|
||||
{ name: 'flip', enabled: false }, // prevent the tooltip from flipping
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="containerClass" data-test-id="evaluation-step">
|
||||
<div :class="$style.content">
|
||||
<N8nTooltip
|
||||
placement="right"
|
||||
:disabled="!externalTooltip"
|
||||
:show-arrow="false"
|
||||
:popper-class="$style.evaluationTooltip"
|
||||
:popper-options="{ modifiers: popperModifiers }"
|
||||
:content="tooltip"
|
||||
>
|
||||
<div :class="$style.header" @click="toggleExpand">
|
||||
<div :class="$style.label">
|
||||
<N8nText bold>
|
||||
<slot v-if="$slots.title" name="title" />
|
||||
<template v-else>{{ title }}</template>
|
||||
</N8nText>
|
||||
<N8nInfoTip
|
||||
v-if="!externalTooltip"
|
||||
:class="$style.infoTip"
|
||||
:bold="true"
|
||||
type="tooltip"
|
||||
theme="info"
|
||||
tooltip-placement="top"
|
||||
:enterable="false"
|
||||
>
|
||||
{{ tooltip }}
|
||||
</N8nInfoTip>
|
||||
</div>
|
||||
<div :class="$style.actions">
|
||||
<N8nInfoTip
|
||||
v-if="renderIssues"
|
||||
:bold="true"
|
||||
type="tooltip"
|
||||
theme="warning"
|
||||
tooltip-placement="top"
|
||||
:enterable="false"
|
||||
>
|
||||
{{ issuesList }}
|
||||
</N8nInfoTip>
|
||||
<N8nText
|
||||
v-if="$slots.cardContent"
|
||||
data-test-id="evaluation-step-collapse-button"
|
||||
size="xsmall"
|
||||
:color="hasIssues ? 'primary' : 'text-base'"
|
||||
bold
|
||||
>
|
||||
{{
|
||||
isExpanded
|
||||
? locale.baseText('testDefinition.edit.step.collapse')
|
||||
: locale.baseText('testDefinition.edit.step.configure')
|
||||
}}
|
||||
<font-awesome-icon :icon="isExpanded ? 'angle-up' : 'angle-down'" size="lg" />
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
</N8nTooltip>
|
||||
<div v-if="$slots.cardContent && isExpanded" :class="$style.cardContentWrapper">
|
||||
<div :class="$style.cardContent" data-test-id="evaluation-step-content">
|
||||
<N8nText v-if="description" size="small" color="text-light">{{ description }}</N8nText>
|
||||
<slot name="cardContent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.evaluationStep {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
background: var(--color-background-xlight);
|
||||
border-radius: var(--border-radius-large);
|
||||
border: var(--border-base);
|
||||
width: 100%;
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
.evaluationTooltip {
|
||||
&:global(.el-popper) {
|
||||
background-color: transparent;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-light);
|
||||
line-height: 1rem;
|
||||
max-width: 25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--border-radius-base);
|
||||
overflow: hidden;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
|
||||
&.warning {
|
||||
background-color: var(--color-warning-tint-2);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: var(--spacing-2xs);
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-s);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4xs);
|
||||
}
|
||||
|
||||
.infoTip {
|
||||
opacity: 0;
|
||||
}
|
||||
.evaluationStep:hover .infoTip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
font-size: var(--font-size-s);
|
||||
padding: 0 var(--spacing-s);
|
||||
margin: var(--spacing-s) 0;
|
||||
}
|
||||
|
||||
.cardContentWrapper {
|
||||
border-top: var(--border-base);
|
||||
}
|
||||
|
||||
.has-issues {
|
||||
/**
|
||||
* This comment is needed or the css module
|
||||
* will interpret as undefined
|
||||
*/
|
||||
}
|
||||
</style>
|
||||
@@ -1,247 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Canvas from '@/components/canvas/Canvas.vue';
|
||||
import CanvasNode from '@/components/canvas/elements/nodes/CanvasNode.vue';
|
||||
import { useCanvasMapping } from '@/composables/useCanvasMapping';
|
||||
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import type { CanvasConnectionPort, CanvasNodeData } from '@/types';
|
||||
import { N8nButton, N8nHeading, N8nSpinner, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||
import { useVueFlow } from '@vue-flow/core';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const locale = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const { resetWorkspace, initializeWorkspace } = useCanvasOperations({ router });
|
||||
|
||||
const uuid = crypto.randomUUID();
|
||||
|
||||
type PinnedNode = { name: string; id: string };
|
||||
const model = defineModel<PinnedNode[]>({ required: true });
|
||||
|
||||
const isLoading = ref(false);
|
||||
|
||||
const workflowId = computed(() => route.params.name as string);
|
||||
const testId = computed(() => route.params.testId as string);
|
||||
const workflow = computed(() => workflowsStore.getWorkflowById(workflowId.value));
|
||||
const workflowObject = computed(() => workflowsStore.getCurrentWorkflow(true));
|
||||
const canvasId = computed(() => `${uuid}-${testId.value}`);
|
||||
|
||||
const { onNodesInitialized, fitView, zoomTo, onNodeClick, viewport } = useVueFlow({
|
||||
id: canvasId.value,
|
||||
});
|
||||
const nodes = computed(() => workflow.value.nodes ?? []);
|
||||
const connections = computed(() => workflow.value.connections);
|
||||
|
||||
const { nodes: mappedNodes, connections: mappedConnections } = useCanvasMapping({
|
||||
nodes,
|
||||
connections,
|
||||
workflowObject,
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true;
|
||||
workflowsStore.resetState();
|
||||
resetWorkspace();
|
||||
await Promise.all([
|
||||
nodeTypesStore.getNodeTypes(),
|
||||
workflowsStore.fetchWorkflow(workflowId.value),
|
||||
]);
|
||||
|
||||
// remove editor pinned data
|
||||
workflow.value.pinData = {};
|
||||
initializeWorkspace(workflow.value);
|
||||
}
|
||||
|
||||
function getNodeNameById(id: string) {
|
||||
return mappedNodes.value.find((node) => node.id === id)?.data?.name;
|
||||
}
|
||||
|
||||
function isMocked(data: CanvasNodeData) {
|
||||
return model.value.some((node) => node.id === data.id);
|
||||
}
|
||||
|
||||
function canBeMocked(outputs: CanvasConnectionPort[], inputs: CanvasConnectionPort[]) {
|
||||
return outputs.length === 1 && inputs.length >= 1;
|
||||
}
|
||||
|
||||
function handleNodeClick(data: CanvasNodeData) {
|
||||
const nodeName = getNodeNameById(data.id);
|
||||
if (!nodeName || !canBeMocked(data.outputs, data.inputs)) return;
|
||||
|
||||
const mocked = isMocked(data);
|
||||
|
||||
model.value = mocked
|
||||
? model.value.filter((node) => node.id !== data.id)
|
||||
: model.value.concat({ name: nodeName, id: data.id });
|
||||
|
||||
if (!mocked) {
|
||||
telemetry.track('User selected node to be mocked', {
|
||||
node_id: data.id,
|
||||
test_id: testId.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function tooltipContent(data: CanvasNodeData) {
|
||||
if (nodeTypesStore.isTriggerNode(data.type)) {
|
||||
return locale.baseText('testDefinition.edit.nodesPinning.triggerTooltip');
|
||||
}
|
||||
|
||||
if (!canBeMocked(data.outputs, data.inputs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMocked(data)) {
|
||||
return locale.baseText('testDefinition.edit.nodesPinning.pinButtonTooltip.pinned');
|
||||
} else {
|
||||
return locale.baseText('testDefinition.edit.nodesPinning.pinButtonTooltip');
|
||||
}
|
||||
}
|
||||
|
||||
function tooltipOffset(data: CanvasNodeData) {
|
||||
if (nodeTypesStore.isTriggerNode(data.type)) return;
|
||||
|
||||
return 45 * viewport.value.zoom;
|
||||
}
|
||||
|
||||
function tooltipProps(data: CanvasNodeData) {
|
||||
const content = tooltipContent(data);
|
||||
return {
|
||||
disabled: !content,
|
||||
content,
|
||||
offset: tooltipOffset(data),
|
||||
};
|
||||
}
|
||||
|
||||
onNodeClick(({ node }) => handleNodeClick(node.data));
|
||||
|
||||
onNodesInitialized(async () => {
|
||||
await fitView();
|
||||
await zoomTo(0.7);
|
||||
// Wait for the zoom to be applied and the canvas edges to recompute
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
isLoading.value = false;
|
||||
});
|
||||
onMounted(loadData);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="mappedNodes.length === 0" :class="$style.noNodes">
|
||||
<N8nHeading size="large" :bold="true" :class="$style.noNodesTitle">
|
||||
{{ locale.baseText('testDefinition.edit.pinNodes.noNodes.title') }}
|
||||
</N8nHeading>
|
||||
<N8nText>{{ locale.baseText('testDefinition.edit.pinNodes.noNodes.description') }}</N8nText>
|
||||
</div>
|
||||
<div v-else :class="$style.container">
|
||||
<N8nSpinner v-if="isLoading" size="large" type="dots" :class="$style.spinner" />
|
||||
<Canvas
|
||||
:id="canvasId"
|
||||
:loading="isLoading"
|
||||
:nodes="mappedNodes"
|
||||
:connections="mappedConnections"
|
||||
:show-bug-reporting-button="false"
|
||||
:read-only="true"
|
||||
>
|
||||
<template #node="{ nodeProps }">
|
||||
<N8nTooltip placement="top" v-bind="tooltipProps(nodeProps.data)">
|
||||
<CanvasNode
|
||||
v-bind="nodeProps"
|
||||
:class="{
|
||||
[$style.isTrigger]: nodeTypesStore.isTriggerNode(nodeProps.data.type),
|
||||
[$style.mockNode]: true,
|
||||
}"
|
||||
>
|
||||
<template #toolbar="{ data, outputs, inputs }">
|
||||
<div
|
||||
v-if="canBeMocked(outputs, inputs)"
|
||||
:class="{
|
||||
[$style.pinButtonContainer]: true,
|
||||
[$style.pinButtonContainerPinned]: isMocked(data),
|
||||
}"
|
||||
>
|
||||
<N8nButton
|
||||
icon="thumbtack"
|
||||
block
|
||||
type="secondary"
|
||||
:class="{ [$style.customSecondary]: isMocked(data) }"
|
||||
data-test-id="node-pin-button"
|
||||
>
|
||||
<template v-if="isMocked(data)">
|
||||
{{ locale.baseText('contextMenu.unpin') }}
|
||||
</template>
|
||||
<template v-else> {{ locale.baseText('contextMenu.pin') }}</template>
|
||||
</N8nButton>
|
||||
</div>
|
||||
</template>
|
||||
</CanvasNode>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
</Canvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.mockNode {
|
||||
// remove selection outline
|
||||
--color-canvas-selected-transparent: transparent;
|
||||
}
|
||||
|
||||
.isTrigger {
|
||||
--canvas-node--border-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--color-foreground-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.pinButtonContainer {
|
||||
position: absolute;
|
||||
right: 50%;
|
||||
bottom: -5px;
|
||||
height: calc(100% + 47px);
|
||||
border: 1px solid transparent;
|
||||
padding: 5px 5px;
|
||||
border-radius: 8px;
|
||||
width: calc(100% + 10px);
|
||||
transform: translateX(50%);
|
||||
|
||||
&.pinButtonContainerPinned {
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.customSecondary {
|
||||
--button-background-color: var(--color-secondary);
|
||||
--button-font-color: var(--color-button-primary-font);
|
||||
--button-border-color: var(--color-secondary);
|
||||
|
||||
--button-hover-background-color: var(--color-secondary);
|
||||
--button-hover-border-color: var(--color-button-primary-font);
|
||||
--button-hover-font-color: var(--color-button-primary-font);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.noNodes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,113 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { ITag } from '@/Interface';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { computed } from 'vue';
|
||||
import type { EditableField } from '../types';
|
||||
|
||||
export interface TagsInputProps {
|
||||
modelValue: EditableField<string[]>;
|
||||
allTags: ITag[];
|
||||
tagsById: Record<string, ITag>;
|
||||
isLoading: boolean;
|
||||
startEditing: (field: 'tags') => void;
|
||||
saveChanges: (field: 'tags') => void;
|
||||
cancelEditing: (field: 'tags') => void;
|
||||
createTag?: (name: string) => Promise<ITag>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TagsInputProps>(), {
|
||||
modelValue: () => ({
|
||||
isEditing: false,
|
||||
value: [],
|
||||
tempValue: [],
|
||||
}),
|
||||
createTag: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: TagsInputProps['modelValue']] }>();
|
||||
|
||||
const locale = useI18n();
|
||||
const tagsEventBus = createEventBus();
|
||||
|
||||
/**
|
||||
* Compute the tag name by ID
|
||||
*/
|
||||
const getTagName = computed(() => (tagId: string) => {
|
||||
return props.tagsById[tagId]?.name ?? '';
|
||||
});
|
||||
|
||||
/**
|
||||
* Update the tempValue of the tags when the dropdown changes.
|
||||
* This does not finalize the changes; that happens on blur or hitting enter.
|
||||
*/
|
||||
function updateTags(tags: string[]) {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
tempValue: tags,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-test-id="workflow-tags-field">
|
||||
<n8n-input-label
|
||||
:label="locale.baseText('testDefinition.edit.tagName')"
|
||||
:bold="false"
|
||||
size="small"
|
||||
>
|
||||
<!-- Read-only view -->
|
||||
<div v-if="!modelValue.isEditing" :class="$style.tagsRead" @click="startEditing('tags')">
|
||||
<n8n-text v-if="modelValue.value.length === 0" size="small">
|
||||
{{ locale.baseText('testDefinition.edit.selectTag') }}
|
||||
</n8n-text>
|
||||
<n8n-tag
|
||||
v-for="tagId in modelValue.value"
|
||||
:key="tagId"
|
||||
:text="getTagName(tagId)"
|
||||
data-test-id="evaluation-tag-field"
|
||||
/>
|
||||
<n8n-icon-button
|
||||
:class="$style.editInputButton"
|
||||
icon="pen"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
transparent
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Editing view -->
|
||||
<TagsDropdown
|
||||
v-else
|
||||
:model-value="modelValue.tempValue"
|
||||
:placeholder="locale.baseText('executionAnnotationView.chooseOrCreateATag')"
|
||||
:create-enabled="modelValue.tempValue.length === 0"
|
||||
:all-tags="allTags"
|
||||
:is-loading="isLoading"
|
||||
:tags-by-id="tagsById"
|
||||
data-test-id="workflow-tags-dropdown"
|
||||
:event-bus="tagsEventBus"
|
||||
:create-tag="createTag"
|
||||
:manage-enabled="false"
|
||||
:multiple-limit="1"
|
||||
@update:model-value="updateTags"
|
||||
@esc="cancelEditing('tags')"
|
||||
@blur="saveChanges('tags')"
|
||||
/>
|
||||
</n8n-input-label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.tagsRead {
|
||||
&:hover .editInputButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.editInputButton {
|
||||
opacity: 0;
|
||||
border: none;
|
||||
--button-font-color: var(--prim-gray-490);
|
||||
}
|
||||
</style>
|
||||
@@ -1,69 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { EditableField } from '../types';
|
||||
|
||||
export interface EvaluationHeaderProps {
|
||||
modelValue: EditableField<string>;
|
||||
startEditing: (field: 'name') => void;
|
||||
saveChanges: (field: 'name') => void;
|
||||
handleKeydown: (e: KeyboardEvent, field: 'name') => void;
|
||||
}
|
||||
|
||||
defineEmits<{ 'update:modelValue': [value: EditableField<string>] }>();
|
||||
defineProps<EvaluationHeaderProps>();
|
||||
|
||||
const locale = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2 :class="$style.title">
|
||||
<template v-if="!modelValue.isEditing">
|
||||
<span :class="$style.titleText">
|
||||
{{ modelValue.value }}
|
||||
</span>
|
||||
<n8n-icon-button
|
||||
:class="$style.editInputButton"
|
||||
icon="pen"
|
||||
type="tertiary"
|
||||
@click="startEditing('name')"
|
||||
/>
|
||||
</template>
|
||||
<N8nInput
|
||||
v-else
|
||||
ref="nameInput"
|
||||
data-test-id="evaluation-name-input"
|
||||
:model-value="modelValue.tempValue"
|
||||
type="text"
|
||||
:placeholder="locale.baseText('testDefinition.edit.namePlaceholder')"
|
||||
@update:model-value="$emit('update:modelValue', { ...modelValue, tempValue: $event })"
|
||||
@blur="() => saveChanges('name')"
|
||||
@keydown="(e: KeyboardEvent) => handleKeydown(e, 'name')"
|
||||
/>
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.titleText {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.editInputButton {
|
||||
--button-font-color: var(--prim-gray-490);
|
||||
opacity: 0.2;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,122 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { SAMPLE_EVALUATION_WORKFLOW } from '@/constants.workflows';
|
||||
import type { IWorkflowDataCreate } from '@/Interface';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { N8nButton, N8nLink } from '@n8n/design-system';
|
||||
import type { INodeParameterResourceLocator, IPinData } from 'n8n-workflow';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
interface WorkflowSelectorProps {
|
||||
modelValue: INodeParameterResourceLocator;
|
||||
examplePinnedData?: IPinData;
|
||||
sampleWorkflowName?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<WorkflowSelectorProps>(), {
|
||||
modelValue: () => ({
|
||||
mode: 'id',
|
||||
value: '',
|
||||
__rl: true,
|
||||
}),
|
||||
examplePinnedData: () => ({}),
|
||||
sampleWorkflowName: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: WorkflowSelectorProps['modelValue']];
|
||||
workflowCreated: [workflowId: string];
|
||||
}>();
|
||||
const locale = useI18n();
|
||||
|
||||
const projectStore = useProjectsStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const router = useRouter();
|
||||
|
||||
const subworkflowName = computed(() => {
|
||||
if (props.sampleWorkflowName) {
|
||||
return locale.baseText('testDefinition.workflowInput.subworkflowName', {
|
||||
interpolate: { name: props.sampleWorkflowName },
|
||||
});
|
||||
}
|
||||
return locale.baseText('testDefinition.workflowInput.subworkflowName.default');
|
||||
});
|
||||
|
||||
const sampleWorkflow = computed<IWorkflowDataCreate>(() => {
|
||||
return {
|
||||
...SAMPLE_EVALUATION_WORKFLOW,
|
||||
name: subworkflowName.value,
|
||||
pinData: props.examplePinnedData,
|
||||
};
|
||||
});
|
||||
|
||||
const selectorVisible = ref(false);
|
||||
|
||||
const updateModelValue = (value: INodeParameterResourceLocator) => emit('update:modelValue', value);
|
||||
|
||||
/**
|
||||
* copy pasted from WorkflowSelectorParameterInput.vue
|
||||
* but we should remove it from here
|
||||
*/
|
||||
const handleDefineEvaluation = async () => {
|
||||
const projectId = projectStore.currentProjectId;
|
||||
const workflowName = sampleWorkflow.value.name ?? 'My Sub-Workflow';
|
||||
const sampleSubWorkflows = workflowsStore.allWorkflows.filter(
|
||||
(w) => w.name && new RegExp(workflowName).test(w.name),
|
||||
);
|
||||
|
||||
const workflow: IWorkflowDataCreate = {
|
||||
...sampleWorkflow.value,
|
||||
name: `${workflowName} ${sampleSubWorkflows.length + 1}`,
|
||||
};
|
||||
if (projectId) {
|
||||
workflow.projectId = projectId;
|
||||
}
|
||||
|
||||
const newWorkflow = await workflowsStore.createNewWorkflow(workflow);
|
||||
const { href } = router.resolve({ name: VIEWS.WORKFLOW, params: { name: newWorkflow.id } });
|
||||
|
||||
updateModelValue({
|
||||
...props.modelValue,
|
||||
value: newWorkflow.id,
|
||||
cachedResultName: workflow.name,
|
||||
});
|
||||
|
||||
window.open(href, '_blank');
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div class="mt-xs">
|
||||
<template v-if="!modelValue.value">
|
||||
<N8nButton type="secondary" class="mb-xs" @click="handleDefineEvaluation">
|
||||
{{ locale.baseText('testDefinition.workflow.createNew') }}
|
||||
</N8nButton>
|
||||
<N8nLink class="mb-xs" style="display: block" @click="selectorVisible = !selectorVisible">
|
||||
{{ locale.baseText('testDefinition.workflow.createNew.or') }}
|
||||
</N8nLink>
|
||||
</template>
|
||||
|
||||
<WorkflowSelectorParameterInput
|
||||
v-if="modelValue.value || selectorVisible"
|
||||
:parameter="{
|
||||
displayName: locale.baseText('testDefinition.edit.workflowSelectorDisplayName'),
|
||||
name: 'workflowId',
|
||||
type: 'workflowSelector',
|
||||
default: '',
|
||||
}"
|
||||
:model-value="modelValue"
|
||||
:display-title="locale.baseText('testDefinition.edit.workflowSelectorTitle')"
|
||||
:is-value-expression="false"
|
||||
:expression-edit-dialog-visible="false"
|
||||
:path="'workflows'"
|
||||
:allow-new="false"
|
||||
:sample-workflow="sampleWorkflow"
|
||||
:new-resource-label="locale.baseText('testDefinition.workflow.createNew')"
|
||||
@update:model-value="updateModelValue"
|
||||
@workflow-created="emit('workflowCreated', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,216 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import BlockArrow from '@/components/TestDefinition/EditDefinition/BlockArrow.vue';
|
||||
import EvaluationStep from '@/components/TestDefinition/EditDefinition/EvaluationStep.vue';
|
||||
import NodesPinning from '@/components/TestDefinition/EditDefinition/NodesPinning.vue';
|
||||
import WorkflowSelector from '@/components/TestDefinition/EditDefinition/WorkflowSelector.vue';
|
||||
import type { EditableFormState, EvaluationFormState } from '@/components/TestDefinition/types';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { NODE_PINNING_MODAL_KEY } from '@/constants';
|
||||
import type { ITag } from '@/Interface';
|
||||
import { N8nButton, N8nHeading, N8nTag, N8nText } from '@n8n/design-system';
|
||||
import type { IPinData } from 'n8n-workflow';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
tagsById: Record<string, ITag>;
|
||||
isLoading: boolean;
|
||||
examplePinnedData?: IPinData;
|
||||
sampleWorkflowName?: string;
|
||||
hasRuns: boolean;
|
||||
getFieldIssues: (key: string) => Array<{ field: string; message: string }>;
|
||||
startEditing: (field: keyof EditableFormState) => void;
|
||||
saveChanges: (field: keyof EditableFormState) => void;
|
||||
cancelEditing: (field: keyof EditableFormState) => void;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
openPinningModal: [];
|
||||
openExecutionsViewForTag: [];
|
||||
renameTag: [tag: string];
|
||||
evaluationWorkflowCreated: [workflowId: string];
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
const tags = defineModel<EvaluationFormState['tags']>('tags', { required: true });
|
||||
|
||||
const renameTag = async () => {
|
||||
const { prompt } = useMessage();
|
||||
|
||||
const result = await prompt(locale.baseText('testDefinition.edit.step.tag.placeholder'), {
|
||||
inputValue: props.tagsById[tags.value.value[0]]?.name,
|
||||
inputPlaceholder: locale.baseText('testDefinition.edit.step.tag.placeholder'),
|
||||
inputValidator: (value) => {
|
||||
if (!value) {
|
||||
return locale.baseText('testDefinition.edit.step.tag.validation.required');
|
||||
}
|
||||
|
||||
if (value.length > 21) {
|
||||
return locale.baseText('testDefinition.edit.step.tag.validation.tooLong');
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
if (result?.action === 'confirm') {
|
||||
emit('renameTag', result.value);
|
||||
}
|
||||
};
|
||||
|
||||
const evaluationWorkflow = defineModel<EvaluationFormState['evaluationWorkflow']>(
|
||||
'evaluationWorkflow',
|
||||
{ required: true },
|
||||
);
|
||||
const mockedNodes = defineModel<EvaluationFormState['mockedNodes']>('mockedNodes', {
|
||||
required: true,
|
||||
});
|
||||
|
||||
const selectedTag = computed(() => props.tagsById[tags.value.value[0]] ?? {});
|
||||
|
||||
function openExecutionsView() {
|
||||
emit('openExecutionsViewForTag');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div :class="$style.editForm">
|
||||
<template v-if="!hasRuns">
|
||||
<N8nText tag="div" color="text-dark" size="large" class="text-center">
|
||||
{{ locale.baseText('testDefinition.edit.step.intro') }}
|
||||
</N8nText>
|
||||
<BlockArrow class="mt-5xs mb-5xs" />
|
||||
</template>
|
||||
|
||||
<!-- Select Executions -->
|
||||
<EvaluationStep
|
||||
:issues="getFieldIssues('tags')"
|
||||
:tooltip="locale.baseText('testDefinition.edit.step.executions.tooltip')"
|
||||
:external-tooltip="!hasRuns"
|
||||
>
|
||||
<template #title>
|
||||
{{
|
||||
locale.baseText('testDefinition.edit.step.executions', {
|
||||
adjustToNumber: selectedTag?.usageCount ?? 0,
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template #cardContent>
|
||||
<div :class="$style.tagInputTag">
|
||||
<i18n-t keypath="testDefinition.edit.step.tag">
|
||||
<template #tag>
|
||||
<N8nTag :text="selectedTag.name" :clickable="true" @click="renameTag">
|
||||
<template #tag>
|
||||
{{ selectedTag.name }} <font-awesome-icon icon="pen" size="sm" />
|
||||
</template>
|
||||
</N8nTag>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<N8nButton
|
||||
label="Select executions"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
@click="openExecutionsView"
|
||||
/>
|
||||
</template>
|
||||
</EvaluationStep>
|
||||
<div :class="$style.nestedSteps">
|
||||
<BlockArrow class="mt-5xs mb-5xs" />
|
||||
<div style="display: flex; flex-direction: column">
|
||||
<BlockArrow class="mt-5xs mb-5xs ml-auto mr-2xl" />
|
||||
<!-- Mocked Nodes -->
|
||||
<EvaluationStep
|
||||
:issues="getFieldIssues('mockedNodes')"
|
||||
:tooltip="locale.baseText('testDefinition.edit.step.nodes.tooltip')"
|
||||
:external-tooltip="!hasRuns"
|
||||
>
|
||||
<template #title>
|
||||
{{
|
||||
locale.baseText('testDefinition.edit.step.mockedNodes', {
|
||||
adjustToNumber: mockedNodes?.length ?? 0,
|
||||
})
|
||||
}}
|
||||
<N8nText>({{ locale.baseText('generic.optional') }})</N8nText>
|
||||
</template>
|
||||
<template #cardContent>
|
||||
<N8nButton
|
||||
size="small"
|
||||
data-test-id="select-nodes-button"
|
||||
:label="locale.baseText('testDefinition.edit.selectNodes')"
|
||||
type="tertiary"
|
||||
@click="$emit('openPinningModal')"
|
||||
/>
|
||||
</template>
|
||||
</EvaluationStep>
|
||||
|
||||
<BlockArrow class="mt-5xs mb-5xs ml-auto mr-2xl" />
|
||||
<!-- Re-run Executions -->
|
||||
<EvaluationStep
|
||||
:title="locale.baseText('testDefinition.edit.step.reRunExecutions')"
|
||||
:tooltip="locale.baseText('testDefinition.edit.step.reRunExecutions.tooltip')"
|
||||
:external-tooltip="!hasRuns"
|
||||
/>
|
||||
<BlockArrow class="mt-5xs mb-5xs ml-auto mr-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compare Executions -->
|
||||
<EvaluationStep
|
||||
:title="locale.baseText('testDefinition.edit.step.compareExecutions')"
|
||||
:description="locale.baseText('testDefinition.edit.workflowSelectorLabel')"
|
||||
:issues="getFieldIssues('evaluationWorkflow')"
|
||||
:tooltip="locale.baseText('testDefinition.edit.step.compareExecutions.tooltip')"
|
||||
:external-tooltip="!hasRuns"
|
||||
>
|
||||
<template #cardContent>
|
||||
<WorkflowSelector
|
||||
v-model="evaluationWorkflow"
|
||||
:example-pinned-data="examplePinnedData"
|
||||
:class="{ 'has-issues': getFieldIssues('evaluationWorkflow').length > 0 }"
|
||||
:sample-workflow-name="sampleWorkflowName"
|
||||
@workflow-created="$emit('evaluationWorkflowCreated', $event)"
|
||||
/>
|
||||
</template>
|
||||
</EvaluationStep>
|
||||
</div>
|
||||
<Modal
|
||||
width="calc(100% - (48px * 2))"
|
||||
height="calc(100% - (48px * 2))"
|
||||
:custom-class="$style.pinnigModal"
|
||||
:name="NODE_PINNING_MODAL_KEY"
|
||||
>
|
||||
<template #header>
|
||||
<N8nHeading tag="h3" size="xlarge" color="text-dark" class="mb-2xs">
|
||||
{{ locale.baseText('testDefinition.edit.selectNodes') }}
|
||||
</N8nHeading>
|
||||
<N8nText color="text-base">
|
||||
{{ locale.baseText('testDefinition.edit.modal.description') }}
|
||||
</N8nText>
|
||||
</template>
|
||||
<template #content>
|
||||
<NodesPinning v-model="mockedNodes" data-test-id="nodes-pinning-modal" />
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.pinnigModal {
|
||||
--dialog-max-width: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nestedSteps {
|
||||
display: grid;
|
||||
grid-template-columns: 20% 1fr;
|
||||
}
|
||||
|
||||
.tagInputTag {
|
||||
display: flex;
|
||||
gap: var(--spacing-3xs);
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-text-base);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
</style>
|
||||
@@ -1,140 +0,0 @@
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import NodesPinning from '../NodesPinning.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
|
||||
import {
|
||||
createTestNode,
|
||||
createTestWorkflow,
|
||||
createTestWorkflowObject,
|
||||
mockNodeTypeDescription,
|
||||
} from '@/__tests__/mocks';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import { SET_NODE_TYPE } from '@/constants';
|
||||
|
||||
vi.mock('vue-router', () => {
|
||||
const push = vi.fn();
|
||||
return {
|
||||
useRouter: () => ({
|
||||
push,
|
||||
}),
|
||||
useRoute: () => ({
|
||||
params: {
|
||||
name: 'test-workflow',
|
||||
testId: 'test-123',
|
||||
},
|
||||
}),
|
||||
RouterLink: {
|
||||
template: '<a><slot /></a>',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(NodesPinning, {
|
||||
props: {
|
||||
modelValue: [{ id: '1', name: 'Node 1' }],
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia()],
|
||||
},
|
||||
});
|
||||
|
||||
describe('NodesPinning', () => {
|
||||
const workflowsStore = mockedStore(useWorkflowsStore);
|
||||
const nodes = [
|
||||
createTestNode({ id: '1', name: 'Node 1', type: SET_NODE_TYPE }),
|
||||
createTestNode({ id: '2', name: 'Node 2', type: SET_NODE_TYPE }),
|
||||
];
|
||||
|
||||
const nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||
const nodeTypeDescription = mockNodeTypeDescription({
|
||||
name: SET_NODE_TYPE,
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
});
|
||||
nodeTypesStore.nodeTypes = {
|
||||
node: { 1: nodeTypeDescription },
|
||||
};
|
||||
|
||||
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription);
|
||||
const workflow = createTestWorkflow({
|
||||
id: 'test-workflow',
|
||||
name: 'Test Workflow',
|
||||
nodes,
|
||||
connections: {},
|
||||
});
|
||||
|
||||
const workflowObject = createTestWorkflowObject(workflow);
|
||||
|
||||
workflowsStore.getWorkflowById = vi.fn().mockReturnValue(workflow);
|
||||
workflowsStore.getCurrentWorkflow = vi.fn().mockReturnValue(workflowObject);
|
||||
beforeEach(() => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render workflow nodes', async () => {
|
||||
const { container } = renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-node-name="Node 1"]')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-node-name="Node 2"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should update UI when pinning/unpinning nodes', async () => {
|
||||
const { container, getAllByTestId } = renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-node-name="Node 1"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const buttons = getAllByTestId('node-pin-button');
|
||||
expect(buttons.length).toBe(2);
|
||||
|
||||
expect(buttons[0]).toHaveTextContent('Unpin');
|
||||
expect(buttons[1]).toHaveTextContent('Pin');
|
||||
});
|
||||
|
||||
it('should emit update:modelValue when pinning nodes', async () => {
|
||||
const { container, emitted, getAllByTestId } = renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-node-name="Node 1"]')).toBeInTheDocument();
|
||||
});
|
||||
const pinButton = getAllByTestId('node-pin-button')[1];
|
||||
pinButton?.click();
|
||||
|
||||
expect(emitted('update:modelValue')).toBeTruthy();
|
||||
expect(emitted('update:modelValue')[0]).toEqual([
|
||||
[
|
||||
{ id: '1', name: 'Node 1' },
|
||||
{ id: '2', name: 'Node 2' },
|
||||
],
|
||||
]);
|
||||
});
|
||||
it('should emit update:modelValue when unpinning nodes', async () => {
|
||||
const { container, emitted, getAllByTestId } = renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-node-name="Node 1"]')).toBeInTheDocument();
|
||||
});
|
||||
const pinButton = getAllByTestId('node-pin-button')[0];
|
||||
pinButton?.click();
|
||||
|
||||
expect(emitted('update:modelValue')).toBeTruthy();
|
||||
expect(emitted('update:modelValue')[0]).toEqual([[]]);
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { N8nBadge, N8nButton, N8nText } from '@n8n/design-system';
|
||||
import { computed } from 'vue';
|
||||
|
||||
defineEmits<{ 'create-test': [] }>();
|
||||
|
||||
const locale = useI18n();
|
||||
|
||||
/**
|
||||
* TODO: fully implement the logic here
|
||||
*/
|
||||
const canCreateEvaluations = computed(() => true);
|
||||
const isRegisteredCommunity = computed(() => false);
|
||||
const isNotRegisteredCommunity = computed(() => false);
|
||||
const hasReachedLimit = computed(() => false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<div :class="{ [$style.card]: true, [$style.cardActive]: true }">
|
||||
<N8nBadge theme="warning" size="small">New</N8nBadge>
|
||||
<div :class="$style.cardContent">
|
||||
<N8nText tag="h2" size="xlarge" color="text-base" class="mb-2xs">
|
||||
{{ locale.baseText('testDefinition.list.evaluations') }}
|
||||
</N8nText>
|
||||
<N8nText tag="div" color="text-base" class="mb-s ml-s mr-s">
|
||||
{{ locale.baseText('testDefinition.list.actionDescription') }}
|
||||
</N8nText>
|
||||
<template v-if="canCreateEvaluations">
|
||||
<N8nButton @click="$emit('create-test')">
|
||||
{{ locale.baseText('testDefinition.list.actionButton') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
<template v-else-if="isRegisteredCommunity">
|
||||
<N8nButton @click="$emit('create-test')">
|
||||
{{ locale.baseText('testDefinition.list.actionButton') }}
|
||||
</N8nButton>
|
||||
<N8nText tag="div" color="text-light" size="small" class="mt-2xs">
|
||||
{{ locale.baseText('testDefinition.list.actionDescription.registered') }}
|
||||
</N8nText>
|
||||
</template>
|
||||
<template v-else-if="isNotRegisteredCommunity">
|
||||
<div :class="$style.divider" class="mb-s"></div>
|
||||
<N8nText tag="div" color="text-light" size="small" class="mb-s">
|
||||
{{ locale.baseText('testDefinition.list.actionDescription.unregistered') }}
|
||||
</N8nText>
|
||||
<N8nButton>
|
||||
{{ locale.baseText('testDefinition.list.actionButton.unregistered') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
<template v-else-if="hasReachedLimit">
|
||||
<div :class="$style.divider" class="mb-s"></div>
|
||||
<N8nText tag="div" color="text-light" size="small" class="mb-s">
|
||||
{{ locale.baseText('testDefinition.list.actionDescription.atLimit') }}
|
||||
</N8nText>
|
||||
<N8nButton>
|
||||
{{ locale.baseText('generic.upgrade') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="{ [$style.card]: true, [$style.cardInActive]: true }">
|
||||
<N8nBadge>
|
||||
{{ locale.baseText('testDefinition.list.unitTests.badge') }}
|
||||
</N8nBadge>
|
||||
<div :class="$style.cardContent">
|
||||
<N8nText tag="h2" size="xlarge" color="text-base" class="mb-2xs">
|
||||
{{ locale.baseText('testDefinition.list.unitTests.title') }}
|
||||
</N8nText>
|
||||
<N8nText tag="div" color="text-base" class="mb-s">
|
||||
{{ locale.baseText('testDefinition.list.unitTests.description') }}
|
||||
</N8nText>
|
||||
<N8nButton type="secondary">
|
||||
{{ locale.baseText('testDefinition.list.unitTests.cta') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: var(--border-radius-base);
|
||||
width: 280px;
|
||||
height: 290px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.cardActive {
|
||||
border: 1px solid var(--color-foreground-base);
|
||||
background-color: var(--color-background-xlight);
|
||||
}
|
||||
|
||||
.cardInActive {
|
||||
border: 1px dashed var(--color-foreground-base);
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-top: 1px solid var(--color-foreground-light);
|
||||
}
|
||||
</style>
|
||||
@@ -1,180 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import TimeAgo from '@/components/TimeAgo.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import type { IconColor } from '@n8n/design-system/types/icon';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
testCases: number;
|
||||
execution?: TestRunRecord;
|
||||
errors?: Array<{ field: string; message: string }>;
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
|
||||
type IconDefinition = { icon: string; color: IconColor; spin?: boolean };
|
||||
|
||||
const statusesColorDictionary: Record<TestRunRecord['status'], IconDefinition> = {
|
||||
new: {
|
||||
icon: 'circle',
|
||||
color: 'foreground-dark',
|
||||
},
|
||||
running: {
|
||||
icon: 'spinner',
|
||||
color: 'secondary',
|
||||
spin: true,
|
||||
},
|
||||
completed: {
|
||||
icon: 'exclamation-circle',
|
||||
color: 'success',
|
||||
},
|
||||
error: {
|
||||
icon: 'exclamation-triangle',
|
||||
color: 'danger',
|
||||
},
|
||||
cancelled: {
|
||||
icon: 'minus-circle',
|
||||
color: 'foreground-xdark',
|
||||
},
|
||||
warning: {
|
||||
icon: 'exclamation-circle',
|
||||
color: 'warning',
|
||||
},
|
||||
success: {
|
||||
icon: 'circle-check',
|
||||
color: 'success',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const statusRender = computed<IconDefinition & { label: string }>(() => {
|
||||
if (props.errors?.length) {
|
||||
return {
|
||||
icon: 'adjust',
|
||||
color: 'foreground-dark',
|
||||
label: 'Incomplete',
|
||||
};
|
||||
}
|
||||
|
||||
if (!props.execution) {
|
||||
return {
|
||||
icon: 'circle',
|
||||
color: 'foreground-dark',
|
||||
label: 'Never ran',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...statusesColorDictionary[props.execution.status],
|
||||
label: props.execution.status,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.testCard">
|
||||
<div :class="$style.testCardContent">
|
||||
<div>
|
||||
<N8nText bold tag="div" :class="$style.name">{{ name }}</N8nText>
|
||||
<N8nText tag="div" color="text-base" size="small">
|
||||
{{
|
||||
locale.baseText('testDefinition.list.item.tests', {
|
||||
adjustToNumber: testCases,
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
</div>
|
||||
<div>
|
||||
<div :class="$style.status">
|
||||
<N8nIcon v-bind="statusRender" size="small" />
|
||||
<N8nText size="small" color="text-base">
|
||||
{{ statusRender.label }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<N8nText v-if="errors?.length" tag="div" color="text-base" size="small" class="ml-m">
|
||||
{{
|
||||
locale.baseText('testDefinition.list.item.missingFields', {
|
||||
adjustToNumber: errors.length,
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
<N8nText v-else-if="execution" tag="div" color="text-base" size="small" class="ml-m">
|
||||
<TimeAgo :date="execution.updatedAt" />
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<div :class="$style.metrics">
|
||||
<template v-if="execution?.metrics">
|
||||
<template v-for="[key, value] in Object.entries(execution.metrics)" :key>
|
||||
<N8nText
|
||||
color="text-base"
|
||||
size="small"
|
||||
style="overflow: hidden; text-overflow: ellipsis"
|
||||
>
|
||||
{{ key }}
|
||||
</N8nText>
|
||||
<N8nText color="text-base" size="small" bold>
|
||||
{{ Math.round((value + Number.EPSILON) * 100) / 100 }}
|
||||
</N8nText>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot name="prepend"></slot>
|
||||
<slot name="append"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.testCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--color-background-xlight);
|
||||
padding: var(--spacing-xs) 20px var(--spacing-xs) var(--spacing-m);
|
||||
gap: var(--spacing-s);
|
||||
border-bottom: 1px solid var(--color-foreground-base);
|
||||
cursor: pointer;
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: inherit;
|
||||
border-top-right-radius: inherit;
|
||||
}
|
||||
&:last-child {
|
||||
border-bottom-color: transparent;
|
||||
border-bottom-left-radius: inherit;
|
||||
border-bottom-right-radius: inherit;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-light);
|
||||
.name {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
text-transform: capitalize;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.testCardContent {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
column-gap: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,109 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import type { IconColor } from '@n8n/design-system/types/icon';
|
||||
import { computed } from 'vue';
|
||||
import type { TestTableColumn } from '../shared/TestTableBase.vue';
|
||||
import TestTableBase from '../shared/TestTableBase.vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
rowClick: [run: TestRunRecord & { index: number }];
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
runs: Array<TestRunRecord & { index: number }>;
|
||||
columns: Array<TestTableColumn<TestRunRecord & { index: number }>>;
|
||||
}>();
|
||||
|
||||
const statusDictionary: Record<TestRunRecord['status'], { icon: string; color: IconColor }> = {
|
||||
new: {
|
||||
icon: 'status-new',
|
||||
color: 'foreground-xdark',
|
||||
},
|
||||
running: {
|
||||
icon: 'spinner',
|
||||
color: 'secondary',
|
||||
},
|
||||
completed: {
|
||||
icon: 'status-completed',
|
||||
color: 'success',
|
||||
},
|
||||
error: {
|
||||
icon: 'status-error',
|
||||
color: 'danger',
|
||||
},
|
||||
cancelled: {
|
||||
icon: 'status-canceled',
|
||||
color: 'foreground-xdark',
|
||||
},
|
||||
warning: {
|
||||
icon: 'status-warning',
|
||||
color: 'warning',
|
||||
},
|
||||
success: {
|
||||
icon: 'status-completed',
|
||||
color: 'success',
|
||||
},
|
||||
};
|
||||
|
||||
const locale = useI18n();
|
||||
|
||||
// Combine test run statuses and finalResult to get the final status
|
||||
const runSummaries = computed(() => {
|
||||
return props.runs.map(({ status, finalResult, ...run }) => {
|
||||
if (status === 'completed' && finalResult) {
|
||||
return { ...run, status: finalResult };
|
||||
}
|
||||
|
||||
return { ...run, status };
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading" color="text-base">
|
||||
{{ locale.baseText('testDefinition.edit.pastRuns.total', { adjustToNumber: runs.length }) }}
|
||||
<N8nText> ({{ runs.length }}) </N8nText>
|
||||
</N8nHeading>
|
||||
|
||||
<TestTableBase
|
||||
:data="runSummaries"
|
||||
:columns="columns"
|
||||
:default-sort="{ prop: 'runAt', order: 'descending' }"
|
||||
@row-click="(row) => emit('rowClick', row)"
|
||||
>
|
||||
<template #id="{ row }">#{{ row.index }} </template>
|
||||
<template #status="{ row }">
|
||||
<div
|
||||
style="display: inline-flex; gap: 8px; text-transform: capitalize; align-items: center"
|
||||
>
|
||||
<N8nText v-if="row.status === 'running'" color="secondary" class="mr-2xs">
|
||||
<AnimatedSpinner />
|
||||
</N8nText>
|
||||
<N8nIcon
|
||||
v-else
|
||||
:icon="statusDictionary[row.status].icon"
|
||||
:color="statusDictionary[row.status].color"
|
||||
class="mr-2xs"
|
||||
/>
|
||||
<template v-if="row.status === 'error'">
|
||||
{{ row.status }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ row.status }}
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</TestTableBase>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,206 +0,0 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import type { ComponentPublicInstance, ComputedRef } from 'vue';
|
||||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||
import type AnnotationTagsDropdownEe from '@/components/AnnotationTagsDropdown.ee.vue';
|
||||
import type { N8nInput } from '@n8n/design-system';
|
||||
import type { UpdateTestDefinitionParams } from '@/api/testDefinition.ee';
|
||||
import type { EditableField, EditableFormState, EvaluationFormState } from '../types';
|
||||
|
||||
type FormRefs = {
|
||||
nameInput: ComponentPublicInstance<typeof N8nInput>;
|
||||
tagsInput: ComponentPublicInstance<typeof AnnotationTagsDropdownEe>;
|
||||
};
|
||||
|
||||
export function useTestDefinitionForm() {
|
||||
const evaluationsStore = useTestDefinitionStore();
|
||||
|
||||
// State initialization
|
||||
const state = ref<EvaluationFormState>({
|
||||
name: {
|
||||
value: `My Test ${evaluationsStore.allTestDefinitions.length + 1}`,
|
||||
tempValue: '',
|
||||
isEditing: false,
|
||||
},
|
||||
tags: {
|
||||
value: [],
|
||||
tempValue: [],
|
||||
isEditing: false,
|
||||
},
|
||||
description: {
|
||||
value: '',
|
||||
tempValue: '',
|
||||
isEditing: false,
|
||||
},
|
||||
evaluationWorkflow: {
|
||||
mode: 'list',
|
||||
value: '',
|
||||
__rl: true,
|
||||
},
|
||||
mockedNodes: [],
|
||||
});
|
||||
|
||||
const isSaving = ref(false);
|
||||
const fields = ref<FormRefs>({} as FormRefs);
|
||||
|
||||
const editableFields: ComputedRef<{
|
||||
name: EditableField<string>;
|
||||
tags: EditableField<string[]>;
|
||||
description: EditableField<string>;
|
||||
}> = computed(() => ({
|
||||
name: state.value.name,
|
||||
tags: state.value.tags,
|
||||
description: state.value.description,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Load test data including metrics.
|
||||
*/
|
||||
const loadTestData = async (testId: string, workflowId: string) => {
|
||||
try {
|
||||
await evaluationsStore.fetchAll({ force: true, workflowId });
|
||||
const testDefinition = evaluationsStore.testDefinitionsById[testId];
|
||||
|
||||
if (testDefinition) {
|
||||
state.value.description = {
|
||||
value: testDefinition.description ?? '',
|
||||
isEditing: false,
|
||||
tempValue: '',
|
||||
};
|
||||
state.value.name = {
|
||||
value: testDefinition.name ?? '',
|
||||
isEditing: false,
|
||||
tempValue: '',
|
||||
};
|
||||
state.value.tags = {
|
||||
isEditing: false,
|
||||
value: testDefinition.annotationTagId ? [testDefinition.annotationTagId] : [],
|
||||
tempValue: [],
|
||||
};
|
||||
state.value.evaluationWorkflow = {
|
||||
mode: 'list',
|
||||
value: testDefinition.evaluationWorkflowId ?? '',
|
||||
__rl: true,
|
||||
};
|
||||
state.value.mockedNodes = testDefinition.mockedNodes ?? [];
|
||||
evaluationsStore.updateRunFieldIssues(testDefinition.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load test data', error);
|
||||
}
|
||||
};
|
||||
|
||||
const createTest = async (workflowId: string) => {
|
||||
if (isSaving.value) return;
|
||||
|
||||
isSaving.value = true;
|
||||
|
||||
try {
|
||||
const params = {
|
||||
name: state.value.name.value,
|
||||
workflowId,
|
||||
description: state.value.description.value,
|
||||
};
|
||||
return await evaluationsStore.create(params);
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateTest = async (testId: string) => {
|
||||
if (isSaving.value) return;
|
||||
|
||||
isSaving.value = true;
|
||||
|
||||
try {
|
||||
if (!testId) {
|
||||
throw new Error('Test ID is required for updating a test');
|
||||
}
|
||||
|
||||
const params: UpdateTestDefinitionParams = {
|
||||
name: state.value.name.value,
|
||||
description: state.value.description.value,
|
||||
};
|
||||
|
||||
if (state.value.evaluationWorkflow.value) {
|
||||
params.evaluationWorkflowId = state.value.evaluationWorkflow.value.toString();
|
||||
}
|
||||
|
||||
const annotationTagId = state.value.tags.value[0];
|
||||
if (annotationTagId) {
|
||||
params.annotationTagId = annotationTagId;
|
||||
}
|
||||
params.mockedNodes = state.value.mockedNodes;
|
||||
|
||||
const response = await evaluationsStore.update({ ...params, id: testId });
|
||||
return response;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Start editing an editable field by copying `value` to `tempValue`.
|
||||
*/
|
||||
function startEditing<T extends keyof EditableFormState>(field: T) {
|
||||
const fieldObj = editableFields.value[field];
|
||||
if (fieldObj.isEditing) {
|
||||
// Already editing, do nothing
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(fieldObj.value)) {
|
||||
fieldObj.tempValue = [...fieldObj.value];
|
||||
} else {
|
||||
fieldObj.tempValue = fieldObj.value;
|
||||
}
|
||||
fieldObj.isEditing = true;
|
||||
}
|
||||
/**
|
||||
* Save changes by copying `tempValue` back into `value`.
|
||||
*/
|
||||
function saveChanges<T extends keyof EditableFormState>(field: T) {
|
||||
const fieldObj = editableFields.value[field];
|
||||
fieldObj.value = Array.isArray(fieldObj.tempValue)
|
||||
? [...fieldObj.tempValue]
|
||||
: fieldObj.tempValue;
|
||||
fieldObj.isEditing = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel editing and revert `tempValue` from `value`.
|
||||
*/
|
||||
function cancelEditing<T extends keyof EditableFormState>(field: T) {
|
||||
const fieldObj = editableFields.value[field];
|
||||
if (Array.isArray(fieldObj.value)) {
|
||||
fieldObj.tempValue = [...fieldObj.value];
|
||||
} else {
|
||||
fieldObj.tempValue = fieldObj.value;
|
||||
}
|
||||
fieldObj.isEditing = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard events during editing.
|
||||
*/
|
||||
function handleKeydown<T extends keyof EditableFormState>(event: KeyboardEvent, field: T) {
|
||||
if (event.key === 'Escape') {
|
||||
cancelEditing(field);
|
||||
} else if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
saveChanges(field);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
fields,
|
||||
isSaving: computed(() => isSaving.value),
|
||||
loadTestData,
|
||||
createTest,
|
||||
updateTest,
|
||||
startEditing,
|
||||
saveChanges,
|
||||
cancelEditing,
|
||||
handleKeydown,
|
||||
};
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { useTestDefinitionForm } from '../composables/useTestDefinitionForm';
|
||||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
|
||||
|
||||
const TEST_DEF_A: TestDefinitionRecord = {
|
||||
id: '1',
|
||||
name: 'Test Definition A',
|
||||
description: 'Description A',
|
||||
evaluationWorkflowId: '456',
|
||||
workflowId: '123',
|
||||
annotationTagId: '789',
|
||||
annotationTag: null,
|
||||
createdAt: '2023-01-01T00:00:00.000Z',
|
||||
};
|
||||
const TEST_DEF_B: TestDefinitionRecord = {
|
||||
id: '2',
|
||||
name: 'Test Definition B',
|
||||
workflowId: '123',
|
||||
description: 'Description B',
|
||||
annotationTag: null,
|
||||
createdAt: '2023-01-01T00:00:00.000Z',
|
||||
};
|
||||
const TEST_DEF_NEW: TestDefinitionRecord = {
|
||||
id: '3',
|
||||
workflowId: '123',
|
||||
name: 'New Test Definition',
|
||||
description: 'New Description',
|
||||
annotationTag: null,
|
||||
createdAt: '2023-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
const pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useTestDefinitionForm', () => {
|
||||
it('should initialize with default props', () => {
|
||||
const { state } = useTestDefinitionForm();
|
||||
|
||||
expect(state.value.description.value).toBe('');
|
||||
expect(state.value.name.value).toContain('My Test');
|
||||
expect(state.value.tags.value).toEqual([]);
|
||||
expect(state.value.evaluationWorkflow.value).toBe('');
|
||||
});
|
||||
|
||||
it('should load test data', async () => {
|
||||
const { loadTestData, state } = useTestDefinitionForm();
|
||||
const fetchSpy = vi.spyOn(useTestDefinitionStore(), 'fetchAll');
|
||||
const evaluationsStore = mockedStore(useTestDefinitionStore);
|
||||
|
||||
evaluationsStore.testDefinitionsById = {
|
||||
[TEST_DEF_A.id]: TEST_DEF_A,
|
||||
[TEST_DEF_B.id]: TEST_DEF_B,
|
||||
};
|
||||
|
||||
await loadTestData(TEST_DEF_A.id, '123');
|
||||
expect(fetchSpy).toBeCalled();
|
||||
expect(state.value.name.value).toEqual(TEST_DEF_A.name);
|
||||
expect(state.value.description.value).toEqual(TEST_DEF_A.description);
|
||||
expect(state.value.tags.value).toEqual([TEST_DEF_A.annotationTagId]);
|
||||
expect(state.value.evaluationWorkflow.value).toEqual(TEST_DEF_A.evaluationWorkflowId);
|
||||
});
|
||||
|
||||
it('should gracefully handle loadTestData when no test definition found', async () => {
|
||||
const { loadTestData, state } = useTestDefinitionForm();
|
||||
const fetchSpy = vi.spyOn(useTestDefinitionStore(), 'fetchAll');
|
||||
const evaluationsStore = mockedStore(useTestDefinitionStore);
|
||||
|
||||
evaluationsStore.testDefinitionsById = {};
|
||||
|
||||
await loadTestData('unknown-id', '123');
|
||||
expect(fetchSpy).toBeCalled();
|
||||
// Should remain unchanged since no definition found
|
||||
expect(state.value.description.value).toBe('');
|
||||
expect(state.value.name.value).toContain('My Test');
|
||||
expect(state.value.tags.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle errors while loading test data', async () => {
|
||||
const { loadTestData } = useTestDefinitionForm();
|
||||
const fetchSpy = vi
|
||||
.spyOn(useTestDefinitionStore(), 'fetchAll')
|
||||
.mockRejectedValue(new Error('Fetch Failed'));
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await loadTestData(TEST_DEF_A.id, '123');
|
||||
expect(fetchSpy).toBeCalled();
|
||||
expect(consoleErrorSpy).toBeCalledWith('Failed to load test data', expect.any(Error));
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should save a new test', async () => {
|
||||
const { createTest, state } = useTestDefinitionForm();
|
||||
const createSpy = vi.spyOn(useTestDefinitionStore(), 'create').mockResolvedValue(TEST_DEF_NEW);
|
||||
|
||||
state.value.name.value = TEST_DEF_NEW.name;
|
||||
state.value.description.value = TEST_DEF_NEW.description ?? '';
|
||||
|
||||
const newTest = await createTest('123');
|
||||
expect(createSpy).toBeCalledWith({
|
||||
name: TEST_DEF_NEW.name,
|
||||
description: TEST_DEF_NEW.description,
|
||||
workflowId: '123',
|
||||
});
|
||||
expect(newTest).toEqual(TEST_DEF_NEW);
|
||||
});
|
||||
|
||||
it('should handle errors when creating a new test', async () => {
|
||||
const { createTest } = useTestDefinitionForm();
|
||||
const createSpy = vi
|
||||
.spyOn(useTestDefinitionStore(), 'create')
|
||||
.mockRejectedValue(new Error('Create Failed'));
|
||||
|
||||
await expect(createTest('123')).rejects.toThrow('Create Failed');
|
||||
expect(createSpy).toBeCalled();
|
||||
});
|
||||
|
||||
it('should update an existing test', async () => {
|
||||
const { updateTest, state } = useTestDefinitionForm();
|
||||
const updatedBTest = {
|
||||
...TEST_DEF_B,
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
};
|
||||
const updateSpy = vi.spyOn(useTestDefinitionStore(), 'update').mockResolvedValue(updatedBTest);
|
||||
|
||||
state.value.name.value = TEST_DEF_B.name;
|
||||
state.value.description.value = TEST_DEF_B.description ?? '';
|
||||
|
||||
const updatedTest = await updateTest(TEST_DEF_A.id);
|
||||
expect(updateSpy).toBeCalledWith({
|
||||
id: TEST_DEF_A.id,
|
||||
name: TEST_DEF_B.name,
|
||||
description: TEST_DEF_B.description,
|
||||
mockedNodes: [],
|
||||
});
|
||||
expect(updatedTest).toEqual(updatedBTest);
|
||||
});
|
||||
|
||||
it('should throw an error if no testId is provided when updating a test', async () => {
|
||||
const { updateTest } = useTestDefinitionForm();
|
||||
await expect(updateTest('')).rejects.toThrow('Test ID is required for updating a test');
|
||||
});
|
||||
|
||||
it('should handle errors when updating a test', async () => {
|
||||
const { updateTest, state } = useTestDefinitionForm();
|
||||
const updateSpy = vi
|
||||
.spyOn(useTestDefinitionStore(), 'update')
|
||||
.mockRejectedValue(new Error('Update Failed'));
|
||||
|
||||
state.value.name.value = 'Test';
|
||||
state.value.description.value = 'Some description';
|
||||
|
||||
await expect(updateTest(TEST_DEF_A.id)).rejects.toThrow('Update Failed');
|
||||
expect(updateSpy).toBeCalled();
|
||||
});
|
||||
|
||||
it('should start editing a field', () => {
|
||||
const { state, startEditing } = useTestDefinitionForm();
|
||||
|
||||
startEditing('name');
|
||||
expect(state.value.name.isEditing).toBe(true);
|
||||
expect(state.value.name.tempValue).toBe(state.value.name.value);
|
||||
|
||||
startEditing('tags');
|
||||
expect(state.value.tags.isEditing).toBe(true);
|
||||
expect(state.value.tags.tempValue).toEqual(state.value.tags.value);
|
||||
});
|
||||
|
||||
it('should do nothing if startEditing is called while already editing', () => {
|
||||
const { state, startEditing } = useTestDefinitionForm();
|
||||
state.value.name.isEditing = true;
|
||||
state.value.name.tempValue = 'Original Name';
|
||||
|
||||
startEditing('name');
|
||||
// Should remain unchanged because it was already editing
|
||||
expect(state.value.name.isEditing).toBe(true);
|
||||
expect(state.value.name.tempValue).toBe('Original Name');
|
||||
});
|
||||
|
||||
it('should save changes to a field', () => {
|
||||
const { state, startEditing, saveChanges } = useTestDefinitionForm();
|
||||
|
||||
// Name
|
||||
startEditing('name');
|
||||
state.value.name.tempValue = 'New Name';
|
||||
saveChanges('name');
|
||||
expect(state.value.name.isEditing).toBe(false);
|
||||
expect(state.value.name.value).toBe('New Name');
|
||||
|
||||
// Tags
|
||||
startEditing('tags');
|
||||
state.value.tags.tempValue = ['123'];
|
||||
saveChanges('tags');
|
||||
expect(state.value.tags.isEditing).toBe(false);
|
||||
expect(state.value.tags.value).toEqual(['123']);
|
||||
});
|
||||
|
||||
it('should cancel editing a field', () => {
|
||||
const { state, startEditing, cancelEditing } = useTestDefinitionForm();
|
||||
|
||||
const originalName = state.value.name.value;
|
||||
startEditing('name');
|
||||
state.value.name.tempValue = 'New Name';
|
||||
cancelEditing('name');
|
||||
expect(state.value.name.isEditing).toBe(false);
|
||||
expect(state.value.name.tempValue).toBe(originalName);
|
||||
|
||||
const originalTags = [...state.value.tags.value];
|
||||
startEditing('tags');
|
||||
state.value.tags.tempValue = ['123'];
|
||||
cancelEditing('tags');
|
||||
expect(state.value.tags.isEditing).toBe(false);
|
||||
expect(state.value.tags.tempValue).toEqual(originalTags);
|
||||
});
|
||||
|
||||
it('should handle keydown - Escape', () => {
|
||||
const { state, startEditing, handleKeydown } = useTestDefinitionForm();
|
||||
|
||||
startEditing('name');
|
||||
handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }), 'name');
|
||||
expect(state.value.name.isEditing).toBe(false);
|
||||
|
||||
startEditing('tags');
|
||||
handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }), 'tags');
|
||||
expect(state.value.tags.isEditing).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle keydown - Enter', () => {
|
||||
const { state, startEditing, handleKeydown } = useTestDefinitionForm();
|
||||
|
||||
startEditing('name');
|
||||
state.value.name.tempValue = 'New Name';
|
||||
handleKeydown(new KeyboardEvent('keydown', { key: 'Enter' }), 'name');
|
||||
expect(state.value.name.isEditing).toBe(false);
|
||||
expect(state.value.name.value).toBe('New Name');
|
||||
|
||||
startEditing('tags');
|
||||
state.value.tags.tempValue = ['123'];
|
||||
handleKeydown(new KeyboardEvent('keydown', { key: 'Enter' }), 'tags');
|
||||
expect(state.value.tags.isEditing).toBe(false);
|
||||
expect(state.value.tags.value).toEqual(['123']);
|
||||
});
|
||||
|
||||
it('should not save changes when shift+Enter is pressed', () => {
|
||||
const { state, startEditing, handleKeydown } = useTestDefinitionForm();
|
||||
|
||||
startEditing('name');
|
||||
state.value.name.tempValue = 'New Name With Shift';
|
||||
handleKeydown(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true }), 'name');
|
||||
expect(state.value.name.isEditing).toBe(true);
|
||||
expect(state.value.name.value).not.toBe('New Name With Shift');
|
||||
});
|
||||
});
|
||||
@@ -176,7 +176,7 @@ function onRetryMenuItemSelect(action: string): void {
|
||||
<template #content>
|
||||
<span>{{ locale.baseText('executionsList.evaluation') }}</span>
|
||||
</template>
|
||||
<FontAwesomeIcon :class="[$style.icon, $style.evaluation]" icon="tasks" />
|
||||
<FontAwesomeIcon :class="[$style.icon, $style.evaluation]" icon="check-double" />
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
Reference in New Issue
Block a user