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

View File

@@ -1,125 +1,32 @@
<script setup lang="ts">
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>
<script setup lang="ts"></script>
<template>
<div :class="classes">
<div :class="$style.blockArrow">
<div :class="$style.stalk"></div>
<div :class="$style.arrowHead"></div>
</div>
</template>
<style module lang="scss">
.arrowConnector {
position: relative;
height: var(--arrow-height, 3rem);
margin: 0.1rem 0;
.blockArrow {
display: flex;
flex-direction: column;
align-items: center;
}
.stalk {
position: relative;
width: var(--stalk-width, 0.125rem);
height: calc(100% - var(--arrow-tip-height, 0.5rem));
background-color: var(--arrow-color, var(--color-text-dark));
transition: all 0.2s ease;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 1rem;
height: 100%;
cursor: pointer;
}
min-height: 14px;
width: 2px;
background-color: var(--color-foreground-xdark);
flex: 1;
}
.arrowHead {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: calc(var(--arrow-tip-width, 0.75rem) / 2) solid transparent;
border-right: calc(var(--arrow-tip-width, 0.75rem) / 2) solid transparent;
border-top: var(--arrow-tip-height, 0.5rem) solid var(--arrow-color, var(--color-text-dark));
transition: all 0.2s ease;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
&::after {
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));
}
}
border-top: 10px solid var(--color-foreground-xdark);
}
</style>

View File

