feat(editor): Evaluation UI tweaks (#12659) (no-changelog)

This commit is contained in:
oleg 2025-01-20 15:14:23 +01:00 committed by GitHub
parent b66a9dc8fb
commit 89777d32ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 845 additions and 544 deletions

View file

@ -1,96 +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>
<div :class="$style.header">
<n8n-icon-button
:class="$style.backButton"
icon="arrow-left"
type="tertiary"
:title="locale.baseText('testDefinition.edit.backButtonTitle')"
@click="$router.back()"
/>
<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>
</div>
</template>
<style module lang="scss">
.header {
display: flex;
align-items: center;
&:hover {
.editInputButton {
opacity: 1;
}
}
}
.title {
margin: 0;
flex-grow: 1;
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;
}
.backButton {
--button-font-color: var(--color-text-light);
border: none;
padding-left: 0;
}
</style>

View file

@ -9,6 +9,8 @@ interface EvaluationStep {
small?: boolean;
expanded?: boolean;
description?: string;
issues?: Array<{ field: string; message: string }>;
showIssues?: boolean;
}
const props = withDefaults(defineProps<EvaluationStep>(), {
@ -16,6 +18,8 @@ const props = withDefaults(defineProps<EvaluationStep>(), {
warning: false,
small: false,
expanded: true,
issues: () => [],
showIssues: true,
});
const locale = useI18n();
@ -46,7 +50,11 @@ const toggleExpand = async () => {
<slot name="icon" />
</div>
<h3 :class="$style.title">{{ title }}</h3>
<span v-if="warning" :class="$style.warningIcon"></span>
<span v-if="issues.length > 0 && showIssues" :class="$style.warningIcon">
<N8nInfoTip :bold="true" type="tooltip" theme="warning" tooltip-placement="right">
{{ issues.map((issue) => issue.message).join(', ') }}
</N8nInfoTip>
</span>
<button
v-if="$slots.cardContent"
:class="$style.collapseButton"

View file

@ -115,7 +115,13 @@ onMounted(loadData);
</script>
<template>
<div :class="$style.container">
<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="xlarge" type="dots" :class="$style.spinner" />
<Canvas
:id="canvasId"
@ -185,4 +191,11 @@ onMounted(loadData);
left: 50%;
transform: translate(-50%, -50%);
}
.noNodes {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View file

@ -0,0 +1,69 @@
<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

@ -0,0 +1,235 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import EvaluationStep from '@/components/TestDefinition/EditDefinition/EvaluationStep.vue';
import TagsInput from '@/components/TestDefinition/EditDefinition/TagsInput.vue';
import WorkflowSelector from '@/components/TestDefinition/EditDefinition/WorkflowSelector.vue';
import MetricsInput from '@/components/TestDefinition/EditDefinition/MetricsInput.vue';
import type { TestMetricRecord } from '@/api/testDefinition.ee';
import type { EditableFormState, EvaluationFormState } from '@/components/TestDefinition/types';
import type { ITag, ModalState } from '@/Interface';
import { NODE_PINNING_MODAL_KEY } from '@/constants';
import { ref } from 'vue';
defineProps<{
showConfig: boolean;
tagUsageCount: number;
allTags: ITag[];
tagsById: Record<string, ITag>;
isLoading: boolean;
getFieldIssues: (key: string) => Array<{ field: string; message: string }>;
startEditing: (field: keyof EditableFormState) => void;
saveChanges: (field: keyof EditableFormState) => void;
cancelEditing: (field: keyof EditableFormState) => void;
createTag?: (name: string) => Promise<ITag>;
}>();
const changedFieldsKeys = ref<string[]>([]);
const tags = defineModel<EvaluationFormState['tags']>('tags', { required: true });
const evaluationWorkflow = defineModel<EvaluationFormState['evaluationWorkflow']>(
'evaluationWorkflow',
{ required: true },
);
const metrics = defineModel<EvaluationFormState['metrics']>('metrics', { required: true });
const mockedNodes = defineModel<EvaluationFormState['mockedNodes']>('mockedNodes', {
required: true,
});
const nodePinningModal = ref<ModalState | null>(null);
const emit = defineEmits<{
openPinningModal: [];
deleteMetric: [metric: Partial<TestMetricRecord>];
}>();
const locale = useI18n();
function updateChangedFieldsKeys(key: string) {
changedFieldsKeys.value.push(key);
}
function showFieldIssues(fieldKey: string) {
return changedFieldsKeys.value.includes(fieldKey);
}
</script>
<template>
<div :class="[$style.panelBlock, { [$style.hidden]: !showConfig }]">
<div :class="$style.panelIntro">
{{ locale.baseText('testDefinition.edit.step.intro') }}
</div>
<BlockArrow :class="$style.introArrow" />
<!-- Select Executions -->
<EvaluationStep
:class="$style.step"
:title="
locale.baseText('testDefinition.edit.step.executions', {
adjustToNumber: tagUsageCount,
})
"
:description="locale.baseText('testDefinition.edit.step.executions.description')"
:issues="getFieldIssues('tags')"
:show-issues="showFieldIssues('tags')"
>
<template #icon><font-awesome-icon icon="history" size="lg" /></template>
<template #cardContent>
<TagsInput
v-model="tags"
:class="{ 'has-issues': getFieldIssues('tags') }"
:all-tags="allTags"
:tags-by-id="tagsById"
:is-loading="isLoading"
:start-editing="startEditing"
:save-changes="saveChanges"
:cancel-editing="cancelEditing"
:create-tag="createTag"
@update:model-value="updateChangedFieldsKeys('tags')"
/>
</template>
</EvaluationStep>
<div :class="$style.evaluationArrows">
<BlockArrow />
<BlockArrow />
</div>
<!-- Mocked Nodes -->
<EvaluationStep
:class="$style.step"
:title="
locale.baseText('testDefinition.edit.step.mockedNodes', {
adjustToNumber: mockedNodes?.length ?? 0,
})
"
:small="true"
:expanded="true"
:description="locale.baseText('testDefinition.edit.step.nodes.description')"
:issues="getFieldIssues('mockedNodes')"
:show-issues="showFieldIssues('mockedNodes')"
>
<template #icon><font-awesome-icon icon="thumbtack" size="lg" /></template>
<template #cardContent>
<n8n-button
size="small"
data-test-id="select-nodes-button"
:label="locale.baseText('testDefinition.edit.selectNodes')"
type="tertiary"
@click="$emit('openPinningModal')"
/>
</template>
</EvaluationStep>
<!-- Re-run Executions -->
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.reRunExecutions')"
:small="true"
:description="locale.baseText('testDefinition.edit.step.reRunExecutions.description')"
>
<template #icon><font-awesome-icon icon="redo" size="lg" /></template>
</EvaluationStep>
<!-- Compare Executions -->
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.compareExecutions')"
:description="locale.baseText('testDefinition.edit.step.compareExecutions.description')"
:issues="getFieldIssues('evaluationWorkflow')"
:show-issues="showFieldIssues('evaluationWorkflow')"
>
<template #icon><font-awesome-icon icon="equals" size="lg" /></template>
<template #cardContent>
<WorkflowSelector
v-model="evaluationWorkflow"
:class="{ 'has-issues': getFieldIssues('evaluationWorkflow').length > 0 }"
@update:model-value="updateChangedFieldsKeys('evaluationWorkflow')"
/>
</template>
</EvaluationStep>
<!-- Metrics -->
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.metrics')"
:description="locale.baseText('testDefinition.edit.step.metrics.description')"
:issues="getFieldIssues('metrics')"
:show-issues="showFieldIssues('metrics')"
>
<template #icon><font-awesome-icon icon="chart-bar" size="lg" /></template>
<template #cardContent>
<MetricsInput
v-model="metrics"
:class="{ 'has-issues': getFieldIssues('metrics').length > 0 }"
@delete-metric="(metric) => emit('deleteMetric', metric)"
@update:model-value="updateChangedFieldsKeys('metrics')"
/>
</template>
</EvaluationStep>
<Modal ref="nodePinningModal" width="80vw" height="85vh" :name="NODE_PINNING_MODAL_KEY">
<template #header>
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading">{{
locale.baseText('testDefinition.edit.selectNodes')
}}</N8nHeading>
</template>
<template #content>
<NodesPinning v-model="mockedNodes" data-test-id="nodes-pinning-modal" />
</template>
</Modal>
</div>
</template>
<style module lang="scss">
.panelBlock {
width: var(--evaluation-edit-panel-width);
display: grid;
height: 100%;
overflow-y: auto;
flex-shrink: 0;
padding-bottom: var(--spacing-l);
margin-left: var(--spacing-2xl);
transition: width 0.2s ease;
&.hidden {
margin-left: 0;
width: 0;
overflow: hidden;
flex-shrink: 1;
}
.noRuns & {
overflow-y: initial;
}
}
.panelIntro {
font-size: var(--font-size-m);
color: var(--color-text-dark);
justify-self: center;
position: relative;
display: block;
}
.step {
position: relative;
&:not(:first-child) {
margin-top: var(--spacing-m);
}
}
.introArrow {
--arrow-height: 1.5rem;
margin-bottom: -1rem;
justify-self: center;
}
.evaluationArrows {
--arrow-height: 23rem;
display: flex;
justify-content: space-between;
width: 100%;
max-width: 80%;
margin: 0 auto;
margin-bottom: -100%;
z-index: 0;
}
</style>

View file

@ -0,0 +1,134 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import TestNameInput from '@/components/TestDefinition/EditDefinition/TestNameInput.vue';
import DescriptionInput from '@/components/TestDefinition/EditDefinition/DescriptionInput.vue';
import type { EditableField, EditableFormState } from '@/components/TestDefinition/types';
import { computed } from 'vue';
const props = defineProps<{
hasRuns: boolean;
isSaving: boolean;
showConfig: boolean;
runTestEnabled: boolean;
startEditing: <T extends keyof EditableFormState>(field: T) => void;
saveChanges: <T extends keyof EditableFormState>(field: T) => void;
handleKeydown: <T extends keyof EditableFormState>(event: KeyboardEvent, field: T) => void;
onSaveTest: () => Promise<void>;
runTest: () => Promise<void>;
toggleConfig: () => void;
getFieldIssues: (key: string) => Array<{ field: string; message: string }>;
}>();
const name = defineModel<EditableField<string>>('name', { required: true });
const description = defineModel<EditableField<string>>('description', { required: true });
const locale = useI18n();
const showSavingIndicator = computed(() => {
return !name.value.isEditing;
});
</script>
<template>
<div :class="$style.headerSection">
<div :class="$style.headerMeta">
<div :class="$style.name">
<n8n-icon-button
:class="$style.backButton"
icon="arrow-left"
type="tertiary"
:title="locale.baseText('testDefinition.edit.backButtonTitle')"
@click="$router.back()"
/>
<TestNameInput
v-model="name"
:class="{ 'has-issues': getFieldIssues('name').length > 0 }"
:start-editing="startEditing"
:save-changes="saveChanges"
:handle-keydown="handleKeydown"
/>
<div v-if="showSavingIndicator" :class="$style.lastSaved">
<template v-if="isSaving">
{{ locale.baseText('testDefinition.edit.saving') }}
</template>
<template v-else> {{ locale.baseText('testDefinition.edit.saved') }} </template>
</div>
</div>
<DescriptionInput
v-model="description"
:start-editing="startEditing"
:save-changes="saveChanges"
:handle-keydown="handleKeydown"
:class="$style.descriptionInput"
/>
</div>
<div :class="$style.controls">
<N8nButton
v-if="props.hasRuns"
size="small"
:icon="showConfig ? 'eye-slash' : 'eye'"
data-test-id="toggle-config-button"
:label="
showConfig
? locale.baseText('testDefinition.edit.hideConfig')
: locale.baseText('testDefinition.edit.showConfig')
"
type="tertiary"
@click="toggleConfig"
/>
<N8nTooltip :disabled="runTestEnabled" :placement="'left'">
<N8nButton
:disabled="!runTestEnabled"
:class="$style.runTestButton"
size="small"
data-test-id="run-test-button"
:label="locale.baseText('testDefinition.runTest')"
type="primary"
@click="runTest"
/>
<template #content>
<slot name="runTestTooltip" />
</template>
</N8nTooltip>
</div>
</div>
</template>
<style module lang="scss">
.headerSection {
display: flex;
justify-content: space-between;
align-items: flex-start;
background-color: var(--color-background-light);
width: 100%;
}
.headerMeta {
max-width: 50%;
}
.name {
display: flex;
align-items: center;
justify-content: flex-start;
.lastSaved {
font-size: var(--font-size-s);
color: var(--color-text-light);
}
}
.descriptionInput {
margin-top: var(--spacing-2xs);
}
.controls {
display: flex;
gap: var(--spacing-s);
}
.backButton {
--button-font-color: var(--color-text-light);
border: none;
padding-left: 0;
}
</style>

View file

@ -0,0 +1,52 @@
<script setup lang="ts">
import type { TestRunRecord } from '@/api/testDefinition.ee';
import type { AppliedThemeOption } from '@/Interface';
import MetricsChart from '@/components/TestDefinition/ListRuns/MetricsChart.vue';
import TestRunsTable from '@/components/TestDefinition/ListRuns/TestRunsTable.vue';
defineProps<{
runs: TestRunRecord[];
testId: string;
appliedTheme: AppliedThemeOption;
}>();
const emit = defineEmits<{
deleteRuns: [runs: TestRunRecord[]];
}>();
const selectedMetric = defineModel<string>('selectedMetric', { required: true });
function onDeleteRuns(toDelete: TestRunRecord[]) {
emit('deleteRuns', toDelete);
}
</script>
<template>
<div :class="$style.runs">
<!-- Metrics Chart -->
<MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" :theme="appliedTheme" />
<!-- Past Runs Table -->
<TestRunsTable
:class="$style.runsTable"
:runs="runs"
:selectable="true"
data-test-id="past-runs-table"
@delete-runs="onDeleteRuns"
/>
</div>
</template>
<style module lang="scss">
.runs {
display: flex;
flex-direction: column;
gap: var(--spacing-m);
flex: 1;
padding-top: var(--spacing-3xs);
overflow: auto;
@media (min-height: 56rem) {
margin-top: var(--spacing-2xl);
}
}
</style>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { TestListItem } from '@/components/TestDefinition/types';
import type { TestListItem, TestItemAction } from '@/components/TestDefinition/types';
import TimeAgo from '@/components/TimeAgo.vue';
import { useI18n } from '@/composables/useI18n';
import n8nIconButton from 'n8n-design-system/components/N8nIconButton';
@ -7,55 +7,19 @@ import { computed } from 'vue';
export interface TestItemProps {
test: TestListItem;
actions: TestItemAction[];
}
const props = defineProps<TestItemProps>();
const locale = useI18n();
const emit = defineEmits<{
'run-test': [testId: string];
defineEmits<{
'view-details': [testId: string];
'edit-test': [testId: string];
'delete-test': [testId: string];
'cancel-test-run': [testId: string, testRunId: string | null];
}>();
const actions = [
{
icon: 'play',
id: 'run',
event: () => emit('run-test', props.test.id),
tooltip: locale.baseText('testDefinition.runTest'),
show: () => props.test.execution.status !== 'running',
},
{
icon: 'stop',
id: 'cancel',
event: () => emit('cancel-test-run', props.test.id, props.test.execution.id),
tooltip: locale.baseText('testDefinition.cancelTestRun'),
show: () => props.test.execution.status === 'running',
},
{
icon: 'list',
id: 'view',
event: () => emit('view-details', props.test.id),
tooltip: locale.baseText('testDefinition.viewDetails'),
},
{
icon: 'pen',
id: 'edit',
event: () => emit('edit-test', props.test.id),
tooltip: locale.baseText('testDefinition.editTest'),
},
{
icon: 'trash',
id: 'delete',
event: () => emit('delete-test', props.test.id),
tooltip: locale.baseText('testDefinition.deleteTest'),
},
];
const visibleActions = computed(() => actions.filter((action) => action.show?.() ?? true));
const visibleActions = computed(() =>
props.actions.filter((action) => action.show?.(props.test.id) ?? true),
);
</script>
<template>
@ -102,17 +66,16 @@ const visibleActions = computed(() => actions.filter((action) => action.show?.()
:key="action.icon"
placement="top"
:show-after="1000"
:content="action.tooltip(test.id)"
>
<template #content>
{{ action.tooltip }}
</template>
<component
:is="n8nIconButton"
:icon="action.icon"
:data-test-id="`${action.id}-test-button-${test.id}`"
type="tertiary"
size="mini"
@click.stop="action.event"
:disabled="action?.disabled ? action.disabled(test.id) : false"
@click.stop="action.event(test.id)"
/>
</n8n-tooltip>
</div>

View file

@ -1,12 +1,13 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import TestItem from './TestItem.vue';
import type { TestListItem } from '@/components/TestDefinition/types';
import type { TestListItem, TestItemAction } from '@/components/TestDefinition/types';
export interface TestListProps {
tests: TestListItem[];
actions: TestItemAction[];
}
defineEmits<{ 'create-test': [] }>();
defineEmits<{ 'create-test': []; 'view-details': [testId: string] }>();
defineProps<TestListProps>();
const locale = useI18n();
</script>
@ -19,7 +20,13 @@ const locale = useI18n();
@click="$emit('create-test')"
/>
</div>
<TestItem v-for="test in tests" :key="test.id" :test="test" v-bind="$attrs" />
<TestItem
v-for="test in tests"
:key="test.id"
:test="test"
:actions="actions"
@view-details="$emit('view-details', test.id)"
/>
</div>
</template>

View file

@ -76,7 +76,7 @@ function onSelectionChange(runs: TestRunRecord[]) {
emit('selectionChange', runs);
}
function deleteRuns() {
async function deleteRuns() {
emit('deleteRuns', selectedRows.value);
}
</script>

View file

@ -41,11 +41,8 @@ export function useTestDefinitionForm() {
});
const isSaving = ref(false);
const fieldsIssues = ref<Array<{ field: string; message: string }>>([]);
const fields = ref<FormRefs>({} as FormRefs);
// A computed mapping of editable fields to their states
// This ensures TS knows the exact type of each field.
const editableFields: ComputedRef<{
name: EditableField<string>;
tags: EditableField<string[]>;
@ -99,7 +96,6 @@ export function useTestDefinitionForm() {
if (isSaving.value) return;
isSaving.value = true;
fieldsIssues.value = [];
try {
const params = {
@ -144,7 +140,6 @@ export function useTestDefinitionForm() {
if (isSaving.value) return;
isSaving.value = true;
fieldsIssues.value = [];
try {
if (!testId) {
@ -232,7 +227,6 @@ export function useTestDefinitionForm() {
state,
fields,
isSaving: computed(() => isSaving.value),
fieldsIssues: computed(() => fieldsIssues.value),
deleteMetric,
updateMetrics,
loadTestData,

View file

@ -7,6 +7,15 @@ export interface EditableField<T = string> {
isEditing: boolean;
}
export interface TestItemAction {
icon: string;
id: string;
event: (testId: string) => void | Promise<void>;
tooltip: (testId: string) => string;
disabled?: (testId: string) => boolean;
show?: (testId: string) => boolean;
}
export interface EditableFormState {
name: EditableField<string>;
tags: EditableField<string[]>;
@ -33,4 +42,5 @@ export interface TestListItem {
tagName: string;
testCases: number;
execution: TestExecution;
fieldsIssues?: Array<{ field: string; message: string }>;
}

View file

@ -258,6 +258,7 @@ const onAddResourceClicked = () => {
}"
:width="width"
:event-bus="eventBus"
:value="modelValue"
@update:model-value="onListItemSelected"
@filter="onSearchFilter"
@load-more="populateNextWorkflowsPage"

View file

@ -229,6 +229,12 @@ async function handleActionItemClick(commandData: Command) {
</template>
<FontAwesomeIcon icon="flask" />
</N8nTooltip>
<N8nTooltip v-if="execution.mode === 'evaluation'" placement="top">
<template #content>
<span>{{ i18n.baseText('executionsList.evaluation') }}</span>
</template>
<FontAwesomeIcon icon="tasks" />
</N8nTooltip>
</td>
<td>
<div :class="$style.buttonCell">

View file

@ -167,11 +167,13 @@ function onRetryMenuItemSelect(action: string): void {
<template #content>
<span>{{ locale.baseText('executionsList.test') }}</span>
</template>
<FontAwesomeIcon
v-if="execution.mode === 'manual'"
:class="[$style.icon, $style.manual]"
icon="flask"
/>
<FontAwesomeIcon :class="[$style.icon, $style.manual]" icon="flask" />
</N8nTooltip>
<N8nTooltip v-if="execution.mode === 'evaluation'" placement="top">
<template #content>
<span>{{ locale.baseText('executionsList.evaluation') }}</span>
</template>
<FontAwesomeIcon :class="[$style.icon, $style.evaluation]" icon="tasks" />
</N8nTooltip>
</div>
</router-link>

View file

@ -751,6 +751,7 @@
"executionsList.selected": "{count} execution selected: | {count} executions selected:",
"executionsList.selectAll": "Select {executionNum} finished execution | Select all {executionNum} finished executions",
"executionsList.test": "Test execution",
"executionsList.evaluation": "Evaluation execution",
"executionsList.showError.handleDeleteSelected.title": "Problem deleting executions",
"executionsList.showError.loadMore.title": "Problem loading executions",
"executionsList.showError.loadWorkflows.title": "Problem loading workflows",
@ -2799,6 +2800,8 @@
"testDefinition.edit.testSaveFailed": "Failed to save test",
"testDefinition.edit.description": "Description",
"testDefinition.edit.description.description": "Add details about what this test evaluates and what success looks like",
"testDefinition.edit.pinNodes.noNodes.title": "No nodes to pin",
"testDefinition.edit.pinNodes.noNodes.description": "Your workflow needs to have at least one node to run a test",
"testDefinition.edit.tagName": "Tag name",
"testDefinition.edit.step.intro": "When running a test",
"testDefinition.edit.step.executions": "1. Fetch N past executions tagged | 1. Fetch {count} past execution tagged | 1. Fetch {count} past executions tagged",
@ -2863,6 +2866,13 @@
"testDefinition.viewDetails": "View Details",
"testDefinition.editTest": "Edit Test",
"testDefinition.deleteTest": "Delete Test",
"testDefinition.deleteTest.warning": "The test and all associated runs will be removed. This cannot be undone",
"testDefinition.testIsRunning": "Test is running. Please wait for it to finish.",
"testDefinition.completeConfig": "Complete the configuration below to run the test:",
"testDefinition.configError.noEvaluationTag": "No evaluation tag set",
"testDefinition.configError.noExecutionsAddedToTag": "No executions added to this tag",
"testDefinition.configError.noEvaluationWorkflow": "No evaluation workflow set",
"testDefinition.configError.noMetrics": "No metrics set",
"freeAi.credits.callout.claim.title": "Get {credits} free OpenAI API credits",
"freeAi.credits.callout.claim.button.label": "Claim credits",
"freeAi.credits.callout.success.title.part1": "Claimed {credits} free OpenAI API credits! Please note these free credits are only for the following models:",

View file

@ -2,7 +2,9 @@ import { createPinia, setActivePinia } from 'pinia';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee'; // Adjust the import path as necessary
import { useRootStore } from '@/stores/root.store';
import { usePostHog } from '@/stores/posthog.store';
import { useAnnotationTagsStore } from '@/stores/tags.store';
import type { TestDefinitionRecord, TestRunRecord } from '@/api/testDefinition.ee';
import { mockedStore } from '@/__tests__/utils';
const {
createTestDefinition,
@ -101,6 +103,7 @@ describe('testDefinition.store.ee', () => {
rootStoreMock = useRootStore();
posthogStoreMock = usePostHog();
mockedStore(useAnnotationTagsStore).fetchAll = vi.fn().mockResolvedValue([]);
getTestDefinitions.mockResolvedValue({
count: 2,
testDefinitions: [TEST_DEF_A, TEST_DEF_B],
@ -114,6 +117,7 @@ describe('testDefinition.store.ee', () => {
getTestRun.mockResolvedValue(TEST_RUN);
startTestRun.mockResolvedValue({ success: true });
deleteTestRun.mockResolvedValue({ success: true });
getTestMetrics.mockResolvedValue([TEST_METRIC]);
});
test('Initialization', () => {
@ -276,8 +280,6 @@ describe('testDefinition.store.ee', () => {
describe('Metrics', () => {
test('Fetching Metrics for a Test Definition', async () => {
getTestMetrics.mockResolvedValue([TEST_METRIC]);
const metrics = await store.fetchMetrics('1');
expect(getTestMetrics).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1');

View file

@ -5,6 +5,10 @@ import * as testDefinitionsApi from '@/api/testDefinition.ee';
import type { TestDefinitionRecord, TestRunRecord } from '@/api/testDefinition.ee';
import { usePostHog } from './posthog.store';
import { STORES, WORKFLOW_EVALUATION_EXPERIMENT } from '@/constants';
import { useAnnotationTagsStore } from './tags.store';
import { useI18n } from '@/composables/useI18n';
type FieldIssue = { field: string; message: string };
export const useTestDefinitionStore = defineStore(
STORES.TEST_DEFINITION,
@ -16,11 +20,13 @@ export const useTestDefinitionStore = defineStore(
const metricsById = ref<Record<string, testDefinitionsApi.TestMetricRecord>>({});
const testRunsById = ref<Record<string, TestRunRecord>>({});
const pollingTimeouts = ref<Record<string, NodeJS.Timeout>>({});
const fieldsIssues = ref<Record<string, FieldIssue[]>>({});
// Store instances
const posthogStore = usePostHog();
const rootStore = useRootStore();
const tagsStore = useAnnotationTagsStore();
const locale = useI18n();
// Computed
const allTestDefinitions = computed(() => {
return Object.values(testDefinitionsById.value).sort((a, b) =>
@ -100,6 +106,8 @@ export const useTestDefinitionStore = defineStore(
);
});
const getFieldIssues = (testId: string) => fieldsIssues.value[testId] || [];
// Methods
const setAllTestDefinitions = (definitions: TestDefinitionRecord[]) => {
testDefinitionsById.value = definitions.reduce(
@ -144,13 +152,18 @@ export const useTestDefinitionStore = defineStore(
}
};
const fetchMetricsForAllTests = async () => {
const testDefinitions = Object.values(testDefinitionsById.value);
await Promise.all(testDefinitions.map(async (testDef) => await fetchMetrics(testDef.id)));
};
const fetchTestDefinition = async (id: string) => {
const testDefinition = await testDefinitionsApi.getTestDefinition(
rootStore.restApiContext,
id,
);
testDefinitionsById.value[testDefinition.id] = testDefinition;
updateRunFieldIssues(id);
return testDefinition;
};
@ -178,7 +191,11 @@ export const useTestDefinitionStore = defineStore(
setAllTestDefinitions(retrievedDefinitions.testDefinitions);
fetchedAll.value = true;
await fetchRunsForAllTests();
await Promise.all([
tagsStore.fetchAll({ withUsageCount: true }),
fetchRunsForAllTests(),
fetchMetricsForAllTests(),
]);
return retrievedDefinitions;
} finally {
loading.value = false;
@ -203,6 +220,7 @@ export const useTestDefinitionStore = defineStore(
params,
);
upsertTestDefinitions([createdDefinition]);
updateRunFieldIssues(createdDefinition.id);
return createdDefinition;
};
@ -216,6 +234,7 @@ export const useTestDefinitionStore = defineStore(
updateParams,
);
upsertTestDefinitions([updatedDefinition]);
updateRunFieldIssues(params.id);
return updatedDefinition;
};
@ -240,9 +259,9 @@ export const useTestDefinitionStore = defineStore(
try {
const metrics = await testDefinitionsApi.getTestMetrics(rootStore.restApiContext, testId);
metrics.forEach((metric) => {
metricsById.value[metric.id] = metric;
metricsById.value[metric.id] = { ...metric, testDefinitionId: testId };
});
return metrics;
return metrics.map((metric) => ({ ...metric, testDefinitionId: testId }));
} finally {
loading.value = false;
}
@ -253,7 +272,7 @@ export const useTestDefinitionStore = defineStore(
testDefinitionId: string;
}): Promise<testDefinitionsApi.TestMetricRecord> => {
const metric = await testDefinitionsApi.createTestMetric(rootStore.restApiContext, params);
metricsById.value[metric.id] = metric;
metricsById.value[metric.id] = { ...metric, testDefinitionId: params.testDefinitionId };
return metric;
};
@ -261,7 +280,9 @@ export const useTestDefinitionStore = defineStore(
params: testDefinitionsApi.TestMetricRecord,
): Promise<testDefinitionsApi.TestMetricRecord> => {
const metric = await testDefinitionsApi.updateTestMetric(rootStore.restApiContext, params);
metricsById.value[metric.id] = metric;
metricsById.value[metric.id] = { ...metric, testDefinitionId: params.testDefinitionId };
updateRunFieldIssues(params.testDefinitionId);
return metric;
};
@ -271,6 +292,8 @@ export const useTestDefinitionStore = defineStore(
await testDefinitionsApi.deleteTestMetric(rootStore.restApiContext, params);
const { [params.id]: deleted, ...rest } = metricsById.value;
metricsById.value = rest;
updateRunFieldIssues(params.testDefinitionId);
};
// Test Runs Methods
@ -296,6 +319,7 @@ export const useTestDefinitionStore = defineStore(
const getTestRun = async (params: { testDefinitionId: string; runId: string }) => {
const run = await testDefinitionsApi.getTestRun(rootStore.restApiContext, params);
testRunsById.value[run.id] = run;
updateRunFieldIssues(params.testDefinitionId);
return run;
};
@ -346,6 +370,52 @@ export const useTestDefinitionStore = defineStore(
pollingTimeouts.value = {};
};
const updateRunFieldIssues = (testId: string) => {
const issues: FieldIssue[] = [];
const testDefinition = testDefinitionsById.value[testId];
if (!testDefinition) {
return;
}
if (!testDefinition.annotationTagId) {
issues.push({
field: 'tags',
message: locale.baseText('testDefinition.configError.noEvaluationTag'),
});
} else {
const tagUsageCount = tagsStore.tagsById[testDefinition.annotationTagId]?.usageCount ?? 0;
if (tagUsageCount === 0) {
issues.push({
field: 'tags',
message: locale.baseText('testDefinition.configError.noExecutionsAddedToTag'),
});
}
}
if (!testDefinition.evaluationWorkflowId) {
issues.push({
field: 'evaluationWorkflow',
message: locale.baseText('testDefinition.configError.noEvaluationWorkflow'),
});
}
const metrics = metricsByTestId.value[testId] || [];
if (metrics.filter((metric) => metric.name).length === 0) {
issues.push({
field: 'metrics',
message: locale.baseText('testDefinition.configError.noMetrics'),
});
}
fieldsIssues.value = {
...fieldsIssues.value,
[testId]: issues,
};
return issues;
};
return {
// State
fetchedAll,
@ -381,6 +451,8 @@ export const useTestDefinitionStore = defineStore(
cancelTestRun,
deleteTestRun,
cleanupPolling,
getFieldIssues,
updateRunFieldIssues,
};
},
{},

View file

@ -8,19 +8,12 @@ import { useAnnotationTagsStore } from '@/stores/tags.store';
import { useDebounce } from '@/composables/useDebounce';
import { useTestDefinitionForm } from '@/components/TestDefinition/composables/useTestDefinitionForm';
import EvaluationHeader from '@/components/TestDefinition/EditDefinition/EvaluationHeader.vue';
import DescriptionInput from '@/components/TestDefinition/EditDefinition/DescriptionInput.vue';
import EvaluationStep from '@/components/TestDefinition/EditDefinition/EvaluationStep.vue';
import TagsInput from '@/components/TestDefinition/EditDefinition/TagsInput.vue';
import WorkflowSelector from '@/components/TestDefinition/EditDefinition/WorkflowSelector.vue';
import MetricsInput from '@/components/TestDefinition/EditDefinition/MetricsInput.vue';
import HeaderSection from '@/components/TestDefinition/EditDefinition/sections/HeaderSection.vue';
import RunsSection from '@/components/TestDefinition/EditDefinition/sections/RunsSection.vue';
import type { TestMetricRecord, TestRunRecord } from '@/api/testDefinition.ee';
import Modal from '@/components/Modal.vue';
import type { ModalState } from '@/Interface';
import { useUIStore } from '@/stores/ui.store';
import TestRunsTable from '@/components/TestDefinition/ListRuns/TestRunsTable.vue';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import ConfigSection from '@/components/TestDefinition/EditDefinition/sections/ConfigSection.vue';
const props = defineProps<{
testId?: string;
}>();
@ -33,9 +26,9 @@ const toast = useToast();
const testDefinitionStore = useTestDefinitionStore();
const tagsStore = useAnnotationTagsStore();
const uiStore = useUIStore();
const {
state,
fieldsIssues,
isSaving,
cancelEditing,
loadTestData,
@ -58,11 +51,11 @@ const tagUsageCount = computed(
() => tagsStore.tagsById[state.value.tags.value[0]]?.usageCount ?? 0,
);
const hasRuns = computed(() => runs.value.length > 0);
const nodePinningModal = ref<ModalState | null>(null);
const modalContentWidth = ref(0);
const showConfig = ref(true);
const selectedMetric = ref<string>('');
const fieldsIssues = computed(() => testDefinitionStore.getFieldIssues(testId.value) ?? []);
onMounted(async () => {
if (!testDefinitionStore.isFeatureEnabled) {
toast.showMessage({
@ -76,7 +69,6 @@ onMounted(async () => {
});
return; // Add early return to prevent loading if feature is disabled
}
void tagsStore.fetchAll({ withUsageCount: true });
if (testId.value) {
await loadTestData(testId.value);
} else {
@ -103,8 +95,8 @@ async function onSaveTest() {
}
}
function hasIssues(key: string) {
return fieldsIssues.value.some((issue) => issue.field === key);
function getFieldIssues(key: string) {
return fieldsIssues.value.filter((issue) => issue.field === key);
}
async function onDeleteMetric(deletedMetric: Partial<TestMetricRecord>) {
@ -138,6 +130,9 @@ const runs = computed(() =>
),
);
const isRunning = computed(() => runs.value.some((run) => run.status === 'running'));
const isRunTestEnabled = computed(() => fieldsIssues.value.length === 0 && !isRunning.value);
async function onDeleteRuns(toDelete: TestRunRecord[]) {
await Promise.all(
toDelete.map(async (run) => {
@ -172,196 +167,62 @@ watch(
<template>
<div :class="[$style.container, { [$style.noRuns]: !hasRuns }]">
<div :class="$style.headerSection">
<div :class="$style.headerMeta">
<div :class="$style.name">
<EvaluationHeader
v-model="state.name"
:class="{ 'has-issues': hasIssues('name') }"
:start-editing="startEditing"
:save-changes="saveChanges"
:handle-keydown="handleKeydown"
/>
<div :class="$style.lastSaved">
<template v-if="isSaving">
{{ locale.baseText('testDefinition.edit.saving') }}
</template>
<template v-else> {{ locale.baseText('testDefinition.edit.saved') }} </template>
</div>
</div>
<DescriptionInput
v-model="state.description"
:start-editing="startEditing"
:save-changes="saveChanges"
:handle-keydown="handleKeydown"
:class="$style.descriptionInput"
/>
</div>
<div :class="$style.controls">
<n8n-button
v-if="runs.length > 0"
size="small"
:icon="showConfig ? 'eye-slash' : 'eye'"
data-test-id="toggle-config-button"
:label="
showConfig
? locale.baseText('testDefinition.edit.hideConfig')
: locale.baseText('testDefinition.edit.showConfig')
"
type="tertiary"
@click="toggleConfig"
/>
<n8n-button
v-if="state.evaluationWorkflow.value && state.tags.value.length > 0"
:class="$style.runTestButton"
size="small"
data-test-id="run-test-button"
:label="locale.baseText('testDefinition.runTest')"
type="primary"
@click="runTest"
/>
<n8n-button
v-else
:class="$style.runTestButton"
size="small"
data-test-id="run-test-button"
:label="locale.baseText('testDefinition.edit.saveTest')"
type="primary"
@click="onSaveTest"
/>
</div>
</div>
<HeaderSection
v-model:name="state.name"
v-model:description="state.description"
v-model:tags="state.tags"
:has-runs="hasRuns"
:is-saving="isSaving"
:get-field-issues="getFieldIssues"
:start-editing="startEditing"
:save-changes="saveChanges"
:handle-keydown="handleKeydown"
:on-save-test="onSaveTest"
:run-test="runTest"
:show-config="showConfig"
:toggle-config="toggleConfig"
:run-test-enabled="isRunTestEnabled"
>
<template #runTestTooltip>
<template v-if="fieldsIssues.length > 0">
<div>{{ locale.baseText('testDefinition.completeConfig') }}</div>
<div v-for="issue in fieldsIssues" :key="issue.field">- {{ issue.message }}</div>
</template>
<template v-if="isRunning">
{{ locale.baseText('testDefinition.testIsRunning') }}
</template>
</template>
</HeaderSection>
<div :class="$style.content">
<div v-if="runs.length > 0" :class="$style.runs">
<!-- Metrics Chart -->
<MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" :theme="appliedTheme" />
<!-- Past Runs Table -->
<TestRunsTable
:class="$style.runsTable"
:runs="runs"
:selectable="true"
data-test-id="past-runs-table"
@delete-runs="onDeleteRuns"
/>
</div>
<RunsSection
v-if="runs.length > 0"
v-model:selectedMetric="selectedMetric"
:runs="runs"
:test-id="testId"
:applied-theme="appliedTheme"
@delete-runs="onDeleteRuns"
/>
<div :class="[$style.panelBlock, { [$style.hidden]: !showConfig }]">
<div :class="$style.panelIntro">
{{ locale.baseText('testDefinition.edit.step.intro') }}
</div>
<BlockArrow :class="$style.introArrow" />
<!-- Select Executions -->
<EvaluationStep
:class="$style.step"
:title="
locale.baseText('testDefinition.edit.step.executions', {
adjustToNumber: tagUsageCount,
})
"
:description="locale.baseText('testDefinition.edit.step.executions.description')"
>
<template #icon><font-awesome-icon icon="history" size="lg" /></template>
<template #cardContent>
<TagsInput
v-model="state.tags"
:class="{ 'has-issues': hasIssues('tags') }"
:all-tags="allTags"
:tags-by-id="tagsById"
:is-loading="isLoading"
:start-editing="startEditing"
:save-changes="saveChanges"
:cancel-editing="cancelEditing"
:create-tag="handleCreateTag"
/>
</template>
</EvaluationStep>
<div :class="$style.evaluationArrows">
<BlockArrow />
<BlockArrow />
</div>
<!-- Mocked Nodes -->
<EvaluationStep
:class="$style.step"
:title="
locale.baseText('testDefinition.edit.step.mockedNodes', {
adjustToNumber: state.mockedNodes?.length ?? 0,
})
"
:small="true"
:expanded="true"
:description="locale.baseText('testDefinition.edit.step.nodes.description')"
>
<template #icon><font-awesome-icon icon="thumbtack" size="lg" /></template>
<template #cardContent>
<n8n-button
size="small"
data-test-id="select-nodes-button"
:label="locale.baseText('testDefinition.edit.selectNodes')"
type="tertiary"
@click="openPinningModal"
/>
</template>
</EvaluationStep>
<!-- Re-run Executions -->
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.reRunExecutions')"
:small="true"
:description="locale.baseText('testDefinition.edit.step.reRunExecutions.description')"
>
<template #icon><font-awesome-icon icon="redo" size="lg" /></template>
</EvaluationStep>
<!-- Compare Executions -->
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.compareExecutions')"
:description="locale.baseText('testDefinition.edit.step.compareExecutions.description')"
>
<template #icon><font-awesome-icon icon="equals" size="lg" /></template>
<template #cardContent>
<WorkflowSelector
v-model="state.evaluationWorkflow"
:class="{ 'has-issues': hasIssues('evaluationWorkflow') }"
/>
</template>
</EvaluationStep>
<!-- Metrics -->
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.metrics')"
:description="locale.baseText('testDefinition.edit.step.metrics.description')"
>
<template #icon><font-awesome-icon icon="chart-bar" size="lg" /></template>
<template #cardContent>
<MetricsInput
v-model="state.metrics"
:class="{ 'has-issues': hasIssues('metrics') }"
@delete-metric="onDeleteMetric"
/>
</template>
</EvaluationStep>
</div>
<ConfigSection
v-model:tags="state.tags"
v-model:evaluationWorkflow="state.evaluationWorkflow"
v-model:metrics="state.metrics"
v-model:mockedNodes="state.mockedNodes"
:cancel-editing="cancelEditing"
:show-config="showConfig"
:tag-usage-count="tagUsageCount"
:all-tags="allTags"
:tags-by-id="tagsById"
:is-loading="isLoading"
:get-field-issues="getFieldIssues"
:start-editing="startEditing"
:save-changes="saveChanges"
:create-tag="handleCreateTag"
@open-pinning-modal="openPinningModal"
@delete-metric="onDeleteMetric"
/>
</div>
<Modal ref="nodePinningModal" width="80vw" height="85vh" :name="NODE_PINNING_MODAL_KEY">
<template #header>
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading">{{
locale.baseText('testDefinition.edit.selectNodes')
}}</N8nHeading>
</template>
<template #content>
<NodesPinning
v-model="state.mockedNodes"
:width="modalContentWidth"
data-test-id="nodes-pinning-modal"
/>
</template>
</Modal>
</div>
</template>
@ -392,104 +253,4 @@ watch(
overflow-y: auto;
}
}
.headerSection {
display: flex;
justify-content: space-between;
align-items: flex-start;
background-color: var(--color-background-light);
width: 100%;
}
.headerMeta {
max-width: 50%;
}
.name {
display: flex;
align-items: center;
.lastSaved {
font-size: var(--font-size-s);
color: var(--color-text-light);
}
}
.descriptionInput {
margin-top: var(--spacing-2xs);
}
.runs {
display: flex;
flex-direction: column;
gap: var(--spacing-m);
flex: 1;
padding-top: var(--spacing-3xs);
overflow: auto;
@media (min-height: 56rem) {
margin-top: var(--spacing-2xl);
}
}
.panelBlock {
width: var(--evaluation-edit-panel-width);
display: grid;
height: 100%;
overflow-y: auto;
flex-shrink: 0;
padding-bottom: var(--spacing-l);
margin-left: var(--spacing-2xl);
transition: width 0.2s ease;
&.hidden {
margin-left: 0;
width: 0;
overflow: hidden;
flex-shrink: 1;
}
.noRuns & {
overflow-y: initial;
}
}
.panelIntro {
font-size: var(--font-size-m);
color: var(--color-text-dark);
justify-self: center;
position: relative;
display: block;
}
.step {
position: relative;
&:not(:first-child) {
margin-top: var(--spacing-m);
}
}
.introArrow {
--arrow-height: 1.5rem;
margin-bottom: -1rem;
justify-self: center;
}
.evaluationArrows {
--arrow-height: 22rem;
display: flex;
justify-content: space-between;
width: 100%;
max-width: 80%;
margin: 0 auto;
margin-bottom: -100%;
z-index: 0;
}
.controls {
display: flex;
gap: var(--spacing-s);
}
</style>

View file

@ -1,15 +1,20 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { VIEWS } from '@/constants';
import { MODAL_CONFIRM, VIEWS } from '@/constants';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { useToast } from '@/composables/useToast';
import { useI18n } from '@/composables/useI18n';
import EmptyState from '@/components/TestDefinition/ListDefinition/EmptyState.vue';
import TestsList from '@/components/TestDefinition/ListDefinition/TestsList.vue';
import type { TestExecution, TestListItem } from '@/components/TestDefinition/types';
import type {
TestExecution,
TestItemAction,
TestListItem,
} from '@/components/TestDefinition/types';
import { useAnnotationTagsStore } from '@/stores/tags.store';
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
import { useMessage } from '@/composables/useMessage';
const router = useRouter();
const tagsStore = useAnnotationTagsStore();
@ -17,6 +22,46 @@ const testDefinitionStore = useTestDefinitionStore();
const isLoading = ref(false);
const toast = useToast();
const locale = useI18n();
const { confirm } = useMessage();
const actions = computed<TestItemAction[]>(() => [
{
icon: 'play',
id: 'run',
event: onRunTest,
disabled: isRunDisabled,
show: (testId) => !isTestRunning(testId),
tooltip: (testId) =>
isRunDisabled(testId)
? getDisabledRunTooltip(testId)
: locale.baseText('testDefinition.runTest'),
},
{
icon: 'stop',
id: 'cancel',
event: onCancelTestRun,
tooltip: () => locale.baseText('testDefinition.cancelTestRun'),
show: (testId) => isTestRunning(testId),
},
{
icon: 'list',
id: 'view',
event: onViewDetails,
tooltip: () => locale.baseText('testDefinition.viewDetails'),
},
{
icon: 'pen',
id: 'edit',
event: onEditTest,
tooltip: () => locale.baseText('testDefinition.editTest'),
},
{
icon: 'trash',
id: 'delete',
event: onDeleteTest,
tooltip: () => locale.baseText('testDefinition.deleteTest'),
},
]);
const tests = computed<TestListItem[]>(() => {
return (
@ -32,6 +77,7 @@ const tests = computed<TestListItem[]>(() => {
tagName: test.annotationTagId ? getTagName(test.annotationTagId) : '',
testCases: testDefinitionStore.testRunsByTestId[test.id]?.length ?? 0,
execution: getTestExecution(test.id),
fieldsIssues: testDefinitionStore.getFieldIssues(test.id),
}));
});
@ -43,6 +89,14 @@ function getTagName(tagId: string) {
return matchingTag?.name ?? '';
}
function getDisabledRunTooltip(testId: string) {
const issues = testDefinitionStore
.getFieldIssues(testId)
?.map((i) => i.message)
.join('<br />- ');
return `${locale.baseText('testDefinition.completeConfig')} <br /> - ${issues}`;
}
function getTestExecution(testId: string): TestExecution {
const lastRun = testDefinitionStore.lastRunByTestId[testId];
@ -67,6 +121,13 @@ function getTestExecution(testId: string): TestExecution {
return mockExecutions;
}
function isTestRunning(testId: string) {
return testDefinitionStore.lastRunByTestId[testId]?.status === 'running';
}
function isRunDisabled(testId: string) {
return testDefinitionStore.getFieldIssues(testId)?.length > 0;
}
// Action handlers
function onCreateTest() {
void router.push({ name: VIEWS.NEW_TEST_DEFINITION });
@ -91,8 +152,9 @@ async function onRunTest(testId: string) {
}
}
async function onCancelTestRun(testId: string, testRunId: string | null) {
async function onCancelTestRun(testId: string) {
try {
const testRunId = testDefinitionStore.lastRunByTestId[testId]?.id;
// FIXME: testRunId might be null for a short period of time between user clicking start and the test run being created and fetched. Just ignore it for now.
if (!testRunId) {
throw new Error('Failed to cancel test run');
@ -119,11 +181,25 @@ async function onViewDetails(testId: string) {
void router.push({ name: VIEWS.TEST_DEFINITION_RUNS, params: { testId } });
}
function onEditTest(testId: number) {
function onEditTest(testId: string) {
void router.push({ name: VIEWS.TEST_DEFINITION_EDIT, params: { testId } });
}
async function onDeleteTest(testId: string) {
const deleteConfirmed = await confirm(
locale.baseText('testDefinition.deleteTest.warning'),
locale.baseText('testDefinition.deleteTest'),
{
type: 'warning',
confirmButtonText: locale.baseText('generic.delete'),
cancelButtonText: locale.baseText('generic.cancel'),
closeOnClickModal: true,
},
);
if (deleteConfirmed !== MODAL_CONFIRM) {
return;
}
await testDefinitionStore.deleteById(testId);
toast.showMessage({
@ -138,12 +214,9 @@ async function loadInitialData() {
// Add guard to prevent multiple loading states
isLoading.value = true;
try {
await Promise.all([
tagsStore.fetchAll(),
testDefinitionStore.fetchAll({
workflowId: router.currentRoute.value.params.name as string,
}),
]);
await testDefinitionStore.fetchAll({
workflowId: router.currentRoute.value.params.name as string,
});
isLoading.value = false;
} catch (error) {
toast.showError(error, locale.baseText('testDefinition.list.loadError'));
@ -153,7 +226,7 @@ async function loadInitialData() {
}
}
onMounted(() => {
onMounted(async () => {
if (!testDefinitionStore.isFeatureEnabled) {
toast.showMessage({
title: locale.baseText('testDefinition.notImplemented'),
@ -166,8 +239,8 @@ onMounted(() => {
});
return; // Add early return to prevent loading if feature is disabled
}
void loadInitialData();
await loadInitialData();
tests.value.forEach((test) => testDefinitionStore.updateRunFieldIssues(test.id));
});
</script>
@ -186,12 +259,9 @@ onMounted(() => {
<TestsList
v-else
:tests="tests"
@create-test="onCreateTest"
@run-test="onRunTest"
:actions="actions"
@view-details="onViewDetails"
@edit-test="onEditTest"
@delete-test="onDeleteTest"
@cancel-test-run="onCancelTestRun"
@create-test="onCreateTest"
/>
</template>
</div>

View file

@ -4,10 +4,11 @@ import { useRouter } from 'vue-router';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import type { TestRunRecord } from '@/api/testDefinition.ee';
import TestRunsTable from '@/components/TestDefinition/ListRuns/TestRunsTable.vue';
import { VIEWS } from '@/constants';
import { MODAL_CONFIRM, VIEWS } from '@/constants';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import { useUIStore } from '@/stores/ui.store';
import { useMessage } from '@/composables/useMessage';
const router = useRouter();
const testDefinitionStore = useTestDefinitionStore();
@ -75,6 +76,19 @@ async function runTest() {
}
async function onDeleteRuns(runsToDelete: TestRunRecord[]) {
const { confirm } = useMessage();
const deleteConfirmed = await confirm(locale.baseText('testDefinition.deleteTest'), {
type: 'warning',
confirmButtonText: locale.baseText(
'settings.log-streaming.destinationDelete.confirmButtonText',
),
cancelButtonText: locale.baseText('settings.log-streaming.destinationDelete.cancelButtonText'),
});
if (deleteConfirmed !== MODAL_CONFIRM) {
return;
}
await Promise.all(
runsToDelete.map(async (run) => {
await testDefinitionStore.deleteTestRun({ testDefinitionId: testId.value, runId: run.id });
@ -98,7 +112,7 @@ onMounted(async () => {
<N8nLoading :rows="5" />
<N8nLoading :rows="10" />
</template>
<div :class="$style.details" v-else-if="runs.length > 0">
<div v-else-if="runs.length > 0" :class="$style.details">
<MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" :theme="appliedTheme" />
<TestRunsTable :runs="runs" @get-run-detail="getRunDetail" @delete-runs="onDeleteRuns" />
</div>

View file

@ -147,21 +147,6 @@ describe('TestDefinitionEditView', () => {
expect(createTestMock).toHaveBeenCalled();
});
it('should update test and show success message on save if testId is present', async () => {
vi.mocked(useRoute).mockReturnValue({
params: { testId: '1' },
name: VIEWS.TEST_DEFINITION_EDIT,
} as unknown as ReturnType<typeof useRoute>);
const { getByTestId } = renderComponentWithFeatureEnabled();
const saveButton = getByTestId('run-test-button');
saveButton.click();
await nextTick();
expect(updateTestMock).toHaveBeenCalledWith('1');
});
it('should show error message on failed test creation', async () => {
createTestMock.mockRejectedValue(new Error('Save failed'));
@ -180,53 +165,39 @@ describe('TestDefinitionEditView', () => {
expect(showErrorMock).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
});
it('should display "Save Test" button when editing test without eval workflow and tags', async () => {
it('should display disabled "run test" button when editing test without tags', async () => {
vi.mocked(useRoute).mockReturnValue({
params: { testId: '1' },
name: VIEWS.TEST_DEFINITION_EDIT,
} as unknown as ReturnType<typeof useRoute>);
const { getByTestId } = renderComponentWithFeatureEnabled();
const { getByTestId, mockedTestDefinitionStore } = renderComponentWithFeatureEnabled();
mockedTestDefinitionStore.getFieldIssues = vi
.fn()
.mockReturnValue([{ field: 'tags', message: 'Tag is required' }]);
await nextTick();
const updateButton = getByTestId('run-test-button');
expect(updateButton.textContent?.toLowerCase()).toContain('save');
});
expect(updateButton.textContent?.toLowerCase()).toContain('run test');
expect(updateButton).toHaveClass('disabled');
it('should display "Save Test" button when creating new test', async () => {
vi.mocked(useRoute).mockReturnValue({
params: {},
name: VIEWS.NEW_TEST_DEFINITION,
} as unknown as ReturnType<typeof useRoute>);
const { getByTestId } = renderComponentWithFeatureEnabled();
const saveButton = getByTestId('run-test-button');
expect(saveButton.textContent?.toLowerCase()).toContain('save test');
mockedTestDefinitionStore.getFieldIssues = vi.fn().mockReturnValue([]);
await nextTick();
expect(updateButton).not.toHaveClass('disabled');
});
it('should apply "has-issues" class to inputs with issues', async () => {
vi.mocked(useTestDefinitionForm).mockReturnValue({
...vi.mocked(useTestDefinitionForm)(),
fieldsIssues: ref([
{ field: 'name', message: 'Name is required' },
{ field: 'tags', message: 'Tag is required' },
]),
} as unknown as ReturnType<typeof useTestDefinitionForm>);
const { container } = renderComponentWithFeatureEnabled();
const { container, mockedTestDefinitionStore } = renderComponentWithFeatureEnabled();
mockedTestDefinitionStore.getFieldIssues = vi
.fn()
.mockReturnValue([{ field: 'tags', message: 'Tag is required' }]);
await nextTick();
const issueElements = container.querySelectorAll('.has-issues');
expect(issueElements.length).toBeGreaterThan(0);
});
it('should fetch all tags on mount', async () => {
renderComponentWithFeatureEnabled();
await nextTick();
expect(mockedStore(useAnnotationTagsStore).fetchAll).toHaveBeenCalled();
});
describe('Test Runs functionality', () => {
it('should display test runs table when runs exist', async () => {
vi.mocked(useRoute).mockReturnValue({

View file

@ -6,21 +6,22 @@ import { createComponentRenderer } from '@/__tests__/render';
import TestDefinitionListView from '@/views/TestDefinition/TestDefinitionListView.vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from '@/composables/useToast';
import { useAnnotationTagsStore } from '@/stores/tags.store';
import { useMessage } from '@/composables/useMessage';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { nextTick, ref } from 'vue';
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
import { VIEWS } from '@/constants';
import { MODAL_CONFIRM, VIEWS } from '@/constants';
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
vi.mock('vue-router');
vi.mock('@/composables/useToast');
vi.mock('@/composables/useMessage');
describe('TestDefinitionListView', () => {
const renderComponent = createComponentRenderer(TestDefinitionListView);
let showMessageMock: Mock;
let showErrorMock: Mock;
let confirmMock: Mock;
let startTestRunMock: Mock;
let fetchTestRunsMock: Mock;
let deleteByIdMock: Mock;
@ -65,6 +66,7 @@ describe('TestDefinitionListView', () => {
showMessageMock = vi.fn();
showErrorMock = vi.fn();
confirmMock = vi.fn().mockResolvedValue(MODAL_CONFIRM);
startTestRunMock = vi.fn().mockResolvedValue({ success: true });
fetchTestRunsMock = vi.fn();
deleteByIdMock = vi.fn();
@ -74,6 +76,10 @@ describe('TestDefinitionListView', () => {
showMessage: showMessageMock,
showError: showErrorMock,
} as unknown as ReturnType<typeof useToast>);
vi.mocked(useMessage).mockReturnValue({
confirm: confirmMock,
} as unknown as ReturnType<typeof useMessage>);
});
afterEach(() => {
@ -89,7 +95,6 @@ describe('TestDefinitionListView', () => {
setActivePinia(pinia);
const testDefinitionStore = mockedStore(useTestDefinitionStore);
// const tagsStore = mockedStore(useAnnotationTagsStore);
testDefinitionStore.isFeatureEnabled = true;
testDefinitionStore.fetchAll = fetchAllMock;
testDefinitionStore.startTestRun = startTestRunMock;
@ -120,7 +125,6 @@ describe('TestDefinitionListView', () => {
expect(testDefinitionStore.fetchAll).toHaveBeenCalledWith({
workflowId: 'workflow1',
});
expect(mockedStore(useAnnotationTagsStore).fetchAll).toHaveBeenCalled();
});
it('should start test run and show success message', async () => {
@ -150,13 +154,12 @@ describe('TestDefinitionListView', () => {
});
it('should delete test and show success message', async () => {
const { getByTestId, testDefinitionStore } = await renderComponentWithFeatureEnabled();
const { getByTestId } = await renderComponentWithFeatureEnabled();
const deleteButton = getByTestId('delete-test-button-1');
deleteButton.click();
await nextTick();
await waitAllPromises();
expect(testDefinitionStore.deleteById).toHaveBeenCalledWith('1');
expect(deleteByIdMock).toHaveBeenCalledWith('1');
expect(showMessageMock).toHaveBeenCalledWith({
title: expect.any(String),
type: 'success',