fix(editor): Ai 672 minor UI fixes on evaluation creation (#13461)

This commit is contained in:
Raúl Gómez Morales
2025-02-24 16:32:54 +01:00
committed by GitHub
parent 5ad950f603
commit b791677ffa
11 changed files with 271 additions and 536 deletions

View File

@@ -25,6 +25,7 @@ interface InfoTipProps {
type?: (typeof TYPE)[number]; type?: (typeof TYPE)[number];
bold?: boolean; bold?: boolean;
tooltipPlacement?: Placement; tooltipPlacement?: Placement;
enterable?: boolean;
} }
defineOptions({ name: 'N8nInfoTip' }); defineOptions({ name: 'N8nInfoTip' });
@@ -33,6 +34,7 @@ const props = withDefaults(defineProps<InfoTipProps>(), {
type: 'note', type: 'note',
bold: true, bold: true,
tooltipPlacement: 'top', tooltipPlacement: 'top',
enterable: true,
}); });
const iconData = computed<{ icon: IconMap[keyof IconMap]; color: IconColor }>(() => { const iconData = computed<{ icon: IconMap[keyof IconMap]; color: IconColor }>(() => {
@@ -60,6 +62,7 @@ const iconData = computed<{ icon: IconMap[keyof IconMap]; color: IconColor }>(()
:placement="tooltipPlacement" :placement="tooltipPlacement"
:popper-class="$style.tooltipPopper" :popper-class="$style.tooltipPopper"
:disabled="type !== 'tooltip'" :disabled="type !== 'tooltip'"
:enterable
> >
<span :class="$style.iconText"> <span :class="$style.iconText">
<N8nIcon :icon="iconData.icon" :color="iconData.color" /> <N8nIcon :icon="iconData.icon" :color="iconData.color" />

View File

@@ -1,125 +1,32 @@
<script setup lang="ts"> <script setup lang="ts"></script>
import { computed, useCssModule } from 'vue';
const props = defineProps<{
state?: 'default' | 'error' | 'success';
hoverable?: boolean;
}>();
const css = useCssModule();
const classes = computed(() => ({
[css.arrowConnector]: true,
[css.hoverable]: props.hoverable,
[css.error]: props.state === 'error',
[css.success]: props.state === 'success',
}));
</script>
<template> <template>
<div :class="classes"> <div :class="$style.blockArrow">
<div :class="$style.stalk"></div> <div :class="$style.stalk"></div>
<div :class="$style.arrowHead"></div> <div :class="$style.arrowHead"></div>
</div> </div>
</template> </template>
<style module lang="scss"> <style module lang="scss">
.arrowConnector { .blockArrow {
position: relative;
height: var(--arrow-height, 3rem);
margin: 0.1rem 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
} }
.stalk { .stalk {
position: relative; min-height: 14px;
width: var(--stalk-width, 0.125rem); width: 2px;
height: calc(100% - var(--arrow-tip-height, 0.5rem)); background-color: var(--color-foreground-xdark);
background-color: var(--arrow-color, var(--color-text-dark)); flex: 1;
transition: all 0.2s ease;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 1rem;
height: 100%;
cursor: pointer;
}
} }
.arrowHead { .arrowHead {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 0; width: 0;
height: 0; height: 0;
border-left: calc(var(--arrow-tip-width, 0.75rem) / 2) solid transparent; border-left: 5px solid transparent;
border-right: calc(var(--arrow-tip-width, 0.75rem) / 2) solid transparent; border-right: 5px solid transparent;
border-top: var(--arrow-tip-height, 0.5rem) solid var(--arrow-color, var(--color-text-dark));
transition: all 0.2s ease;
&::after { border-top: 10px solid var(--color-foreground-xdark);
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 1.5rem;
height: 1.5rem;
cursor: pointer;
}
}
.hoverable {
--hover-scale: var(--arrow-hover-scale, 1.8);
cursor: pointer;
&:hover {
.stalk {
width: calc(var(--stalk-width, 0.125rem) * var(--hover-scale));
background-color: var(--arrow-hover-color, var(--arrow-color, var(--color-text-dark)));
}
.arrowHead {
border-left-width: calc(var(--arrow-tip-width, 0.75rem) / 2 * var(--hover-scale));
border-right-width: calc(var(--arrow-tip-width, 0.75rem) / 2 * var(--hover-scale));
border-top-width: calc(var(--arrow-tip-height, 0.5rem) * var(--hover-scale));
border-top-color: var(--arrow-hover-color, var(--arrow-color, var(--color-text-dark)));
}
}
}
.error {
--stalk-width: 0.1875rem;
--arrow-color: var(--color-danger);
--arrow-tip-width: 1rem;
--arrow-tip-height: 0.625rem;
}
.success {
--stalk-width: 0.1875rem;
--arrow-color: var(--color-success);
--arrow-tip-width: 1rem;
--arrow-tip-height: 0.625rem;
.stalk {
position: relative;
&::after {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1rem;
color: var(--arrow-color, var(--color-success));
}
}
} }
</style> </style>

View File

@@ -1,23 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { ElCollapseTransition } from 'element-plus'; import { type Modifier, detectOverflow } from '@popperjs/core';
import { computed, nextTick, ref, useCssModule } from 'vue'; import { N8nInfoTip, N8nText, N8nTooltip } from 'n8n-design-system';
import { computed, ref, useCssModule } from 'vue';
interface EvaluationStep { interface EvaluationStep {
title?: string; title?: string;
warning?: boolean; warning?: boolean;
small?: boolean;
expanded?: boolean; expanded?: boolean;
description?: string; description?: string;
issues?: Array<{ field: string; message: string }>; issues?: Array<{ field: string; message: string }>;
showIssues?: boolean; showIssues?: boolean;
tooltip?: string; tooltip: string;
externalTooltip?: boolean;
} }
const props = withDefaults(defineProps<EvaluationStep>(), { const props = withDefaults(defineProps<EvaluationStep>(), {
description: '', description: '',
warning: false, warning: false,
small: false,
expanded: false, expanded: false,
issues: () => [], issues: () => [],
showIssues: true, showIssues: true,
@@ -26,110 +26,115 @@ const props = withDefaults(defineProps<EvaluationStep>(), {
const locale = useI18n(); const locale = useI18n();
const isExpanded = ref(props.expanded); const isExpanded = ref(props.expanded);
const contentRef = ref<HTMLElement | null>(null);
const containerRef = ref<HTMLElement | null>(null);
const showTooltip = ref(false);
const $style = useCssModule(); const $style = useCssModule();
const hasIssues = computed(() => props.issues.length > 0);
const containerClass = computed(() => { const containerClass = computed(() => {
return { return {
[$style.wrap]: true, [$style.evaluationStep]: true,
[$style.expanded]: isExpanded.value, [$style['has-issues']]: true,
[$style.hasIssues]: props.issues.length > 0,
}; };
}); });
const toggleExpand = async () => { const toggleExpand = () => (isExpanded.value = !isExpanded.value);
isExpanded.value = !isExpanded.value;
if (isExpanded.value) {
await nextTick();
if (containerRef.value) {
containerRef.value.style.height = 'auto';
}
}
};
const handleMouseEnter = () => {
if (!props.tooltip) return;
showTooltip.value = true;
};
const handleMouseLeave = () => {
showTooltip.value = false;
};
const renderIssues = computed(() => props.showIssues && props.issues.length); const renderIssues = computed(() => props.showIssues && props.issues.length);
const issuesList = computed(() => props.issues.map((issue) => issue.message).join(', ')); const issuesList = computed(() => props.issues.map((issue) => issue.message).join(', '));
/**
* @see https://popper.js.org/docs/v2/modifiers/#custom-modifiers
*/
const resizeModifier: Modifier<'resize', {}> = {
name: 'resize',
enabled: true,
phase: 'beforeWrite',
requires: ['preventOverflow'],
fn({ state }) {
const overflow = detectOverflow(state);
const MARGIN_RIGHT = 15;
const maxWidth = state.rects.popper.width - overflow.right - MARGIN_RIGHT;
state.styles.popper.width = `${maxWidth}px`;
},
};
const popperModifiers = [
resizeModifier,
{ name: 'preventOverflow', options: { boundary: 'document' } },
{ name: 'flip', enabled: false }, // prevent the tooltip from flipping
];
</script> </script>
<template> <template>
<div :class="containerClass"> <div :class="containerClass" data-test-id="evaluation-step">
<slot name="containerPrefix" />
<div
ref="containerRef"
:class="[$style.evaluationStep, small && $style.small]"
data-test-id="evaluation-step"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<div :class="$style.content"> <div :class="$style.content">
<N8nTooltip
placement="right"
:disabled="!externalTooltip"
:show-arrow="false"
:popper-class="$style.evaluationTooltip"
:popper-options="{ modifiers: popperModifiers }"
:content="tooltip"
>
<div :class="$style.header" @click="toggleExpand"> <div :class="$style.header" @click="toggleExpand">
<h3 :class="$style.title"> <div :class="$style.label">
<span :class="$style.label"> <N8nText bold>
<slot v-if="$slots.title" name="title" /> <slot v-if="$slots.title" name="title" />
<span v-else>{{ title }}</span> <template v-else>{{ title }}</template>
</N8nText>
<N8nInfoTip <N8nInfoTip
v-if="tooltip" v-if="!externalTooltip"
:class="$style.infoTip" :class="$style.infoTip"
:bold="true" :bold="true"
type="tooltip" type="tooltip"
theme="info" theme="info"
tooltip-placement="top" tooltip-placement="top"
:enterable="false"
> >
{{ tooltip }} {{ tooltip }}
</N8nInfoTip> </N8nInfoTip>
</span> </div>
</h3> <div :class="$style.actions">
<span v-if="renderIssues" :class="$style.warningIcon"> <N8nInfoTip
<N8nInfoTip :bold="true" type="tooltip" theme="warning" tooltip-placement="right"> v-if="renderIssues"
:bold="true"
type="tooltip"
theme="warning"
tooltip-placement="top"
:enterable="false"
>
{{ issuesList }} {{ issuesList }}
</N8nInfoTip> </N8nInfoTip>
</span> <N8nText
<button
v-if="$slots.cardContent" v-if="$slots.cardContent"
:class="$style.collapseButton"
:aria-expanded="isExpanded"
data-test-id="evaluation-step-collapse-button" data-test-id="evaluation-step-collapse-button"
size="xsmall"
:color="hasIssues ? 'primary' : 'text-base'"
bold
> >
{{ {{
isExpanded isExpanded
? locale.baseText('testDefinition.edit.step.collapse') ? locale.baseText('testDefinition.edit.step.collapse')
: locale.baseText('testDefinition.edit.step.configure') : locale.baseText('testDefinition.edit.step.configure')
}} }}
<font-awesome-icon :icon="isExpanded ? 'angle-down' : 'angle-right'" size="lg" /> <font-awesome-icon :icon="isExpanded ? 'angle-up' : 'angle-down'" size="lg" />
</button> </N8nText>
</div> </div>
<ElCollapseTransition v-if="$slots.cardContent"> </div>
<div v-show="isExpanded" :class="$style.cardContentWrapper"> </N8nTooltip>
<div <div v-if="$slots.cardContent && isExpanded" :class="$style.cardContentWrapper">
ref="contentRef" <div :class="$style.cardContent" data-test-id="evaluation-step-content">
:class="$style.cardContent" <N8nText v-if="description" size="small" color="text-light">{{ description }}</N8nText>
data-test-id="evaluation-step-content"
>
<div v-if="description" :class="$style.description">{{ description }}</div>
<slot name="cardContent" /> <slot name="cardContent" />
</div> </div>
</div> </div>
</ElCollapseTransition>
</div>
</div> </div>
</div> </div>
</template> </template>
<style module lang="scss"> <style module lang="scss">
.wrap {
position: relative;
}
.evaluationStep { .evaluationStep {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -140,11 +145,18 @@ const issuesList = computed(() => props.issues.map((issue) => issue.message).joi
color: var(--color-text-dark); color: var(--color-text-dark);
position: relative; position: relative;
z-index: 1; z-index: 1;
&.small { }
width: 80%;
margin-left: auto; .evaluationTooltip {
&:global(.el-popper) {
background-color: transparent;
font-size: var(--font-size-xs);
color: var(--color-text-light);
line-height: 1rem;
max-width: 25rem;
} }
} }
.icon { .icon {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -171,24 +183,23 @@ const issuesList = computed(() => props.issues.map((issue) => issue.message).joi
padding: var(--spacing-s); padding: var(--spacing-s);
} }
.title {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-s);
line-height: 1.125rem;
}
.label { .label {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-4xs); gap: var(--spacing-4xs);
} }
.infoTip { .infoTip {
opacity: 0; opacity: 0;
} }
.evaluationStep:hover .infoTip { .evaluationStep:hover .infoTip {
opacity: 1; opacity: 1;
} }
.warningIcon {
color: var(--color-warning); .actions {
margin-left: auto;
display: flex;
gap: var(--spacing-2xs);
} }
.cardContent { .cardContent {
@@ -196,43 +207,15 @@ const issuesList = computed(() => props.issues.map((issue) => issue.message).joi
padding: 0 var(--spacing-s); padding: 0 var(--spacing-s);
margin: var(--spacing-s) 0; margin: var(--spacing-s) 0;
} }
.collapseButton {
pointer-events: none;
border: none;
background: none;
padding: 0;
font-size: var(--font-size-3xs);
color: var(--color-text-base);
margin-left: auto;
text-wrap: none;
overflow: hidden;
min-width: fit-content;
.hasIssues & {
color: var(--color-danger);
}
}
.cardContentWrapper { .cardContentWrapper {
height: max-content;
.expanded & {
border-top: var(--border-base); border-top: var(--border-base);
}
} }
.description { .has-issues {
font-size: var(--font-size-2xs); /**
color: var(--color-text-light); * This comment is needed or the css module
line-height: 1rem; * will interpret as undefined
} */
.customTooltip {
position: absolute;
left: 0;
background: var(--color-background-dark);
color: var(--color-text-light);
padding: var(--spacing-3xs) var(--spacing-2xs);
border-radius: var(--border-radius-base);
font-size: var(--font-size-2xs);
pointer-events: none;
} }
</style> </style>

View File

@@ -2,7 +2,7 @@
import { useTemplateRef, nextTick } from 'vue'; import { useTemplateRef, nextTick } from 'vue';
import type { TestMetricRecord } from '@/api/testDefinition.ee'; import type { TestMetricRecord } from '@/api/testDefinition.ee';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { N8nInput } from 'n8n-design-system'; import { N8nInput, N8nButton, N8nIconButton } from 'n8n-design-system';
export interface MetricsInputProps { export interface MetricsInputProps {
modelValue: Array<Partial<TestMetricRecord>>; modelValue: Array<Partial<TestMetricRecord>>;
@@ -38,14 +38,13 @@ function onDeleteMetric(metric: Partial<TestMetricRecord>, index: number) {
</script> </script>
<template> <template>
<div :class="[$style.metrics]"> <div>
<n8n-input-label <div
:label="locale.baseText('testDefinition.edit.metricsFields')" v-for="(metric, index) in modelValue"
:bold="false" :key="index"
:class="$style.metricField" :class="$style.metricItem"
class="mb-xs"
> >
<div :class="$style.metricsContainer">
<div v-for="(metric, index) in modelValue" :key="index" :class="$style.metricItem">
<N8nInput <N8nInput
ref="metric" ref="metric"
data-test-id="evaluation-metric-item" data-test-id="evaluation-metric-item"
@@ -53,47 +52,19 @@ function onDeleteMetric(metric: Partial<TestMetricRecord>, index: number) {
:placeholder="locale.baseText('testDefinition.edit.metricsPlaceholder')" :placeholder="locale.baseText('testDefinition.edit.metricsPlaceholder')"
@update:model-value="(value: string) => updateMetric(index, value)" @update:model-value="(value: string) => updateMetric(index, value)"
/> />
<n8n-icon-button icon="trash" type="text" @click="onDeleteMetric(metric, index)" /> <N8nIconButton icon="trash" type="secondary" text @click="onDeleteMetric(metric, index)" />
</div> </div>
<n8n-button <N8nButton
type="tertiary" type="secondary"
:label="locale.baseText('testDefinition.edit.metricsNew')" :label="locale.baseText('testDefinition.edit.metricsNew')"
:class="$style.newMetricButton"
@click="addNewMetric" @click="addNewMetric"
/> />
</div> </div>
</n8n-input-label>
</div>
</template> </template>
<style module lang="scss"> <style module lang="scss">
.metricsContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.metricItem { .metricItem {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.metricField {
width: 100%;
margin-top: var(--spacing-xs);
}
.metricsDivider {
margin-top: var(--spacing-4xs);
margin-bottom: var(--spacing-3xs);
}
.newMetricButton {
align-self: flex-start;
margin-top: var(--spacing-2xs);
width: 100%;
background-color: var(--color-sticky-code-background);
border-color: var(--color-button-secondary-focus-outline);
color: var(--color-button-secondary-font);
}
</style> </style>

View File

@@ -9,11 +9,11 @@ import { useI18n } from '@/composables/useI18n';
import { useMessage } from '@/composables/useMessage'; import { useMessage } from '@/composables/useMessage';
import { NODE_PINNING_MODAL_KEY } from '@/constants'; import { NODE_PINNING_MODAL_KEY } from '@/constants';
import type { ITag, ModalState } from '@/Interface'; import type { ITag, ModalState } from '@/Interface';
import { N8nButton, N8nTag, N8nText } from 'n8n-design-system';
import type { IPinData } from 'n8n-workflow'; import type { IPinData } from 'n8n-workflow';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
const props = defineProps<{ const props = defineProps<{
showConfig: boolean;
tagsById: Record<string, ITag>; tagsById: Record<string, ITag>;
isLoading: boolean; isLoading: boolean;
examplePinnedData?: IPinData; examplePinnedData?: IPinData;
@@ -33,14 +33,6 @@ const emit = defineEmits<{
}>(); }>();
const locale = useI18n(); const locale = useI18n();
const activeTooltip = ref<string | null>(null);
const tooltipPosition = ref<{
x: number;
y: number;
width: number;
height: number;
right: number;
} | null>(null);
const tags = defineModel<EvaluationFormState['tags']>('tags', { required: true }); const tags = defineModel<EvaluationFormState['tags']>('tags', { required: true });
const renameTag = async () => { const renameTag = async () => {
@@ -78,55 +70,29 @@ const mockedNodes = defineModel<EvaluationFormState['mockedNodes']>('mockedNodes
const nodePinningModal = ref<ModalState | null>(null); const nodePinningModal = ref<ModalState | null>(null);
const selectedTag = computed(() => { const selectedTag = computed(() => props.tagsById[tags.value.value[0]] ?? {});
return props.tagsById[tags.value.value[0]] ?? {};
});
function openExecutionsView() { function openExecutionsView() {
emit('openExecutionsViewForTag'); emit('openExecutionsViewForTag');
} }
function showTooltip(event: MouseEvent, tooltip: string) {
const container = event.target as HTMLElement;
const containerRect = container.getBoundingClientRect();
activeTooltip.value = tooltip;
tooltipPosition.value = {
x: containerRect.right,
y: containerRect.top,
width: containerRect.width,
height: containerRect.height,
right: window.innerWidth,
};
}
function hideTooltip() {
activeTooltip.value = null;
tooltipPosition.value = null;
}
</script> </script>
<template> <template>
<div :class="[$style.container, { [$style.hidden]: !showConfig }]"> <div>
<div :class="$style.editForm"> <div :class="$style.editForm">
<div :class="$style.panelIntro"> <template v-if="!hasRuns">
<N8nText tag="div" color="text-dark" size="large" class="text-center">
{{ locale.baseText('testDefinition.edit.step.intro') }} {{ locale.baseText('testDefinition.edit.step.intro') }}
</div> </N8nText>
<BlockArrow class="mt-5xs mb-5xs" />
</template>
<!-- Select Executions --> <!-- Select Executions -->
<EvaluationStep <EvaluationStep
:class="[$style.step, $style.reducedSpacing]"
:issues="getFieldIssues('tags')" :issues="getFieldIssues('tags')"
:tooltip=" :tooltip="locale.baseText('testDefinition.edit.step.executions.tooltip')"
hasRuns ? locale.baseText('testDefinition.edit.step.executions.tooltip') : undefined :external-tooltip="!hasRuns"
"
@mouseenter="
showTooltip($event, locale.baseText('testDefinition.edit.step.executions.tooltip'))
"
@mouseleave="hideTooltip"
> >
<template #containerPrefix>
<BlockArrow :class="[$style.middle, $style.diagramArrow, $style.sm]" />
</template>
<template #title> <template #title>
{{ {{
locale.baseText('testDefinition.edit.step.executions', { locale.baseText('testDefinition.edit.step.executions', {
@@ -135,7 +101,6 @@ function hideTooltip() {
}} }}
</template> </template>
<template #cardContent> <template #cardContent>
<div :class="$style.tagInputContainer">
<div :class="$style.tagInputTag"> <div :class="$style.tagInputTag">
<i18n-t keypath="testDefinition.edit.step.tag"> <i18n-t keypath="testDefinition.edit.step.tag">
<template #tag> <template #tag>
@@ -147,36 +112,34 @@ function hideTooltip() {
</template> </template>
</i18n-t> </i18n-t>
</div> </div>
<div :class="$style.tagInputControls"> <N8nButton
<n8n-button
label="Select executions" label="Select executions"
type="tertiary" type="tertiary"
size="small" size="small"
@click="openExecutionsView" @click="openExecutionsView"
/> />
</div>
</div>
</template> </template>
</EvaluationStep> </EvaluationStep>
<div :class="$style.nestedSteps">
<BlockArrow class="mt-5xs mb-5xs" />
<div style="display: flex; flex-direction: column">
<BlockArrow class="mt-5xs mb-5xs ml-auto mr-2xl" />
<!-- Mocked Nodes --> <!-- Mocked Nodes -->
<EvaluationStep <EvaluationStep
:class="$style.step" :issues="getFieldIssues('mockedNodes')"
:title=" :tooltip="locale.baseText('testDefinition.edit.step.nodes.tooltip')"
:external-tooltip="!hasRuns"
>
<template #title>
{{
locale.baseText('testDefinition.edit.step.mockedNodes', { locale.baseText('testDefinition.edit.step.mockedNodes', {
adjustToNumber: mockedNodes?.length ?? 0, adjustToNumber: mockedNodes?.length ?? 0,
}) })
" }}
:small="true" <N8nText>({{ locale.baseText('generic.optional') }})</N8nText>
:issues="getFieldIssues('mockedNodes')"
:tooltip="hasRuns ? locale.baseText('testDefinition.edit.step.nodes.tooltip') : undefined"
@mouseenter="showTooltip($event, locale.baseText('testDefinition.edit.step.nodes.tooltip'))"
@mouseleave="hideTooltip"
>
<template #containerPrefix>
<BlockArrow :class="[$style.diagramArrow, $style.right]" />
</template> </template>
<template #cardContent> <template #cardContent>
<n8n-button <N8nButton
size="small" size="small"
data-test-id="select-nodes-button" data-test-id="select-nodes-button"
:label="locale.baseText('testDefinition.edit.selectNodes')" :label="locale.baseText('testDefinition.edit.selectNodes')"
@@ -186,44 +149,25 @@ function hideTooltip() {
</template> </template>
</EvaluationStep> </EvaluationStep>
<BlockArrow class="mt-5xs mb-5xs ml-auto mr-2xl" />
<!-- Re-run Executions --> <!-- Re-run Executions -->
<EvaluationStep <EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.reRunExecutions')" :title="locale.baseText('testDefinition.edit.step.reRunExecutions')"
:small="true" :tooltip="locale.baseText('testDefinition.edit.step.reRunExecutions.tooltip')"
:tooltip=" :external-tooltip="!hasRuns"
hasRuns ? locale.baseText('testDefinition.edit.step.reRunExecutions.tooltip') : undefined />
" <BlockArrow class="mt-5xs mb-5xs ml-auto mr-2xl" />
@mouseenter=" </div>
showTooltip($event, locale.baseText('testDefinition.edit.step.reRunExecutions.tooltip')) </div>
"
@mouseleave="hideTooltip"
>
<template #containerPrefix>
<BlockArrow :class="[$style.right, $style.diagramArrow]" />
</template>
</EvaluationStep>
<!-- Compare Executions --> <!-- Compare Executions -->
<EvaluationStep <EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.compareExecutions')" :title="locale.baseText('testDefinition.edit.step.compareExecutions')"
:description="locale.baseText('testDefinition.edit.workflowSelectorLabel')" :description="locale.baseText('testDefinition.edit.workflowSelectorLabel')"
:issues="getFieldIssues('evaluationWorkflow')" :issues="getFieldIssues('evaluationWorkflow')"
:tooltip=" :tooltip="locale.baseText('testDefinition.edit.step.compareExecutions.tooltip')"
hasRuns :external-tooltip="!hasRuns"
? locale.baseText('testDefinition.edit.step.compareExecutions.tooltip')
: undefined
"
@mouseenter="
showTooltip($event, locale.baseText('testDefinition.edit.step.compareExecutions.tooltip'))
"
@mouseleave="hideTooltip"
> >
<template #containerPrefix>
<BlockArrow :class="[$style.right, $style.diagramArrow]" />
<BlockArrow :class="[$style.left, $style.diagramArrow, $style.lg]" />
</template>
<template #cardContent> <template #cardContent>
<WorkflowSelector <WorkflowSelector
v-model="evaluationWorkflow" v-model="evaluationWorkflow"
@@ -235,25 +179,20 @@ function hideTooltip() {
</template> </template>
</EvaluationStep> </EvaluationStep>
<BlockArrow class="mt-5xs mb-5xs" />
<!-- Metrics --> <!-- Metrics -->
<EvaluationStep <EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.metrics')" :title="locale.baseText('testDefinition.edit.step.metrics')"
:issues="getFieldIssues('metrics')" :issues="getFieldIssues('metrics')"
:description="locale.baseText('testDefinition.edit.step.metrics.description')" :description="locale.baseText('testDefinition.edit.step.metrics.description')"
:tooltip="hasRuns ? locale.baseText('testDefinition.edit.step.metrics.tooltip') : undefined" :tooltip="locale.baseText('testDefinition.edit.step.metrics.tooltip')"
@mouseenter=" :external-tooltip="!hasRuns"
showTooltip($event, locale.baseText('testDefinition.edit.step.metrics.tooltip'))
"
@mouseleave="hideTooltip"
> >
<template #containerPrefix>
<BlockArrow :class="[$style.middle, $style.diagramArrow]" />
</template>
<template #cardContent> <template #cardContent>
<MetricsInput <MetricsInput
v-model="metrics" v-model="metrics"
:class="{ 'has-issues': getFieldIssues('metrics').length > 0 }" :class="{ 'has-issues': getFieldIssues('metrics').length > 0 }"
class="mt-xs"
@delete-metric="(metric) => emit('deleteMetric', metric)" @delete-metric="(metric) => emit('deleteMetric', metric)"
/> />
</template> </template>
@@ -261,115 +200,25 @@ function hideTooltip() {
</div> </div>
<Modal ref="nodePinningModal" width="80vw" height="85vh" :name="NODE_PINNING_MODAL_KEY"> <Modal ref="nodePinningModal" width="80vw" height="85vh" :name="NODE_PINNING_MODAL_KEY">
<template #header> <template #header>
<N8nHeading size="large" :bold="true">{{ <N8nHeading size="large" :bold="true">
locale.baseText('testDefinition.edit.selectNodes') {{ locale.baseText('testDefinition.edit.selectNodes') }}
}}</N8nHeading> </N8nHeading>
<br /> <br />
<N8nText :class="$style.modalDescription">{{ <N8nText>
locale.baseText('testDefinition.edit.modal.description') {{ locale.baseText('testDefinition.edit.modal.description') }}
}}</N8nText> </N8nText>
</template> </template>
<template #content> <template #content>
<NodesPinning v-model="mockedNodes" data-test-id="nodes-pinning-modal" /> <NodesPinning v-model="mockedNodes" data-test-id="nodes-pinning-modal" />
</template> </template>
</Modal> </Modal>
<div
v-if="tooltipPosition && !hasRuns"
:class="$style.customTooltip"
:style="{
left: `${tooltipPosition.x}px`,
top: `${tooltipPosition.y}px`,
width: `${tooltipPosition.right - tooltipPosition.x}px`,
height: `${tooltipPosition.height}px`,
}"
>
{{ activeTooltip }}
</div>
</div> </div>
</template> </template>
<style module lang="scss"> <style module lang="scss">
.container { .nestedSteps {
overflow-y: auto;
overflow-x: visible;
width: auto;
margin-left: var(--spacing-2xl);
}
.editForm {
width: var(--evaluation-edit-panel-width);
display: grid; display: grid;
height: fit-content; grid-template-columns: 20% 1fr;
flex-shrink: 0;
padding-bottom: var(--spacing-l);
transition: width 0.2s ease;
position: relative;
gap: var(--spacing-l);
margin: 0 auto;
&.hidden {
margin-left: 0;
width: 0;
overflow: hidden;
flex-shrink: 1;
}
.noRuns & {
overflow-y: initial;
}
}
.customTooltip {
position: fixed;
z-index: 1000;
padding: var(--spacing-xs);
max-width: 25rem;
display: flex;
align-items: center;
font-size: var(--font-size-xs);
color: var(--color-text-light);
line-height: 1rem;
}
.panelIntro {
font-size: var(--font-size-m);
color: var(--color-text-dark);
justify-self: center;
position: relative;
display: block;
}
.diagramArrow {
--arrow-height: 4rem;
position: absolute;
bottom: 100%;
left: var(--spacing-2xl);
z-index: 0;
// increase hover radius of the arrow
&.right {
left: unset;
right: var(--spacing-2xl);
}
&.middle {
left: 50%;
transform: translateX(-50%);
}
&.sm {
--arrow-height: 1.5rem;
}
&.lg {
--arrow-height: 14rem;
}
}
.tagInputContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
} }
.tagInputTag { .tagInputTag {
@@ -377,9 +226,6 @@ function hideTooltip() {
gap: var(--spacing-3xs); gap: var(--spacing-3xs);
font-size: var(--font-size-2xs); font-size: var(--font-size-2xs);
color: var(--color-text-base); color: var(--color-text-base);
} margin-bottom: var(--spacing-xs);
.tagInputControls {
display: flex;
gap: var(--spacing-2xs);
} }
</style> </style>

View File

@@ -117,7 +117,7 @@ const statusRender = computed<IconDefinition & { label: string }>(() => {
{{ key }} {{ key }}
</N8nText> </N8nText>
<N8nText color="text-base" size="small" bold> <N8nText color="text-base" size="small" bold>
{{ value }} {{ Math.round((value + Number.EPSILON) * 100) / 100 }}
</N8nText> </N8nText>
</template> </template>
</template> </template>

View File

@@ -19,7 +19,7 @@ describe('MetricsInput', () => {
it('should render correctly with initial metrics', () => { it('should render correctly with initial metrics', () => {
const { getAllByPlaceholderText } = renderComponent({ props }); const { getAllByPlaceholderText } = renderComponent({ props });
const inputs = getAllByPlaceholderText('Enter metric name'); const inputs = getAllByPlaceholderText('e.g. latency');
expect(inputs).toHaveLength(2); expect(inputs).toHaveLength(2);
expect(inputs[0]).toHaveValue('Metric 1'); expect(inputs[0]).toHaveValue('Metric 1');
expect(inputs[1]).toHaveValue('Metric 2'); expect(inputs[1]).toHaveValue('Metric 2');
@@ -31,7 +31,7 @@ describe('MetricsInput', () => {
modelValue: [{ name: '' }], modelValue: [{ name: '' }],
}, },
}); });
const inputs = getAllByPlaceholderText('Enter metric name'); const inputs = getAllByPlaceholderText('e.g. latency');
await userEvent.type(inputs[0], 'Updated Metric 1'); await userEvent.type(inputs[0], 'Updated Metric 1');
// Every character typed triggers an update event. Let's check the last emission. // Every character typed triggers an update event. Let's check the last emission.
@@ -88,7 +88,7 @@ describe('MetricsInput', () => {
modelValue: [{ name: '' }], modelValue: [{ name: '' }],
}, },
}); });
const inputs = getAllByPlaceholderText('Enter metric name'); const inputs = getAllByPlaceholderText('e.g. latency');
await userEvent.type(inputs[0], 'ABC'); await userEvent.type(inputs[0], 'ABC');
const allEmits = emitted('update:modelValue'); const allEmits = emitted('update:modelValue');
@@ -117,7 +117,7 @@ describe('MetricsInput', () => {
const { getAllByPlaceholderText } = renderComponent({ const { getAllByPlaceholderText } = renderComponent({
props: { modelValue: [{ name: '' }] }, props: { modelValue: [{ name: '' }] },
}); });
const updatedInputs = getAllByPlaceholderText('Enter metric name'); const updatedInputs = getAllByPlaceholderText('e.g. latency');
expect(updatedInputs).toHaveLength(1); expect(updatedInputs).toHaveLength(1);
}); });
@@ -125,7 +125,7 @@ describe('MetricsInput', () => {
const { getAllByPlaceholderText, getAllByRole, rerender, emitted } = renderComponent({ const { getAllByPlaceholderText, getAllByRole, rerender, emitted } = renderComponent({
props, props,
}); });
const inputs = getAllByPlaceholderText('Enter metric name'); const inputs = getAllByPlaceholderText('e.g. latency');
expect(inputs).toHaveLength(2); expect(inputs).toHaveLength(2);
const deleteButtons = getAllByRole('button', { name: '' }); const deleteButtons = getAllByRole('button', { name: '' });
@@ -135,7 +135,7 @@ describe('MetricsInput', () => {
expect(emitted('deleteMetric')[0]).toEqual([props.modelValue[0]]); expect(emitted('deleteMetric')[0]).toEqual([props.modelValue[0]]);
await rerender({ modelValue: [{ name: 'Metric 2' }] }); await rerender({ modelValue: [{ name: 'Metric 2' }] });
const updatedInputs = getAllByPlaceholderText('Enter metric name'); const updatedInputs = getAllByPlaceholderText('e.g. latency');
expect(updatedInputs).toHaveLength(1); expect(updatedInputs).toHaveLength(1);
expect(updatedInputs[0]).toHaveValue('Metric 2'); expect(updatedInputs[0]).toHaveValue('Metric 2');
}); });

View File

@@ -51,6 +51,7 @@
"generic.tag_plural": "Tags", "generic.tag_plural": "Tags",
"generic.tag": "Tag | {count} Tags", "generic.tag": "Tag | {count} Tags",
"generic.tests": "Tests", "generic.tests": "Tests",
"generic.optional": "optional",
"generic.or": "or", "generic.or": "or",
"generic.clickToCopy": "Click to copy", "generic.clickToCopy": "Click to copy",
"generic.copiedToClipboard": "Copied to clipboard", "generic.copiedToClipboard": "Copied to clipboard",
@@ -2819,7 +2820,7 @@
"testDefinition.edit.metricsTitle": "Metrics", "testDefinition.edit.metricsTitle": "Metrics",
"testDefinition.edit.metricsHelpText": "The output field of the last node in the evaluation workflow. Metrics will be averaged across all test cases.", "testDefinition.edit.metricsHelpText": "The output field of the last node in the evaluation workflow. Metrics will be averaged across all test cases.",
"testDefinition.edit.metricsFields": "Output fields to use as metrics", "testDefinition.edit.metricsFields": "Output fields to use as metrics",
"testDefinition.edit.metricsPlaceholder": "Enter metric name", "testDefinition.edit.metricsPlaceholder": "e.g. latency",
"testDefinition.edit.metricsNew": "New metric", "testDefinition.edit.metricsNew": "New metric",
"testDefinition.edit.selectTag": "Select tag...", "testDefinition.edit.selectTag": "Select tag...",
"testDefinition.edit.tagsHelpText": "Executions with this tag will be added as test cases to this test.", "testDefinition.edit.tagsHelpText": "Executions with this tag will be added as test cases to this test.",
@@ -2862,7 +2863,7 @@
"testDefinition.edit.pastRuns.total": "No runs | Past run ({count}) | Past runs ({count})", "testDefinition.edit.pastRuns.total": "No runs | Past run ({count}) | Past runs ({count})",
"testDefinition.edit.nodesPinning.pinButtonTooltip": "Use benchmark data for this node during evaluation execution", "testDefinition.edit.nodesPinning.pinButtonTooltip": "Use benchmark data for this node during evaluation execution",
"testDefinition.edit.saving": "Saving...", "testDefinition.edit.saving": "Saving...",
"testDefinition.edit.saved": "Changes saved", "testDefinition.edit.saved": "Test saved",
"testDefinition.list.testDeleted": "Test deleted", "testDefinition.list.testDeleted": "Test deleted",
"testDefinition.list.tests": "Tests", "testDefinition.list.tests": "Tests",
"testDefinition.list.evaluations": "Evaluation", "testDefinition.list.evaluations": "Evaluation",

View File

@@ -173,13 +173,13 @@ function onEvaluationWorkflowCreated(workflowId: string) {
</script> </script>
<template> <template>
<div v-if="!isLoading" :class="[$style.container, { [$style.noRuns]: !hasRuns }]"> <div v-if="!isLoading" :class="[$style.container]">
<div :class="$style.header"> <div :class="$style.header">
<div style="display: flex; align-items: center"> <div style="display: flex; align-items: center">
<N8nIconButton <N8nIconButton
icon="arrow-left" icon="arrow-left"
type="tertiary" type="tertiary"
:class="$style.arrowBack" text
@click="router.push({ name: VIEWS.TEST_DEFINITION, params: { testId } })" @click="router.push({ name: VIEWS.TEST_DEFINITION, params: { testId } })"
></N8nIconButton> ></N8nIconButton>
<InlineNameEdit <InlineNameEdit
@@ -232,17 +232,17 @@ function onEvaluationWorkflowCreated(workflowId: string) {
:maxlength="260" :maxlength="260"
max-height="none" max-height="none"
type="Test description" type="Test description"
:class="$style.editDescription"
@update:model-value="updateDescription" @update:model-value="updateDescription"
> >
<N8nText size="small" color="text-base">{{ state.description.value }}</N8nText> <N8nText size="medium" color="text-base">{{ state.description.value }}</N8nText>
</InlineNameEdit> </InlineNameEdit>
</div> </div>
<div :class="$style.content"> <div :class="{ [$style.content]: true, [$style.contentWithRuns]: hasRuns }">
<RunsSection <RunsSection
v-if="runs.length > 0" v-if="hasRuns"
v-model:selectedMetric="selectedMetric" v-model:selectedMetric="selectedMetric"
:class="$style.runs"
:runs="runs" :runs="runs"
:test-id="testId" :test-id="testId"
:applied-theme="appliedTheme" :applied-theme="appliedTheme"
@@ -250,12 +250,13 @@ function onEvaluationWorkflowCreated(workflowId: string) {
/> />
<ConfigSection <ConfigSection
v-if="showConfig"
v-model:tags="state.tags" v-model:tags="state.tags"
v-model:evaluationWorkflow="state.evaluationWorkflow" v-model:evaluationWorkflow="state.evaluationWorkflow"
v-model:metrics="state.metrics" v-model:metrics="state.metrics"
v-model:mockedNodes="state.mockedNodes" v-model:mockedNodes="state.mockedNodes"
:class="$style.config"
:cancel-editing="cancelEditing" :cancel-editing="cancelEditing"
:show-config="showConfig"
:tags-by-id="tagsById" :tags-by-id="tagsById"
:is-loading="isLoading" :is-loading="isLoading"
:get-field-issues="getFieldIssues" :get-field-issues="getFieldIssues"
@@ -282,8 +283,16 @@ function onEvaluationWorkflowCreated(workflowId: string) {
.content { .content {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: var(--spacing-m); gap: var(--spacing-m);
padding-bottom: var(--spacing-m);
}
.config {
width: 480px;
.contentWithRuns & {
width: 400px;
}
} }
.header { .header {

View File

@@ -29,19 +29,24 @@ const toast = useToast();
const locale = useI18n(); const locale = useI18n();
const { confirm } = useMessage(); const { confirm } = useMessage();
const { state: tests, isLoading } = useAsyncState( const { isLoading } = useAsyncState(
async () => { async () => {
await testDefinitionStore.fetchAll({ workflowId: props.name }); await testDefinitionStore.fetchAll({ workflowId: props.name });
const response = testDefinitionStore.allTestDefinitionsByWorkflowId[props.name] ?? []; const response = testDefinitionStore.allTestDefinitionsByWorkflowId[props.name] ?? [];
response.forEach((test) => testDefinitionStore.updateRunFieldIssues(test.id)); response.forEach((test) => testDefinitionStore.updateRunFieldIssues(test.id));
return response; return [];
}, },
[], [],
{ onError: (error) => toast.showError(error, locale.baseText('testDefinition.list.loadError')) }, {
onError: (error) => toast.showError(error, locale.baseText('testDefinition.list.loadError')),
shallow: false,
},
); );
const tests = computed(() => testDefinitionStore.allTestDefinitionsByWorkflowId[props.name]);
const listItems = computed(() => const listItems = computed(() =>
orderBy(tests.value, [(test) => new Date(test.updatedAt ?? test.createdAt)], ['desc']).map( orderBy(tests.value, [(test) => new Date(test.updatedAt ?? test.createdAt)], ['desc']).map(
(test) => ({ (test) => ({

View File

@@ -134,11 +134,10 @@ describe('TestDefinitionListView', () => {
it('should delete test and show success message', async () => { it('should delete test and show success message', async () => {
const testDefinitionStore = mockedStore(useTestDefinitionStore); const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.allTestDefinitionsByWorkflowId[workflowId] = mockTestDefinitions; testDefinitionStore.allTestDefinitionsByWorkflowId[workflowId] = mockTestDefinitions;
testDefinitionStore.startTestRun.mockRejectedValueOnce(new Error('Run failed'));
message.confirm.mockResolvedValueOnce(MODAL_CONFIRM); message.confirm.mockResolvedValueOnce(MODAL_CONFIRM);
const { getByTestId } = renderComponent({ const { getByTestId, queryByTestId } = renderComponent({
props: { name: workflowId }, props: { name: workflowId },
}); });
@@ -157,6 +156,17 @@ describe('TestDefinitionListView', () => {
expect(testDefinitionStore.deleteById).toHaveBeenCalledWith(testToDelete); expect(testDefinitionStore.deleteById).toHaveBeenCalledWith(testToDelete);
expect(toast.showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })); expect(toast.showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
/**
* since the actions are mocked by default,
* double check the UI updates correctly
*/
testDefinitionStore.allTestDefinitionsByWorkflowId = {
[workflowId]: [mockTestDefinitions[1], mockTestDefinitions[2]],
};
await waitFor(() =>
expect(queryByTestId(`test-actions-${testToDelete}`)).not.toBeInTheDocument(),
);
}); });
it('should sort tests by updated date in descending order', async () => { it('should sort tests by updated date in descending order', async () => {