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:
Eugene
2025-05-26 12:26:28 +02:00
committed by GitHub
parent 3ee15a8331
commit ca8f087a47
87 changed files with 3460 additions and 5103 deletions

View File

@@ -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';

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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';

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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] ??
''
);
};

View File

@@ -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',
},
};

View File

@@ -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',

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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 (

View File

@@ -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,

View File

@@ -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',

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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([[]]);
});
});

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
};
}

View File

@@ -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');
});
});

View File

@@ -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>