@@ -1,23 +1,23 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { ElCollapseTransition } from 'element-plus';
import { computed, nextTick, ref, useCssModule } from 'vue';
import { type Modifier, detectOverflow } from '@popperjs/core';
import { N8nInfoTip, N8nText, N8nTooltip } from 'n8n-design-system';
import { computed, ref, useCssModule } from 'vue';
interface EvaluationStep {
title?: string;
warning?: boolean;
small?: boolean;
expanded?: boolean;
description?: string;
issues?: Array<{ field: string; message: string }>;
showIssues?: boolean;
tooltip?: string;
tooltip: string;
externalTooltip?: boolean;
}
const props = withDefaults(defineProps<EvaluationStep>(), {
description: '',
warning: false,
small: false,
expanded: false,
issues: () => [],
showIssues: true,
@@ -26,110 +26,115 @@ const props = withDefaults(defineProps<EvaluationStep>(), {
const locale = useI18n();
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 hasIssues = computed(() => props.issues.length > 0);
const containerClass = computed(() => {
return {
[$style.wrap]: true,
[$style.expanded]: isExpanded.value,
[$style.hasIssues]: props.issues.length > 0,
[$style.evaluationStep]: true,
[$style['has-issues']]: true,
};
});
const toggleExpand = async () => {
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 toggleExpand = () => (isExpanded.value = !isExpanded.value);
const renderIssues = computed(() => props.showIssues && props.issues.length);
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>
<template>
<div :class="containerClass">
<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="containerClass" data-test-id="evaluation-step">
<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">
<h3 :class="$style.title">
<span :class="$style.label">
<div :class="$style.label">
<N8nText bold>
<slot v-if="$slots.title" name="title" />
<span v-else>{{ title }}</span>
<N8nInfoTip
v-if="tooltip"
:class="$style.infoTip"
:bold="true"
type="tooltip"
theme="info"
tooltip-placement="top"
>
{{ tooltip }}
</N8nInfoTip>
</span>
</h3>
<span v-if="renderIssues" :class="$style.warningIcon">
<N8nInfoTip :bold="true" type="tooltip" theme="warning" tooltip-placement="right">
<template v-else>{{ title }}</template>
</N8nText>
<N8nInfoTip
v-if="!externalTooltip"
:class="$style.infoTip"
:bold="true"
type="tooltip"
theme="info"
tooltip-placement="top"
:enterable="false"
>
{{ tooltip }}
</N8nInfoTip>
</div>
<div :class="$style.actions">
<N8nInfoTip
v-if="renderIssues"
:bold="true"
type="tooltip"
theme="warning"
tooltip-placement="top"
:enterable="false"
>
{{ issuesList }}
</N8nInfoTip>
</span>
<button
v-if="$slots.cardContent"
:class="$style.collapseButton"
:aria-expanded="isExpanded"
data-test-id="evaluation-step-collapse-button"
>
{{
isExpanded
? locale.baseText('testDefinition.edit.step.collapse')
: locale.baseText('testDefinition.edit.step.configure')
}}
<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"
data-test-id="evaluation-step-content"
<N8nText
v-if="$slots.cardContent"
data-test-id="evaluation-step-collapse-button"
size="xsmall"
:color="hasIssues ? 'primary' : 'text-base'"
bold
>
<div v-if="description" :class="$style.description">{{ description }}</div>
<slot name="cardContent" />
</div>
{{
isExpanded
? locale.baseText('testDefinition.edit.step.collapse')
: locale.baseText('testDefinition.edit.step.configure')
}}
<font-awesome-icon :icon="isExpanded ? 'angle-up' : 'angle-down'" size="lg" />
</N8nText>
</div>
</ElCollapseTransition>
</div>
</N8nTooltip>
<div v-if="$slots.cardContent && isExpanded" :class="$style.cardContentWrapper">
<div :class="$style.cardContent" data-test-id="evaluation-step-content">
<N8nText v-if="description" size="small" color="text-light">{{ description }}</N8nText>
<slot name="cardContent" />
</div>
</div>
</div>
</div>
</template>
<style module lang="scss">
.wrap {
position: relative;
}
.evaluationStep {
display: grid;
grid-template-columns: 1fr;
@@ -140,11 +145,18 @@ const issuesList = computed(() => props.issues.map((issue) => issue.message).joi
color: var(--color-text-dark);
position: relative;
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 {
display: flex;
align-items: center;
@@ -171,24 +183,23 @@ const issuesList = computed(() => props.issues.map((issue) => issue.message).joi
padding: var(--spacing-s);
}
.title {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-s);
line-height: 1.125rem;
}
.label {
display: flex;
align-items: center;
gap: var(--spacing-4xs);
}
.infoTip {
opacity: 0;
}
.evaluationStep:hover .infoTip {
opacity: 1;
}
.warningIcon {
color: var(--color-warning);
.actions {
margin-left: auto;
display: flex;
gap: var(--spacing-2xs);
}
.cardContent {
@@ -196,43 +207,15 @@ const issuesList = computed(() => props.issues.map((issue) => issue.message).joi
padding: 0 var(--spacing-s);
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 {
height: max-content;
.expanded & {
border-top: var(--border-base);
}
border-top: var(--border-base);
}
.description {
font-size: var(--font-size-2xs);
color: var(--color-text-light);
line-height: 1rem;
}
.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;
.has-issues {
/**
* This comment is needed or the css module
* will interpret as undefined
*/
}
</style>

View File

@@ -2,7 +2,7 @@
import { useTemplateRef, nextTick } from 'vue';
import type { TestMetricRecord } from '@/api/testDefinition.ee';
import { useI18n } from '@/composables/useI18n';
import { N8nInput } from 'n8n-design-system';
import { N8nInput, N8nButton, N8nIconButton } from 'n8n-design-system';
export interface MetricsInputProps {
modelValue: Array<Partial<TestMetricRecord>>;
@@ -38,62 +38,33 @@ function onDeleteMetric(metric: Partial<TestMetricRecord>, index: number) {
</script>
<template>
<div :class="[$style.metrics]">
<n8n-input-label
:label="locale.baseText('testDefinition.edit.metricsFields')"
:bold="false"
:class="$style.metricField"
<div>
<div
v-for="(metric, index) in modelValue"
:key="index"
:class="$style.metricItem"
class="mb-xs"
>
<div :class="$style.metricsContainer">
<div v-for="(metric, index) in modelValue" :key="index" :class="$style.metricItem">
<N8nInput
ref="metric"
data-test-id="evaluation-metric-item"
:model-value="metric.name"
:placeholder="locale.baseText('testDefinition.edit.metricsPlaceholder')"
@update:model-value="(value: string) => updateMetric(index, value)"
/>
<n8n-icon-button icon="trash" type="text" @click="onDeleteMetric(metric, index)" />
</div>
<n8n-button
type="tertiary"
:label="locale.baseText('testDefinition.edit.metricsNew')"
:class="$style.newMetricButton"
@click="addNewMetric"
/>
</div>
</n8n-input-label>
<N8nInput
ref="metric"
data-test-id="evaluation-metric-item"
:model-value="metric.name"
:placeholder="locale.baseText('testDefinition.edit.metricsPlaceholder')"
@update:model-value="(value: string) => updateMetric(index, value)"
/>
<N8nIconButton icon="trash" type="secondary" text @click="onDeleteMetric(metric, index)" />
</div>
<N8nButton
type="secondary"
:label="locale.baseText('testDefinition.edit.metricsNew')"
@click="addNewMetric"
/>
</div>
</template>
<style module lang="scss">
.metricsContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.metricItem {
display: flex;
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>

View File

@@ -9,11 +9,11 @@ import { useI18n } from '@/composables/useI18n';
import { useMessage } from '@/composables/useMessage';
import { NODE_PINNING_MODAL_KEY } from '@/constants';
import type { ITag, ModalState } from '@/Interface';
import { N8nButton, N8nTag, N8nText } from 'n8n-design-system';
import type { IPinData } from 'n8n-workflow';
import { computed, ref } from 'vue';
const props = defineProps<{
showConfig: boolean;
tagsById: Record<string, ITag>;
isLoading: boolean;
examplePinnedData?: IPinData;
@@ -33,14 +33,6 @@ const emit = defineEmits<{
}>();
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 renameTag = async () => {
@@ -78,55 +70,29 @@ const mockedNodes = defineModel<EvaluationFormState['mockedNodes']>('mockedNodes
const nodePinningModal = ref<ModalState | null>(null);
const selectedTag = computed(() => {
return props.tagsById[tags.value.value[0]] ?? {};
});
const selectedTag = computed(() => props.tagsById[tags.value.value[0]] ?? {});
function openExecutionsView() {
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>
<template>
<div :class="[$style.container, { [$style.hidden]: !showConfig }]">
<div>
<div :class="$style.editForm">
<div :class="$style.panelIntro">
{{ locale.baseText('testDefinition.edit.step.intro') }}
</div>
<template v-if="!hasRuns">
<N8nText tag="div" color="text-dark" size="large" class="text-center">
{{ locale.baseText('testDefinition.edit.step.intro') }}
</N8nText>
<BlockArrow class="mt-5xs mb-5xs" />
</template>
<!-- Select Executions -->
<EvaluationStep
:class="[$style.step, $style.reducedSpacing]"
:issues="getFieldIssues('tags')"
:tooltip="
hasRuns ? locale.baseText('testDefinition.edit.step.executions.tooltip') : undefined
"
@mouseenter="
showTooltip($event, locale.baseText('testDefinition.edit.step.executions.tooltip'))
"
@mouseleave="hideTooltip"
:tooltip="locale.baseText('testDefinition.edit.step.executions.tooltip')"
:external-tooltip="!hasRuns"
>
<template #containerPrefix>
<BlockArrow :class="[$style.middle, $style.diagramArrow, $style.sm]" />
</template>
<template #title>
{{
locale.baseText('testDefinition.edit.step.executions', {
@@ -135,95 +101,73 @@ function hideTooltip() {
}}
</template>
<template #cardContent>
<div :class="$style.tagInputContainer">
<div :class="$style.tagInputTag">
<i18n-t keypath="testDefinition.edit.step.tag">
<template #tag>
<N8nTag :text="selectedTag.name" :clickable="true" @click="renameTag">
<template #tag>
{{ selectedTag.name }} <font-awesome-icon icon="pen" size="sm" />
</template>
</N8nTag>
</template>
</i18n-t>
</div>
<div :class="$style.tagInputControls">
<n8n-button
label="Select executions"
type="tertiary"
size="small"
@click="openExecutionsView"
/>
</div>
<div :class="$style.tagInputTag">
<i18n-t keypath="testDefinition.edit.step.tag">
<template #tag>
<N8nTag :text="selectedTag.name" :clickable="true" @click="renameTag">
<template #tag>
{{ selectedTag.name }} <font-awesome-icon icon="pen" size="sm" />
</template>
</N8nTag>
</template>
</i18n-t>
</div>
</template>
</EvaluationStep>
<!-- Mocked Nodes -->
<EvaluationStep
:class="$style.step"
:title="
locale.baseText('testDefinition.edit.step.mockedNodes', {
adjustToNumber: mockedNodes?.length ?? 0,
})
"
:small="true"
: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 #cardContent>
<n8n-button
size="small"
data-test-id="select-nodes-button"
:label="locale.baseText('testDefinition.edit.selectNodes')"
<N8nButton
label="Select executions"
type="tertiary"
@click="$emit('openPinningModal')"
size="small"
@click="openExecutionsView"
/>
</template>
</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 -->
<EvaluationStep
:issues="getFieldIssues('mockedNodes')"
:tooltip="locale.baseText('testDefinition.edit.step.nodes.tooltip')"
:external-tooltip="!hasRuns"
>
<template #title>
{{
locale.baseText('testDefinition.edit.step.mockedNodes', {
adjustToNumber: mockedNodes?.length ?? 0,
})
}}
<N8nText>({{ locale.baseText('generic.optional') }})</N8nText>
</template>
<template #cardContent>
<N8nButton
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"
:tooltip="
hasRuns ? locale.baseText('testDefinition.edit.step.reRunExecutions.tooltip') : undefined
"
@mouseenter="
showTooltip($event, locale.baseText('testDefinition.edit.step.reRunExecutions.tooltip'))
"
@mouseleave="hideTooltip"
>
<template #containerPrefix>
<BlockArrow :class="[$style.right, $style.diagramArrow]" />
</template>
</EvaluationStep>
<BlockArrow class="mt-5xs mb-5xs ml-auto mr-2xl" />
<!-- Re-run Executions -->
<EvaluationStep
:title="locale.baseText('testDefinition.edit.step.reRunExecutions')"
:tooltip="locale.baseText('testDefinition.edit.step.reRunExecutions.tooltip')"
:external-tooltip="!hasRuns"
/>
<BlockArrow class="mt-5xs mb-5xs ml-auto mr-2xl" />
</div>
</div>
<!-- Compare Executions -->
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.compareExecutions')"
:description="locale.baseText('testDefinition.edit.workflowSelectorLabel')"
:issues="getFieldIssues('evaluationWorkflow')"
:tooltip="
hasRuns
? locale.baseText('testDefinition.edit.step.compareExecutions.tooltip')
: undefined
"
@mouseenter="
showTooltip($event, locale.baseText('testDefinition.edit.step.compareExecutions.tooltip'))
"
@mouseleave="hideTooltip"
:tooltip="locale.baseText('testDefinition.edit.step.compareExecutions.tooltip')"
:external-tooltip="!hasRuns"
>
<template #containerPrefix>
<BlockArrow :class="[$style.right, $style.diagramArrow]" />
<BlockArrow :class="[$style.left, $style.diagramArrow, $style.lg]" />
</template>
<template #cardContent>
<WorkflowSelector
v-model="evaluationWorkflow"
@@ -235,25 +179,20 @@ function hideTooltip() {
</template>
</EvaluationStep>
<BlockArrow class="mt-5xs mb-5xs" />
<!-- Metrics -->
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.metrics')"
:issues="getFieldIssues('metrics')"
:description="locale.baseText('testDefinition.edit.step.metrics.description')"
:tooltip="hasRuns ? locale.baseText('testDefinition.edit.step.metrics.tooltip') : undefined"
@mouseenter="
showTooltip($event, locale.baseText('testDefinition.edit.step.metrics.tooltip'))
"
@mouseleave="hideTooltip"
:tooltip="locale.baseText('testDefinition.edit.step.metrics.tooltip')"
:external-tooltip="!hasRuns"
>
<template #containerPrefix>
<BlockArrow :class="[$style.middle, $style.diagramArrow]" />
</template>
<template #cardContent>
<MetricsInput
v-model="metrics"
:class="{ 'has-issues': getFieldIssues('metrics').length > 0 }"
class="mt-xs"
@delete-metric="(metric) => emit('deleteMetric', metric)"
/>
</template>
@@ -261,115 +200,25 @@ function hideTooltip() {
</div>
<Modal ref="nodePinningModal" width="80vw" height="85vh" :name="NODE_PINNING_MODAL_KEY">
<template #header>
<N8nHeading size="large" :bold="true">{{
locale.baseText('testDefinition.edit.selectNodes')
}}</N8nHeading>
<N8nHeading size="large" :bold="true">
{{ locale.baseText('testDefinition.edit.selectNodes') }}
</N8nHeading>
<br />
<N8nText :class="$style.modalDescription">{{
locale.baseText('testDefinition.edit.modal.description')
}}</N8nText>
<N8nText>
{{ locale.baseText('testDefinition.edit.modal.description') }}
</N8nText>
</template>
<template #content>
<NodesPinning v-model="mockedNodes" data-test-id="nodes-pinning-modal" />
</template>
</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>
</template>
<style module lang="scss">
.container {
overflow-y: auto;
overflow-x: visible;
width: auto;
margin-left: var(--spacing-2xl);
}
.editForm {
width: var(--evaluation-edit-panel-width);
.nestedSteps {
display: grid;
height: fit-content;
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);
grid-template-columns: 20% 1fr;
}
.tagInputTag {
@@ -377,9 +226,6 @@ function hideTooltip() {
gap: var(--spacing-3xs);
font-size: var(--font-size-2xs);
color: var(--color-text-base);
}
.tagInputControls {
display: flex;
gap: var(--spacing-2xs);
margin-bottom: var(--spacing-xs);
}
</style>

View File

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

View File

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

View File

@@ -51,6 +51,7 @@
"generic.tag_plural": "Tags",
"generic.tag": "Tag | {count} Tags",
"generic.tests": "Tests",
"generic.optional": "optional",
"generic.or": "or",
"generic.clickToCopy": "Click to copy",
"generic.copiedToClipboard": "Copied to clipboard",
@@ -2819,7 +2820,7 @@
"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.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.selectTag": "Select tag...",
"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.nodesPinning.pinButtonTooltip": "Use benchmark data for this node during evaluation execution",
"testDefinition.edit.saving": "Saving...",
"testDefinition.edit.saved": "Changes saved",
"testDefinition.edit.saved": "Test saved",
"testDefinition.list.testDeleted": "Test deleted",
"testDefinition.list.tests": "Tests",
"testDefinition.list.evaluations": "Evaluation",

View File

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

View File

@@ -29,19 +29,24 @@ const toast = useToast();
const locale = useI18n();
const { confirm } = useMessage();
const { state: tests, isLoading } = useAsyncState(
const { isLoading } = useAsyncState(
async () => {
await testDefinitionStore.fetchAll({ workflowId: props.name });
const response = testDefinitionStore.allTestDefinitionsByWorkflowId[props.name] ?? [];
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(() =>
orderBy(tests.value, [(test) => new Date(test.updatedAt ?? test.createdAt)], ['desc']).map(
(test) => ({

View File

@@ -134,11 +134,10 @@ describe('TestDefinitionListView', () => {
it('should delete test and show success message', async () => {
const testDefinitionStore = mockedStore(useTestDefinitionStore);
testDefinitionStore.allTestDefinitionsByWorkflowId[workflowId] = mockTestDefinitions;
testDefinitionStore.startTestRun.mockRejectedValueOnce(new Error('Run failed'));
message.confirm.mockResolvedValueOnce(MODAL_CONFIRM);
const { getByTestId } = renderComponent({
const { getByTestId, queryByTestId } = renderComponent({
props: { name: workflowId },
});
@@ -157,6 +156,17 @@ describe('TestDefinitionListView', () => {
expect(testDefinitionStore.deleteById).toHaveBeenCalledWith(testToDelete);
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 () => {