fix(editor): Ai 674 update runs list UI graph (no-changelog) (#14012)

Co-authored-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
Raúl Gómez Morales
2025-04-03 12:30:39 +02:00
committed by GitHub
parent b91be496c3
commit 02d11b5e7a
12 changed files with 193 additions and 665 deletions

View File

@@ -4,28 +4,20 @@ import MetricsChart from '@/components/TestDefinition/ListRuns/MetricsChart.vue'
import TestRunsTable from '@/components/TestDefinition/ListRuns/TestRunsTable.vue';
import { useI18n } from '@/composables/useI18n';
import { VIEWS } from '@/constants';
import type { AppliedThemeOption } from '@/Interface';
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
const props = defineProps<{
runs: TestRunRecord[];
runs: Array<TestRunRecord & { index: number }>;
testId: string;
appliedTheme: AppliedThemeOption;
}>();
const emit = defineEmits<{
deleteRuns: [runs: TestRunRecord[]];
}>();
const locale = useI18n();
const router = useRouter();
const selectedMetric = defineModel<string>('selectedMetric', { required: true });
function onDeleteRuns(toDelete: TestRunRecord[]) {
emit('deleteRuns', toDelete);
}
const metrics = computed(() => {
const metricKeys = props.runs.reduce((acc, run) => {
Object.keys(run.metrics ?? {}).forEach((metric) => acc.add(metric));
@@ -42,15 +34,15 @@ const metricColumns = computed(() =>
showHeaderTooltip: true,
sortMethod: (a: TestRunRecord, b: TestRunRecord) =>
(a.metrics?.[metric] ?? 0) - (b.metrics?.[metric] ?? 0),
formatter: (row: TestRunRecord) => (row.metrics?.[metric] ?? 0).toFixed(2),
formatter: (row: TestRunRecord) =>
row.metrics?.[metric] !== undefined ? (row.metrics?.[metric]).toFixed(2) : '',
})),
);
const columns = computed(() => [
{
prop: 'runNumber',
prop: 'id',
label: locale.baseText('testDefinition.listRuns.runNumber'),
formatter: (row: TestRunRecord) => `${row.id}`,
showOverflowTooltip: true,
},
{
@@ -58,6 +50,10 @@ const columns = computed(() => [
label: 'Run at',
sortable: true,
showOverflowTooltip: true,
formatter: (row: TestRunRecord) => {
const { date, time } = convertToDisplayDate(row.runAt);
return [date, time].join(', ');
},
sortMethod: (a: TestRunRecord, b: TestRunRecord) =>
new Date(a.runAt ?? a.createdAt).getTime() - new Date(b.runAt ?? b.createdAt).getTime(),
},
@@ -79,7 +75,7 @@ const handleRowClick = (row: TestRunRecord) => {
<template>
<div :class="$style.runs">
<MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" :theme="appliedTheme" />
<MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" />
<TestRunsTable
:class="$style.runsTable"
@@ -87,7 +83,6 @@ const handleRowClick = (row: TestRunRecord) => {
:columns
:selectable="true"
data-test-id="past-runs-table"
@delete-runs="onDeleteRuns"
@row-click="handleRowClick"
/>
</div>
@@ -97,7 +92,7 @@ const handleRowClick = (row: TestRunRecord) => {
.runs {
display: flex;
flex-direction: column;
gap: var(--spacing-m);
gap: var(--spacing-s);
flex: 1;
overflow: auto;
margin-bottom: 20px;

View File

@@ -1,7 +1,5 @@
<script setup lang="ts">
import type { TestRunRecord } from '@/api/testDefinition.ee';
import { useI18n } from '@/composables/useI18n';
import type { AppliedThemeOption } from '@/Interface';
import { computed, watchEffect } from 'vue';
import { Line } from 'vue-chartjs';
import { useMetricsChart } from '../composables/useMetricsChart';
@@ -12,12 +10,10 @@ const emit = defineEmits<{
const props = defineProps<{
selectedMetric: string;
runs: TestRunRecord[];
theme?: AppliedThemeOption;
runs: Array<TestRunRecord & { index: number }>;
}>();
const locale = useI18n();
const metricsChart = useMetricsChart(props.theme);
const metricsChart = useMetricsChart();
const availableMetrics = computed(() => {
return props.runs.reduce((acc, run) => {
@@ -26,12 +22,18 @@ const availableMetrics = computed(() => {
}, [] as string[]);
});
const chartData = computed(() => metricsChart.generateChartData(props.runs, props.selectedMetric));
const filteredRuns = computed(() =>
props.runs.filter((run) => run.metrics?.[props.selectedMetric] !== undefined),
);
const chartData = computed(() =>
metricsChart.generateChartData(filteredRuns.value, props.selectedMetric),
);
const chartOptions = computed(() =>
metricsChart.generateChartOptions({
metric: props.selectedMetric,
xTitle: locale.baseText('testDefinition.listRuns.runDate'),
data: filteredRuns.value,
}),
);
@@ -43,7 +45,7 @@ watchEffect(() => {
</script>
<template>
<div v-if="availableMetrics.length > 0" :class="$style.metricsChartContainer">
<div :class="$style.metricsChartContainer">
<div :class="$style.chartHeader">
<N8nSelect
:model-value="selectedMetric"
@@ -62,7 +64,6 @@ watchEffect(() => {
</div>
<div :class="$style.chartWrapper">
<Line
v-if="availableMetrics.length > 0"
:key="selectedMetric"
:data="chartData"
:options="chartOptions"
@@ -76,15 +77,10 @@ watchEffect(() => {
.metricsChartContainer {
background: var(--color-background-xlight);
border-radius: var(--border-radius-large);
box-shadow: var(--box-shadow-base);
border: 1px solid var(--color-foreground-base);
.chartHeader {
display: flex;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-s);
padding: var(--spacing-xs) var(--spacing-s);
border-bottom: 1px solid var(--color-foreground-base);
padding: var(--spacing-xs) var(--spacing-s) 0;
}
.chartTitle {
@@ -99,7 +95,7 @@ watchEffect(() => {
.chartWrapper {
position: relative;
height: var(--metrics-chart-height, 200px);
height: var(--metrics-chart-height, 147px);
width: 100%;
padding: var(--spacing-s);
}

View File

@@ -2,36 +2,53 @@
import type { TestRunRecord } from '@/api/testDefinition.ee';
import { useI18n } from '@/composables/useI18n';
import { N8nIcon, N8nText } from '@n8n/design-system';
import { computed, ref } from 'vue';
import type { IconColor } from '@n8n/design-system/types/icon';
import { computed } from 'vue';
import type { TestTableColumn } from '../shared/TestTableBase.vue';
import TestTableBase from '../shared/TestTableBase.vue';
const emit = defineEmits<{
rowClick: [run: TestRunRecord];
selectionChange: [runs: TestRunRecord[]];
deleteRuns: [runs: TestRunRecord[]];
rowClick: [run: TestRunRecord & { index: number }];
}>();
const props = defineProps<{
runs: TestRunRecord[];
columns: Array<TestTableColumn<TestRunRecord>>;
selectable?: boolean;
runs: Array<TestRunRecord & { index: number }>;
columns: Array<TestTableColumn<TestRunRecord & { index: number }>>;
}>();
const statusesColorDictionary: Record<TestRunRecord['status'], string> = {
new: 'var(--color-primary)',
running: 'var(--color-secondary)',
completed: 'var(--color-success)',
error: 'var(--color-danger)',
cancelled: 'var(--color-foreground-dark)',
warning: 'var(--color-warning)',
success: 'var(--color-success)',
const statusDictionary: Record<TestRunRecord['status'], { icon: string; color: IconColor }> = {
new: {
icon: 'status-new',
color: 'foreground-xdark',
},
running: {
icon: 'spinner',
color: 'secondary',
},
completed: {
icon: 'status-completed',
color: 'success',
},
error: {
icon: 'status-error',
color: 'danger',
},
cancelled: {
icon: 'status-canceled',
color: 'foreground-xdark',
},
warning: {
icon: 'status-warning',
color: 'warning',
},
success: {
icon: 'status-completed',
color: 'success',
},
};
const locale = useI18n();
const selectedRows = ref<TestRunRecord[]>([]);
// Combine test run statuses and finalResult to get the final status
const runSummaries = computed(() => {
return props.runs.map(({ status, finalResult, ...run }) => {
@@ -42,63 +59,41 @@ const runSummaries = computed(() => {
return { ...run, status };
});
});
function onSelectionChange(runs: TestRunRecord[]) {
selectedRows.value = runs;
emit('selectionChange', runs);
}
async function deleteRuns() {
emit('deleteRuns', selectedRows.value);
}
</script>
<template>
<div :class="$style.container">
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading">{{
locale.baseText('testDefinition.edit.pastRuns.total', { adjustToNumber: runs.length })
}}</N8nHeading>
<div :class="$style.header">
<n8n-button
v-show="selectedRows.length > 0"
type="danger"
:class="$style.activator"
size="medium"
icon="trash"
data-test-id="delete-runs-button"
@click="deleteRuns"
>
{{
locale.baseText('testDefinition.listRuns.deleteRuns', {
adjustToNumber: selectedRows.length,
})
}}
</n8n-button>
</div>
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading" color="text-base">
{{ locale.baseText('testDefinition.edit.pastRuns.total', { adjustToNumber: runs.length }) }}
<N8nText> ({{ runs.length }}) </N8nText>
</N8nHeading>
<TestTableBase
:data="runSummaries"
:columns="columns"
selectable
:default-sort="{ prop: 'runAt', order: 'descending' }"
@row-click="(row) => emit('rowClick', row)"
@selection-change="onSelectionChange"
>
<template #id="{ row }">#{{ row.index }} </template>
<template #status="{ row }">
<div
style="display: inline-flex; gap: 8px; text-transform: capitalize; align-items: center"
>
<N8nText v-if="row.status === 'running'" color="secondary" class="mr-2xs">
<AnimatedSpinner />
</N8nText>
<N8nIcon
icon="circle"
size="xsmall"
:style="{ color: statusesColorDictionary[row.status] }"
></N8nIcon>
<N8nText v-if="row.status === 'error'" size="small" bold color="text-base">
{{ row.failedCases }} / {{ row.totalCases }} {{ row.status }}
</N8nText>
<N8nText v-else size="small" bold color="text-base">
v-else
:icon="statusDictionary[row.status].icon"
:color="statusDictionary[row.status].color"
class="mr-2xs"
/>
<template v-if="row.status === 'error'">
{{ row.failedCases }} {{ row.status }}
</template>
<template v-else>
{{ row.status }}
</N8nText>
</template>
</div>
</template>
</TestTableBase>
@@ -109,7 +104,6 @@ async function deleteRuns() {
.container {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
gap: 8px;
}
</style>

View File

@@ -1,68 +1,56 @@
import type { ChartData, ChartOptions } from 'chart.js';
import type { TestRunRecord } from '@/api/testDefinition.ee';
import dateFormat from 'dateformat';
import type { AppliedThemeOption } from '@/Interface';
import { useCssVar } from '@vueuse/core';
const THEME_COLORS = {
light: {
primary: 'rgb(255, 110, 92)',
text: {
primary: 'rgb(68, 68, 68)',
secondary: 'rgb(102, 102, 102)',
},
background: 'rgb(255, 255, 255)',
grid: 'rgba(68, 68, 68, 0.1)',
},
dark: {
primary: 'rgb(255, 110, 92)',
text: {
primary: 'rgb(255, 255, 255)',
secondary: 'rgba(255, 255, 255, 0.7)',
},
background: 'rgb(32, 32, 32)',
grid: 'rgba(255, 255, 255, 0.1)',
},
};
export function useMetricsChart(mode: AppliedThemeOption = 'light') {
const colors = THEME_COLORS[mode];
const toRGBA = (color: string, alpha: number) => {
if (color.includes('rgba')) return color;
return color.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
export function useMetricsChart() {
const colors = {
primary: useCssVar('--color-primary', document.body).value,
textBase: useCssVar('--color-text-base', document.body).value,
backgroundXLight: useCssVar('--color-background-xlight', document.body).value,
foregroundLight: useCssVar('--color-foreground-light', document.body).value,
foregroundBase: useCssVar('--color-foreground-base', document.body).value,
foregroundDark: useCssVar('--color-foreground-dark', document.body).value,
};
function generateChartData(runs: TestRunRecord[], metric: string): ChartData<'line'> {
const sortedRuns = [...runs]
.sort((a, b) => new Date(a.runAt).getTime() - new Date(b.runAt).getTime())
.filter((run) => run.metrics?.[metric]);
return {
labels: sortedRuns.map((run) => {
return dateFormat(run.runAt, 'yyyy-mm-dd HH:MM');
}),
function generateChartData(
runs: Array<TestRunRecord & { index: number }>,
metric: string,
): ChartData<'line'> {
/**
* @see https://www.chartjs.org/docs/latest/general/data-structures.html#object-using-custom-properties
*/
const data: ChartData<'line', TestRunRecord[]> = {
datasets: [
{
label: metric,
data: sortedRuns.map((run) => run.metrics?.[metric] ?? 0),
data: runs,
parsing: {
xAxisKey: 'id',
yAxisKey: `metrics.${metric}`,
},
borderColor: colors.primary,
backgroundColor: toRGBA(colors.primary, 0.1),
borderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: colors.primary,
pointBorderColor: colors.primary,
pointHoverBackgroundColor: colors.background,
pointHoverBorderColor: colors.primary,
tension: 0.4,
fill: true,
backgroundColor: colors.backgroundXLight,
borderWidth: 1,
pointRadius: 2,
pointHoverRadius: 4,
pointBackgroundColor: colors.backgroundXLight,
pointHoverBackgroundColor: colors.backgroundXLight,
},
],
};
// casting to keep vue-chartjs happy!!
return data as unknown as ChartData<'line'>;
}
function generateChartOptions(params: { metric: string; xTitle: string }): ChartOptions<'line'> {
function generateChartOptions({
metric,
data,
}: { metric: string; data: Array<TestRunRecord & { index: number }> }): ChartOptions<'line'> {
return {
responsive: true,
maintainAspectRatio: false,
animation: false,
devicePixelRatio: 2,
interaction: {
mode: 'index' as const,
@@ -70,68 +58,65 @@ export function useMetricsChart(mode: AppliedThemeOption = 'light') {
},
scales: {
y: {
beginAtZero: true,
border: {
display: false,
},
grid: {
color: colors.grid,
color: colors.foregroundBase,
},
ticks: {
padding: 8,
color: colors.text.primary,
},
title: {
display: false,
text: params.metric,
padding: 16,
color: colors.text.primary,
color: colors.textBase,
},
},
x: {
border: {
display: false,
},
grid: {
display: false,
},
ticks: {
display: false,
color: colors.textBase,
// eslint-disable-next-line id-denylist
callback(_tickValue, index) {
return `#${data[index].index}`;
},
title: {
text: params.xTitle,
padding: 1,
color: colors.text.primary,
},
},
},
plugins: {
tooltip: {
backgroundColor: colors.background,
titleColor: colors.text.primary,
backgroundColor: colors.backgroundXLight,
titleColor: colors.textBase,
titleFont: {
weight: '600',
},
bodyColor: colors.text.secondary,
bodyColor: colors.textBase,
bodySpacing: 4,
padding: 12,
borderColor: toRGBA(colors.primary, 0.2),
borderColor: colors.foregroundBase,
borderWidth: 1,
displayColors: true,
callbacks: {
title: (tooltipItems) => tooltipItems[0].label,
label: (context) => `${params.metric}: ${context.parsed.y.toFixed(2)}`,
title: (tooltipItems) => {
return dateFormat((tooltipItems[0].raw as TestRunRecord).runAt, 'yyyy-mm-dd HH:MM');
},
label: (context) => `${metric}: ${context.parsed.y.toFixed(2)}`,
labelColor() {
return {
borderColor: 'rgba(29, 21, 21, 0)',
backgroundColor: colors.primary,
borderWidth: 0,
borderRadius: 5,
};
},
},
},
legend: {
display: false,
},
},
animation: {
duration: 750,
easing: 'easeInOutQuart',
},
transitions: {
active: {
animation: {
duration: 300,
},
},
},
};
}

View File

@@ -1,8 +1,8 @@
<script setup lang="ts" generic="T extends object">
import { N8nIcon, N8nTooltip } from '@n8n/design-system';
import type { TableInstance } from 'element-plus';
import { ElTable, ElTableColumn } from 'element-plus';
import { isEqual } from 'lodash-es';
import { N8nIcon, N8nTooltip } from '@n8n/design-system';
import { nextTick, ref, watch } from 'vue';
import type { RouteLocationRaw } from 'vue-router';
/**
@@ -181,11 +181,7 @@ defineSlots<{
.customRow {
cursor: pointer;
&:hover {
& > .customCell {
background-color: var(--color-background-light);
}
}
--color-table-row-hover-background: var(--color-background-light);
}
.customHeaderCell {
@@ -210,6 +206,10 @@ defineSlots<{
.table {
border-radius: 12px;
:global(.el-scrollbar__wrap) {
overflow: hidden;
}
:global(.el-table__column-resize-proxy) {
background-color: var(--color-primary);
width: 3px;

View File

@@ -1,7 +1,8 @@
import { describe, it, expect } from 'vitest';
import { useMetricsChart } from '../composables/useMetricsChart';
import type { TestRunRecord } from '@/api/testDefinition.ee';
import type { TestRunRecord as Record } from '@/api/testDefinition.ee';
type TestRunRecord = Record & { index: number };
describe('useMetricsChart', () => {
const mockRuns: TestRunRecord[] = [
{
@@ -13,6 +14,7 @@ describe('useMetricsChart', () => {
completedAt: '2025-01-06T10:00:00Z',
runAt: '2025-01-06T10:00:00Z',
metrics: { responseTime: 100, successRate: 95 },
index: 1,
},
{
id: '2',
@@ -23,105 +25,32 @@ describe('useMetricsChart', () => {
completedAt: '2025-01-06T10:00:00Z',
runAt: '2025-01-06T10:00:00Z',
metrics: { responseTime: 150, successRate: 98 },
index: 2,
},
] as TestRunRecord[];
describe('generateChartData', () => {
it('should generate correct chart data structure', () => {
const { generateChartData } = useMetricsChart('light');
const result = generateChartData(mockRuns, 'responseTime');
const { generateChartData } = useMetricsChart();
const {
datasets: [dataset],
} = generateChartData(mockRuns, 'responseTime');
expect(result.labels).toHaveLength(2);
expect(result.datasets).toHaveLength(1);
expect(result.datasets[0].data).toEqual([100, 150]);
});
it('should sort runs by date', () => {
const unsortedRuns = [
{
id: '1',
testDefinitionId: 'test1',
status: 'completed',
createdAt: '2025-01-06T10:05:00Z',
updatedAt: '2025-01-06T10:05:00Z',
completedAt: '2025-01-06T10:05:00Z',
runAt: '2025-01-06T10:05:00Z',
metrics: { responseTime: 150 },
},
{
id: '2',
testDefinitionId: 'test1',
status: 'completed',
createdAt: '2025-01-06T10:00:00Z',
updatedAt: '2025-01-06T10:00:00Z',
completedAt: '2025-01-06T10:00:00Z',
runAt: '2025-01-06T10:00:00Z',
metrics: { responseTime: 100 },
},
] as TestRunRecord[];
const { generateChartData } = useMetricsChart('light');
const result = generateChartData(unsortedRuns, 'responseTime');
expect(result.datasets[0].data).toEqual([100, 150]);
});
it('should filter out runs without specified metric', () => {
const runsWithMissingMetrics = [
{
id: '1',
testDefinitionId: 'test1',
status: 'completed',
createdAt: '2025-01-06T10:00:00Z',
updatedAt: '2025-01-06T10:00:00Z',
completedAt: '2025-01-06T10:00:00Z',
runAt: '2025-01-06T10:00:00Z',
metrics: { responseTime: 100 },
},
{
id: '2',
testDefinitionId: 'test1',
status: 'completed',
createdAt: '2025-01-06T10:00:00Z',
updatedAt: '2025-01-06T10:00:00Z',
completedAt: '2025-01-06T10:00:00Z',
runAt: '2025-01-06T10:00:00Z',
metrics: {},
},
] as TestRunRecord[];
const { generateChartData } = useMetricsChart('light');
const result = generateChartData(runsWithMissingMetrics, 'responseTime');
expect(result.labels).toHaveLength(1);
expect(result.datasets[0].data).toEqual([100]);
});
it('should handle dark theme colors', () => {
const { generateChartData } = useMetricsChart('dark');
const result = generateChartData(mockRuns, 'responseTime');
expect(result.datasets[0].pointHoverBackgroundColor).toBe('rgb(32, 32, 32)');
//@ts-expect-error vue-chartjs types are wrong
expect(dataset.parsing?.yAxisKey).toBe('metrics.responseTime');
expect(dataset.data).toHaveLength(2);
});
});
describe('generateChartOptions', () => {
it('should generate correct chart options structure', () => {
const { generateChartOptions } = useMetricsChart('light');
const result = generateChartOptions({ metric: 'responseTime', xTitle: 'Time' });
const { generateChartOptions } = useMetricsChart();
const result = generateChartOptions({ metric: 'responseTime', data: mockRuns });
expect(result.scales?.y?.title?.text).toBe('responseTime');
expect(result.scales?.x?.title?.text).toBe('Time');
//@ts-expect-error vue-chartjs types are wrong
expect(result.scales?.x?.ticks?.callback?.(undefined, 0, [])).toBe('#1');
expect(result.responsive).toBe(true);
expect(result.maintainAspectRatio).toBe(false);
});
it('should apply correct theme colors', () => {
const { generateChartOptions } = useMetricsChart('dark');
const result = generateChartOptions({ metric: 'responseTime', xTitle: 'Time' });
expect(result.scales?.y?.ticks?.color).toBe('rgb(255, 255, 255)');
expect(result.plugins?.tooltip?.backgroundColor).toBe('rgb(32, 32, 32)');
});
});
});

View File

@@ -12,9 +12,6 @@ import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils';
import type { FrontendSettings } from '@n8n/api-types';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { useExecutionsStore } from '@/stores/executions.store';
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
const showMessage = vi.fn();
const showError = vi.fn();
@@ -71,16 +68,6 @@ const executionDataFactory = (
annotation: { tags, vote: 'up' },
});
const testCaseFactory = (workflowId: string, annotationTagId?: string): TestDefinitionRecord => ({
id: faker.string.uuid(),
createdAt: faker.date.past().toString(),
updatedAt: faker.date.past().toString(),
evaluationWorkflowId: null,
annotationTagId,
workflowId,
name: `My test ${faker.number.int()}`,
});
const renderComponent = createComponentRenderer(WorkflowExecutionsPreview, {
global: {
stubs: {
@@ -134,119 +121,4 @@ describe('WorkflowExecutionsPreview.vue', () => {
expect(getByTestId('stop-execution')).toBeDisabled();
});
describe('test execution crud', () => {
it('should add an execution to a testcase', async () => {
const tag = { id: 'tag_id', name: 'tag_name' };
const execution = executionDataFactory([]);
const testCase = testCaseFactory(execution.workflowId, tag.id);
const testDefinitionStore = mockedStore(useTestDefinitionStore);
const executionsStore = mockedStore(useExecutionsStore);
const settingsStore = mockedStore(useSettingsStore);
testDefinitionStore.allTestDefinitionsByWorkflowId[execution.workflowId] = [testCase];
settingsStore.isEnterpriseFeatureEnabled = {
advancedExecutionFilters: true,
} as FrontendSettings['enterprise'];
const { getByTestId } = renderComponent({
props: { execution: { ...execution, status: 'success' } },
});
await router.push({ params: { name: execution.workflowId }, query: { testId: testCase.id } });
expect(getByTestId('test-execution-crud')).toBeInTheDocument();
expect(getByTestId('test-execution-add')).toBeVisible();
await userEvent.click(getByTestId('test-execution-add'));
expect(executionsStore.annotateExecution).toHaveBeenCalledWith(execution.id, {
tags: [testCase.annotationTagId],
});
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
});
it('should remove an execution from a testcase', async () => {
const tag = { id: 'tag_id', name: 'tag_name' };
const execution = executionDataFactory([tag]);
const testCase = testCaseFactory(execution.workflowId, tag.id);
const testDefinitionStore = mockedStore(useTestDefinitionStore);
const executionsStore = mockedStore(useExecutionsStore);
const settingsStore = mockedStore(useSettingsStore);
testDefinitionStore.allTestDefinitionsByWorkflowId[execution.workflowId] = [testCase];
settingsStore.isEnterpriseFeatureEnabled = {
advancedExecutionFilters: true,
} as FrontendSettings['enterprise'];
const { getByTestId } = renderComponent({
props: { execution: { ...execution, status: 'success' } },
});
await router.push({ params: { name: execution.workflowId }, query: { testId: testCase.id } });
expect(getByTestId('test-execution-crud')).toBeInTheDocument();
expect(getByTestId('test-execution-remove')).toBeVisible();
await userEvent.click(getByTestId('test-execution-remove'));
expect(executionsStore.annotateExecution).toHaveBeenCalledWith(execution.id, {
tags: [],
});
expect(showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
});
it('should toggle an execution', async () => {
const tag1 = { id: 'tag_id', name: 'tag_name' };
const tag2 = { id: 'tag_id_2', name: 'tag_name_2' };
const execution = executionDataFactory([tag1]);
const testCase1 = testCaseFactory(execution.workflowId, tag1.id);
const testCase2 = testCaseFactory(execution.workflowId, tag2.id);
const testDefinitionStore = mockedStore(useTestDefinitionStore);
const executionsStore = mockedStore(useExecutionsStore);
const settingsStore = mockedStore(useSettingsStore);
testDefinitionStore.allTestDefinitionsByWorkflowId[execution.workflowId] = [
testCase1,
testCase2,
];
settingsStore.isEnterpriseFeatureEnabled = {
advancedExecutionFilters: true,
} as FrontendSettings['enterprise'];
const { getByTestId, queryAllByTestId, rerender } = renderComponent({
props: { execution: { ...execution, status: 'success' } },
});
await router.push({ params: { name: execution.workflowId } });
expect(getByTestId('test-execution-crud')).toBeInTheDocument();
expect(getByTestId('test-execution-toggle')).toBeVisible();
// add
await userEvent.click(getByTestId('test-execution-toggle'));
await userEvent.click(queryAllByTestId('test-execution-add-to')[1]);
expect(executionsStore.annotateExecution).toHaveBeenCalledWith(execution.id, {
tags: [tag1.id, tag2.id],
});
const executionWithBothTags = executionDataFactory([tag1, tag2]);
await rerender({ execution: { ...executionWithBothTags, status: 'success' } });
// remove
await userEvent.click(getByTestId('test-execution-toggle'));
await userEvent.click(queryAllByTestId('test-execution-add-to')[1]);
expect(executionsStore.annotateExecution).toHaveBeenLastCalledWith(executionWithBothTags.id, {
tags: [tag1.id],
});
});
});
});

View File

@@ -6,18 +6,15 @@ import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import { useI18n } from '@/composables/useI18n';
import { useMessage } from '@/composables/useMessage';
import { useToast } from '@/composables/useToast';
import { EnterpriseEditionFeature, MODAL_CONFIRM, VIEWS } from '@/constants';
import { getResourcePermissions } from '@/permissions';
import { useExecutionsStore } from '@/stores/executions.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus';
import { N8nButton, N8nIcon, N8nIconButton, N8nText, N8nTooltip } from '@n8n/design-system';
import { N8nButton, N8nIconButton, N8nText } from '@n8n/design-system';
import type { ExecutionSummary } from 'n8n-workflow';
import { computed, h, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import { computed, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
type RetryDropdownRef = InstanceType<typeof ElDropdown>;
@@ -32,17 +29,13 @@ const emit = defineEmits<{
}>();
const route = useRoute();
const router = useRouter();
const locale = useI18n();
const executionHelpers = useExecutionHelpers();
const message = useMessage();
const toast = useToast();
const executionDebugging = useExecutionDebugging();
const workflowsStore = useWorkflowsStore();
const settingsStore = useSettingsStore();
const testDefinitionStore = useTestDefinitionStore();
const executionsStore = useExecutionsStore();
const retryDropdownRef = ref<RetryDropdownRef | null>(null);
const workflowId = computed(() => route.params.name as string);
const workflowPermissions = computed(
@@ -77,117 +70,6 @@ const hasAnnotation = computed(
(props.execution?.annotation.vote || props.execution?.annotation.tags.length > 0),
);
const testDefinitions = computed(
() => testDefinitionStore.allTestDefinitionsByWorkflowId[workflowId.value] ?? [],
);
const testDefinition = computed(() =>
testDefinitions.value.find((test) => test.id === route.query.testId),
);
const disableAddToTestTooltip = computed(() => {
if (props.execution.mode === 'evaluation') {
return locale.baseText('testDefinition.executions.tooltip.noExecutions');
}
if (props.execution.status !== 'success') {
return locale.baseText('testDefinition.executions.tooltip.onlySuccess');
}
return '';
});
type Command = {
type: 'addTag' | 'removeTag' | 'createTest';
id: string;
name: string;
};
const getTagIds = (tags?: Array<{ id: string; name: string }>) => (tags ?? []).map((t) => t.id);
const addExecutionTag = async (annotationTagId: string) => {
const newTags = [...getTagIds(props.execution?.annotation?.tags), annotationTagId];
await executionsStore.annotateExecution(props.execution.id, { tags: newTags });
toast.showToast({
title: locale.baseText('testDefinition.executions.toast.addedTo.title'),
message: h(
N8nText,
{
color: 'primary',
style: { cursor: 'pointer ' },
},
() => locale.baseText('testDefinition.executions.toast.closeTab'),
),
closeOnClick: false,
onClick() {
window.close();
},
type: 'success',
});
};
const removeExecutionTag = async (annotationTagId: string) => {
const newTags = getTagIds(props.execution?.annotation?.tags).filter(
(id) => id !== annotationTagId,
);
await executionsStore.annotateExecution(props.execution.id, { tags: newTags });
toast.showMessage({
title: locale.baseText('testDefinition.executions.toast.removedFrom.title'),
type: 'success',
});
};
const createTestForExecution = async (id: string) => {
await router.push({
name: VIEWS.NEW_TEST_DEFINITION,
params: {
name: workflowId.value,
},
query: {
executionId: id,
annotationTags: getTagIds(props.execution?.annotation?.tags),
},
});
};
const commandCallbacks = {
addTag: addExecutionTag,
removeTag: removeExecutionTag,
createTest: createTestForExecution,
} as const;
const handleCommand = async (command: Command) => {
const action = commandCallbacks[command.type];
return await action(command.id);
};
const testList = computed(() => {
return testDefinitions.value.reduce<
Array<{ label: string; value: string; added: boolean; command: Command }>
>((acc, test) => {
if (!test.annotationTagId) return acc;
const added = isTagAlreadyAdded(test.annotationTagId);
acc.push({
label: test.name,
value: test.annotationTagId,
added,
command: { type: added ? 'removeTag' : 'addTag', id: test.annotationTagId, name: test.name },
});
return acc;
}, []);
});
function isTagAlreadyAdded(tagId?: string | null) {
return Boolean(tagId && props.execution?.annotation?.tags.some((tag) => tag.id === tagId));
}
const executionHasTestTag = computed(() =>
isTagAlreadyAdded(testDefinition.value?.annotationTagId),
);
async function onDeleteExecution(): Promise<void> {
// Prepend the message with a note about annotations if they exist
const confirmationText = [
@@ -226,10 +108,6 @@ function onRetryButtonBlur(event: FocusEvent) {
retryDropdownRef.value.handleClose();
}
}
onMounted(async () => {
await testDefinitionStore.fetchTestDefinitionsByWorkflowId(workflowId.value);
});
</script>
<template>
@@ -324,97 +202,6 @@ onMounted(async () => {
</N8nText>
</div>
<div :class="$style.actions">
<N8nTooltip
placement="top"
:content="disableAddToTestTooltip"
:disabled="!disableAddToTestTooltip"
>
<ElDropdown
trigger="click"
placement="bottom-end"
data-test-id="test-execution-crud"
@command="handleCommand"
>
<div v-if="testDefinition" :class="$style.buttonGroup">
<N8nButton
v-if="executionHasTestTag"
:disabled="!!disableAddToTestTooltip"
type="secondary"
data-test-id="test-execution-remove"
@click.stop="removeExecutionTag(testDefinition.annotationTagId!)"
>
{{
locale.baseText('testDefinition.executions.removeFrom', {
interpolate: { name: testDefinition.name },
})
}}
</N8nButton>
<N8nButton
v-else
:disabled="!!disableAddToTestTooltip"
type="primary"
data-test-id="test-execution-add"
@click.stop="addExecutionTag(testDefinition.annotationTagId!)"
>
{{
locale.baseText('testDefinition.executions.addTo.existing', {
interpolate: { name: testDefinition.name },
})
}}
</N8nButton>
<N8nIconButton
:disabled="!!disableAddToTestTooltip"
icon="angle-down"
:type="executionHasTestTag ? 'secondary' : 'primary'"
data-test-id="test-execution-toggle"
/>
</div>
<N8nButton
v-else
:disabled="!!disableAddToTestTooltip"
type="secondary"
data-test-id="test-execution-toggle"
>
{{ locale.baseText('testDefinition.executions.addTo.new') }}
<N8nIcon icon="angle-down" size="small" class="ml-2xs" />
</N8nButton>
<template #dropdown>
<ElDropdownMenu :class="$style.testDropdownMenu">
<div :class="$style.testDropdownMenuScroll">
<ElDropdownItem
v-for="test in testList"
:key="test.value"
:command="test.command"
data-test-id="test-execution-add-to"
>
<N8nText
:color="test.added ? 'primary' : 'text-dark'"
:class="$style.fontMedium"
>
<N8nIcon v-if="test.added" icon="check" color="primary" />
{{ test.label }}
</N8nText>
</ElDropdownItem>
</div>
<ElDropdownItem
:class="$style.createTestButton"
:command="{ type: 'createTest', id: execution.id }"
:disabled="!workflowPermissions.update"
data-test-id="test-execution-create"
>
<N8nText :class="$style.fontMedium">
<N8nIcon icon="plus" />
{{ locale.baseText('testDefinition.executions.tooltip.addTo') }}
</N8nText>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</N8nTooltip>
<RouterLink
:to="{
name: VIEWS.EXECUTION_DEBUG,
@@ -565,41 +352,4 @@ onMounted(async () => {
display: flex;
gap: var(--spacing-xs);
}
.testDropdownMenu {
padding: 0;
}
.testDropdownMenuScroll {
max-height: 274px;
overflow-y: auto;
overflow-x: hidden;
}
.createTestButton {
border-top: 1px solid var(--color-foreground-base);
background-color: var(--color-background-light-base);
border-bottom-left-radius: var(--border-radius-base);
border-bottom-right-radius: var(--border-radius-base);
&:not(.is-disabled):hover {
color: var(--color-primary);
}
}
.fontMedium {
font-weight: 600;
}
.buttonGroup {
display: inline-flex;
:global(.button:first-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
:global(.button:last-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 0;
}
}
</style>

View File

@@ -2964,7 +2964,7 @@
"testDefinition.edit.modal.description": "Choose which past data to keep when re-running the execution(s). Any mocked node will be replayed rather than re-executed. The trigger is always mocked.",
"testDefinition.edit.runExecution": "Run execution",
"testDefinition.edit.pastRuns": "Past runs",
"testDefinition.edit.pastRuns.total": "No runs | Past run ({count}) | Past runs ({count})",
"testDefinition.edit.pastRuns.total": "No runs | Past run | Past runs",
"testDefinition.edit.nodesPinning.pinButtonTooltip": "Use benchmark data for this node during evaluation execution",
"testDefinition.edit.nodesPinning.pinButtonTooltip.pinned": "This node will not be re-executed",
"testDefinition.edit.nodesPinning.triggerTooltip": "Trigger nodes are mocked by default",

View File

@@ -133,6 +133,18 @@ export const statusUnknown: IconDefinition = {
],
};
export const statusWarning: IconDefinition = {
prefix: 'fas',
iconName: 'status-warning' as IconName,
icon: [
14,
14,
[],
'',
'M 14 7 C 14 10.866 10.866 14 7 14 C 3.134 14 0 10.866 0 7 C 0 3.134 3.134 0 7 0 C 10.866 0 14 3.134 14 7 Z M 6.5 9 C 6.224 9 6 9.224 6 9.5 L 6 10.5 C 6 10.776 6.224 11 6.5 11 L 7.5 11 C 7.776 11 8 10.776 8 10.5 L 8 9.5 C 8 9.224 7.776 9 7.5 9 L 6.5 9 Z M 6.5 3 C 6.224 3 6 3.224 6 3.5 L 6 7.5 C 6 7.776 6.224 8 6.5 8 L 7.5 8 C 7.776 8 8 7.776 8 7.5 L 8 3.5 C 8 3.224 7.776 3 7.5 3 L 6.5 3 Z',
],
};
export const faPopOut: IconDefinition = {
prefix: 'fas',
iconName: 'pop-out' as IconName,

View File

@@ -188,6 +188,7 @@ import {
statusCanceled,
statusNew,
statusUnknown,
statusWarning,
faPopOut,
} from './custom';
import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
@@ -385,6 +386,7 @@ export const FontAwesomePlugin: Plugin = {
addIcon(statusCanceled);
addIcon(statusNew);
addIcon(statusUnknown);
addIcon(statusWarning);
addIcon(faPopOut);

View File

@@ -9,15 +9,15 @@ import { useAnnotationTagsStore } from '@/stores/tags.store';
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { TestRunRecord } from '@/api/testDefinition.ee';
import InlineNameEdit from '@/components/InlineNameEdit.vue';
import ConfigSection from '@/components/TestDefinition/EditDefinition/sections/ConfigSection.vue';
import RunsSection from '@/components/TestDefinition/EditDefinition/sections/RunsSection.vue';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useDocumentVisibility } from '@vueuse/core';
import { N8nButton, N8nIconButton, N8nText } from '@n8n/design-system';
import { useDocumentVisibility } from '@vueuse/core';
import { orderBy } from 'lodash-es';
import type { IDataObject, IPinData } from 'n8n-workflow';
const props = defineProps<{
@@ -50,7 +50,6 @@ const { state, isSaving, cancelEditing, loadTestData, updateTest, startEditing,
const isLoading = computed(() => tagsStore.isLoading);
const tagsById = computed(() => tagsStore.tagsById);
const currentWorkflowId = computed(() => props.name);
const appliedTheme = computed(() => uiStore.appliedTheme);
const workflowName = computed(() => workflowStore.workflow.name);
const hasRuns = computed(() => runs.value.length > 0);
const fieldsIssues = computed(() => testDefinitionStore.getFieldIssues(props.testId) ?? []);
@@ -94,23 +93,19 @@ async function openExecutionsViewForTag() {
window.open(executionsRoute.href, '_blank');
}
const runs = computed(() =>
Object.values(testDefinitionStore.testRunsById ?? {}).filter(
(run) => run.testDefinitionId === props.testId,
),
);
const runs = computed(() => {
const testRuns = Object.values(testDefinitionStore.testRunsById ?? {}).filter(
({ testDefinitionId }) => testDefinitionId === props.testId,
);
return orderBy(testRuns, (record) => new Date(record.runAt), ['asc']).map((record, index) =>
Object.assign(record, { index: index + 1 }),
);
});
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) => {
await testDefinitionStore.deleteTestRun({ testDefinitionId: props.testId, runId: run.id });
}),
);
}
async function renameTag(newName: string) {
await tagsStore.rename({ id: state.value.tags.value[0], name: newName });
}
@@ -225,8 +220,6 @@ function onEvaluationWorkflowCreated(workflowId: string) {
:class="$style.runs"
:runs="runs"
:test-id="testId"
:applied-theme="appliedTheme"
@delete-runs="onDeleteRuns"
/>
<ConfigSection