mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-30 22:02:03 -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;
|
||||
expanded?: boolean;
|
||||
description?: string;
|
||||
issues?: Array<{ field: string; message: string }>;
|
||||
showIssues?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<EvaluationStep>(), {
|
||||
|
@ -16,6 +18,8 @@ const props = withDefaults(defineProps<EvaluationStep>(), {
|
|||
warning: false,
|
||||
small: false,
|
||||
expanded: true,
|
||||
issues: () => [],
|
||||
showIssues: true,
|
||||
});
|
||||
|
||||
const locale = useI18n();
|
||||
|
@ -46,7 +50,11 @@ const toggleExpand = async () => {
|
|||
<slot name="icon" />
|
||||
</div>
|
||||
<h3 :class="$style.title">{{ title }}</h3>
|
||||
<span v-if="warning" :class="$style.warningIcon">⚠</span>
|
||||
<span v-if="issues.length > 0 && showIssues" :class="$style.warningIcon">
|
||||
<N8nInfoTip :bold="true" type="tooltip" theme="warning" tooltip-placement="right">
|
||||
{{ issues.map((issue) => issue.message).join(', ') }}
|
||||
</N8nInfoTip>
|
||||
</span>
|
||||
<button
|
||||
v-if="$slots.cardContent"
|
||||
:class="$style.collapseButton"
|
||||
|
|
|
@ -115,7 +115,13 @@ onMounted(loadData);
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<div v-if="mappedNodes.length === 0" :class="$style.noNodes">
|
||||
<N8nHeading size="large" :bold="true" :class="$style.noNodesTitle">{{
|
||||
locale.baseText('testDefinition.edit.pinNodes.noNodes.title')
|
||||
}}</N8nHeading>
|
||||
<N8nText>{{ locale.baseText('testDefinition.edit.pinNodes.noNodes.description') }}</N8nText>
|
||||
</div>
|
||||
<div v-else :class="$style.container">
|
||||
<N8nSpinner v-if="isLoading" size="xlarge" type="dots" :class="$style.spinner" />
|
||||
<Canvas
|
||||
:id="canvasId"
|
||||
|
@ -185,4 +191,11 @@ onMounted(loadData);
|
|||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.noNodes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -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">
|
||||
import type { TestListItem } from '@/components/TestDefinition/types';
|
||||
import type { TestListItem, TestItemAction } from '@/components/TestDefinition/types';
|
||||
import TimeAgo from '@/components/TimeAgo.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import n8nIconButton from 'n8n-design-system/components/N8nIconButton';
|
||||
|
@ -7,55 +7,19 @@ import { computed } from 'vue';
|
|||
|
||||
export interface TestItemProps {
|
||||
test: TestListItem;
|
||||
actions: TestItemAction[];
|
||||
}
|
||||
|
||||
const props = defineProps<TestItemProps>();
|
||||
const locale = useI18n();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'run-test': [testId: string];
|
||||
defineEmits<{
|
||||
'view-details': [testId: string];
|
||||
'edit-test': [testId: string];
|
||||
'delete-test': [testId: string];
|
||||
'cancel-test-run': [testId: string, testRunId: string | null];
|
||||
}>();
|
||||
|
||||
const actions = [
|
||||
{
|
||||
icon: 'play',
|
||||
id: 'run',
|
||||
event: () => emit('run-test', props.test.id),
|
||||
tooltip: locale.baseText('testDefinition.runTest'),
|
||||
show: () => props.test.execution.status !== 'running',
|
||||
},
|
||||
{
|
||||
icon: 'stop',
|
||||
id: 'cancel',
|
||||
event: () => emit('cancel-test-run', props.test.id, props.test.execution.id),
|
||||
tooltip: locale.baseText('testDefinition.cancelTestRun'),
|
||||
show: () => props.test.execution.status === 'running',
|
||||
},
|
||||
{
|
||||
icon: 'list',
|
||||
id: 'view',
|
||||
event: () => emit('view-details', props.test.id),
|
||||
tooltip: locale.baseText('testDefinition.viewDetails'),
|
||||
},
|
||||
{
|
||||
icon: 'pen',
|
||||
id: 'edit',
|
||||
event: () => emit('edit-test', props.test.id),
|
||||
tooltip: locale.baseText('testDefinition.editTest'),
|
||||
},
|
||||
{
|
||||
icon: 'trash',
|
||||
id: 'delete',
|
||||
event: () => emit('delete-test', props.test.id),
|
||||
tooltip: locale.baseText('testDefinition.deleteTest'),
|
||||
},
|
||||
];
|
||||
|
||||
const visibleActions = computed(() => actions.filter((action) => action.show?.() ?? true));
|
||||
const visibleActions = computed(() =>
|
||||
props.actions.filter((action) => action.show?.(props.test.id) ?? true),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -102,17 +66,16 @@ const visibleActions = computed(() => actions.filter((action) => action.show?.()
|
|||
:key="action.icon"
|
||||
placement="top"
|
||||
:show-after="1000"
|
||||
:content="action.tooltip(test.id)"
|
||||
>
|
||||
<template #content>
|
||||
{{ action.tooltip }}
|
||||
</template>
|
||||
<component
|
||||
:is="n8nIconButton"
|
||||
:icon="action.icon"
|
||||
:data-test-id="`${action.id}-test-button-${test.id}`"
|
||||
type="tertiary"
|
||||
size="mini"
|
||||
@click.stop="action.event"
|
||||
:disabled="action?.disabled ? action.disabled(test.id) : false"
|
||||
@click.stop="action.event(test.id)"
|
||||
/>
|
||||
</n8n-tooltip>
|
||||
</div>
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import TestItem from './TestItem.vue';
|
||||
import type { TestListItem } from '@/components/TestDefinition/types';
|
||||
import type { TestListItem, TestItemAction } from '@/components/TestDefinition/types';
|
||||
export interface TestListProps {
|
||||
tests: TestListItem[];
|
||||
actions: TestItemAction[];
|
||||
}
|
||||
|
||||
defineEmits<{ 'create-test': [] }>();
|
||||
defineEmits<{ 'create-test': []; 'view-details': [testId: string] }>();
|
||||
defineProps<TestListProps>();
|
||||
const locale = useI18n();
|
||||
</script>
|
||||
|
@ -19,7 +20,13 @@ const locale = useI18n();
|
|||
@click="$emit('create-test')"
|
||||
/>
|
||||
</div>
|
||||
<TestItem v-for="test in tests" :key="test.id" :test="test" v-bind="$attrs" />
|
||||
<TestItem
|
||||
v-for="test in tests"
|
||||
:key="test.id"
|
||||
:test="test"
|
||||
:actions="actions"
|
||||
@view-details="$emit('view-details', test.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -76,7 +76,7 @@ function onSelectionChange(runs: TestRunRecord[]) {
|
|||
emit('selectionChange', runs);
|
||||
}
|
||||
|
||||
function deleteRuns() {
|
||||
async function deleteRuns() {
|
||||
emit('deleteRuns', selectedRows.value);
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -41,11 +41,8 @@ export function useTestDefinitionForm() {
|
|||
});
|
||||
|
||||
const isSaving = ref(false);
|
||||
const fieldsIssues = ref<Array<{ field: string; message: string }>>([]);
|
||||
const fields = ref<FormRefs>({} as FormRefs);
|
||||
|
||||
// A computed mapping of editable fields to their states
|
||||
// This ensures TS knows the exact type of each field.
|
||||
const editableFields: ComputedRef<{
|
||||
name: EditableField<string>;
|
||||
tags: EditableField<string[]>;
|
||||
|
@ -99,7 +96,6 @@ export function useTestDefinitionForm() {
|
|||
if (isSaving.value) return;
|
||||
|
||||
isSaving.value = true;
|
||||
fieldsIssues.value = [];
|
||||
|
||||
try {
|
||||
const params = {
|
||||
|
@ -144,7 +140,6 @@ export function useTestDefinitionForm() {
|
|||
if (isSaving.value) return;
|
||||
|
||||
isSaving.value = true;
|
||||
fieldsIssues.value = [];
|
||||
|
||||
try {
|
||||
if (!testId) {
|
||||
|
@ -232,7 +227,6 @@ export function useTestDefinitionForm() {
|
|||
state,
|
||||
fields,
|
||||
isSaving: computed(() => isSaving.value),
|
||||
fieldsIssues: computed(() => fieldsIssues.value),
|
||||
deleteMetric,
|
||||
updateMetrics,
|
||||
loadTestData,
|
||||
|
|
|
@ -7,6 +7,15 @@ export interface EditableField<T = string> {
|
|||
isEditing: boolean;
|
||||
}
|
||||
|
||||
export interface TestItemAction {
|
||||
icon: string;
|
||||
id: string;
|
||||
event: (testId: string) => void | Promise<void>;
|
||||
tooltip: (testId: string) => string;
|
||||
disabled?: (testId: string) => boolean;
|
||||
show?: (testId: string) => boolean;
|
||||
}
|
||||
|
||||
export interface EditableFormState {
|
||||
name: EditableField<string>;
|
||||
tags: EditableField<string[]>;
|
||||
|
@ -33,4 +42,5 @@ export interface TestListItem {
|
|||
tagName: string;
|
||||
testCases: number;
|
||||
execution: TestExecution;
|
||||
fieldsIssues?: Array<{ field: string; message: string }>;
|
||||
}
|
||||
|
|
|
@ -258,6 +258,7 @@ const onAddResourceClicked = () => {
|
|||
}"
|
||||
:width="width"
|
||||
:event-bus="eventBus"
|
||||
:value="modelValue"
|
||||
@update:model-value="onListItemSelected"
|
||||
@filter="onSearchFilter"
|
||||
@load-more="populateNextWorkflowsPage"
|
||||
|
|
|
@ -229,6 +229,12 @@ async function handleActionItemClick(commandData: Command) {
|
|||
</template>
|
||||
<FontAwesomeIcon icon="flask" />
|
||||
</N8nTooltip>
|
||||
<N8nTooltip v-if="execution.mode === 'evaluation'" placement="top">
|
||||
<template #content>
|
||||
<span>{{ i18n.baseText('executionsList.evaluation') }}</span>
|
||||
</template>
|
||||
<FontAwesomeIcon icon="tasks" />
|
||||
</N8nTooltip>
|
||||
</td>
|
||||
<td>
|
||||
<div :class="$style.buttonCell">
|
||||
|
|
|
@ -167,11 +167,13 @@ function onRetryMenuItemSelect(action: string): void {
|
|||
<template #content>
|
||||
<span>{{ locale.baseText('executionsList.test') }}</span>
|
||||
</template>
|
||||
<FontAwesomeIcon
|
||||
v-if="execution.mode === 'manual'"
|
||||
:class="[$style.icon, $style.manual]"
|
||||
icon="flask"
|
||||
/>
|
||||
<FontAwesomeIcon :class="[$style.icon, $style.manual]" icon="flask" />
|
||||
</N8nTooltip>
|
||||
<N8nTooltip v-if="execution.mode === 'evaluation'" placement="top">
|
||||
<template #content>
|
||||
<span>{{ locale.baseText('executionsList.evaluation') }}</span>
|
||||
</template>
|
||||
<FontAwesomeIcon :class="[$style.icon, $style.evaluation]" icon="tasks" />
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</router-link>
|
||||
|
|
|
@ -751,6 +751,7 @@
|
|||
"executionsList.selected": "{count} execution selected: | {count} executions selected:",
|
||||
"executionsList.selectAll": "Select {executionNum} finished execution | Select all {executionNum} finished executions",
|
||||
"executionsList.test": "Test execution",
|
||||
"executionsList.evaluation": "Evaluation execution",
|
||||
"executionsList.showError.handleDeleteSelected.title": "Problem deleting executions",
|
||||
"executionsList.showError.loadMore.title": "Problem loading executions",
|
||||
"executionsList.showError.loadWorkflows.title": "Problem loading workflows",
|
||||
|
@ -2799,6 +2800,8 @@
|
|||
"testDefinition.edit.testSaveFailed": "Failed to save test",
|
||||
"testDefinition.edit.description": "Description",
|
||||
"testDefinition.edit.description.description": "Add details about what this test evaluates and what success looks like",
|
||||
"testDefinition.edit.pinNodes.noNodes.title": "No nodes to pin",
|
||||
"testDefinition.edit.pinNodes.noNodes.description": "Your workflow needs to have at least one node to run a test",
|
||||
"testDefinition.edit.tagName": "Tag name",
|
||||
"testDefinition.edit.step.intro": "When running a test",
|
||||
"testDefinition.edit.step.executions": "1. Fetch N past executions tagged | 1. Fetch {count} past execution tagged | 1. Fetch {count} past executions tagged",
|
||||
|
@ -2863,6 +2866,13 @@
|
|||
"testDefinition.viewDetails": "View Details",
|
||||
"testDefinition.editTest": "Edit Test",
|
||||
"testDefinition.deleteTest": "Delete Test",
|
||||
"testDefinition.deleteTest.warning": "The test and all associated runs will be removed. This cannot be undone",
|
||||
"testDefinition.testIsRunning": "Test is running. Please wait for it to finish.",
|
||||
"testDefinition.completeConfig": "Complete the configuration below to run the test:",
|
||||
"testDefinition.configError.noEvaluationTag": "No evaluation tag set",
|
||||
"testDefinition.configError.noExecutionsAddedToTag": "No executions added to this tag",
|
||||
"testDefinition.configError.noEvaluationWorkflow": "No evaluation workflow set",
|
||||
"testDefinition.configError.noMetrics": "No metrics set",
|
||||
"freeAi.credits.callout.claim.title": "Get {credits} free OpenAI API credits",
|
||||
"freeAi.credits.callout.claim.button.label": "Claim credits",
|
||||
"freeAi.credits.callout.success.title.part1": "Claimed {credits} free OpenAI API credits! Please note these free credits are only for the following models:",
|
||||
|
|
|
@ -2,7 +2,9 @@ import { createPinia, setActivePinia } from 'pinia';
|
|||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee'; // Adjust the import path as necessary
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
import { useAnnotationTagsStore } from '@/stores/tags.store';
|
||||
import type { TestDefinitionRecord, TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
|
||||
const {
|
||||
createTestDefinition,
|
||||
|
@ -101,6 +103,7 @@ describe('testDefinition.store.ee', () => {
|
|||
rootStoreMock = useRootStore();
|
||||
posthogStoreMock = usePostHog();
|
||||
|
||||
mockedStore(useAnnotationTagsStore).fetchAll = vi.fn().mockResolvedValue([]);
|
||||
getTestDefinitions.mockResolvedValue({
|
||||
count: 2,
|
||||
testDefinitions: [TEST_DEF_A, TEST_DEF_B],
|
||||
|
@ -114,6 +117,7 @@ describe('testDefinition.store.ee', () => {
|
|||
getTestRun.mockResolvedValue(TEST_RUN);
|
||||
startTestRun.mockResolvedValue({ success: true });
|
||||
deleteTestRun.mockResolvedValue({ success: true });
|
||||
getTestMetrics.mockResolvedValue([TEST_METRIC]);
|
||||
});
|
||||
|
||||
test('Initialization', () => {
|
||||
|
@ -276,8 +280,6 @@ describe('testDefinition.store.ee', () => {
|
|||
|
||||
describe('Metrics', () => {
|
||||
test('Fetching Metrics for a Test Definition', async () => {
|
||||
getTestMetrics.mockResolvedValue([TEST_METRIC]);
|
||||
|
||||
const metrics = await store.fetchMetrics('1');
|
||||
|
||||
expect(getTestMetrics).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1');
|
||||
|
|
|
@ -5,6 +5,10 @@ import * as testDefinitionsApi from '@/api/testDefinition.ee';
|
|||
import type { TestDefinitionRecord, TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import { usePostHog } from './posthog.store';
|
||||
import { STORES, WORKFLOW_EVALUATION_EXPERIMENT } from '@/constants';
|
||||
import { useAnnotationTagsStore } from './tags.store';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
type FieldIssue = { field: string; message: string };
|
||||
|
||||
export const useTestDefinitionStore = defineStore(
|
||||
STORES.TEST_DEFINITION,
|
||||
|
@ -16,11 +20,13 @@ export const useTestDefinitionStore = defineStore(
|
|||
const metricsById = ref<Record<string, testDefinitionsApi.TestMetricRecord>>({});
|
||||
const testRunsById = ref<Record<string, TestRunRecord>>({});
|
||||
const pollingTimeouts = ref<Record<string, NodeJS.Timeout>>({});
|
||||
const fieldsIssues = ref<Record<string, FieldIssue[]>>({});
|
||||
|
||||
// Store instances
|
||||
const posthogStore = usePostHog();
|
||||
const rootStore = useRootStore();
|
||||
|
||||
const tagsStore = useAnnotationTagsStore();
|
||||
const locale = useI18n();
|
||||
// Computed
|
||||
const allTestDefinitions = computed(() => {
|
||||
return Object.values(testDefinitionsById.value).sort((a, b) =>
|
||||
|
@ -100,6 +106,8 @@ export const useTestDefinitionStore = defineStore(
|
|||
);
|
||||
});
|
||||
|
||||
const getFieldIssues = (testId: string) => fieldsIssues.value[testId] || [];
|
||||
|
||||
// Methods
|
||||
const setAllTestDefinitions = (definitions: TestDefinitionRecord[]) => {
|
||||
testDefinitionsById.value = definitions.reduce(
|
||||
|
@ -144,13 +152,18 @@ export const useTestDefinitionStore = defineStore(
|
|||
}
|
||||
};
|
||||
|
||||
const fetchMetricsForAllTests = async () => {
|
||||
const testDefinitions = Object.values(testDefinitionsById.value);
|
||||
await Promise.all(testDefinitions.map(async (testDef) => await fetchMetrics(testDef.id)));
|
||||
};
|
||||
|
||||
const fetchTestDefinition = async (id: string) => {
|
||||
const testDefinition = await testDefinitionsApi.getTestDefinition(
|
||||
rootStore.restApiContext,
|
||||
id,
|
||||
);
|
||||
testDefinitionsById.value[testDefinition.id] = testDefinition;
|
||||
|
||||
updateRunFieldIssues(id);
|
||||
return testDefinition;
|
||||
};
|
||||
|
||||
|
@ -178,7 +191,11 @@ export const useTestDefinitionStore = defineStore(
|
|||
setAllTestDefinitions(retrievedDefinitions.testDefinitions);
|
||||
fetchedAll.value = true;
|
||||
|
||||
await fetchRunsForAllTests();
|
||||
await Promise.all([
|
||||
tagsStore.fetchAll({ withUsageCount: true }),
|
||||
fetchRunsForAllTests(),
|
||||
fetchMetricsForAllTests(),
|
||||
]);
|
||||
return retrievedDefinitions;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
|
@ -203,6 +220,7 @@ export const useTestDefinitionStore = defineStore(
|
|||
params,
|
||||
);
|
||||
upsertTestDefinitions([createdDefinition]);
|
||||
updateRunFieldIssues(createdDefinition.id);
|
||||
return createdDefinition;
|
||||
};
|
||||
|
||||
|
@ -216,6 +234,7 @@ export const useTestDefinitionStore = defineStore(
|
|||
updateParams,
|
||||
);
|
||||
upsertTestDefinitions([updatedDefinition]);
|
||||
updateRunFieldIssues(params.id);
|
||||
return updatedDefinition;
|
||||
};
|
||||
|
||||
|
@ -240,9 +259,9 @@ export const useTestDefinitionStore = defineStore(
|
|||
try {
|
||||
const metrics = await testDefinitionsApi.getTestMetrics(rootStore.restApiContext, testId);
|
||||
metrics.forEach((metric) => {
|
||||
metricsById.value[metric.id] = metric;
|
||||
metricsById.value[metric.id] = { ...metric, testDefinitionId: testId };
|
||||
});
|
||||
return metrics;
|
||||
return metrics.map((metric) => ({ ...metric, testDefinitionId: testId }));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
@ -253,7 +272,7 @@ export const useTestDefinitionStore = defineStore(
|
|||
testDefinitionId: string;
|
||||
}): Promise<testDefinitionsApi.TestMetricRecord> => {
|
||||
const metric = await testDefinitionsApi.createTestMetric(rootStore.restApiContext, params);
|
||||
metricsById.value[metric.id] = metric;
|
||||
metricsById.value[metric.id] = { ...metric, testDefinitionId: params.testDefinitionId };
|
||||
return metric;
|
||||
};
|
||||
|
||||
|
@ -261,7 +280,9 @@ export const useTestDefinitionStore = defineStore(
|
|||
params: testDefinitionsApi.TestMetricRecord,
|
||||
): Promise<testDefinitionsApi.TestMetricRecord> => {
|
||||
const metric = await testDefinitionsApi.updateTestMetric(rootStore.restApiContext, params);
|
||||
metricsById.value[metric.id] = metric;
|
||||
metricsById.value[metric.id] = { ...metric, testDefinitionId: params.testDefinitionId };
|
||||
|
||||
updateRunFieldIssues(params.testDefinitionId);
|
||||
return metric;
|
||||
};
|
||||
|
||||
|
@ -271,6 +292,8 @@ export const useTestDefinitionStore = defineStore(
|
|||
await testDefinitionsApi.deleteTestMetric(rootStore.restApiContext, params);
|
||||
const { [params.id]: deleted, ...rest } = metricsById.value;
|
||||
metricsById.value = rest;
|
||||
|
||||
updateRunFieldIssues(params.testDefinitionId);
|
||||
};
|
||||
|
||||
// Test Runs Methods
|
||||
|
@ -296,6 +319,7 @@ export const useTestDefinitionStore = defineStore(
|
|||
const getTestRun = async (params: { testDefinitionId: string; runId: string }) => {
|
||||
const run = await testDefinitionsApi.getTestRun(rootStore.restApiContext, params);
|
||||
testRunsById.value[run.id] = run;
|
||||
updateRunFieldIssues(params.testDefinitionId);
|
||||
return run;
|
||||
};
|
||||
|
||||
|
@ -346,6 +370,52 @@ export const useTestDefinitionStore = defineStore(
|
|||
pollingTimeouts.value = {};
|
||||
};
|
||||
|
||||
const updateRunFieldIssues = (testId: string) => {
|
||||
const issues: FieldIssue[] = [];
|
||||
const testDefinition = testDefinitionsById.value[testId];
|
||||
|
||||
if (!testDefinition) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!testDefinition.annotationTagId) {
|
||||
issues.push({
|
||||
field: 'tags',
|
||||
message: locale.baseText('testDefinition.configError.noEvaluationTag'),
|
||||
});
|
||||
} else {
|
||||
const tagUsageCount = tagsStore.tagsById[testDefinition.annotationTagId]?.usageCount ?? 0;
|
||||
|
||||
if (tagUsageCount === 0) {
|
||||
issues.push({
|
||||
field: 'tags',
|
||||
message: locale.baseText('testDefinition.configError.noExecutionsAddedToTag'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!testDefinition.evaluationWorkflowId) {
|
||||
issues.push({
|
||||
field: 'evaluationWorkflow',
|
||||
message: locale.baseText('testDefinition.configError.noEvaluationWorkflow'),
|
||||
});
|
||||
}
|
||||
|
||||
const metrics = metricsByTestId.value[testId] || [];
|
||||
if (metrics.filter((metric) => metric.name).length === 0) {
|
||||
issues.push({
|
||||
field: 'metrics',
|
||||
message: locale.baseText('testDefinition.configError.noMetrics'),
|
||||
});
|
||||
}
|
||||
|
||||
fieldsIssues.value = {
|
||||
...fieldsIssues.value,
|
||||
[testId]: issues,
|
||||
};
|
||||
return issues;
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
fetchedAll,
|
||||
|
@ -381,6 +451,8 @@ export const useTestDefinitionStore = defineStore(
|
|||
cancelTestRun,
|
||||
deleteTestRun,
|
||||
cleanupPolling,
|
||||
getFieldIssues,
|
||||
updateRunFieldIssues,
|
||||
};
|
||||
},
|
||||
{},
|
||||
|
|
|
@ -8,19 +8,12 @@ import { useAnnotationTagsStore } from '@/stores/tags.store';
|
|||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import { useTestDefinitionForm } from '@/components/TestDefinition/composables/useTestDefinitionForm';
|
||||
|
||||
import EvaluationHeader from '@/components/TestDefinition/EditDefinition/EvaluationHeader.vue';
|
||||
import DescriptionInput from '@/components/TestDefinition/EditDefinition/DescriptionInput.vue';
|
||||
import EvaluationStep from '@/components/TestDefinition/EditDefinition/EvaluationStep.vue';
|
||||
import TagsInput from '@/components/TestDefinition/EditDefinition/TagsInput.vue';
|
||||
import WorkflowSelector from '@/components/TestDefinition/EditDefinition/WorkflowSelector.vue';
|
||||
import MetricsInput from '@/components/TestDefinition/EditDefinition/MetricsInput.vue';
|
||||
import HeaderSection from '@/components/TestDefinition/EditDefinition/sections/HeaderSection.vue';
|
||||
import RunsSection from '@/components/TestDefinition/EditDefinition/sections/RunsSection.vue';
|
||||
import type { TestMetricRecord, TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import type { ModalState } from '@/Interface';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import TestRunsTable from '@/components/TestDefinition/ListRuns/TestRunsTable.vue';
|
||||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||
|
||||
import ConfigSection from '@/components/TestDefinition/EditDefinition/sections/ConfigSection.vue';
|
||||
const props = defineProps<{
|
||||
testId?: string;
|
||||
}>();
|
||||
|
@ -33,9 +26,9 @@ const toast = useToast();
|
|||
const testDefinitionStore = useTestDefinitionStore();
|
||||
const tagsStore = useAnnotationTagsStore();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const {
|
||||
state,
|
||||
fieldsIssues,
|
||||
isSaving,
|
||||
cancelEditing,
|
||||
loadTestData,
|
||||
|
@ -58,11 +51,11 @@ const tagUsageCount = computed(
|
|||
() => tagsStore.tagsById[state.value.tags.value[0]]?.usageCount ?? 0,
|
||||
);
|
||||
const hasRuns = computed(() => runs.value.length > 0);
|
||||
const nodePinningModal = ref<ModalState | null>(null);
|
||||
const modalContentWidth = ref(0);
|
||||
const showConfig = ref(true);
|
||||
const selectedMetric = ref<string>('');
|
||||
|
||||
const fieldsIssues = computed(() => testDefinitionStore.getFieldIssues(testId.value) ?? []);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!testDefinitionStore.isFeatureEnabled) {
|
||||
toast.showMessage({
|
||||
|
@ -76,7 +69,6 @@ onMounted(async () => {
|
|||
});
|
||||
return; // Add early return to prevent loading if feature is disabled
|
||||
}
|
||||
void tagsStore.fetchAll({ withUsageCount: true });
|
||||
if (testId.value) {
|
||||
await loadTestData(testId.value);
|
||||
} else {
|
||||
|
@ -103,8 +95,8 @@ async function onSaveTest() {
|
|||
}
|
||||
}
|
||||
|
||||
function hasIssues(key: string) {
|
||||
return fieldsIssues.value.some((issue) => issue.field === key);
|
||||
function getFieldIssues(key: string) {
|
||||
return fieldsIssues.value.filter((issue) => issue.field === key);
|
||||
}
|
||||
|
||||
async function onDeleteMetric(deletedMetric: Partial<TestMetricRecord>) {
|
||||
|
@ -138,6 +130,9 @@ const runs = computed(() =>
|
|||
),
|
||||
);
|
||||
|
||||
const isRunning = computed(() => runs.value.some((run) => run.status === 'running'));
|
||||
const isRunTestEnabled = computed(() => fieldsIssues.value.length === 0 && !isRunning.value);
|
||||
|
||||
async function onDeleteRuns(toDelete: TestRunRecord[]) {
|
||||
await Promise.all(
|
||||
toDelete.map(async (run) => {
|
||||
|
@ -172,196 +167,62 @@ watch(
|
|||
|
||||
<template>
|
||||
<div :class="[$style.container, { [$style.noRuns]: !hasRuns }]">
|
||||
<div :class="$style.headerSection">
|
||||
<div :class="$style.headerMeta">
|
||||
<div :class="$style.name">
|
||||
<EvaluationHeader
|
||||
v-model="state.name"
|
||||
:class="{ 'has-issues': hasIssues('name') }"
|
||||
:start-editing="startEditing"
|
||||
:save-changes="saveChanges"
|
||||
:handle-keydown="handleKeydown"
|
||||
/>
|
||||
<div :class="$style.lastSaved">
|
||||
<template v-if="isSaving">
|
||||
{{ locale.baseText('testDefinition.edit.saving') }}
|
||||
</template>
|
||||
<template v-else> {{ locale.baseText('testDefinition.edit.saved') }} </template>
|
||||
</div>
|
||||
</div>
|
||||
<DescriptionInput
|
||||
v-model="state.description"
|
||||
:start-editing="startEditing"
|
||||
:save-changes="saveChanges"
|
||||
:handle-keydown="handleKeydown"
|
||||
:class="$style.descriptionInput"
|
||||
/>
|
||||
</div>
|
||||
<div :class="$style.controls">
|
||||
<n8n-button
|
||||
v-if="runs.length > 0"
|
||||
size="small"
|
||||
:icon="showConfig ? 'eye-slash' : 'eye'"
|
||||
data-test-id="toggle-config-button"
|
||||
:label="
|
||||
showConfig
|
||||
? locale.baseText('testDefinition.edit.hideConfig')
|
||||
: locale.baseText('testDefinition.edit.showConfig')
|
||||
"
|
||||
type="tertiary"
|
||||
@click="toggleConfig"
|
||||
/>
|
||||
<n8n-button
|
||||
v-if="state.evaluationWorkflow.value && state.tags.value.length > 0"
|
||||
:class="$style.runTestButton"
|
||||
size="small"
|
||||
data-test-id="run-test-button"
|
||||
:label="locale.baseText('testDefinition.runTest')"
|
||||
type="primary"
|
||||
@click="runTest"
|
||||
/>
|
||||
<n8n-button
|
||||
v-else
|
||||
:class="$style.runTestButton"
|
||||
size="small"
|
||||
data-test-id="run-test-button"
|
||||
:label="locale.baseText('testDefinition.edit.saveTest')"
|
||||
type="primary"
|
||||
@click="onSaveTest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HeaderSection
|
||||
v-model:name="state.name"
|
||||
v-model:description="state.description"
|
||||
v-model:tags="state.tags"
|
||||
:has-runs="hasRuns"
|
||||
:is-saving="isSaving"
|
||||
:get-field-issues="getFieldIssues"
|
||||
:start-editing="startEditing"
|
||||
:save-changes="saveChanges"
|
||||
:handle-keydown="handleKeydown"
|
||||
:on-save-test="onSaveTest"
|
||||
:run-test="runTest"
|
||||
:show-config="showConfig"
|
||||
:toggle-config="toggleConfig"
|
||||
:run-test-enabled="isRunTestEnabled"
|
||||
>
|
||||
<template #runTestTooltip>
|
||||
<template v-if="fieldsIssues.length > 0">
|
||||
<div>{{ locale.baseText('testDefinition.completeConfig') }}</div>
|
||||
<div v-for="issue in fieldsIssues" :key="issue.field">- {{ issue.message }}</div>
|
||||
</template>
|
||||
<template v-if="isRunning">
|
||||
{{ locale.baseText('testDefinition.testIsRunning') }}
|
||||
</template>
|
||||
</template>
|
||||
</HeaderSection>
|
||||
|
||||
<div :class="$style.content">
|
||||
<div v-if="runs.length > 0" :class="$style.runs">
|
||||
<!-- Metrics Chart -->
|
||||
<MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" :theme="appliedTheme" />
|
||||
<!-- Past Runs Table -->
|
||||
<TestRunsTable
|
||||
:class="$style.runsTable"
|
||||
:runs="runs"
|
||||
:selectable="true"
|
||||
data-test-id="past-runs-table"
|
||||
@delete-runs="onDeleteRuns"
|
||||
/>
|
||||
</div>
|
||||
<RunsSection
|
||||
v-if="runs.length > 0"
|
||||
v-model:selectedMetric="selectedMetric"
|
||||
:runs="runs"
|
||||
:test-id="testId"
|
||||
:applied-theme="appliedTheme"
|
||||
@delete-runs="onDeleteRuns"
|
||||
/>
|
||||
|
||||
<div :class="[$style.panelBlock, { [$style.hidden]: !showConfig }]">
|
||||
<div :class="$style.panelIntro">
|
||||
{{ locale.baseText('testDefinition.edit.step.intro') }}
|
||||
</div>
|
||||
<BlockArrow :class="$style.introArrow" />
|
||||
<!-- Select Executions -->
|
||||
<EvaluationStep
|
||||
:class="$style.step"
|
||||
:title="
|
||||
locale.baseText('testDefinition.edit.step.executions', {
|
||||
adjustToNumber: tagUsageCount,
|
||||
})
|
||||
"
|
||||
:description="locale.baseText('testDefinition.edit.step.executions.description')"
|
||||
>
|
||||
<template #icon><font-awesome-icon icon="history" size="lg" /></template>
|
||||
<template #cardContent>
|
||||
<TagsInput
|
||||
v-model="state.tags"
|
||||
:class="{ 'has-issues': hasIssues('tags') }"
|
||||
:all-tags="allTags"
|
||||
:tags-by-id="tagsById"
|
||||
:is-loading="isLoading"
|
||||
:start-editing="startEditing"
|
||||
:save-changes="saveChanges"
|
||||
:cancel-editing="cancelEditing"
|
||||
:create-tag="handleCreateTag"
|
||||
/>
|
||||
</template>
|
||||
</EvaluationStep>
|
||||
<div :class="$style.evaluationArrows">
|
||||
<BlockArrow />
|
||||
<BlockArrow />
|
||||
</div>
|
||||
|
||||
<!-- Mocked Nodes -->
|
||||
<EvaluationStep
|
||||
:class="$style.step"
|
||||
:title="
|
||||
locale.baseText('testDefinition.edit.step.mockedNodes', {
|
||||
adjustToNumber: state.mockedNodes?.length ?? 0,
|
||||
})
|
||||
"
|
||||
:small="true"
|
||||
:expanded="true"
|
||||
:description="locale.baseText('testDefinition.edit.step.nodes.description')"
|
||||
>
|
||||
<template #icon><font-awesome-icon icon="thumbtack" size="lg" /></template>
|
||||
<template #cardContent>
|
||||
<n8n-button
|
||||
size="small"
|
||||
data-test-id="select-nodes-button"
|
||||
:label="locale.baseText('testDefinition.edit.selectNodes')"
|
||||
type="tertiary"
|
||||
@click="openPinningModal"
|
||||
/>
|
||||
</template>
|
||||
</EvaluationStep>
|
||||
|
||||
<!-- Re-run Executions -->
|
||||
<EvaluationStep
|
||||
:class="$style.step"
|
||||
:title="locale.baseText('testDefinition.edit.step.reRunExecutions')"
|
||||
:small="true"
|
||||
:description="locale.baseText('testDefinition.edit.step.reRunExecutions.description')"
|
||||
>
|
||||
<template #icon><font-awesome-icon icon="redo" size="lg" /></template>
|
||||
</EvaluationStep>
|
||||
|
||||
<!-- Compare Executions -->
|
||||
<EvaluationStep
|
||||
:class="$style.step"
|
||||
:title="locale.baseText('testDefinition.edit.step.compareExecutions')"
|
||||
:description="locale.baseText('testDefinition.edit.step.compareExecutions.description')"
|
||||
>
|
||||
<template #icon><font-awesome-icon icon="equals" size="lg" /></template>
|
||||
<template #cardContent>
|
||||
<WorkflowSelector
|
||||
v-model="state.evaluationWorkflow"
|
||||
:class="{ 'has-issues': hasIssues('evaluationWorkflow') }"
|
||||
/>
|
||||
</template>
|
||||
</EvaluationStep>
|
||||
|
||||
<!-- Metrics -->
|
||||
<EvaluationStep
|
||||
:class="$style.step"
|
||||
:title="locale.baseText('testDefinition.edit.step.metrics')"
|
||||
:description="locale.baseText('testDefinition.edit.step.metrics.description')"
|
||||
>
|
||||
<template #icon><font-awesome-icon icon="chart-bar" size="lg" /></template>
|
||||
<template #cardContent>
|
||||
<MetricsInput
|
||||
v-model="state.metrics"
|
||||
:class="{ 'has-issues': hasIssues('metrics') }"
|
||||
@delete-metric="onDeleteMetric"
|
||||
/>
|
||||
</template>
|
||||
</EvaluationStep>
|
||||
</div>
|
||||
<ConfigSection
|
||||
v-model:tags="state.tags"
|
||||
v-model:evaluationWorkflow="state.evaluationWorkflow"
|
||||
v-model:metrics="state.metrics"
|
||||
v-model:mockedNodes="state.mockedNodes"
|
||||
:cancel-editing="cancelEditing"
|
||||
:show-config="showConfig"
|
||||
:tag-usage-count="tagUsageCount"
|
||||
:all-tags="allTags"
|
||||
:tags-by-id="tagsById"
|
||||
:is-loading="isLoading"
|
||||
:get-field-issues="getFieldIssues"
|
||||
:start-editing="startEditing"
|
||||
:save-changes="saveChanges"
|
||||
:create-tag="handleCreateTag"
|
||||
@open-pinning-modal="openPinningModal"
|
||||
@delete-metric="onDeleteMetric"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal ref="nodePinningModal" width="80vw" height="85vh" :name="NODE_PINNING_MODAL_KEY">
|
||||
<template #header>
|
||||
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading">{{
|
||||
locale.baseText('testDefinition.edit.selectNodes')
|
||||
}}</N8nHeading>
|
||||
</template>
|
||||
<template #content>
|
||||
<NodesPinning
|
||||
v-model="state.mockedNodes"
|
||||
:width="modalContentWidth"
|
||||
data-test-id="nodes-pinning-modal"
|
||||
/>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -392,104 +253,4 @@ watch(
|
|||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.headerSection {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
background-color: var(--color-background-light);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.headerMeta {
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.lastSaved {
|
||||
font-size: var(--font-size-s);
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
}
|
||||
|
||||
.descriptionInput {
|
||||
margin-top: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.runs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-m);
|
||||
flex: 1;
|
||||
padding-top: var(--spacing-3xs);
|
||||
overflow: auto;
|
||||
|
||||
@media (min-height: 56rem) {
|
||||
margin-top: var(--spacing-2xl);
|
||||
}
|
||||
}
|
||||
|
||||
.panelBlock {
|
||||
width: var(--evaluation-edit-panel-width);
|
||||
display: grid;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
padding-bottom: var(--spacing-l);
|
||||
margin-left: var(--spacing-2xl);
|
||||
transition: width 0.2s ease;
|
||||
|
||||
&.hidden {
|
||||
margin-left: 0;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.noRuns & {
|
||||
overflow-y: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.panelIntro {
|
||||
font-size: var(--font-size-m);
|
||||
color: var(--color-text-dark);
|
||||
|
||||
justify-self: center;
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.step {
|
||||
position: relative;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: var(--spacing-m);
|
||||
}
|
||||
}
|
||||
|
||||
.introArrow {
|
||||
--arrow-height: 1.5rem;
|
||||
margin-bottom: -1rem;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.evaluationArrows {
|
||||
--arrow-height: 22rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 80%;
|
||||
margin: 0 auto;
|
||||
margin-bottom: -100%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,15 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { MODAL_CONFIRM, VIEWS } from '@/constants';
|
||||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import EmptyState from '@/components/TestDefinition/ListDefinition/EmptyState.vue';
|
||||
import TestsList from '@/components/TestDefinition/ListDefinition/TestsList.vue';
|
||||
import type { TestExecution, TestListItem } from '@/components/TestDefinition/types';
|
||||
import type {
|
||||
TestExecution,
|
||||
TestItemAction,
|
||||
TestListItem,
|
||||
} from '@/components/TestDefinition/types';
|
||||
import { useAnnotationTagsStore } from '@/stores/tags.store';
|
||||
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
|
||||
const router = useRouter();
|
||||
const tagsStore = useAnnotationTagsStore();
|
||||
|
@ -17,6 +22,46 @@ const testDefinitionStore = useTestDefinitionStore();
|
|||
const isLoading = ref(false);
|
||||
const toast = useToast();
|
||||
const locale = useI18n();
|
||||
const { confirm } = useMessage();
|
||||
|
||||
const actions = computed<TestItemAction[]>(() => [
|
||||
{
|
||||
icon: 'play',
|
||||
id: 'run',
|
||||
event: onRunTest,
|
||||
disabled: isRunDisabled,
|
||||
show: (testId) => !isTestRunning(testId),
|
||||
tooltip: (testId) =>
|
||||
isRunDisabled(testId)
|
||||
? getDisabledRunTooltip(testId)
|
||||
: locale.baseText('testDefinition.runTest'),
|
||||
},
|
||||
{
|
||||
icon: 'stop',
|
||||
id: 'cancel',
|
||||
event: onCancelTestRun,
|
||||
tooltip: () => locale.baseText('testDefinition.cancelTestRun'),
|
||||
show: (testId) => isTestRunning(testId),
|
||||
},
|
||||
{
|
||||
icon: 'list',
|
||||
id: 'view',
|
||||
event: onViewDetails,
|
||||
tooltip: () => locale.baseText('testDefinition.viewDetails'),
|
||||
},
|
||||
{
|
||||
icon: 'pen',
|
||||
id: 'edit',
|
||||
event: onEditTest,
|
||||
tooltip: () => locale.baseText('testDefinition.editTest'),
|
||||
},
|
||||
{
|
||||
icon: 'trash',
|
||||
id: 'delete',
|
||||
event: onDeleteTest,
|
||||
tooltip: () => locale.baseText('testDefinition.deleteTest'),
|
||||
},
|
||||
]);
|
||||
|
||||
const tests = computed<TestListItem[]>(() => {
|
||||
return (
|
||||
|
@ -32,6 +77,7 @@ const tests = computed<TestListItem[]>(() => {
|
|||
tagName: test.annotationTagId ? getTagName(test.annotationTagId) : '',
|
||||
testCases: testDefinitionStore.testRunsByTestId[test.id]?.length ?? 0,
|
||||
execution: getTestExecution(test.id),
|
||||
fieldsIssues: testDefinitionStore.getFieldIssues(test.id),
|
||||
}));
|
||||
});
|
||||
|
||||
|
@ -43,6 +89,14 @@ function getTagName(tagId: string) {
|
|||
|
||||
return matchingTag?.name ?? '';
|
||||
}
|
||||
function getDisabledRunTooltip(testId: string) {
|
||||
const issues = testDefinitionStore
|
||||
.getFieldIssues(testId)
|
||||
?.map((i) => i.message)
|
||||
.join('<br />- ');
|
||||
|
||||
return `${locale.baseText('testDefinition.completeConfig')} <br /> - ${issues}`;
|
||||
}
|
||||
|
||||
function getTestExecution(testId: string): TestExecution {
|
||||
const lastRun = testDefinitionStore.lastRunByTestId[testId];
|
||||
|
@ -67,6 +121,13 @@ function getTestExecution(testId: string): TestExecution {
|
|||
return mockExecutions;
|
||||
}
|
||||
|
||||
function isTestRunning(testId: string) {
|
||||
return testDefinitionStore.lastRunByTestId[testId]?.status === 'running';
|
||||
}
|
||||
|
||||
function isRunDisabled(testId: string) {
|
||||
return testDefinitionStore.getFieldIssues(testId)?.length > 0;
|
||||
}
|
||||
// Action handlers
|
||||
function onCreateTest() {
|
||||
void router.push({ name: VIEWS.NEW_TEST_DEFINITION });
|
||||
|
@ -91,8 +152,9 @@ async function onRunTest(testId: string) {
|
|||
}
|
||||
}
|
||||
|
||||
async function onCancelTestRun(testId: string, testRunId: string | null) {
|
||||
async function onCancelTestRun(testId: string) {
|
||||
try {
|
||||
const testRunId = testDefinitionStore.lastRunByTestId[testId]?.id;
|
||||
// FIXME: testRunId might be null for a short period of time between user clicking start and the test run being created and fetched. Just ignore it for now.
|
||||
if (!testRunId) {
|
||||
throw new Error('Failed to cancel test run');
|
||||
|
@ -119,11 +181,25 @@ async function onViewDetails(testId: string) {
|
|||
void router.push({ name: VIEWS.TEST_DEFINITION_RUNS, params: { testId } });
|
||||
}
|
||||
|
||||
function onEditTest(testId: number) {
|
||||
function onEditTest(testId: string) {
|
||||
void router.push({ name: VIEWS.TEST_DEFINITION_EDIT, params: { testId } });
|
||||
}
|
||||
|
||||
async function onDeleteTest(testId: string) {
|
||||
const deleteConfirmed = await confirm(
|
||||
locale.baseText('testDefinition.deleteTest.warning'),
|
||||
locale.baseText('testDefinition.deleteTest'),
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: locale.baseText('generic.delete'),
|
||||
cancelButtonText: locale.baseText('generic.cancel'),
|
||||
closeOnClickModal: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (deleteConfirmed !== MODAL_CONFIRM) {
|
||||
return;
|
||||
}
|
||||
await testDefinitionStore.deleteById(testId);
|
||||
|
||||
toast.showMessage({
|
||||
|
@ -138,12 +214,9 @@ async function loadInitialData() {
|
|||
// Add guard to prevent multiple loading states
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await Promise.all([
|
||||
tagsStore.fetchAll(),
|
||||
testDefinitionStore.fetchAll({
|
||||
workflowId: router.currentRoute.value.params.name as string,
|
||||
}),
|
||||
]);
|
||||
await testDefinitionStore.fetchAll({
|
||||
workflowId: router.currentRoute.value.params.name as string,
|
||||
});
|
||||
isLoading.value = false;
|
||||
} catch (error) {
|
||||
toast.showError(error, locale.baseText('testDefinition.list.loadError'));
|
||||
|
@ -153,7 +226,7 @@ async function loadInitialData() {
|
|||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
if (!testDefinitionStore.isFeatureEnabled) {
|
||||
toast.showMessage({
|
||||
title: locale.baseText('testDefinition.notImplemented'),
|
||||
|
@ -166,8 +239,8 @@ onMounted(() => {
|
|||
});
|
||||
return; // Add early return to prevent loading if feature is disabled
|
||||
}
|
||||
|
||||
void loadInitialData();
|
||||
await loadInitialData();
|
||||
tests.value.forEach((test) => testDefinitionStore.updateRunFieldIssues(test.id));
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -186,12 +259,9 @@ onMounted(() => {
|
|||
<TestsList
|
||||
v-else
|
||||
:tests="tests"
|
||||
@create-test="onCreateTest"
|
||||
@run-test="onRunTest"
|
||||
:actions="actions"
|
||||
@view-details="onViewDetails"
|
||||
@edit-test="onEditTest"
|
||||
@delete-test="onDeleteTest"
|
||||
@cancel-test-run="onCancelTestRun"
|
||||
@create-test="onCreateTest"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
|
|
@ -4,10 +4,11 @@ import { useRouter } from 'vue-router';
|
|||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||
import type { TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import TestRunsTable from '@/components/TestDefinition/ListRuns/TestRunsTable.vue';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { MODAL_CONFIRM, VIEWS } from '@/constants';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
|
||||
const router = useRouter();
|
||||
const testDefinitionStore = useTestDefinitionStore();
|
||||
|
@ -75,6 +76,19 @@ async function runTest() {
|
|||
}
|
||||
|
||||
async function onDeleteRuns(runsToDelete: TestRunRecord[]) {
|
||||
const { confirm } = useMessage();
|
||||
|
||||
const deleteConfirmed = await confirm(locale.baseText('testDefinition.deleteTest'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: locale.baseText(
|
||||
'settings.log-streaming.destinationDelete.confirmButtonText',
|
||||
),
|
||||
cancelButtonText: locale.baseText('settings.log-streaming.destinationDelete.cancelButtonText'),
|
||||
});
|
||||
|
||||
if (deleteConfirmed !== MODAL_CONFIRM) {
|
||||
return;
|
||||
}
|
||||
await Promise.all(
|
||||
runsToDelete.map(async (run) => {
|
||||
await testDefinitionStore.deleteTestRun({ testDefinitionId: testId.value, runId: run.id });
|
||||
|
@ -98,7 +112,7 @@ onMounted(async () => {
|
|||
<N8nLoading :rows="5" />
|
||||
<N8nLoading :rows="10" />
|
||||
</template>
|
||||
<div :class="$style.details" v-else-if="runs.length > 0">
|
||||
<div v-else-if="runs.length > 0" :class="$style.details">
|
||||
<MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" :theme="appliedTheme" />
|
||||
<TestRunsTable :runs="runs" @get-run-detail="getRunDetail" @delete-runs="onDeleteRuns" />
|
||||
</div>
|
||||
|
|
|
@ -147,21 +147,6 @@ describe('TestDefinitionEditView', () => {
|
|||
expect(createTestMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update test and show success message on save if testId is present', async () => {
|
||||
vi.mocked(useRoute).mockReturnValue({
|
||||
params: { testId: '1' },
|
||||
name: VIEWS.TEST_DEFINITION_EDIT,
|
||||
} as unknown as ReturnType<typeof useRoute>);
|
||||
|
||||
const { getByTestId } = renderComponentWithFeatureEnabled();
|
||||
|
||||
const saveButton = getByTestId('run-test-button');
|
||||
saveButton.click();
|
||||
await nextTick();
|
||||
|
||||
expect(updateTestMock).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
it('should show error message on failed test creation', async () => {
|
||||
createTestMock.mockRejectedValue(new Error('Save failed'));
|
||||
|
||||
|
@ -180,53 +165,39 @@ describe('TestDefinitionEditView', () => {
|
|||
expect(showErrorMock).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
|
||||
});
|
||||
|
||||
it('should display "Save Test" button when editing test without eval workflow and tags', async () => {
|
||||
it('should display disabled "run test" button when editing test without tags', async () => {
|
||||
vi.mocked(useRoute).mockReturnValue({
|
||||
params: { testId: '1' },
|
||||
name: VIEWS.TEST_DEFINITION_EDIT,
|
||||
} as unknown as ReturnType<typeof useRoute>);
|
||||
|
||||
const { getByTestId } = renderComponentWithFeatureEnabled();
|
||||
const { getByTestId, mockedTestDefinitionStore } = renderComponentWithFeatureEnabled();
|
||||
|
||||
mockedTestDefinitionStore.getFieldIssues = vi
|
||||
.fn()
|
||||
.mockReturnValue([{ field: 'tags', message: 'Tag is required' }]);
|
||||
|
||||
await nextTick();
|
||||
|
||||
const updateButton = getByTestId('run-test-button');
|
||||
expect(updateButton.textContent?.toLowerCase()).toContain('save');
|
||||
});
|
||||
expect(updateButton.textContent?.toLowerCase()).toContain('run test');
|
||||
expect(updateButton).toHaveClass('disabled');
|
||||
|
||||
it('should display "Save Test" button when creating new test', async () => {
|
||||
vi.mocked(useRoute).mockReturnValue({
|
||||
params: {},
|
||||
name: VIEWS.NEW_TEST_DEFINITION,
|
||||
} as unknown as ReturnType<typeof useRoute>);
|
||||
|
||||
const { getByTestId } = renderComponentWithFeatureEnabled();
|
||||
|
||||
const saveButton = getByTestId('run-test-button');
|
||||
expect(saveButton.textContent?.toLowerCase()).toContain('save test');
|
||||
mockedTestDefinitionStore.getFieldIssues = vi.fn().mockReturnValue([]);
|
||||
await nextTick();
|
||||
expect(updateButton).not.toHaveClass('disabled');
|
||||
});
|
||||
|
||||
it('should apply "has-issues" class to inputs with issues', async () => {
|
||||
vi.mocked(useTestDefinitionForm).mockReturnValue({
|
||||
...vi.mocked(useTestDefinitionForm)(),
|
||||
fieldsIssues: ref([
|
||||
{ field: 'name', message: 'Name is required' },
|
||||
{ field: 'tags', message: 'Tag is required' },
|
||||
]),
|
||||
} as unknown as ReturnType<typeof useTestDefinitionForm>);
|
||||
|
||||
const { container } = renderComponentWithFeatureEnabled();
|
||||
|
||||
const { container, mockedTestDefinitionStore } = renderComponentWithFeatureEnabled();
|
||||
mockedTestDefinitionStore.getFieldIssues = vi
|
||||
.fn()
|
||||
.mockReturnValue([{ field: 'tags', message: 'Tag is required' }]);
|
||||
await nextTick();
|
||||
const issueElements = container.querySelectorAll('.has-issues');
|
||||
expect(issueElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should fetch all tags on mount', async () => {
|
||||
renderComponentWithFeatureEnabled();
|
||||
await nextTick();
|
||||
expect(mockedStore(useAnnotationTagsStore).fetchAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Test Runs functionality', () => {
|
||||
it('should display test runs table when runs exist', async () => {
|
||||
vi.mocked(useRoute).mockReturnValue({
|
||||
|
|
|
@ -6,21 +6,22 @@ import { createComponentRenderer } from '@/__tests__/render';
|
|||
import TestDefinitionListView from '@/views/TestDefinition/TestDefinitionListView.vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useAnnotationTagsStore } from '@/stores/tags.store';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { MODAL_CONFIRM, VIEWS } from '@/constants';
|
||||
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
|
||||
|
||||
vi.mock('vue-router');
|
||||
vi.mock('@/composables/useToast');
|
||||
|
||||
vi.mock('@/composables/useMessage');
|
||||
describe('TestDefinitionListView', () => {
|
||||
const renderComponent = createComponentRenderer(TestDefinitionListView);
|
||||
|
||||
let showMessageMock: Mock;
|
||||
let showErrorMock: Mock;
|
||||
let confirmMock: Mock;
|
||||
let startTestRunMock: Mock;
|
||||
let fetchTestRunsMock: Mock;
|
||||
let deleteByIdMock: Mock;
|
||||
|
@ -65,6 +66,7 @@ describe('TestDefinitionListView', () => {
|
|||
|
||||
showMessageMock = vi.fn();
|
||||
showErrorMock = vi.fn();
|
||||
confirmMock = vi.fn().mockResolvedValue(MODAL_CONFIRM);
|
||||
startTestRunMock = vi.fn().mockResolvedValue({ success: true });
|
||||
fetchTestRunsMock = vi.fn();
|
||||
deleteByIdMock = vi.fn();
|
||||
|
@ -74,6 +76,10 @@ describe('TestDefinitionListView', () => {
|
|||
showMessage: showMessageMock,
|
||||
showError: showErrorMock,
|
||||
} as unknown as ReturnType<typeof useToast>);
|
||||
|
||||
vi.mocked(useMessage).mockReturnValue({
|
||||
confirm: confirmMock,
|
||||
} as unknown as ReturnType<typeof useMessage>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -89,7 +95,6 @@ describe('TestDefinitionListView', () => {
|
|||
setActivePinia(pinia);
|
||||
|
||||
const testDefinitionStore = mockedStore(useTestDefinitionStore);
|
||||
// const tagsStore = mockedStore(useAnnotationTagsStore);
|
||||
testDefinitionStore.isFeatureEnabled = true;
|
||||
testDefinitionStore.fetchAll = fetchAllMock;
|
||||
testDefinitionStore.startTestRun = startTestRunMock;
|
||||
|
@ -120,7 +125,6 @@ describe('TestDefinitionListView', () => {
|
|||
expect(testDefinitionStore.fetchAll).toHaveBeenCalledWith({
|
||||
workflowId: 'workflow1',
|
||||
});
|
||||
expect(mockedStore(useAnnotationTagsStore).fetchAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should start test run and show success message', async () => {
|
||||
|
@ -150,13 +154,12 @@ describe('TestDefinitionListView', () => {
|
|||
});
|
||||
|
||||
it('should delete test and show success message', async () => {
|
||||
const { getByTestId, testDefinitionStore } = await renderComponentWithFeatureEnabled();
|
||||
|
||||
const { getByTestId } = await renderComponentWithFeatureEnabled();
|
||||
const deleteButton = getByTestId('delete-test-button-1');
|
||||
deleteButton.click();
|
||||
await nextTick();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(testDefinitionStore.deleteById).toHaveBeenCalledWith('1');
|
||||
expect(deleteByIdMock).toHaveBeenCalledWith('1');
|
||||
expect(showMessageMock).toHaveBeenCalledWith({
|
||||
title: expect.any(String),
|
||||
type: 'success',
|
||||
|
|
Loading…
Reference in a new issue