feat(editor): Enhance workflow evaluations edit view (no-changelog) (#12586)

This commit is contained in:
oleg 2025-01-15 10:12:37 +01:00 committed by GitHub
parent bdf266cf55
commit ee68afe561
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 514 additions and 297 deletions

View file

@ -1,40 +1,84 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import type { EditableField } from '../types';
interface Props {
modelValue: string;
modelValue: EditableField<string>;
startEditing: (field: 'description') => void;
saveChanges: (field: 'description') => void;
handleKeydown: (e: KeyboardEvent, field: 'description') => void;
}
withDefaults(defineProps<Props>(), {
modelValue: '',
});
defineEmits<{ 'update:modelValue': [value: string] }>();
defineProps<Props>();
defineEmits<{ 'update:modelValue': [value: EditableField<string>] }>();
const locale = useI18n();
</script>
<template>
<div :class="[$style.description]">
<n8n-input-label
:label="locale.baseText('testDefinition.edit.description')"
:bold="false"
size="small"
:class="$style.field"
>
<N8nInput
:model-value="modelValue"
type="textarea"
:placeholder="locale.baseText('testDefinition.edit.descriptionPlaceholder')"
@update:model-value="$emit('update:modelValue', $event)"
<div :class="$style.description">
<template v-if="!modelValue.isEditing">
<span :class="$style.descriptionText" @click="startEditing('description')">
<n8n-icon
v-if="modelValue.value.length === 0"
:class="$style.icon"
icon="plus"
color="text-light"
size="medium"
/>
<N8nText size="medium">
{{ modelValue.value.length > 0 ? modelValue.value : 'Add a description' }}
</N8nText>
</span>
<n8n-icon-button
:class="$style.editInputButton"
icon="pen"
type="tertiary"
@click="startEditing('description')"
/>
</n8n-input-label>
</template>
<N8nInput
v-else
ref="descriptionInput"
data-test-id="evaluation-description-input"
:model-value="modelValue.tempValue"
type="textarea"
:placeholder="locale.baseText('testDefinition.edit.descriptionPlaceholder')"
@update:model-value="$emit('update:modelValue', { ...modelValue, tempValue: $event })"
@blur="() => saveChanges('description')"
@keydown="(e: KeyboardEvent) => handleKeydown(e, 'description')"
/>
</div>
</template>
<style module lang="scss">
.field {
width: 100%;
margin-top: var(--spacing-xs);
.description {
display: flex;
align-items: center;
color: var(--color-text-light);
font-size: var(--font-size-s);
&:hover {
.editInputButton {
opacity: 1;
}
}
}
.descriptionText {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.icon {
margin-right: var(--spacing-2xs);
}
}
.editInputButton {
--button-font-color: var(--prim-gray-490);
opacity: 0;
border: none;
}
</style>

View file

@ -18,8 +18,8 @@ const locale = useI18n();
<template>
<div :class="$style.header">
<n8n-icon-button
icon="arrow-left"
:class="$style.backButton"
icon="arrow-left"
type="tertiary"
:title="locale.baseText('testDefinition.edit.backButtonTitle')"
@click="$router.back()"
@ -55,8 +55,6 @@ const locale = useI18n();
.header {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
margin-bottom: var(--spacing-l);
&:hover {
.editInputButton {
@ -86,12 +84,13 @@ const locale = useI18n();
.editInputButton {
--button-font-color: var(--prim-gray-490);
opacity: 0;
opacity: 0.2;
border: none;
}
.backButton {
--button-font-color: var(--color-text-light);
border: none;
padding-left: 0;
}
</style>

View file

@ -90,6 +90,7 @@ const toggleExpand = async () => {
&.small {
width: 80%;
margin-left: auto;
}
}
.icon {

View file

@ -11,19 +11,28 @@ const locale = useI18n();
<div :class="$style.header">
<h1>{{ locale.baseText('testDefinition.list.tests') }}</h1>
</div>
<n8n-action-box
:description="locale.baseText('testDefinition.list.actionDescription')"
:button-text="locale.baseText('testDefinition.list.actionButton')"
@click:button="$emit('create-test')"
/>
<div :class="$style.content">
<n8n-action-box
:class="$style.actionBox"
:heading="locale.baseText('testDefinition.list.evaluations')"
:description="locale.baseText('testDefinition.list.actionDescription')"
:button-text="locale.baseText('testDefinition.list.actionButton')"
@click:button="$emit('create-test')"
/>
<n8n-action-box
:class="$style.actionBox"
:heading="locale.baseText('testDefinition.list.unitTests.title')"
:description="locale.baseText('testDefinition.list.unitTests.description')"
:button-text="locale.baseText('testDefinition.list.unitTests.cta')"
button-type="tertiary"
/>
</div>
</div>
</template>
<style module lang="scss">
.container {
max-width: 44rem;
margin: var(--spacing-4xl) auto 0;
gap: var(--spacing-l);
max-width: 75rem;
}
.header {
display: flex;
@ -37,4 +46,12 @@ const locale = useI18n();
margin: 0;
}
}
.content {
width: 100%;
display: flex;
gap: var(--spacing-m);
}
.actionBox {
flex: 1;
}
</style>

View file

@ -45,11 +45,11 @@ watchEffect(() => {
<template>
<div v-if="availableMetrics.length > 0" :class="$style.metricsChartContainer">
<div :class="$style.chartHeader">
<N8nText>{{ locale.baseText('testDefinition.listRuns.metricsOverTime') }}</N8nText>
<N8nSelect
:model-value="selectedMetric"
:class="$style.metricSelect"
placeholder="Select metric"
size="small"
@update:model-value="emit('update:selectedMetric', $event)"
>
<N8nOption
@ -59,6 +59,7 @@ watchEffect(() => {
:value="metric"
/>
</N8nSelect>
<N8nText>{{ locale.baseText('testDefinition.listRuns.metricsOverTime') }}</N8nText>
</div>
<div :class="$style.chartWrapper">
<Line
@ -74,17 +75,17 @@ watchEffect(() => {
<style lang="scss" module>
.metricsChartContainer {
margin: var(--spacing-m) 0;
background: var(--color-background-xlight);
border-radius: var(--border-radius-large);
box-shadow: var(--box-shadow-base);
.chartHeader {
display: flex;
justify-content: space-between;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-s);
margin-bottom: var(--spacing-m);
padding: var(--spacing-s);
padding: var(--spacing-xs) var(--spacing-s);
border-bottom: 1px solid var(--color-foreground-base);
}
@ -100,7 +101,7 @@ watchEffect(() => {
.chartWrapper {
position: relative;
height: 400px;
height: var(--metrics-chart-height, 400px);
width: 100%;
padding: var(--spacing-s);
}

View file

@ -1,8 +1,8 @@
<script setup lang="ts">
import type { TestRunRecord } from '@/api/testDefinition.ee';
import { computed, ref } from 'vue';
import type { TestDefinitionTableColumn } from '../shared/TestDefinitionTable.vue';
import TestDefinitionTable from '../shared/TestDefinitionTable.vue';
import type { TestTableColumn } from '../shared/TestTableBase.vue';
import TestTableBase from '../shared/TestTableBase.vue';
import { convertToDisplayDate } from '@/utils/typesUtils';
import { VIEWS } from '@/constants';
import { useI18n } from '@/composables/useI18n';
@ -29,7 +29,7 @@ const metrics = computed(() => {
}, [] as string[]);
});
const columns = computed((): Array<TestDefinitionTableColumn<TestRunRecord>> => {
const columns = computed((): Array<TestTableColumn<TestRunRecord>> => {
return [
{
prop: 'runNumber',
@ -58,6 +58,8 @@ const columns = computed((): Array<TestDefinitionTableColumn<TestRunRecord>> =>
label: locale.baseText('testDefinition.listRuns.runDate'),
sortable: true,
formatter: (row: TestRunRecord) => convertToDisplayDate(new Date(row.runAt).getTime()),
sortMethod: (a: TestRunRecord, b: TestRunRecord) =>
new Date(a.runAt).getTime() - new Date(b.runAt).getTime(),
},
...metrics.value.map((metric) => ({
@ -81,13 +83,16 @@ function deleteRuns() {
<template>
<div :class="$style.container">
<div :class="$style.footer">
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading">{{
locale.baseText('testDefinition.edit.pastRuns')
}}</N8nHeading>
<div :class="$style.header">
<n8n-button
v-show="selectedRows.length > 0"
type="danger"
:class="$style.activator"
:size="'medium'"
:icon="'trash'"
size="medium"
icon="trash"
data-test-id="delete-runs-button"
@click="deleteRuns"
>
@ -98,7 +103,7 @@ function deleteRuns() {
}}
</n8n-button>
</div>
<TestDefinitionTable
<TestTableBase
:data="runs"
:columns="columns"
selectable
@ -116,5 +121,6 @@ function deleteRuns() {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
}
</style>

View file

@ -79,7 +79,7 @@ export function useMetricsChart(mode: AppliedThemeOption = 'light') {
color: colors.text.primary,
},
title: {
display: true,
display: false,
text: params.metric,
padding: 16,
color: colors.text.primary,
@ -90,14 +90,11 @@ export function useMetricsChart(mode: AppliedThemeOption = 'light') {
display: false,
},
ticks: {
maxRotation: 45,
minRotation: 45,
color: colors.text.primary,
display: false,
},
title: {
display: true,
text: params.xTitle,
padding: 16,
padding: 1,
color: colors.text.primary,
},
},

View file

@ -26,7 +26,11 @@ export function useTestDefinitionForm() {
tempValue: [],
isEditing: false,
},
description: '',
description: {
value: '',
tempValue: '',
isEditing: false,
},
evaluationWorkflow: {
mode: 'list',
value: '',
@ -45,9 +49,11 @@ export function useTestDefinitionForm() {
const editableFields: ComputedRef<{
name: EditableField<string>;
tags: EditableField<string[]>;
description: EditableField<string>;
}> = computed(() => ({
name: state.value.name,
tags: state.value.tags,
description: state.value.description,
}));
/**
@ -61,7 +67,11 @@ export function useTestDefinitionForm() {
if (testDefinition) {
const metrics = await evaluationsStore.fetchMetrics(testId);
state.value.description = testDefinition.description ?? '';
state.value.description = {
value: testDefinition.description ?? '',
isEditing: false,
tempValue: '',
};
state.value.name = {
value: testDefinition.name ?? '',
isEditing: false,
@ -95,7 +105,7 @@ export function useTestDefinitionForm() {
const params = {
name: state.value.name.value,
workflowId,
description: state.value.description,
description: state.value.description.value,
};
return await evaluationsStore.create(params);
} finally {
@ -125,8 +135,9 @@ export function useTestDefinitionForm() {
});
}
});
isSaving.value = true;
await Promise.all(promises);
isSaving.value = false;
};
const updateTest = async (testId: string) => {
@ -142,7 +153,7 @@ export function useTestDefinitionForm() {
const params: UpdateTestDefinitionParams = {
name: state.value.name.value,
description: state.value.description,
description: state.value.description.value,
};
if (state.value.evaluationWorkflow.value) {
@ -157,7 +168,8 @@ export function useTestDefinitionForm() {
params.mockedNodes = state.value.mockedNodes;
}
return await evaluationsStore.update({ ...params, id: testId });
const response = await evaluationsStore.update({ ...params, id: testId });
return response;
} finally {
isSaving.value = false;
}

View file

@ -1,10 +1,10 @@
<script setup lang="ts" generic="T">
import { useI18n } from '@/composables/useI18n';
import type { TestDefinitionTableColumn } from './TestDefinitionTable.vue';
import type { TestTableColumn } from './TestTableBase.vue';
import { useRouter } from 'vue-router';
defineProps<{
column: TestDefinitionTableColumn<T>;
column: TestTableColumn<T>;
row: T;
}>();
@ -44,7 +44,7 @@ function hasProperty(row: unknown, prop: string): row is Record<string, unknown>
return typeof row === 'object' && row !== null && prop in row;
}
const getCellContent = (column: TestDefinitionTableColumn<T>, row: T) => {
const getCellContent = (column: TestTableColumn<T>, row: T) => {
if (column.formatter) {
return column.formatter(row);
}

View file

@ -1,95 +0,0 @@
<script setup lang="ts" generic="T">
import type { RouteLocationRaw } from 'vue-router';
import TableCell from './TableCell.vue';
import { ElTable, ElTableColumn } from 'element-plus';
import { ref } from 'vue';
import type { TableInstance } from 'element-plus';
/**
* A reusable table component for displaying test definition data
* @template T - The type of data being displayed in the table rows
*/
/**
* Configuration for a table column
* @template TRow - The type of data in each table row
*/
export type TestDefinitionTableColumn<TRow> = {
prop: string;
label: string;
width?: number;
sortable?: boolean;
filters?: Array<{ text: string; value: string }>;
filterMethod?: (value: string, row: TRow) => boolean;
route?: (row: TRow) => RouteLocationRaw;
openInNewTab?: boolean;
formatter?: (row: TRow) => string;
};
withDefaults(
defineProps<{
data: T[];
columns: Array<TestDefinitionTableColumn<T>>;
showControls?: boolean;
defaultSort?: { prop: string; order: 'ascending' | 'descending' };
selectable?: boolean;
selectableFilter?: (row: T) => boolean;
}>(),
{
defaultSort: () => ({ prop: 'date', order: 'ascending' }),
selectable: false,
selectableFilter: () => true,
},
);
const tableRef = ref<TableInstance>();
const selectedRows = ref<T[]>([]);
const emit = defineEmits<{
rowClick: [row: T];
selectionChange: [rows: T[]];
}>();
const handleSelectionChange = (rows: T[]) => {
selectedRows.value = rows;
emit('selectionChange', rows);
};
</script>
<template>
<ElTable
ref="tableRef"
:default-sort="defaultSort"
:data="data"
style="width: 100%"
:border="true"
max-height="800"
resizable
@selection-change="handleSelectionChange"
>
<ElTableColumn
v-if="selectable"
type="selection"
:selectable="selectableFilter"
width="55"
data-test-id="table-column-select"
/>
<ElTableColumn
v-for="column in columns"
:key="column.prop"
v-bind="column"
style="width: 100%"
:resizable="true"
data-test-id="table-column"
>
<template #default="{ row }">
<TableCell
:column="column"
:row="row"
@click="$emit('rowClick', row)"
data-test-id="table-cell"
/>
</template>
</ElTableColumn>
</ElTable>
</template>

View file

@ -0,0 +1,152 @@
<script setup lang="ts" generic="T extends object">
import type { RouteLocationRaw } from 'vue-router';
import TableCell from './TableCell.vue';
import { ElTable, ElTableColumn } from 'element-plus';
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue';
import type { TableInstance } from 'element-plus';
import { isEqual } from 'lodash-es';
/**
* A reusable table component for displaying evaluation results data
* @template T - The type of data being displayed in the table rows
*/
/**
* Configuration for a table column
* @template TRow - The type of data in each table row
*/
export type TestTableColumn<TRow> = {
prop: string;
label: string;
width?: number;
sortable?: boolean;
filters?: Array<{ text: string; value: string }>;
filterMethod?: (value: string, row: TRow) => boolean;
route?: (row: TRow) => RouteLocationRaw;
sortMethod?: (a: TRow, b: TRow) => number;
openInNewTab?: boolean;
formatter?: (row: TRow) => string;
};
type TableRow = T & { id: string };
const MIN_TABLE_HEIGHT = 350;
const MAX_TABLE_HEIGHT = 1400;
const props = withDefaults(
defineProps<{
data: TableRow[];
columns: Array<TestTableColumn<TableRow>>;
showControls?: boolean;
defaultSort?: { prop: string; order: 'ascending' | 'descending' };
selectable?: boolean;
selectableFilter?: (row: TableRow) => boolean;
}>(),
{
defaultSort: () => ({ prop: 'date', order: 'descending' }),
selectable: false,
selectableFilter: () => true,
},
);
const tableRef = ref<TableInstance>();
const selectedRows = ref<TableRow[]>([]);
const localData = ref<TableRow[]>([]);
const tableHeight = ref<string>('100%');
const emit = defineEmits<{
rowClick: [row: TableRow];
selectionChange: [rows: TableRow[]];
}>();
// Watch for changes to the data prop and update the local data state
// This preserves selected rows when the data changes by:
// 1. Storing current selection IDs
// 2. Updating local data with new data
// 3. Re-applying default sort
// 4. Re-selecting previously selected rows that still exist in new data
watch(
() => props.data,
async (newData) => {
if (!isEqual(localData.value, newData)) {
const currentSelectionIds = selectedRows.value.map((row) => row.id);
localData.value = newData;
await nextTick();
tableRef.value?.sort(props.defaultSort.prop, props.defaultSort.order);
currentSelectionIds.forEach((id) => {
const row = localData.value.find((r) => r.id === id);
if (row) {
tableRef.value?.toggleRowSelection(row, true);
}
});
}
},
{ immediate: true, deep: true },
);
const handleSelectionChange = (rows: TableRow[]) => {
selectedRows.value = rows;
emit('selectionChange', rows);
};
const computeTableHeight = () => {
const containerHeight = tableRef.value?.$el?.parentElement?.clientHeight ?? 600;
const height = Math.min(Math.max(containerHeight, MIN_TABLE_HEIGHT), MAX_TABLE_HEIGHT);
tableHeight.value = `${height - 100}px`;
};
onMounted(() => {
computeTableHeight();
window.addEventListener('resize', computeTableHeight);
});
onUnmounted(() => {
window.removeEventListener('resize', computeTableHeight);
});
</script>
<template>
<ElTable
ref="tableRef"
:class="$style.table"
:default-sort="defaultSort"
:data="localData"
:border="true"
:max-height="tableHeight"
resizable
@selection-change="handleSelectionChange"
@vue:mounted="computeTableHeight"
>
<ElTableColumn
v-if="selectable"
type="selection"
:selectable="selectableFilter"
data-test-id="table-column-select"
/>
<ElTableColumn
v-for="column in columns"
:key="column.prop"
v-bind="column"
:resizable="true"
data-test-id="table-column"
>
<template #default="{ row }">
<TableCell
:key="row.status"
:column="column"
:row="row"
data-test-id="table-cell"
@click="$emit('rowClick', row)"
/>
</template>
</ElTableColumn>
</ElTable>
</template>
<style module lang="scss">
.table {
:global(.el-table__cell) {
padding: var(--spacing-3xs) 0;
}
}
</style>

View file

@ -42,7 +42,7 @@ describe('useTestDefinitionForm', () => {
it('should initialize with default props', () => {
const { state } = useTestDefinitionForm();
expect(state.value.description).toBe('');
expect(state.value.description.value).toBe('');
expect(state.value.name.value).toContain('My Test');
expect(state.value.tags.value).toEqual([]);
expect(state.value.metrics).toEqual([]);
@ -70,7 +70,7 @@ describe('useTestDefinitionForm', () => {
expect(fetchSpy).toBeCalled();
expect(fetchMetricsSpy).toBeCalledWith(TEST_DEF_A.id);
expect(state.value.name.value).toEqual(TEST_DEF_A.name);
expect(state.value.description).toEqual(TEST_DEF_A.description);
expect(state.value.description.value).toEqual(TEST_DEF_A.description);
expect(state.value.tags.value).toEqual([TEST_DEF_A.annotationTagId]);
expect(state.value.evaluationWorkflow.value).toEqual(TEST_DEF_A.evaluationWorkflowId);
expect(state.value.metrics).toEqual([
@ -88,7 +88,7 @@ describe('useTestDefinitionForm', () => {
await loadTestData('unknown-id');
expect(fetchSpy).toBeCalled();
// Should remain unchanged since no definition found
expect(state.value.description).toBe('');
expect(state.value.description.value).toBe('');
expect(state.value.name.value).toContain('My Test');
expect(state.value.tags.value).toEqual([]);
expect(state.value.metrics).toEqual([]);
@ -112,7 +112,7 @@ describe('useTestDefinitionForm', () => {
const createSpy = vi.spyOn(useTestDefinitionStore(), 'create').mockResolvedValue(TEST_DEF_NEW);
state.value.name.value = TEST_DEF_NEW.name;
state.value.description = TEST_DEF_NEW.description ?? '';
state.value.description.value = TEST_DEF_NEW.description ?? '';
const newTest = await createTest('123');
expect(createSpy).toBeCalledWith({
@ -143,7 +143,7 @@ describe('useTestDefinitionForm', () => {
const updateSpy = vi.spyOn(useTestDefinitionStore(), 'update').mockResolvedValue(updatedBTest);
state.value.name.value = TEST_DEF_B.name;
state.value.description = TEST_DEF_B.description ?? '';
state.value.description.value = TEST_DEF_B.description ?? '';
const updatedTest = await updateTest(TEST_DEF_A.id);
expect(updateSpy).toBeCalledWith({
@ -166,7 +166,7 @@ describe('useTestDefinitionForm', () => {
.mockRejectedValue(new Error('Update Failed'));
state.value.name.value = 'Test';
state.value.description = 'Some description';
state.value.description.value = 'Some description';
await expect(updateTest(TEST_DEF_A.id)).rejects.toThrow('Update Failed');
expect(updateSpy).toBeCalled();

View file

@ -10,10 +10,10 @@ export interface EditableField<T = string> {
export interface EditableFormState {
name: EditableField<string>;
tags: EditableField<string[]>;
description: EditableField<string>;
}
export interface EvaluationFormState extends EditableFormState {
description: string;
evaluationWorkflow: INodeParameterResourceLocator;
metrics: TestMetricRecord[];
mockedNodes: Array<{ name: string; id: string }>;

View file

@ -2775,7 +2775,9 @@
"communityPlusModal.button.confirm": "Send me a free license key",
"executeWorkflowTrigger.createNewSubworkflow": "Create a sub-workflow in {projectName}",
"executeWorkflowTrigger.createNewSubworkflow.noProject": "Create a new sub-workflow",
"testDefinition.edit.descriptionPlaceholder": "",
"testDefinition.edit.descriptionPlaceholder": "Enter test description",
"testDefinition.edit.showConfig": "Show config",
"testDefinition.edit.hideConfig": "Hide config",
"testDefinition.edit.backButtonTitle": "Back to Workflow Evaluation",
"testDefinition.edit.namePlaceholder": "Enter test name",
"testDefinition.edit.metricsTitle": "Metrics",
@ -2790,14 +2792,15 @@
"testDefinition.edit.workflowSelectorTitle": "Workflow to make comparisons",
"testDefinition.edit.workflowSelectorHelpText": "This workflow will be called once for each test case.",
"testDefinition.edit.updateTest": "Update test",
"testDefinition.edit.saveTest": "Run test",
"testDefinition.edit.saveTest": "Save test",
"testDefinition.edit.runTest": "Run test",
"testDefinition.edit.testSaved": "Test saved",
"testDefinition.edit.testSaveFailed": "Failed to save test",
"testDefinition.edit.description": "Description",
"testDefinition.edit.description.description": "Add details about what this test evaluates and what success looks like",
"testDefinition.edit.tagName": "Tag name",
"testDefinition.edit.step.intro": "When running a test",
"testDefinition.edit.step.executions": "1. Fetch N past executions tagge | Fetch {count} past execution tagged | Fetch {count} past executions tagged",
"testDefinition.edit.step.executions": "1. Fetch N past executions tagged | 1. Fetch {count} past execution tagged | 1. Fetch {count} past executions tagged",
"testDefinition.edit.step.executions.description": "Use a tag to select past executions for use as test cases in evaluation. The trigger data from each of these past executions will be used as input to run your workflow. The outputs of past executions are used as benchmark and compared against to check whether performance has changed based on logic and metrics that you define below.",
"testDefinition.edit.step.mockedNodes": "2. Mock N nodes |2. Mock {count} node |2. Mock {count} nodes",
"testDefinition.edit.step.nodes.description": "Mocked nodes have their data replayed rather than being re-executed. Do this to avoid calling external services, save time executing, and isolate what you are evaluating. If a node is mocked, the tagged past execution's output data for that node is used in the evaluation instead.",
@ -2814,11 +2817,17 @@
"testDefinition.edit.pastRuns": "Past runs",
"testDefinition.edit.pastRuns.total": "No runs | {count} run | {count} runs",
"testDefinition.edit.nodesPinning.pinButtonTooltip": "Pin execution data of this node during test run",
"testDefinition.edit.saving": "Saving...",
"testDefinition.edit.saved": "Changes saved",
"testDefinition.list.testDeleted": "Test deleted",
"testDefinition.list.tests": "Tests",
"testDefinition.list.evaluations": "Evaluations",
"testDefinition.list.unitTests.title": "Unit tests",
"testDefinition.list.unitTests.description": "Test sections of your workflow in isolation",
"testDefinition.list.unitTests.cta": "Register interest",
"testDefinition.list.createNew": "Create new test",
"testDefinition.list.actionDescription": "Replay past executions to check whether performance has changed",
"testDefinition.list.actionButton": "Create Test",
"testDefinition.list.actionButton": "Create an Evaluation",
"testDefinition.list.testRuns": "No test runs | {count} test run | {count} test runs",
"testDefinition.list.lastRun": "Ran",
"testDefinition.list.running": "Running",

View file

@ -53,6 +53,7 @@ import {
faEnvelope,
faEquals,
faEye,
faEyeSlash,
faExclamationTriangle,
faExpand,
faExpandAlt,
@ -228,6 +229,7 @@ export const FontAwesomePlugin: Plugin = {
addIcon(faEnvelope);
addIcon(faEquals);
addIcon(faEye);
addIcon(faEyeSlash);
addIcon(faExclamationTriangle);
addIcon(faExclamationCircle);
addIcon(faExpand);

View file

@ -36,6 +36,7 @@ const uiStore = useUIStore();
const {
state,
fieldsIssues,
isSaving,
cancelEditing,
loadTestData,
createTest,
@ -52,13 +53,15 @@ const allTags = computed(() => tagsStore.allTags);
const tagsById = computed(() => tagsStore.tagsById);
const testId = computed(() => props.testId ?? (route.params.testId as string));
const currentWorkflowId = computed(() => route.params.name as string);
const appliedTheme = computed(() => uiStore.appliedTheme);
const tagUsageCount = computed(
() => tagsStore.tagsById[state.value.tags.value[0]]?.usageCount ?? 0,
);
const hasRuns = computed(() => runs.value.length > 0);
const nodePinningModal = ref<ModalState | null>(null);
const modalContentWidth = ref(0);
const showConfig = ref(true);
const selectedMetric = ref<string>('');
onMounted(async () => {
if (!testDefinitionStore.isFeatureEnabled) {
@ -135,14 +138,18 @@ const runs = computed(() =>
),
);
async function onDeleteRuns(runs: TestRunRecord[]) {
async function onDeleteRuns(toDelete: TestRunRecord[]) {
await Promise.all(
runs.map(async (run) => {
toDelete.map(async (run) => {
await testDefinitionStore.deleteTestRun({ testDefinitionId: testId.value, runId: run.id });
}),
);
}
function toggleConfig() {
showConfig.value = !showConfig.value;
}
// Debounced watchers for auto-saving
watch(
() => state.value.metrics,
@ -164,29 +171,82 @@ watch(
</script>
<template>
<div :class="$style.container">
<div :class="$style.formContent">
<!-- Name -->
<EvaluationHeader
v-model="state.name"
:class="{ 'has-issues': hasIssues('name') }"
:start-editing="startEditing"
:save-changes="saveChanges"
:handle-keydown="handleKeydown"
/>
<div :class="$style.panelBlock">
<!-- Description -->
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.description')"
:expanded="false"
>
<template #icon><font-awesome-icon icon="thumbtack" size="lg" /></template>
<template #cardContent>
<DescriptionInput v-model="state.description" />
</template>
</EvaluationStep>
<div :class="[$style.container, { [$style.noRuns]: !hasRuns }]">
<div :class="$style.headerSection">
<div :class="$style.headerMeta">
<div :class="$style.name">
<EvaluationHeader
v-model="state.name"
:class="{ 'has-issues': hasIssues('name') }"
:start-editing="startEditing"
:save-changes="saveChanges"
:handle-keydown="handleKeydown"
/>
<div :class="$style.lastSaved">
<template v-if="isSaving">
{{ locale.baseText('testDefinition.edit.saving') }}
</template>
<template v-else> {{ locale.baseText('testDefinition.edit.saved') }} </template>
</div>
</div>
<DescriptionInput
v-model="state.description"
:start-editing="startEditing"
:save-changes="saveChanges"
:handle-keydown="handleKeydown"
:class="$style.descriptionInput"
/>
</div>
<div :class="$style.controls">
<n8n-button
v-if="runs.length > 0"
size="small"
:icon="showConfig ? 'eye-slash' : 'eye'"
data-test-id="toggle-config-button"
:label="
showConfig
? locale.baseText('testDefinition.edit.hideConfig')
: locale.baseText('testDefinition.edit.showConfig')
"
type="tertiary"
@click="toggleConfig"
/>
<n8n-button
v-if="state.evaluationWorkflow.value && state.tags.value.length > 0"
:class="$style.runTestButton"
size="small"
data-test-id="run-test-button"
:label="locale.baseText('testDefinition.runTest')"
type="primary"
@click="runTest"
/>
<n8n-button
v-else
:class="$style.runTestButton"
size="small"
data-test-id="run-test-button"
:label="locale.baseText('testDefinition.edit.saveTest')"
type="primary"
@click="onSaveTest"
/>
</div>
</div>
<div :class="$style.content">
<div v-if="runs.length > 0" :class="$style.runs">
<!-- Metrics Chart -->
<MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" :theme="appliedTheme" />
<!-- Past Runs Table -->
<TestRunsTable
:class="$style.runsTable"
:runs="runs"
:selectable="true"
data-test-id="past-runs-table"
@delete-runs="onDeleteRuns"
/>
</div>
<div :class="[$style.panelBlock, { [$style.hidden]: !showConfig }]">
<div :class="$style.panelIntro">
{{ locale.baseText('testDefinition.edit.step.intro') }}
</div>
@ -286,37 +346,6 @@ watch(
</template>
</EvaluationStep>
</div>
<n8n-button
v-if="state.evaluationWorkflow.value && state.tags.value.length > 0"
:class="$style.runTestButton"
size="small"
data-test-id="run-test-button"
:label="locale.baseText('testDefinition.runTest')"
type="primary"
@click="runTest"
/>
<n8n-button
v-else
:class="$style.runTestButton"
size="small"
data-test-id="run-test-button"
:label="'Save Test'"
type="primary"
@click="onSaveTest"
/>
</div>
<!-- Past Runs Table -->
<div v-if="runs.length > 0" :class="$style.runsTable">
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading">{{
locale.baseText('testDefinition.edit.pastRuns')
}}</N8nHeading>
<TestRunsTable
:runs="runs"
:selectable="true"
data-test-id="past-runs-table"
@delete-runs="onDeleteRuns"
/>
</div>
<Modal ref="nodePinningModal" width="80vw" height="85vh" :name="NODE_PINNING_MODAL_KEY">
@ -338,52 +367,102 @@ watch(
<style module lang="scss">
.container {
--evaluation-edit-panel-width: 35rem;
width: 100%;
--evaluation-edit-panel-width: 24rem;
--metrics-chart-height: 10rem;
height: 100%;
overflow: hidden;
padding: var(--spacing-s);
display: grid;
grid-template-columns: minmax(auto, var(--evaluation-edit-panel-width)) 1fr;
gap: var(--spacing-2xl);
}
.formContent {
width: 100%;
min-width: fit-content;
padding-bottom: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
@media (min-height: 56rem) {
--metrics-chart-height: 16rem;
}
@include mixins.breakpoint('lg-and-up') {
--evaluation-edit-panel-width: 30rem;
}
}
.runsTableTotal {
display: block;
margin-bottom: var(--spacing-xs);
.content {
display: flex;
overflow-y: hidden;
position: relative;
.noRuns & {
justify-content: center;
overflow-y: auto;
}
}
.runsTable {
flex-shrink: 1;
max-width: 100%;
max-height: 80vh;
.headerSection {
display: flex;
justify-content: space-between;
align-items: flex-start;
background-color: var(--color-background-light);
width: 100%;
}
.headerMeta {
max-width: 50%;
}
.name {
display: flex;
align-items: center;
.lastSaved {
font-size: var(--font-size-s);
color: var(--color-text-light);
}
}
.descriptionInput {
margin-top: var(--spacing-2xs);
}
.runs {
display: flex;
flex-direction: column;
gap: var(--spacing-m);
flex: 1;
padding-top: var(--spacing-3xs);
overflow: auto;
@media (min-height: 56rem) {
margin-top: var(--spacing-2xl);
}
}
.runsTableHeading {
display: block;
margin-bottom: var(--spacing-xl);
}
.panelBlock {
max-width: var(--evaluation-edit-panel-width, 35rem);
width: var(--evaluation-edit-panel-width);
display: grid;
height: 100%;
overflow-y: auto;
min-height: 0;
flex-shrink: 0;
padding-bottom: var(--spacing-l);
margin-left: var(--spacing-2xl);
transition: width 0.2s ease;
&.hidden {
margin-left: 0;
width: 0;
overflow: hidden;
flex-shrink: 1;
}
.noRuns & {
overflow-y: initial;
}
}
.panelIntro {
font-size: var(--font-size-m);
color: var(--color-text-dark);
margin-top: var(--spacing-s);
justify-self: center;
position: relative;
display: block;
}
.step {
position: relative;
@ -391,10 +470,13 @@ watch(
margin-top: var(--spacing-m);
}
}
.introArrow {
--arrow-height: 1.5rem;
margin-bottom: -1rem;
justify-self: center;
}
.evaluationArrows {
--arrow-height: 22rem;
display: flex;
@ -405,38 +487,9 @@ watch(
margin-bottom: -100%;
z-index: 0;
}
.footer {
margin-top: var(--spacing-xl);
.controls {
display: flex;
justify-content: flex-start;
}
.workflow {
padding: var(--spacing-l);
background-color: var(--color-background-light);
border-radius: var(--border-radius-large);
border: var(--border-base);
}
.workflowSteps {
display: grid;
gap: var(--spacing-2xs);
max-width: 42rem;
margin: 0 auto;
}
.sideBySide {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: var(--spacing-2xs);
justify-items: end;
align-items: start;
}
.mockedNodesLabel {
min-height: 1.5rem;
display: block;
}
.runTestButton {
margin-top: var(--spacing-m);
gap: var(--spacing-s);
}
</style>

View file

@ -198,10 +198,10 @@ onMounted(() => {
</template>
<style module lang="scss">
.container {
padding: var(--spacing-xl) var(--spacing-l);
height: 100%;
width: 100%;
max-width: var(--content-container-width);
margin: auto;
}
.loading {
display: flex;

View file

@ -21,5 +21,16 @@ onMounted(initWorkflow);
</script>
<template>
<router-view />
<div :class="$style.evaluationsView">
<router-view />
</div>
</template>
<style module lang="scss">
.evaluationsView {
width: 100%;
height: 100%;
margin: auto;
padding: var(--spacing-xl) var(--spacing-l);
}
</style>

View file

@ -5,8 +5,8 @@ import { useRouter } from 'vue-router';
import { convertToDisplayDate } from '@/utils/typesUtils';
import { useI18n } from '@/composables/useI18n';
import { N8nCard, N8nText } from 'n8n-design-system';
import TestDefinitionTable from '@/components/TestDefinition/shared/TestDefinitionTable.vue';
import type { TestDefinitionTableColumn } from '@/components/TestDefinition/shared/TestDefinitionTable.vue';
import TestTableBase from '@/components/TestDefinition/shared/TestTableBase.vue';
import type { TestTableColumn } from '@/components/TestDefinition/shared/TestTableBase.vue';
import { useExecutionsStore } from '@/stores/executions.store';
import { get } from 'lodash-es';
import type { ExecutionSummaryWithScopes } from '@/Interface';
@ -36,7 +36,7 @@ const filteredTestCases = computed(() => {
});
const columns = computed(
(): Array<TestDefinitionTableColumn<TestCase>> => [
(): Array<TestTableColumn<TestCase>> => [
{
prop: 'id',
width: 200,
@ -193,7 +193,7 @@ onMounted(async () => {
<div v-if="isLoading" :class="$style.loading">
<n8n-loading :loading="true" :rows="5" />
</div>
<TestDefinitionTable
<TestTableBase
v-else
:data="filteredTestCases"
:columns="columns"
@ -205,10 +205,10 @@ onMounted(async () => {
<style module lang="scss">
.container {
padding: var(--spacing-xl) var(--spacing-l);
height: 100%;
width: 100%;
max-width: var(--content-container-width);
margin: auto;
}
.backButton {

View file

@ -98,10 +98,10 @@ onMounted(async () => {
<N8nLoading :rows="5" />
<N8nLoading :rows="10" />
</template>
<template v-else-if="runs.length > 0">
<div :class="$style.details" v-else-if="runs.length > 0">
<MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" :theme="appliedTheme" />
<TestRunsTable :runs="runs" @get-run-detail="getRunDetail" @delete-runs="onDeleteRuns" />
</template>
</div>
<template v-else>
<N8nActionBox
:heading="locale.baseText('testDefinition.listRuns.noRuns')"
@ -114,16 +114,24 @@ onMounted(async () => {
</template>
<style module lang="scss">
.container {
padding: var(--spacing-xl) var(--spacing-l);
height: 100%;
width: 100%;
max-width: var(--content-container-width);
margin: auto;
display: flex;
flex-direction: column;
}
.backButton {
color: var(--color-text-base);
}
.description {
margin-top: var(--spacing-xs);
margin: var(--spacing-s) 0;
display: block;
}
.details {
display: flex;
height: 100%;
flex-direction: column;
gap: var(--spacing-xl);
}
</style>

View file

@ -75,7 +75,7 @@ describe('TestDefinitionEditView', () => {
vi.mocked(useTestDefinitionForm).mockReturnValue({
state: ref({
name: { value: '', isEditing: false, tempValue: '' },
description: '',
description: { value: '', isEditing: false, tempValue: '' },
tags: { value: [], tempValue: [], isEditing: false },
evaluationWorkflow: { mode: 'list', value: '', __rl: true },
metrics: [],
@ -273,7 +273,7 @@ describe('TestDefinitionEditView', () => {
...vi.mocked(useTestDefinitionForm)(),
state: ref({
name: { value: 'Test', isEditing: false, tempValue: '' },
description: '',
description: { value: '', isEditing: false, tempValue: '' },
tags: { value: ['tag1'], tempValue: [], isEditing: false },
evaluationWorkflow: { mode: 'list', value: 'workflow1', __rl: true },
metrics: [],