Add tooltips and limit tag selection in test definitions

- Add tooltips to test definition steps explaining their purpose
- Limit tag selection to single tag in test definitions
- Add multiple-limit prop to N8nSelect component
- Update TagsDropdown to support multiple-limit
This commit is contained in:
Oleg Ivaniv 2024-12-10 08:41:51 +01:00
parent 42926ba960
commit ee05fd9fef
No known key found for this signature in database
6 changed files with 93 additions and 73 deletions

View file

@ -31,6 +31,10 @@ const props = defineProps({
multiple: { multiple: {
type: Boolean, type: Boolean,
}, },
multipleLimit: {
type: Number,
default: 0,
},
filterMethod: { filterMethod: {
type: Function, type: Function,
}, },
@ -120,6 +124,7 @@ defineExpose({
<ElSelect <ElSelect
v-bind="{ ...$props, ...listeners }" v-bind="{ ...$props, ...listeners }"
ref="innerSelect" ref="innerSelect"
:multiple-limit="props.multipleLimit"
:model-value="props.modelValue ?? undefined" :model-value="props.modelValue ?? undefined"
:size="computedSize" :size="computedSize"
:popper-class="props.popperClass" :popper-class="props.popperClass"

View file

@ -19,6 +19,7 @@ interface TagsDropdownProps {
createEnabled?: boolean; createEnabled?: boolean;
manageEnabled?: boolean; manageEnabled?: boolean;
createTag?: (name: string) => Promise<ITag>; createTag?: (name: string) => Promise<ITag>;
multipleLimit?: number;
} }
const i18n = useI18n(); const i18n = useI18n();
@ -32,6 +33,7 @@ const props = withDefaults(defineProps<TagsDropdownProps>(), {
createEnabled: true, createEnabled: true,
manageEnabled: true, manageEnabled: true,
createTag: undefined, createTag: undefined,
multipleLimit: 0,
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@ -222,6 +224,7 @@ onClickOutside(
:filter-method="filterOptions" :filter-method="filterOptions"
filterable filterable
multiple multiple
:multiple-limit="props.multipleLimit"
:reserve-keyword="false" :reserve-keyword="false"
loading-text="..." loading-text="..."
:popper-class="dropdownClasses" :popper-class="dropdownClasses"

View file

@ -2,12 +2,14 @@
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { ElCollapseTransition } from 'element-plus'; import { ElCollapseTransition } from 'element-plus';
import { ref, nextTick } from 'vue'; import { ref, nextTick } from 'vue';
import N8nTooltip from 'n8n-design-system/components/N8nTooltip';
interface EvaluationStep { interface EvaluationStep {
title: string; title: string;
warning?: boolean; warning?: boolean;
small?: boolean; small?: boolean;
expanded?: boolean; expanded?: boolean;
tooltip?: string;
} }
const props = withDefaults(defineProps<EvaluationStep>(), { const props = withDefaults(defineProps<EvaluationStep>(), {
@ -15,6 +17,7 @@ const props = withDefaults(defineProps<EvaluationStep>(), {
warning: false, warning: false,
small: false, small: false,
expanded: true, expanded: true,
tooltip: '',
}); });
const locale = useI18n(); const locale = useI18n();
@ -35,36 +38,41 @@ const toggleExpand = async () => {
<template> <template>
<div ref="containerRef" :class="[$style.evaluationStep, small && $style.small]"> <div ref="containerRef" :class="[$style.evaluationStep, small && $style.small]">
<div :class="$style.content"> <N8nTooltip :disabled="!tooltip" placement="right" :offset="25">
<div :class="$style.header"> <template #content>
<div :class="[$style.icon, warning && $style.warning]"> {{ tooltip }}
<slot name="icon" /> </template>
</div> <div :class="$style.content">
<h3 :class="$style.title">{{ title }}</h3> <div :class="$style.header">
<span v-if="warning" :class="$style.warningIcon"></span> <div :class="[$style.icon, warning && $style.warning]">
<button <slot name="icon" />
v-if="$slots.cardContent"
:class="$style.collapseButton"
:aria-expanded="isExpanded"
:aria-controls="'content-' + title.replace(/\s+/g, '-')"
@click="toggleExpand"
>
{{
isExpanded
? locale.baseText('testDefinition.edit.step.collapse')
: locale.baseText('testDefinition.edit.step.expand')
}}
<font-awesome-icon :icon="isExpanded ? 'angle-down' : 'angle-right'" size="lg" />
</button>
</div>
<ElCollapseTransition v-if="$slots.cardContent">
<div v-show="isExpanded" :class="$style.cardContentWrapper">
<div ref="contentRef" :class="$style.cardContent">
<slot name="cardContent" />
</div> </div>
<h3 :class="$style.title">{{ title }}</h3>
<span v-if="warning" :class="$style.warningIcon"></span>
<button
v-if="$slots.cardContent"
:class="$style.collapseButton"
:aria-expanded="isExpanded"
:aria-controls="'content-' + title.replace(/\s+/g, '-')"
@click="toggleExpand"
>
{{
isExpanded
? locale.baseText('testDefinition.edit.step.collapse')
: locale.baseText('testDefinition.edit.step.expand')
}}
<font-awesome-icon :icon="isExpanded ? 'angle-down' : 'angle-right'" size="lg" />
</button>
</div> </div>
</ElCollapseTransition> <ElCollapseTransition v-if="$slots.cardContent">
</div> <div v-show="isExpanded" :class="$style.cardContentWrapper">
<div ref="contentRef" :class="$style.cardContent">
<slot name="cardContent" />
</div>
</div>
</ElCollapseTransition>
</div>
</N8nTooltip>
</div> </div>
</template> </template>

View file

@ -89,6 +89,7 @@ function updateTags(tags: string[]) {
:event-bus="tagsEventBus" :event-bus="tagsEventBus"
:create-tag="createTag" :create-tag="createTag"
:manage-enabled="false" :manage-enabled="false"
:multiple-limit="1"
@update:model-value="updateTags" @update:model-value="updateTags"
@esc="cancelEditing('tags')" @esc="cancelEditing('tags')"
@blur="saveChanges('tags')" @blur="saveChanges('tags')"

View file

@ -2759,14 +2759,20 @@
"testDefinition.edit.testSaved": "Test saved", "testDefinition.edit.testSaved": "Test saved",
"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.tooltip": "Add details about what this test evaluates and what success looks like",
"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": "Fetch past executions | Fetch {count} past execution | Fetch {count} past executions", "testDefinition.edit.step.executions": "Fetch past executions | Fetch {count} past execution | Fetch {count} past executions",
"testDefinition.edit.step.executions.tooltip": "Select which tagged executions to use as test cases. Each execution will be replayed to compare performance",
"testDefinition.edit.step.nodes": "Mock nodes", "testDefinition.edit.step.nodes": "Mock nodes",
"testDefinition.edit.step.mockedNodes": "No nodes mocked | {count} node mocked | {count} nodes mocked", "testDefinition.edit.step.mockedNodes": "No nodes mocked | {count} node mocked | {count} nodes mocked",
"testDefinition.edit.step.nodes.tooltip": "Replace specific nodes with test data to isolate what you're testing",
"testDefinition.edit.step.reRunExecutions": "Re-run executions", "testDefinition.edit.step.reRunExecutions": "Re-run executions",
"testDefinition.edit.step.reRunExecutions.tooltip": "Each test case will be re-run using the current workflow version",
"testDefinition.edit.step.compareExecutions": "Compare each past and new execution", "testDefinition.edit.step.compareExecutions": "Compare each past and new execution",
"testDefinition.edit.step.compareExecutions.tooltip": "Select which workflow to use for running the comparison tests",
"testDefinition.edit.step.metrics": "Summarise metrics", "testDefinition.edit.step.metrics": "Summarise metrics",
"testDefinition.edit.step.metrics.tooltip": "Define which output fields to track and compare between test runs",
"testDefinition.edit.step.collapse": "Collapse", "testDefinition.edit.step.collapse": "Collapse",
"testDefinition.edit.step.expand": "Expand", "testDefinition.edit.step.expand": "Expand",
"testDefinition.list.testDeleted": "Test deleted", "testDefinition.list.testDeleted": "Test deleted",

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { VIEWS } from '@/constants'; import { VIEWS } from '@/constants';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
@ -15,8 +15,6 @@ import TagsInput from '@/components/TestDefinition/EditDefinition/TagsInput.vue'
import WorkflowSelector from '@/components/TestDefinition/EditDefinition/WorkflowSelector.vue'; import WorkflowSelector from '@/components/TestDefinition/EditDefinition/WorkflowSelector.vue';
import MetricsInput from '@/components/TestDefinition/EditDefinition/MetricsInput.vue'; import MetricsInput from '@/components/TestDefinition/EditDefinition/MetricsInput.vue';
import type { TestMetricRecord } from '@/api/testDefinition.ee'; import type { TestMetricRecord } from '@/api/testDefinition.ee';
import { useExecutionsStore } from '@/stores/executions.store';
import type { IExecutionsListResponse } from '@/Interface';
const props = defineProps<{ const props = defineProps<{
testId?: string; testId?: string;
@ -27,23 +25,7 @@ const route = useRoute();
const locale = useI18n(); const locale = useI18n();
const { debounce } = useDebounce(); const { debounce } = useDebounce();
const toast = useToast(); const toast = useToast();
const { fetchExecutions } = useExecutionsStore();
const tagsStore = useAnnotationTagsStore(); const tagsStore = useAnnotationTagsStore();
const isLoading = computed(() => tagsStore.isLoading);
const allTags = computed(() => tagsStore.allTags);
const tagsById = computed(() => tagsStore.tagsById);
const testId = computed(() => props.testId ?? (route.params.testId as string));
const currentWorkflowId = computed(() => route.params.name as string);
const buttonLabel = computed(() =>
testId.value
? locale.baseText('testDefinition.edit.updateTest')
: locale.baseText('testDefinition.edit.saveTest'),
);
const matchedExecutions = ref<IExecutionsListResponse['results']>([]);
const { const {
state, state,
fieldsIssues, fieldsIssues,
@ -59,14 +41,26 @@ const {
updateMetrics, updateMetrics,
} = useTestDefinitionForm(); } = useTestDefinitionForm();
const isLoading = computed(() => tagsStore.isLoading);
const allTags = computed(() => tagsStore.allTags);
const tagsById = computed(() => tagsStore.tagsById);
const testId = computed(() => props.testId ?? (route.params.testId as string));
const currentWorkflowId = computed(() => route.params.name as string);
const buttonLabel = computed(() =>
testId.value
? locale.baseText('testDefinition.edit.updateTest')
: locale.baseText('testDefinition.edit.saveTest'),
);
const tagUsageCount = computed(
() => tagsStore.tagsById[state.value.tags.value[0]]?.usageCount ?? 0,
);
onMounted(async () => { onMounted(async () => {
await tagsStore.fetchAll(); void tagsStore.fetchAll({ withUsageCount: true });
if (testId.value) { if (testId.value) {
await loadTestData(testId.value); await loadTestData(testId.value);
// Now tags are in state.tags.value instead of appliedTagIds
if (state.value.tags.value.length > 0) {
await fetchSelectedExecutions();
}
} else { } else {
await onSaveTest(); await onSaveTest();
} }
@ -105,25 +99,6 @@ async function onDeleteMetric(deletedMetric: Partial<TestMetricRecord>) {
} }
} }
async function fetchSelectedExecutions() {
// Use state.tags.value for the annotationTags
const executionsForTags = await fetchExecutions({
annotationTags: state.value.tags.value,
});
matchedExecutions.value = executionsForTags.results;
}
// Debounced watchers for auto-saving
watch([() => state.value.evaluationWorkflow], debounce(onSaveTest, { debounceTime: 400 }), {
deep: true,
});
watch(
() => state.value.metrics,
debounce(async () => await updateMetrics(testId.value), { debounceTime: 400 }),
{ deep: true },
);
async function handleCreateTag(tagName: string) { async function handleCreateTag(tagName: string) {
try { try {
const newTag = await tagsStore.create(tagName); const newTag = await tagsStore.create(tagName);
@ -134,7 +109,23 @@ async function handleCreateTag(tagName: string) {
} }
} }
watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: true }); // Debounced watchers for auto-saving
watch(
() => state.value.metrics,
debounce(async () => await updateMetrics(testId.value), { debounceTime: 400 }),
{ deep: true },
);
watch(
() => [
state.value.description,
state.value.name,
state.value.tags,
state.value.evaluationWorkflow,
],
debounce(onSaveTest, { debounceTime: 400 }),
{ deep: true },
);
</script> </script>
<template> <template>
@ -152,6 +143,7 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
:class="$style.step" :class="$style.step"
:title="locale.baseText('testDefinition.edit.description')" :title="locale.baseText('testDefinition.edit.description')"
:expanded="false" :expanded="false"
:tooltip="locale.baseText('testDefinition.edit.description.tooltip')"
> >
<template #icon><font-awesome-icon icon="thumbtack" size="lg" /></template> <template #icon><font-awesome-icon icon="thumbtack" size="lg" /></template>
<template #cardContent> <template #cardContent>
@ -166,9 +158,10 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
:class="$style.step" :class="$style.step"
:title=" :title="
locale.baseText('testDefinition.edit.step.executions', { locale.baseText('testDefinition.edit.step.executions', {
adjustToNumber: matchedExecutions.length, adjustToNumber: tagUsageCount,
}) })
" "
:tooltip="locale.baseText('testDefinition.edit.step.executions.tooltip')"
> >
<template #icon><font-awesome-icon icon="history" size="lg" /></template> <template #icon><font-awesome-icon icon="history" size="lg" /></template>
<template #cardContent> <template #cardContent>
@ -194,6 +187,7 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
:title="locale.baseText('testDefinition.edit.step.nodes')" :title="locale.baseText('testDefinition.edit.step.nodes')"
:small="true" :small="true"
:expanded="false" :expanded="false"
:tooltip="locale.baseText('testDefinition.edit.step.nodes.tooltip')"
> >
<template #icon><font-awesome-icon icon="thumbtack" size="lg" /></template> <template #icon><font-awesome-icon icon="thumbtack" size="lg" /></template>
<template #cardContent>{{ <template #cardContent>{{
@ -205,6 +199,7 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
:class="$style.step" :class="$style.step"
:title="locale.baseText('testDefinition.edit.step.reRunExecutions')" :title="locale.baseText('testDefinition.edit.step.reRunExecutions')"
:small="true" :small="true"
:tooltip="locale.baseText('testDefinition.edit.step.reRunExecutions.tooltip')"
> >
<template #icon><font-awesome-icon icon="redo" size="lg" /></template> <template #icon><font-awesome-icon icon="redo" size="lg" /></template>
</EvaluationStep> </EvaluationStep>
@ -212,6 +207,7 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
<EvaluationStep <EvaluationStep
:class="$style.step" :class="$style.step"
:title="locale.baseText('testDefinition.edit.step.compareExecutions')" :title="locale.baseText('testDefinition.edit.step.compareExecutions')"
:tooltip="locale.baseText('testDefinition.edit.step.compareExecutions.tooltip')"
> >
<template #icon><font-awesome-icon icon="equals" size="lg" /></template> <template #icon><font-awesome-icon icon="equals" size="lg" /></template>
<template #cardContent> <template #cardContent>
@ -225,6 +221,7 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
<EvaluationStep <EvaluationStep
:class="$style.step" :class="$style.step"
:title="locale.baseText('testDefinition.edit.step.metrics')" :title="locale.baseText('testDefinition.edit.step.metrics')"
:tooltip="locale.baseText('testDefinition.edit.step.metrics.tooltip')"
> >
<template #icon><font-awesome-icon icon="chart-bar" size="lg" /></template> <template #icon><font-awesome-icon icon="chart-bar" size="lg" /></template>
<template #cardContent> <template #cardContent>