mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(editor): Evaluation UI tweaks (#12659) (no-changelog)
This commit is contained in:
parent
b66a9dc8fb
commit
89777d32ac
|
@ -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>
|
|
|
@ -9,6 +9,8 @@ interface EvaluationStep {
|
||||||
small?: boolean;
|
small?: boolean;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
issues?: Array<{ field: string; message: string }>;
|
||||||
|
showIssues?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<EvaluationStep>(), {
|
const props = withDefaults(defineProps<EvaluationStep>(), {
|
||||||
|
@ -16,6 +18,8 @@ const props = withDefaults(defineProps<EvaluationStep>(), {
|
||||||
warning: false,
|
warning: false,
|
||||||
small: false,
|
small: false,
|
||||||
expanded: true,
|
expanded: true,
|
||||||
|
issues: () => [],
|
||||||
|
showIssues: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const locale = useI18n();
|
const locale = useI18n();
|
||||||
|
@ -46,7 +50,11 @@ const toggleExpand = async () => {
|
||||||
<slot name="icon" />
|
<slot name="icon" />
|
||||||
</div>
|
</div>
|
||||||
<h3 :class="$style.title">{{ title }}</h3>
|
<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
|
<button
|
||||||
v-if="$slots.cardContent"
|
v-if="$slots.cardContent"
|
||||||
:class="$style.collapseButton"
|
:class="$style.collapseButton"
|
||||||
|
|
|
@ -115,7 +115,13 @@ onMounted(loadData);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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" />
|
<N8nSpinner v-if="isLoading" size="xlarge" type="dots" :class="$style.spinner" />
|
||||||
<Canvas
|
<Canvas
|
||||||
:id="canvasId"
|
:id="canvasId"
|
||||||
|
@ -185,4 +191,11 @@ onMounted(loadData);
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
}
|
}
|
||||||
|
.noNodes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<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 TimeAgo from '@/components/TimeAgo.vue';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import n8nIconButton from 'n8n-design-system/components/N8nIconButton';
|
import n8nIconButton from 'n8n-design-system/components/N8nIconButton';
|
||||||
|
@ -7,55 +7,19 @@ import { computed } from 'vue';
|
||||||
|
|
||||||
export interface TestItemProps {
|
export interface TestItemProps {
|
||||||
test: TestListItem;
|
test: TestListItem;
|
||||||
|
actions: TestItemAction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<TestItemProps>();
|
const props = defineProps<TestItemProps>();
|
||||||
const locale = useI18n();
|
const locale = useI18n();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
defineEmits<{
|
||||||
'run-test': [testId: string];
|
|
||||||
'view-details': [testId: string];
|
'view-details': [testId: string];
|
||||||
'edit-test': [testId: string];
|
|
||||||
'delete-test': [testId: string];
|
|
||||||
'cancel-test-run': [testId: string, testRunId: string | null];
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const actions = [
|
const visibleActions = computed(() =>
|
||||||
{
|
props.actions.filter((action) => action.show?.(props.test.id) ?? true),
|
||||||
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));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -102,17 +66,16 @@ const visibleActions = computed(() => actions.filter((action) => action.show?.()
|
||||||
:key="action.icon"
|
:key="action.icon"
|
||||||
placement="top"
|
placement="top"
|
||||||
:show-after="1000"
|
:show-after="1000"
|
||||||
|
:content="action.tooltip(test.id)"
|
||||||
>
|
>
|
||||||
<template #content>
|
|
||||||
{{ action.tooltip }}
|
|
||||||
</template>
|
|
||||||
<component
|
<component
|
||||||
:is="n8nIconButton"
|
:is="n8nIconButton"
|
||||||
:icon="action.icon"
|
:icon="action.icon"
|
||||||
:data-test-id="`${action.id}-test-button-${test.id}`"
|
:data-test-id="`${action.id}-test-button-${test.id}`"
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
size="mini"
|
size="mini"
|
||||||
@click.stop="action.event"
|
:disabled="action?.disabled ? action.disabled(test.id) : false"
|
||||||
|
@click.stop="action.event(test.id)"
|
||||||
/>
|
/>
|
||||||
</n8n-tooltip>
|
</n8n-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import TestItem from './TestItem.vue';
|
import TestItem from './TestItem.vue';
|
||||||
import type { TestListItem } from '@/components/TestDefinition/types';
|
import type { TestListItem, TestItemAction } from '@/components/TestDefinition/types';
|
||||||
export interface TestListProps {
|
export interface TestListProps {
|
||||||
tests: TestListItem[];
|
tests: TestListItem[];
|
||||||
|
actions: TestItemAction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
defineEmits<{ 'create-test': [] }>();
|
defineEmits<{ 'create-test': []; 'view-details': [testId: string] }>();
|
||||||
defineProps<TestListProps>();
|
defineProps<TestListProps>();
|
||||||
const locale = useI18n();
|
const locale = useI18n();
|
||||||
</script>
|
</script>
|
||||||
|
@ -19,7 +20,13 @@ const locale = useI18n();
|
||||||
@click="$emit('create-test')"
|
@click="$emit('create-test')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -76,7 +76,7 @@ function onSelectionChange(runs: TestRunRecord[]) {
|
||||||
emit('selectionChange', runs);
|
emit('selectionChange', runs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteRuns() {
|
async function deleteRuns() {
|
||||||
emit('deleteRuns', selectedRows.value);
|
emit('deleteRuns', selectedRows.value);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -41,11 +41,8 @@ export function useTestDefinitionForm() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const isSaving = ref(false);
|
const isSaving = ref(false);
|
||||||
const fieldsIssues = ref<Array<{ field: string; message: string }>>([]);
|
|
||||||
const fields = ref<FormRefs>({} as FormRefs);
|
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<{
|
const editableFields: ComputedRef<{
|
||||||
name: EditableField<string>;
|
name: EditableField<string>;
|
||||||
tags: EditableField<string[]>;
|
tags: EditableField<string[]>;
|
||||||
|
@ -99,7 +96,6 @@ export function useTestDefinitionForm() {
|
||||||
if (isSaving.value) return;
|
if (isSaving.value) return;
|
||||||
|
|
||||||
isSaving.value = true;
|
isSaving.value = true;
|
||||||
fieldsIssues.value = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = {
|
const params = {
|
||||||
|
@ -144,7 +140,6 @@ export function useTestDefinitionForm() {
|
||||||
if (isSaving.value) return;
|
if (isSaving.value) return;
|
||||||
|
|
||||||
isSaving.value = true;
|
isSaving.value = true;
|
||||||
fieldsIssues.value = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!testId) {
|
if (!testId) {
|
||||||
|
@ -232,7 +227,6 @@ export function useTestDefinitionForm() {
|
||||||
state,
|
state,
|
||||||
fields,
|
fields,
|
||||||
isSaving: computed(() => isSaving.value),
|
isSaving: computed(() => isSaving.value),
|
||||||
fieldsIssues: computed(() => fieldsIssues.value),
|
|
||||||
deleteMetric,
|
deleteMetric,
|
||||||
updateMetrics,
|
updateMetrics,
|
||||||
loadTestData,
|
loadTestData,
|
||||||
|
|
|
@ -7,6 +7,15 @@ export interface EditableField<T = string> {
|
||||||
isEditing: boolean;
|
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 {
|
export interface EditableFormState {
|
||||||
name: EditableField<string>;
|
name: EditableField<string>;
|
||||||
tags: EditableField<string[]>;
|
tags: EditableField<string[]>;
|
||||||
|
@ -33,4 +42,5 @@ export interface TestListItem {
|
||||||
tagName: string;
|
tagName: string;
|
||||||
testCases: number;
|
testCases: number;
|
||||||
execution: TestExecution;
|
execution: TestExecution;
|
||||||
|
fieldsIssues?: Array<{ field: string; message: string }>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -258,6 +258,7 @@ const onAddResourceClicked = () => {
|
||||||
}"
|
}"
|
||||||
:width="width"
|
:width="width"
|
||||||
:event-bus="eventBus"
|
:event-bus="eventBus"
|
||||||
|
:value="modelValue"
|
||||||
@update:model-value="onListItemSelected"
|
@update:model-value="onListItemSelected"
|
||||||
@filter="onSearchFilter"
|
@filter="onSearchFilter"
|
||||||
@load-more="populateNextWorkflowsPage"
|
@load-more="populateNextWorkflowsPage"
|
||||||
|
|
|
@ -229,6 +229,12 @@ async function handleActionItemClick(commandData: Command) {
|
||||||
</template>
|
</template>
|
||||||
<FontAwesomeIcon icon="flask" />
|
<FontAwesomeIcon icon="flask" />
|
||||||
</N8nTooltip>
|
</N8nTooltip>
|
||||||
|
<N8nTooltip v-if="execution.mode === 'evaluation'" placement="top">
|
||||||
|
<template #content>
|
||||||
|
<span>{{ i18n.baseText('executionsList.evaluation') }}</span>
|
||||||
|
</template>
|
||||||
|
<FontAwesomeIcon icon="tasks" />
|
||||||
|
</N8nTooltip>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div :class="$style.buttonCell">
|
<div :class="$style.buttonCell">
|
||||||
|
|
|
@ -167,11 +167,13 @@ function onRetryMenuItemSelect(action: string): void {
|
||||||
<template #content>
|
<template #content>
|
||||||
<span>{{ locale.baseText('executionsList.test') }}</span>
|
<span>{{ locale.baseText('executionsList.test') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon :class="[$style.icon, $style.manual]" icon="flask" />
|
||||||
v-if="execution.mode === 'manual'"
|
</N8nTooltip>
|
||||||
:class="[$style.icon, $style.manual]"
|
<N8nTooltip v-if="execution.mode === 'evaluation'" placement="top">
|
||||||
icon="flask"
|
<template #content>
|
||||||
/>
|
<span>{{ locale.baseText('executionsList.evaluation') }}</span>
|
||||||
|
</template>
|
||||||
|
<FontAwesomeIcon :class="[$style.icon, $style.evaluation]" icon="tasks" />
|
||||||
</N8nTooltip>
|
</N8nTooltip>
|
||||||
</div>
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
|
@ -751,6 +751,7 @@
|
||||||
"executionsList.selected": "{count} execution selected: | {count} executions selected:",
|
"executionsList.selected": "{count} execution selected: | {count} executions selected:",
|
||||||
"executionsList.selectAll": "Select {executionNum} finished execution | Select all {executionNum} finished executions",
|
"executionsList.selectAll": "Select {executionNum} finished execution | Select all {executionNum} finished executions",
|
||||||
"executionsList.test": "Test execution",
|
"executionsList.test": "Test execution",
|
||||||
|
"executionsList.evaluation": "Evaluation execution",
|
||||||
"executionsList.showError.handleDeleteSelected.title": "Problem deleting executions",
|
"executionsList.showError.handleDeleteSelected.title": "Problem deleting executions",
|
||||||
"executionsList.showError.loadMore.title": "Problem loading executions",
|
"executionsList.showError.loadMore.title": "Problem loading executions",
|
||||||
"executionsList.showError.loadWorkflows.title": "Problem loading workflows",
|
"executionsList.showError.loadWorkflows.title": "Problem loading workflows",
|
||||||
|
@ -2799,6 +2800,8 @@
|
||||||
"testDefinition.edit.testSaveFailed": "Failed to save test",
|
"testDefinition.edit.testSaveFailed": "Failed to save test",
|
||||||
"testDefinition.edit.description": "Description",
|
"testDefinition.edit.description": "Description",
|
||||||
"testDefinition.edit.description.description": "Add details about what this test evaluates and what success looks like",
|
"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.tagName": "Tag name",
|
||||||
"testDefinition.edit.step.intro": "When running a test",
|
"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",
|
"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.viewDetails": "View Details",
|
||||||
"testDefinition.editTest": "Edit Test",
|
"testDefinition.editTest": "Edit Test",
|
||||||
"testDefinition.deleteTest": "Delete 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.title": "Get {credits} free OpenAI API credits",
|
||||||
"freeAi.credits.callout.claim.button.label": "Claim 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:",
|
"freeAi.credits.callout.success.title.part1": "Claimed {credits} free OpenAI API credits! Please note these free credits are only for the following models:",
|
||||||
|
|
|
@ -2,7 +2,9 @@ import { createPinia, setActivePinia } from 'pinia';
|
||||||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee'; // Adjust the import path as necessary
|
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee'; // Adjust the import path as necessary
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
import { usePostHog } from '@/stores/posthog.store';
|
import { usePostHog } from '@/stores/posthog.store';
|
||||||
|
import { useAnnotationTagsStore } from '@/stores/tags.store';
|
||||||
import type { TestDefinitionRecord, TestRunRecord } from '@/api/testDefinition.ee';
|
import type { TestDefinitionRecord, TestRunRecord } from '@/api/testDefinition.ee';
|
||||||
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
createTestDefinition,
|
createTestDefinition,
|
||||||
|
@ -101,6 +103,7 @@ describe('testDefinition.store.ee', () => {
|
||||||
rootStoreMock = useRootStore();
|
rootStoreMock = useRootStore();
|
||||||
posthogStoreMock = usePostHog();
|
posthogStoreMock = usePostHog();
|
||||||
|
|
||||||
|
mockedStore(useAnnotationTagsStore).fetchAll = vi.fn().mockResolvedValue([]);
|
||||||
getTestDefinitions.mockResolvedValue({
|
getTestDefinitions.mockResolvedValue({
|
||||||
count: 2,
|
count: 2,
|
||||||
testDefinitions: [TEST_DEF_A, TEST_DEF_B],
|
testDefinitions: [TEST_DEF_A, TEST_DEF_B],
|
||||||
|
@ -114,6 +117,7 @@ describe('testDefinition.store.ee', () => {
|
||||||
getTestRun.mockResolvedValue(TEST_RUN);
|
getTestRun.mockResolvedValue(TEST_RUN);
|
||||||
startTestRun.mockResolvedValue({ success: true });
|
startTestRun.mockResolvedValue({ success: true });
|
||||||
deleteTestRun.mockResolvedValue({ success: true });
|
deleteTestRun.mockResolvedValue({ success: true });
|
||||||
|
getTestMetrics.mockResolvedValue([TEST_METRIC]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Initialization', () => {
|
test('Initialization', () => {
|
||||||
|
@ -276,8 +280,6 @@ describe('testDefinition.store.ee', () => {
|
||||||
|
|
||||||
describe('Metrics', () => {
|
describe('Metrics', () => {
|
||||||
test('Fetching Metrics for a Test Definition', async () => {
|
test('Fetching Metrics for a Test Definition', async () => {
|
||||||
getTestMetrics.mockResolvedValue([TEST_METRIC]);
|
|
||||||
|
|
||||||
const metrics = await store.fetchMetrics('1');
|
const metrics = await store.fetchMetrics('1');
|
||||||
|
|
||||||
expect(getTestMetrics).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1');
|
expect(getTestMetrics).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1');
|
||||||
|
|
|
@ -5,6 +5,10 @@ import * as testDefinitionsApi from '@/api/testDefinition.ee';
|
||||||
import type { TestDefinitionRecord, TestRunRecord } from '@/api/testDefinition.ee';
|
import type { TestDefinitionRecord, TestRunRecord } from '@/api/testDefinition.ee';
|
||||||
import { usePostHog } from './posthog.store';
|
import { usePostHog } from './posthog.store';
|
||||||
import { STORES, WORKFLOW_EVALUATION_EXPERIMENT } from '@/constants';
|
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(
|
export const useTestDefinitionStore = defineStore(
|
||||||
STORES.TEST_DEFINITION,
|
STORES.TEST_DEFINITION,
|
||||||
|
@ -16,11 +20,13 @@ export const useTestDefinitionStore = defineStore(
|
||||||
const metricsById = ref<Record<string, testDefinitionsApi.TestMetricRecord>>({});
|
const metricsById = ref<Record<string, testDefinitionsApi.TestMetricRecord>>({});
|
||||||
const testRunsById = ref<Record<string, TestRunRecord>>({});
|
const testRunsById = ref<Record<string, TestRunRecord>>({});
|
||||||
const pollingTimeouts = ref<Record<string, NodeJS.Timeout>>({});
|
const pollingTimeouts = ref<Record<string, NodeJS.Timeout>>({});
|
||||||
|
const fieldsIssues = ref<Record<string, FieldIssue[]>>({});
|
||||||
|
|
||||||
// Store instances
|
// Store instances
|
||||||
const posthogStore = usePostHog();
|
const posthogStore = usePostHog();
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
|
const tagsStore = useAnnotationTagsStore();
|
||||||
|
const locale = useI18n();
|
||||||
// Computed
|
// Computed
|
||||||
const allTestDefinitions = computed(() => {
|
const allTestDefinitions = computed(() => {
|
||||||
return Object.values(testDefinitionsById.value).sort((a, b) =>
|
return Object.values(testDefinitionsById.value).sort((a, b) =>
|
||||||
|
@ -100,6 +106,8 @@ export const useTestDefinitionStore = defineStore(
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getFieldIssues = (testId: string) => fieldsIssues.value[testId] || [];
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
const setAllTestDefinitions = (definitions: TestDefinitionRecord[]) => {
|
const setAllTestDefinitions = (definitions: TestDefinitionRecord[]) => {
|
||||||
testDefinitionsById.value = definitions.reduce(
|
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 fetchTestDefinition = async (id: string) => {
|
||||||
const testDefinition = await testDefinitionsApi.getTestDefinition(
|
const testDefinition = await testDefinitionsApi.getTestDefinition(
|
||||||
rootStore.restApiContext,
|
rootStore.restApiContext,
|
||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
testDefinitionsById.value[testDefinition.id] = testDefinition;
|
testDefinitionsById.value[testDefinition.id] = testDefinition;
|
||||||
|
updateRunFieldIssues(id);
|
||||||
return testDefinition;
|
return testDefinition;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -178,7 +191,11 @@ export const useTestDefinitionStore = defineStore(
|
||||||
setAllTestDefinitions(retrievedDefinitions.testDefinitions);
|
setAllTestDefinitions(retrievedDefinitions.testDefinitions);
|
||||||
fetchedAll.value = true;
|
fetchedAll.value = true;
|
||||||
|
|
||||||
await fetchRunsForAllTests();
|
await Promise.all([
|
||||||
|
tagsStore.fetchAll({ withUsageCount: true }),
|
||||||
|
fetchRunsForAllTests(),
|
||||||
|
fetchMetricsForAllTests(),
|
||||||
|
]);
|
||||||
return retrievedDefinitions;
|
return retrievedDefinitions;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
@ -203,6 +220,7 @@ export const useTestDefinitionStore = defineStore(
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
upsertTestDefinitions([createdDefinition]);
|
upsertTestDefinitions([createdDefinition]);
|
||||||
|
updateRunFieldIssues(createdDefinition.id);
|
||||||
return createdDefinition;
|
return createdDefinition;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -216,6 +234,7 @@ export const useTestDefinitionStore = defineStore(
|
||||||
updateParams,
|
updateParams,
|
||||||
);
|
);
|
||||||
upsertTestDefinitions([updatedDefinition]);
|
upsertTestDefinitions([updatedDefinition]);
|
||||||
|
updateRunFieldIssues(params.id);
|
||||||
return updatedDefinition;
|
return updatedDefinition;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -240,9 +259,9 @@ export const useTestDefinitionStore = defineStore(
|
||||||
try {
|
try {
|
||||||
const metrics = await testDefinitionsApi.getTestMetrics(rootStore.restApiContext, testId);
|
const metrics = await testDefinitionsApi.getTestMetrics(rootStore.restApiContext, testId);
|
||||||
metrics.forEach((metric) => {
|
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 {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
@ -253,7 +272,7 @@ export const useTestDefinitionStore = defineStore(
|
||||||
testDefinitionId: string;
|
testDefinitionId: string;
|
||||||
}): Promise<testDefinitionsApi.TestMetricRecord> => {
|
}): Promise<testDefinitionsApi.TestMetricRecord> => {
|
||||||
const metric = await testDefinitionsApi.createTestMetric(rootStore.restApiContext, params);
|
const metric = await testDefinitionsApi.createTestMetric(rootStore.restApiContext, params);
|
||||||
metricsById.value[metric.id] = metric;
|
metricsById.value[metric.id] = { ...metric, testDefinitionId: params.testDefinitionId };
|
||||||
return metric;
|
return metric;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -261,7 +280,9 @@ export const useTestDefinitionStore = defineStore(
|
||||||
params: testDefinitionsApi.TestMetricRecord,
|
params: testDefinitionsApi.TestMetricRecord,
|
||||||
): Promise<testDefinitionsApi.TestMetricRecord> => {
|
): Promise<testDefinitionsApi.TestMetricRecord> => {
|
||||||
const metric = await testDefinitionsApi.updateTestMetric(rootStore.restApiContext, params);
|
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;
|
return metric;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -271,6 +292,8 @@ export const useTestDefinitionStore = defineStore(
|
||||||
await testDefinitionsApi.deleteTestMetric(rootStore.restApiContext, params);
|
await testDefinitionsApi.deleteTestMetric(rootStore.restApiContext, params);
|
||||||
const { [params.id]: deleted, ...rest } = metricsById.value;
|
const { [params.id]: deleted, ...rest } = metricsById.value;
|
||||||
metricsById.value = rest;
|
metricsById.value = rest;
|
||||||
|
|
||||||
|
updateRunFieldIssues(params.testDefinitionId);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Test Runs Methods
|
// Test Runs Methods
|
||||||
|
@ -296,6 +319,7 @@ export const useTestDefinitionStore = defineStore(
|
||||||
const getTestRun = async (params: { testDefinitionId: string; runId: string }) => {
|
const getTestRun = async (params: { testDefinitionId: string; runId: string }) => {
|
||||||
const run = await testDefinitionsApi.getTestRun(rootStore.restApiContext, params);
|
const run = await testDefinitionsApi.getTestRun(rootStore.restApiContext, params);
|
||||||
testRunsById.value[run.id] = run;
|
testRunsById.value[run.id] = run;
|
||||||
|
updateRunFieldIssues(params.testDefinitionId);
|
||||||
return run;
|
return run;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -346,6 +370,52 @@ export const useTestDefinitionStore = defineStore(
|
||||||
pollingTimeouts.value = {};
|
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 {
|
return {
|
||||||
// State
|
// State
|
||||||
fetchedAll,
|
fetchedAll,
|
||||||
|
@ -381,6 +451,8 @@ export const useTestDefinitionStore = defineStore(
|
||||||
cancelTestRun,
|
cancelTestRun,
|
||||||
deleteTestRun,
|
deleteTestRun,
|
||||||
cleanupPolling,
|
cleanupPolling,
|
||||||
|
getFieldIssues,
|
||||||
|
updateRunFieldIssues,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
|
|
|
@ -8,19 +8,12 @@ import { useAnnotationTagsStore } from '@/stores/tags.store';
|
||||||
import { useDebounce } from '@/composables/useDebounce';
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
import { useTestDefinitionForm } from '@/components/TestDefinition/composables/useTestDefinitionForm';
|
import { useTestDefinitionForm } from '@/components/TestDefinition/composables/useTestDefinitionForm';
|
||||||
|
|
||||||
import EvaluationHeader from '@/components/TestDefinition/EditDefinition/EvaluationHeader.vue';
|
import HeaderSection from '@/components/TestDefinition/EditDefinition/sections/HeaderSection.vue';
|
||||||
import DescriptionInput from '@/components/TestDefinition/EditDefinition/DescriptionInput.vue';
|
import RunsSection from '@/components/TestDefinition/EditDefinition/sections/RunsSection.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 type { TestMetricRecord, TestRunRecord } from '@/api/testDefinition.ee';
|
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 { useUIStore } from '@/stores/ui.store';
|
||||||
import TestRunsTable from '@/components/TestDefinition/ListRuns/TestRunsTable.vue';
|
|
||||||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||||
|
import ConfigSection from '@/components/TestDefinition/EditDefinition/sections/ConfigSection.vue';
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
testId?: string;
|
testId?: string;
|
||||||
}>();
|
}>();
|
||||||
|
@ -33,9 +26,9 @@ const toast = useToast();
|
||||||
const testDefinitionStore = useTestDefinitionStore();
|
const testDefinitionStore = useTestDefinitionStore();
|
||||||
const tagsStore = useAnnotationTagsStore();
|
const tagsStore = useAnnotationTagsStore();
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
fieldsIssues,
|
|
||||||
isSaving,
|
isSaving,
|
||||||
cancelEditing,
|
cancelEditing,
|
||||||
loadTestData,
|
loadTestData,
|
||||||
|
@ -58,11 +51,11 @@ const tagUsageCount = computed(
|
||||||
() => tagsStore.tagsById[state.value.tags.value[0]]?.usageCount ?? 0,
|
() => tagsStore.tagsById[state.value.tags.value[0]]?.usageCount ?? 0,
|
||||||
);
|
);
|
||||||
const hasRuns = computed(() => runs.value.length > 0);
|
const hasRuns = computed(() => runs.value.length > 0);
|
||||||
const nodePinningModal = ref<ModalState | null>(null);
|
|
||||||
const modalContentWidth = ref(0);
|
|
||||||
const showConfig = ref(true);
|
const showConfig = ref(true);
|
||||||
const selectedMetric = ref<string>('');
|
const selectedMetric = ref<string>('');
|
||||||
|
|
||||||
|
const fieldsIssues = computed(() => testDefinitionStore.getFieldIssues(testId.value) ?? []);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!testDefinitionStore.isFeatureEnabled) {
|
if (!testDefinitionStore.isFeatureEnabled) {
|
||||||
toast.showMessage({
|
toast.showMessage({
|
||||||
|
@ -76,7 +69,6 @@ onMounted(async () => {
|
||||||
});
|
});
|
||||||
return; // Add early return to prevent loading if feature is disabled
|
return; // Add early return to prevent loading if feature is disabled
|
||||||
}
|
}
|
||||||
void tagsStore.fetchAll({ withUsageCount: true });
|
|
||||||
if (testId.value) {
|
if (testId.value) {
|
||||||
await loadTestData(testId.value);
|
await loadTestData(testId.value);
|
||||||
} else {
|
} else {
|
||||||
|
@ -103,8 +95,8 @@ async function onSaveTest() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasIssues(key: string) {
|
function getFieldIssues(key: string) {
|
||||||
return fieldsIssues.value.some((issue) => issue.field === key);
|
return fieldsIssues.value.filter((issue) => issue.field === key);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onDeleteMetric(deletedMetric: Partial<TestMetricRecord>) {
|
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[]) {
|
async function onDeleteRuns(toDelete: TestRunRecord[]) {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
toDelete.map(async (run) => {
|
toDelete.map(async (run) => {
|
||||||
|
@ -172,197 +167,63 @@ watch(
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="[$style.container, { [$style.noRuns]: !hasRuns }]">
|
<div :class="[$style.container, { [$style.noRuns]: !hasRuns }]">
|
||||||
<div :class="$style.headerSection">
|
<HeaderSection
|
||||||
<div :class="$style.headerMeta">
|
v-model:name="state.name"
|
||||||
<div :class="$style.name">
|
v-model:description="state.description"
|
||||||
<EvaluationHeader
|
v-model:tags="state.tags"
|
||||||
v-model="state.name"
|
:has-runs="hasRuns"
|
||||||
:class="{ 'has-issues': hasIssues('name') }"
|
:is-saving="isSaving"
|
||||||
|
:get-field-issues="getFieldIssues"
|
||||||
:start-editing="startEditing"
|
:start-editing="startEditing"
|
||||||
:save-changes="saveChanges"
|
:save-changes="saveChanges"
|
||||||
:handle-keydown="handleKeydown"
|
:handle-keydown="handleKeydown"
|
||||||
/>
|
:on-save-test="onSaveTest"
|
||||||
<div :class="$style.lastSaved">
|
:run-test="runTest"
|
||||||
<template v-if="isSaving">
|
:show-config="showConfig"
|
||||||
{{ locale.baseText('testDefinition.edit.saving') }}
|
: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>
|
||||||
<template v-else> {{ locale.baseText('testDefinition.edit.saved') }} </template>
|
<template v-if="isRunning">
|
||||||
</div>
|
{{ locale.baseText('testDefinition.testIsRunning') }}
|
||||||
</div>
|
</template>
|
||||||
<DescriptionInput
|
</template>
|
||||||
v-model="state.description"
|
</HeaderSection>
|
||||||
: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>
|
|
||||||
|
|
||||||
<div :class="$style.content">
|
<div :class="$style.content">
|
||||||
<div v-if="runs.length > 0" :class="$style.runs">
|
<RunsSection
|
||||||
<!-- Metrics Chart -->
|
v-if="runs.length > 0"
|
||||||
<MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" :theme="appliedTheme" />
|
v-model:selectedMetric="selectedMetric"
|
||||||
<!-- Past Runs Table -->
|
|
||||||
<TestRunsTable
|
|
||||||
:class="$style.runsTable"
|
|
||||||
:runs="runs"
|
:runs="runs"
|
||||||
:selectable="true"
|
:test-id="testId"
|
||||||
data-test-id="past-runs-table"
|
:applied-theme="appliedTheme"
|
||||||
@delete-runs="onDeleteRuns"
|
@delete-runs="onDeleteRuns"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div :class="[$style.panelBlock, { [$style.hidden]: !showConfig }]">
|
<ConfigSection
|
||||||
<div :class="$style.panelIntro">
|
v-model:tags="state.tags"
|
||||||
{{ locale.baseText('testDefinition.edit.step.intro') }}
|
v-model:evaluationWorkflow="state.evaluationWorkflow"
|
||||||
</div>
|
v-model:metrics="state.metrics"
|
||||||
<BlockArrow :class="$style.introArrow" />
|
v-model:mockedNodes="state.mockedNodes"
|
||||||
<!-- Select Executions -->
|
:cancel-editing="cancelEditing"
|
||||||
<EvaluationStep
|
:show-config="showConfig"
|
||||||
:class="$style.step"
|
:tag-usage-count="tagUsageCount"
|
||||||
: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"
|
:all-tags="allTags"
|
||||||
:tags-by-id="tagsById"
|
:tags-by-id="tagsById"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
|
:get-field-issues="getFieldIssues"
|
||||||
:start-editing="startEditing"
|
:start-editing="startEditing"
|
||||||
:save-changes="saveChanges"
|
:save-changes="saveChanges"
|
||||||
:cancel-editing="cancelEditing"
|
|
||||||
:create-tag="handleCreateTag"
|
:create-tag="handleCreateTag"
|
||||||
/>
|
@open-pinning-modal="openPinningModal"
|
||||||
</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"
|
@delete-metric="onDeleteMetric"
|
||||||
/>
|
/>
|
||||||
</template>
|
|
||||||
</EvaluationStep>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
|
@ -392,104 +253,4 @@ watch(
|
||||||
overflow-y: auto;
|
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>
|
</style>
|
||||||
|
|
|
@ -1,15 +1,20 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { VIEWS } from '@/constants';
|
import { MODAL_CONFIRM, VIEWS } from '@/constants';
|
||||||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import EmptyState from '@/components/TestDefinition/ListDefinition/EmptyState.vue';
|
import EmptyState from '@/components/TestDefinition/ListDefinition/EmptyState.vue';
|
||||||
import TestsList from '@/components/TestDefinition/ListDefinition/TestsList.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 { useAnnotationTagsStore } from '@/stores/tags.store';
|
||||||
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
|
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
|
||||||
|
import { useMessage } from '@/composables/useMessage';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const tagsStore = useAnnotationTagsStore();
|
const tagsStore = useAnnotationTagsStore();
|
||||||
|
@ -17,6 +22,46 @@ const testDefinitionStore = useTestDefinitionStore();
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const locale = useI18n();
|
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[]>(() => {
|
const tests = computed<TestListItem[]>(() => {
|
||||||
return (
|
return (
|
||||||
|
@ -32,6 +77,7 @@ const tests = computed<TestListItem[]>(() => {
|
||||||
tagName: test.annotationTagId ? getTagName(test.annotationTagId) : '',
|
tagName: test.annotationTagId ? getTagName(test.annotationTagId) : '',
|
||||||
testCases: testDefinitionStore.testRunsByTestId[test.id]?.length ?? 0,
|
testCases: testDefinitionStore.testRunsByTestId[test.id]?.length ?? 0,
|
||||||
execution: getTestExecution(test.id),
|
execution: getTestExecution(test.id),
|
||||||
|
fieldsIssues: testDefinitionStore.getFieldIssues(test.id),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -43,6 +89,14 @@ function getTagName(tagId: string) {
|
||||||
|
|
||||||
return matchingTag?.name ?? '';
|
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 {
|
function getTestExecution(testId: string): TestExecution {
|
||||||
const lastRun = testDefinitionStore.lastRunByTestId[testId];
|
const lastRun = testDefinitionStore.lastRunByTestId[testId];
|
||||||
|
@ -67,6 +121,13 @@ function getTestExecution(testId: string): TestExecution {
|
||||||
return mockExecutions;
|
return mockExecutions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTestRunning(testId: string) {
|
||||||
|
return testDefinitionStore.lastRunByTestId[testId]?.status === 'running';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRunDisabled(testId: string) {
|
||||||
|
return testDefinitionStore.getFieldIssues(testId)?.length > 0;
|
||||||
|
}
|
||||||
// Action handlers
|
// Action handlers
|
||||||
function onCreateTest() {
|
function onCreateTest() {
|
||||||
void router.push({ name: VIEWS.NEW_TEST_DEFINITION });
|
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 {
|
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.
|
// 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) {
|
if (!testRunId) {
|
||||||
throw new Error('Failed to cancel test run');
|
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 } });
|
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 } });
|
void router.push({ name: VIEWS.TEST_DEFINITION_EDIT, params: { testId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onDeleteTest(testId: string) {
|
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);
|
await testDefinitionStore.deleteById(testId);
|
||||||
|
|
||||||
toast.showMessage({
|
toast.showMessage({
|
||||||
|
@ -138,12 +214,9 @@ async function loadInitialData() {
|
||||||
// Add guard to prevent multiple loading states
|
// Add guard to prevent multiple loading states
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await testDefinitionStore.fetchAll({
|
||||||
tagsStore.fetchAll(),
|
|
||||||
testDefinitionStore.fetchAll({
|
|
||||||
workflowId: router.currentRoute.value.params.name as string,
|
workflowId: router.currentRoute.value.params.name as string,
|
||||||
}),
|
});
|
||||||
]);
|
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.showError(error, locale.baseText('testDefinition.list.loadError'));
|
toast.showError(error, locale.baseText('testDefinition.list.loadError'));
|
||||||
|
@ -153,7 +226,7 @@ async function loadInitialData() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
if (!testDefinitionStore.isFeatureEnabled) {
|
if (!testDefinitionStore.isFeatureEnabled) {
|
||||||
toast.showMessage({
|
toast.showMessage({
|
||||||
title: locale.baseText('testDefinition.notImplemented'),
|
title: locale.baseText('testDefinition.notImplemented'),
|
||||||
|
@ -166,8 +239,8 @@ onMounted(() => {
|
||||||
});
|
});
|
||||||
return; // Add early return to prevent loading if feature is disabled
|
return; // Add early return to prevent loading if feature is disabled
|
||||||
}
|
}
|
||||||
|
await loadInitialData();
|
||||||
void loadInitialData();
|
tests.value.forEach((test) => testDefinitionStore.updateRunFieldIssues(test.id));
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -186,12 +259,9 @@ onMounted(() => {
|
||||||
<TestsList
|
<TestsList
|
||||||
v-else
|
v-else
|
||||||
:tests="tests"
|
:tests="tests"
|
||||||
@create-test="onCreateTest"
|
:actions="actions"
|
||||||
@run-test="onRunTest"
|
|
||||||
@view-details="onViewDetails"
|
@view-details="onViewDetails"
|
||||||
@edit-test="onEditTest"
|
@create-test="onCreateTest"
|
||||||
@delete-test="onDeleteTest"
|
|
||||||
@cancel-test-run="onCancelTestRun"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,10 +4,11 @@ import { useRouter } from 'vue-router';
|
||||||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||||
import type { TestRunRecord } from '@/api/testDefinition.ee';
|
import type { TestRunRecord } from '@/api/testDefinition.ee';
|
||||||
import TestRunsTable from '@/components/TestDefinition/ListRuns/TestRunsTable.vue';
|
import TestRunsTable from '@/components/TestDefinition/ListRuns/TestRunsTable.vue';
|
||||||
import { VIEWS } from '@/constants';
|
import { MODAL_CONFIRM, VIEWS } from '@/constants';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { useMessage } from '@/composables/useMessage';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const testDefinitionStore = useTestDefinitionStore();
|
const testDefinitionStore = useTestDefinitionStore();
|
||||||
|
@ -75,6 +76,19 @@ async function runTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onDeleteRuns(runsToDelete: TestRunRecord[]) {
|
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(
|
await Promise.all(
|
||||||
runsToDelete.map(async (run) => {
|
runsToDelete.map(async (run) => {
|
||||||
await testDefinitionStore.deleteTestRun({ testDefinitionId: testId.value, runId: run.id });
|
await testDefinitionStore.deleteTestRun({ testDefinitionId: testId.value, runId: run.id });
|
||||||
|
@ -98,7 +112,7 @@ onMounted(async () => {
|
||||||
<N8nLoading :rows="5" />
|
<N8nLoading :rows="5" />
|
||||||
<N8nLoading :rows="10" />
|
<N8nLoading :rows="10" />
|
||||||
</template>
|
</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" />
|
<MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" :theme="appliedTheme" />
|
||||||
<TestRunsTable :runs="runs" @get-run-detail="getRunDetail" @delete-runs="onDeleteRuns" />
|
<TestRunsTable :runs="runs" @get-run-detail="getRunDetail" @delete-runs="onDeleteRuns" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -147,21 +147,6 @@ describe('TestDefinitionEditView', () => {
|
||||||
expect(createTestMock).toHaveBeenCalled();
|
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 () => {
|
it('should show error message on failed test creation', async () => {
|
||||||
createTestMock.mockRejectedValue(new Error('Save failed'));
|
createTestMock.mockRejectedValue(new Error('Save failed'));
|
||||||
|
|
||||||
|
@ -180,53 +165,39 @@ describe('TestDefinitionEditView', () => {
|
||||||
expect(showErrorMock).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
|
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({
|
vi.mocked(useRoute).mockReturnValue({
|
||||||
params: { testId: '1' },
|
params: { testId: '1' },
|
||||||
name: VIEWS.TEST_DEFINITION_EDIT,
|
name: VIEWS.TEST_DEFINITION_EDIT,
|
||||||
} as unknown as ReturnType<typeof useRoute>);
|
} 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();
|
await nextTick();
|
||||||
|
|
||||||
const updateButton = getByTestId('run-test-button');
|
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 () => {
|
mockedTestDefinitionStore.getFieldIssues = vi.fn().mockReturnValue([]);
|
||||||
vi.mocked(useRoute).mockReturnValue({
|
await nextTick();
|
||||||
params: {},
|
expect(updateButton).not.toHaveClass('disabled');
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should apply "has-issues" class to inputs with issues', async () => {
|
it('should apply "has-issues" class to inputs with issues', async () => {
|
||||||
vi.mocked(useTestDefinitionForm).mockReturnValue({
|
const { container, mockedTestDefinitionStore } = renderComponentWithFeatureEnabled();
|
||||||
...vi.mocked(useTestDefinitionForm)(),
|
mockedTestDefinitionStore.getFieldIssues = vi
|
||||||
fieldsIssues: ref([
|
.fn()
|
||||||
{ field: 'name', message: 'Name is required' },
|
.mockReturnValue([{ field: 'tags', message: 'Tag is required' }]);
|
||||||
{ field: 'tags', message: 'Tag is required' },
|
|
||||||
]),
|
|
||||||
} as unknown as ReturnType<typeof useTestDefinitionForm>);
|
|
||||||
|
|
||||||
const { container } = renderComponentWithFeatureEnabled();
|
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
const issueElements = container.querySelectorAll('.has-issues');
|
const issueElements = container.querySelectorAll('.has-issues');
|
||||||
expect(issueElements.length).toBeGreaterThan(0);
|
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', () => {
|
describe('Test Runs functionality', () => {
|
||||||
it('should display test runs table when runs exist', async () => {
|
it('should display test runs table when runs exist', async () => {
|
||||||
vi.mocked(useRoute).mockReturnValue({
|
vi.mocked(useRoute).mockReturnValue({
|
||||||
|
|
|
@ -6,21 +6,22 @@ import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import TestDefinitionListView from '@/views/TestDefinition/TestDefinitionListView.vue';
|
import TestDefinitionListView from '@/views/TestDefinition/TestDefinitionListView.vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useAnnotationTagsStore } from '@/stores/tags.store';
|
import { useMessage } from '@/composables/useMessage';
|
||||||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||||
import { nextTick, ref } from 'vue';
|
import { nextTick, ref } from 'vue';
|
||||||
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
|
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
|
||||||
import { VIEWS } from '@/constants';
|
import { MODAL_CONFIRM, VIEWS } from '@/constants';
|
||||||
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
|
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
|
||||||
|
|
||||||
vi.mock('vue-router');
|
vi.mock('vue-router');
|
||||||
vi.mock('@/composables/useToast');
|
vi.mock('@/composables/useToast');
|
||||||
|
vi.mock('@/composables/useMessage');
|
||||||
describe('TestDefinitionListView', () => {
|
describe('TestDefinitionListView', () => {
|
||||||
const renderComponent = createComponentRenderer(TestDefinitionListView);
|
const renderComponent = createComponentRenderer(TestDefinitionListView);
|
||||||
|
|
||||||
let showMessageMock: Mock;
|
let showMessageMock: Mock;
|
||||||
let showErrorMock: Mock;
|
let showErrorMock: Mock;
|
||||||
|
let confirmMock: Mock;
|
||||||
let startTestRunMock: Mock;
|
let startTestRunMock: Mock;
|
||||||
let fetchTestRunsMock: Mock;
|
let fetchTestRunsMock: Mock;
|
||||||
let deleteByIdMock: Mock;
|
let deleteByIdMock: Mock;
|
||||||
|
@ -65,6 +66,7 @@ describe('TestDefinitionListView', () => {
|
||||||
|
|
||||||
showMessageMock = vi.fn();
|
showMessageMock = vi.fn();
|
||||||
showErrorMock = vi.fn();
|
showErrorMock = vi.fn();
|
||||||
|
confirmMock = vi.fn().mockResolvedValue(MODAL_CONFIRM);
|
||||||
startTestRunMock = vi.fn().mockResolvedValue({ success: true });
|
startTestRunMock = vi.fn().mockResolvedValue({ success: true });
|
||||||
fetchTestRunsMock = vi.fn();
|
fetchTestRunsMock = vi.fn();
|
||||||
deleteByIdMock = vi.fn();
|
deleteByIdMock = vi.fn();
|
||||||
|
@ -74,6 +76,10 @@ describe('TestDefinitionListView', () => {
|
||||||
showMessage: showMessageMock,
|
showMessage: showMessageMock,
|
||||||
showError: showErrorMock,
|
showError: showErrorMock,
|
||||||
} as unknown as ReturnType<typeof useToast>);
|
} as unknown as ReturnType<typeof useToast>);
|
||||||
|
|
||||||
|
vi.mocked(useMessage).mockReturnValue({
|
||||||
|
confirm: confirmMock,
|
||||||
|
} as unknown as ReturnType<typeof useMessage>);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@ -89,7 +95,6 @@ describe('TestDefinitionListView', () => {
|
||||||
setActivePinia(pinia);
|
setActivePinia(pinia);
|
||||||
|
|
||||||
const testDefinitionStore = mockedStore(useTestDefinitionStore);
|
const testDefinitionStore = mockedStore(useTestDefinitionStore);
|
||||||
// const tagsStore = mockedStore(useAnnotationTagsStore);
|
|
||||||
testDefinitionStore.isFeatureEnabled = true;
|
testDefinitionStore.isFeatureEnabled = true;
|
||||||
testDefinitionStore.fetchAll = fetchAllMock;
|
testDefinitionStore.fetchAll = fetchAllMock;
|
||||||
testDefinitionStore.startTestRun = startTestRunMock;
|
testDefinitionStore.startTestRun = startTestRunMock;
|
||||||
|
@ -120,7 +125,6 @@ describe('TestDefinitionListView', () => {
|
||||||
expect(testDefinitionStore.fetchAll).toHaveBeenCalledWith({
|
expect(testDefinitionStore.fetchAll).toHaveBeenCalledWith({
|
||||||
workflowId: 'workflow1',
|
workflowId: 'workflow1',
|
||||||
});
|
});
|
||||||
expect(mockedStore(useAnnotationTagsStore).fetchAll).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should start test run and show success message', async () => {
|
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 () => {
|
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');
|
const deleteButton = getByTestId('delete-test-button-1');
|
||||||
deleteButton.click();
|
deleteButton.click();
|
||||||
await nextTick();
|
await waitAllPromises();
|
||||||
|
|
||||||
expect(testDefinitionStore.deleteById).toHaveBeenCalledWith('1');
|
expect(deleteByIdMock).toHaveBeenCalledWith('1');
|
||||||
expect(showMessageMock).toHaveBeenCalledWith({
|
expect(showMessageMock).toHaveBeenCalledWith({
|
||||||
title: expect.any(String),
|
title: expect.any(String),
|
||||||
type: 'success',
|
type: 'success',
|
||||||
|
|
Loading…
Reference in a new issue