feat(editor): Add dragging and hiding for evaluation table columns (#17587)

This commit is contained in:
Mutasem Aldmour
2025-07-28 11:17:49 +02:00
committed by GitHub
parent 49a52a1150
commit 921cdb6fd0
32 changed files with 4732 additions and 454 deletions

View File

@@ -1,10 +1,12 @@
<script setup lang="ts" generic="T extends object">
import { SHORT_TABLE_CELL_MIN_WIDTH } from '@/views/Evaluations.ee/utils';
import { N8nIcon, N8nTooltip } from '@n8n/design-system';
import type { TableInstance } from 'element-plus';
import type { ColumnCls, TableInstance } from 'element-plus';
import { ElTable, ElTableColumn } from 'element-plus';
import isEqual from 'lodash/isEqual';
import { nextTick, ref, watch } from 'vue';
import { nextTick, ref, useCssModule, watch } from 'vue';
import type { RouteLocationRaw } from 'vue-router';
/**
* A reusable table component for displaying evaluation results data
* @template T - The type of data being displayed in the table rows
@@ -28,6 +30,7 @@ export type TestTableColumn<TRow> = {
sortMethod?: (a: TRow, b: TRow) => number;
openInNewTab?: boolean;
formatter?: (row: TRow) => string;
minWidth?: number;
};
type TableRow = T & { id: string };
@@ -39,14 +42,18 @@ const props = withDefaults(
defaultSort?: { prop: string; order: 'ascending' | 'descending' };
selectable?: boolean;
selectableFilter?: (row: TableRow) => boolean;
expandedRows?: Set<string>;
}>(),
{
defaultSort: () => ({ prop: 'date', order: 'descending' }),
selectable: false,
selectableFilter: () => true,
expandedRows: () => new Set(),
},
);
const $style = useCssModule();
const tableRef = ref<TableInstance>();
const selectedRows = ref<TableRow[]>([]);
const localData = ref<TableRow[]>([]);
@@ -98,8 +105,21 @@ const handleColumnResize = (
}
};
const getCellClassName: ColumnCls<TableRow> = ({ row }) => {
return `${props.expandedRows?.has(row.id) ? $style.expandedCell : $style.baseCell}`;
};
const getRowClassName: ColumnCls<TableRow> = ({ row }) => {
const baseClass =
'status' in row && row?.status === 'error' ? $style.customDisabledRow : $style.customRow;
const expandedClass = props.expandedRows?.has(row.id) ? $style.expandedRow : '';
return `${baseClass} ${expandedClass}`;
};
defineSlots<{
id(props: { row: TableRow }): unknown;
index(props: { row: TableRow }): unknown;
status(props: { row: TableRow }): unknown;
}>();
</script>
@@ -111,10 +131,8 @@ defineSlots<{
:default-sort="defaultSort"
:data="localData"
:border="true"
:cell-class-name="$style.customCell"
:row-class-name="
({ row }) => (row?.status === 'error' ? $style.customDisabledRow : $style.customRow)
"
:cell-class-name="getCellClassName"
:row-class-name="getRowClassName"
scrollbar-always-on
@selection-change="handleSelectionChange"
@header-dragend="handleColumnResize"
@@ -135,7 +153,7 @@ defineSlots<{
v-bind="column"
:resizable="true"
data-test-id="table-column"
:min-width="125"
:min-width="column.minWidth ?? SHORT_TABLE_CELL_MIN_WIDTH"
>
<template #header="headerProps">
<N8nTooltip
@@ -161,6 +179,7 @@ defineSlots<{
</template>
<template #default="{ row }">
<slot v-if="column.prop === 'id'" name="id" v-bind="{ row }"></slot>
<slot v-if="column.prop === 'index'" name="index" v-bind="{ row }"></slot>
<slot v-if="column.prop === 'status'" name="status" v-bind="{ row }"></slot>
</template>
</ElTableColumn>
@@ -168,26 +187,32 @@ defineSlots<{
</template>
<style module lang="scss">
.customCell {
.baseCell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid var(--border-color-light) !important;
vertical-align: top !important;
> div {
max-height: 100px;
white-space: nowrap !important;
}
}
.cell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.expandedCell {
white-space: normal;
background: var(--color-background-light-base);
border-bottom: 1px solid var(--border-color-light) !important;
vertical-align: top !important;
> div {
white-space: normal !important;
}
}
.customRow {
cursor: pointer;
--color-table-row-hover-background: var(--color-background-light);
--color-table-row-hover-background: var(--color-background-base);
}
.customDisabledRow {
@@ -217,10 +242,6 @@ defineSlots<{
.table {
border-radius: 12px;
:global(.el-scrollbar__wrap) {
overflow: hidden;
}
:global(.el-table__column-resize-proxy) {
background-color: var(--color-primary);
width: 3px;
@@ -241,5 +262,16 @@ defineSlots<{
:global(.el-scrollbar__bar) {
opacity: 1;
}
* {
// hide browser scrollbars completely
// but still allow mouse gestures to scroll
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
}
</style>

View File

@@ -0,0 +1,368 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { indexedDbCache } from './cache';
// @ts-ignore
import FDBFactory from 'fake-indexeddb/lib/FDBFactory';
// @ts-ignore
import FDBKeyRange from 'fake-indexeddb/lib/FDBKeyRange';
const globalTeardown = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (global as any).indexedDB;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (global as any).IDBKeyRange;
};
const globalSetup = () => {
global.indexedDB = new FDBFactory();
global.IDBKeyRange = FDBKeyRange;
};
describe('indexedDbCache', () => {
const dbName = 'testDb';
const storeName = 'testStore';
beforeEach(() => {
globalSetup();
});
afterEach(() => {
globalTeardown();
});
it('should create cache instance and initialize empty', async () => {
const cache = await indexedDbCache(dbName, storeName);
expect(cache.getItem('nonexistent')).toBe(null);
});
it('should set and get items', async () => {
const cache = await indexedDbCache(dbName, storeName);
cache.setItem('key1', 'value1');
expect(cache.getItem('key1')).toBe('value1');
cache.setItem('key2', 'value2');
expect(cache.getItem('key2')).toBe('value2');
});
it('should return null for non-existent keys', async () => {
const cache = await indexedDbCache(dbName, storeName);
expect(cache.getItem('nonexistent')).toBe(null);
});
it('should remove items', async () => {
const cache = await indexedDbCache(dbName, storeName);
cache.setItem('key1', 'value1');
expect(cache.getItem('key1')).toBe('value1');
cache.removeItem('key1');
expect(cache.getItem('key1')).toBe(null);
});
it('should clear all items', async () => {
const cache = await indexedDbCache(dbName, storeName);
cache.setItem('key1', 'value1');
cache.setItem('key2', 'value2');
cache.clear();
expect(cache.getItem('key1')).toBe(null);
expect(cache.getItem('key2')).toBe(null);
});
it('should get all items with prefix', async () => {
const cache = await indexedDbCache(dbName, storeName);
cache.setItem('prefix:key1', 'value1');
cache.setItem('prefix:key2', 'value2');
cache.setItem('other:key3', 'value3');
await new Promise((resolve) => setTimeout(resolve, 100));
const results = await cache.getAllWithPrefix('prefix:');
expect(results).toEqual({
'prefix:key1': 'value1',
'prefix:key2': 'value2',
});
expect(results['other:key3']).toBeUndefined();
});
it('should persist data between cache instances', async () => {
const cache1 = await indexedDbCache(dbName, storeName);
cache1.setItem('persistent', 'value');
await new Promise((resolve) => setTimeout(resolve, 100));
const cache2 = await indexedDbCache(dbName, storeName);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(cache2.getItem('persistent')).toBe('value');
});
it('should handle empty prefix queries', async () => {
const cache = await indexedDbCache(dbName, storeName);
cache.setItem('key1', 'value1');
cache.setItem('key2', 'value2');
await new Promise((resolve) => setTimeout(resolve, 100));
const results = await cache.getAllWithPrefix('');
expect(results).toEqual({
key1: 'value1',
key2: 'value2',
});
});
it('should handle non-matching prefix queries', async () => {
const cache = await indexedDbCache(dbName, storeName);
cache.setItem('key1', 'value1');
cache.setItem('key2', 'value2');
await new Promise((resolve) => setTimeout(resolve, 100));
const results = await cache.getAllWithPrefix('nonexistent:');
expect(results).toEqual({});
});
it('should handle concurrent operations', async () => {
const cache = await indexedDbCache(dbName, storeName);
const promises = [];
for (let i = 0; i < 10; i++) {
promises.push(
new Promise<void>((resolve) => {
cache.setItem(`key${i}`, `value${i}`);
resolve();
}),
);
}
await Promise.all(promises);
for (let i = 0; i < 10; i++) {
expect(cache.getItem(`key${i}`)).toBe(`value${i}`);
}
});
it('should update existing items', async () => {
const cache = await indexedDbCache(dbName, storeName);
cache.setItem('key1', 'originalValue');
expect(cache.getItem('key1')).toBe('originalValue');
cache.setItem('key1', 'updatedValue');
expect(cache.getItem('key1')).toBe('updatedValue');
});
it('should handle special characters in keys and values', async () => {
const cache = await indexedDbCache(dbName, storeName);
const specialKey = 'key:with/special\\chars';
const specialValue = 'value with "quotes" and \nnewlines';
cache.setItem(specialKey, specialValue);
expect(cache.getItem(specialKey)).toBe(specialValue);
});
it('should work with different database names', async () => {
const cache1 = await indexedDbCache('db1', storeName);
const cache2 = await indexedDbCache('db2', storeName);
cache1.setItem('key', 'value1');
cache2.setItem('key', 'value2');
expect(cache1.getItem('key')).toBe('value1');
expect(cache2.getItem('key')).toBe('value2');
});
it('should handle database initialization errors gracefully', async () => {
const originalIndexedDB = global.indexedDB;
const mockIndexedDB = {
open: vi.fn().mockImplementation(() => {
const request = {
onerror: null as ((event: Event) => void) | null,
onsuccess: null as ((event: Event) => void) | null,
onupgradeneeded: null as ((event: Event) => void) | null,
result: null,
error: new Error('Database error'),
};
setTimeout(() => {
if (request.onerror) request.onerror(new Event('error'));
}, 0);
return request;
}),
cmp: vi.fn(),
databases: vi.fn(),
deleteDatabase: vi.fn(),
};
global.indexedDB = mockIndexedDB;
await expect(indexedDbCache(dbName, storeName)).rejects.toThrow();
global.indexedDB = originalIndexedDB;
});
it('should ensure IndexedDB operations are persisted correctly', async () => {
const cache = await indexedDbCache(dbName, storeName);
// Set multiple items
cache.setItem('persist1', 'value1');
cache.setItem('persist2', 'value2');
cache.setItem('persist3', 'value3');
// Wait for async operations to complete
await new Promise((resolve) => setTimeout(resolve, 100));
// Create new instance to verify persistence
const newCache = await indexedDbCache(dbName, storeName);
// Wait for load to complete
await new Promise((resolve) => setTimeout(resolve, 100));
expect(newCache.getItem('persist1')).toBe('value1');
expect(newCache.getItem('persist2')).toBe('value2');
expect(newCache.getItem('persist3')).toBe('value3');
});
it('should ensure removeItem persists deletion to IndexedDB', async () => {
const cache = await indexedDbCache(dbName, storeName);
// Set and verify item exists
cache.setItem('toDelete', 'value');
await new Promise((resolve) => setTimeout(resolve, 50));
const cache2 = await indexedDbCache(dbName, storeName);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(cache2.getItem('toDelete')).toBe('value');
// Remove item and verify it's deleted from IndexedDB
cache.removeItem('toDelete');
await new Promise((resolve) => setTimeout(resolve, 100));
const cache3 = await indexedDbCache(dbName, storeName);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(cache3.getItem('toDelete')).toBe(null);
});
it('should ensure clear persists to IndexedDB', async () => {
const cache = await indexedDbCache(dbName, storeName);
// Set multiple items
cache.setItem('clear1', 'value1');
cache.setItem('clear2', 'value2');
await new Promise((resolve) => setTimeout(resolve, 100));
// Verify items exist in new instance
const cache2 = await indexedDbCache(dbName, storeName);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(cache2.getItem('clear1')).toBe('value1');
expect(cache2.getItem('clear2')).toBe('value2');
// Clear and verify persistence
cache.clear();
await new Promise((resolve) => setTimeout(resolve, 100));
const cache3 = await indexedDbCache(dbName, storeName);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(cache3.getItem('clear1')).toBe(null);
expect(cache3.getItem('clear2')).toBe(null);
});
it('should handle rapid successive operations correctly', async () => {
const cache = await indexedDbCache(dbName, storeName);
// Rapid operations on same key
cache.setItem('rapid', 'value1');
cache.setItem('rapid', 'value2');
cache.setItem('rapid', 'value3');
cache.removeItem('rapid');
cache.setItem('rapid', 'final');
// In-memory should be immediate
expect(cache.getItem('rapid')).toBe('final');
// Wait for all async operations to settle
await new Promise((resolve) => setTimeout(resolve, 200));
// Verify final state persisted correctly
const newCache = await indexedDbCache(dbName, storeName);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(newCache.getItem('rapid')).toBe('final');
});
it('should maintain consistency between memory cache and IndexedDB', async () => {
const cache = await indexedDbCache(dbName, storeName);
const operations = [
() => cache.setItem('consistency1', 'value1'),
() => cache.setItem('consistency2', 'value2'),
() => cache.removeItem('consistency1'),
() => cache.setItem('consistency3', 'value3'),
() => cache.clear(),
() => cache.setItem('final', 'finalValue'),
];
// Execute operations with small delays
for (const op of operations) {
op();
await new Promise((resolve) => setTimeout(resolve, 10));
}
// Wait for all operations to complete
await new Promise((resolve) => setTimeout(resolve, 200));
// Verify final state in new instance
const newCache = await indexedDbCache(dbName, storeName);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(newCache.getItem('consistency1')).toBe(null);
expect(newCache.getItem('consistency2')).toBe(null);
expect(newCache.getItem('consistency3')).toBe(null);
expect(newCache.getItem('final')).toBe('finalValue');
});
it('should verify transaction operations are called with correct parameters', async () => {
const cache = await indexedDbCache(dbName, storeName);
// Test that operations work through the full IndexedDB integration
cache.setItem('txTest1', 'value1');
cache.setItem('txTest2', 'value2');
cache.removeItem('txTest1');
// Wait for operations to complete
await new Promise((resolve) => setTimeout(resolve, 100));
// Verify in-memory state
expect(cache.getItem('txTest1')).toBe(null);
expect(cache.getItem('txTest2')).toBe('value2');
// Verify persistence to IndexedDB
const newCache = await indexedDbCache(dbName, storeName);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(newCache.getItem('txTest1')).toBe(null);
expect(newCache.getItem('txTest2')).toBe('value2');
// Clear and verify
cache.clear();
await new Promise((resolve) => setTimeout(resolve, 100));
const clearedCache = await indexedDbCache(dbName, storeName);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(clearedCache.getItem('txTest2')).toBe(null);
});
});

View File

@@ -1,7 +1,7 @@
import * as tsvfs from '@typescript/vfs';
import { COMPILER_OPTIONS, TYPESCRIPT_FILES } from './constants';
import ts from 'typescript';
import type { IndexedDbCache } from './cache';
import type { IndexedDbCache } from '../../../cache';
import globalTypes from './type-declarations/globals.d.ts?raw';
import n8nTypes from './type-declarations/n8n.d.ts?raw';

View File

@@ -1,6 +1,6 @@
import * as Comlink from 'comlink';
import type { LanguageServiceWorker, LanguageServiceWorkerInit } from '../types';
import { indexedDbCache } from './cache';
import { indexedDbCache } from '../../../cache';
import { bufferChangeSets, fnPrefix } from './utils';
import type { CodeExecutionMode } from 'n8n-workflow';

View File

@@ -9,13 +9,47 @@ import type { BaseTextKey } from '@n8n/i18n';
import { useEvaluationStore } from '@/stores/evaluation.store.ee';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
import { N8nText, N8nTooltip, N8nIcon } from '@n8n/design-system';
import {
N8nText,
N8nTooltip,
N8nIcon,
N8nTableHeaderControlsButton,
N8nExternalLink,
} from '@n8n/design-system';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import orderBy from 'lodash/orderBy';
import { statusDictionary } from '@/components/Evaluations.ee/shared/statusDictionary';
import { getErrorBaseKey } from '@/components/Evaluations.ee/shared/errorCodes';
import { getTestCasesColumns, mapToNumericColumns } from './utils';
import {
applyCachedSortOrder,
applyCachedVisibility,
getDefaultOrderedColumns,
getTestCasesColumns,
getTestTableHeaders,
} from './utils';
import { indexedDbCache } from '@/plugins/cache';
import { jsonParse } from 'n8n-workflow';
export type Column =
| {
key: string;
label: string;
visible: boolean;
numeric?: boolean;
disabled: false;
columnType: 'inputs' | 'outputs' | 'metrics';
}
// Disabled state ensures current sort order is not lost if user resorts teh columns
// even if some columns are disabled / not available in the current run
| { key: string; disabled: true };
interface UserPreferences {
order: string[];
visibility: Record<string, boolean>;
}
export type Header = TestTableColumn<TestCaseExecutionRecord & { index: number }>;
const router = useRouter();
const toast = useToast();
@@ -31,6 +65,9 @@ const runId = computed(() => router.currentRoute.value.params.runId as string);
const workflowId = computed(() => router.currentRoute.value.params.name as string);
const workflowName = computed(() => workflowsStore.getWorkflowById(workflowId.value)?.name ?? '');
const cachedUserPreferences = ref<UserPreferences | undefined>();
const expandedRows = ref<Set<string>>(new Set());
const run = computed(() => evaluationStore.testRunsById[runId.value]);
const runErrorDetails = computed(() => {
return run.value?.errorDetails as Record<string, string | number>;
@@ -42,6 +79,8 @@ const filteredTestCases = computed(() =>
),
);
const isAllExpanded = computed(() => expandedRows.value.size === filteredTestCases.value.length);
const testRunIndex = computed(() =>
Object.values(
orderBy(evaluationStore.testRunsById, (record) => new Date(record.runAt), ['asc']).filter(
@@ -52,7 +91,7 @@ const testRunIndex = computed(() =>
const formattedTime = computed(() => convertToDisplayDate(new Date(run.value?.runAt).getTime()));
const handleRowClick = (row: TestCaseExecutionRecord) => {
const openRelatedExecution = (row: TestCaseExecutionRecord) => {
const executionId = row.executionId;
if (executionId) {
const { href } = router.resolve({
@@ -68,35 +107,27 @@ const handleRowClick = (row: TestCaseExecutionRecord) => {
const inputColumns = computed(() => getTestCasesColumns(filteredTestCases.value, 'inputs'));
const columns = computed(
(): Array<TestTableColumn<TestCaseExecutionRecord & { index: number }>> => {
const specialKeys = ['promptTokens', 'completionTokens', 'totalTokens', 'executionTime'];
const metricColumns = Object.keys(run.value?.metrics ?? {}).filter(
(key) => !specialKeys.includes(key),
);
const specialColumns = specialKeys.filter((key) =>
run.value?.metrics ? key in run.value.metrics : false,
);
const orderedColumns = computed((): Column[] => {
const defaultOrder = getDefaultOrderedColumns(run.value, filteredTestCases.value);
const appliedCachedOrder = applyCachedSortOrder(defaultOrder, cachedUserPreferences.value?.order);
return [
{
prop: 'index',
width: 100,
label: locale.baseText('evaluation.runDetail.testCase'),
sortable: true,
formatter: (row: TestCaseExecutionRecord & { index: number }) => `#${row.index}`,
},
{
prop: 'status',
label: locale.baseText('evaluation.listRuns.status'),
},
...inputColumns.value,
...getTestCasesColumns(filteredTestCases.value, 'outputs'),
...mapToNumericColumns(metricColumns),
...mapToNumericColumns(specialColumns),
];
},
);
return applyCachedVisibility(appliedCachedOrder, cachedUserPreferences.value?.visibility);
});
const columns = computed((): Header[] => [
{
prop: 'index',
width: 100,
label: locale.baseText('evaluation.runDetail.testCase'),
sortable: true,
} satisfies Header,
{
prop: 'status',
label: locale.baseText('evaluation.listRuns.status'),
minWidth: 125,
} satisfies Header,
...getTestTableHeaders(orderedColumns.value, filteredTestCases.value),
]);
const metrics = computed(() => run.value?.metrics ?? {});
@@ -126,8 +157,54 @@ const fetchExecutionTestCases = async () => {
}
};
async function loadCachedUserPreferences() {
const cache = await indexedDbCache('workflows', 'evaluations');
cachedUserPreferences.value = jsonParse(cache.getItem(workflowId.value) ?? '', {
fallbackValue: {
order: [],
visibility: {},
},
});
}
async function saveCachedUserPreferences() {
const cache = await indexedDbCache('workflows', 'evaluations');
cache.setItem(workflowId.value, JSON.stringify(cachedUserPreferences.value));
}
async function handleColumnVisibilityUpdate(columnKey: string, visibility: boolean) {
cachedUserPreferences.value ??= { order: [], visibility: {} };
cachedUserPreferences.value.visibility[columnKey] = visibility;
await saveCachedUserPreferences();
}
async function handleColumnOrderUpdate(newOrder: string[]) {
cachedUserPreferences.value ??= { order: [], visibility: {} };
cachedUserPreferences.value.order = newOrder;
await saveCachedUserPreferences();
}
function toggleRowExpansion(row: { id: string }) {
if (expandedRows.value.has(row.id)) {
expandedRows.value.delete(row.id);
} else {
expandedRows.value.add(row.id);
}
}
function toggleAllExpansion() {
if (isAllExpanded.value) {
// Collapse all
expandedRows.value.clear();
} else {
// Expand all
expandedRows.value = new Set(filteredTestCases.value.map((row) => row.id));
}
}
onMounted(async () => {
await fetchExecutionTestCases();
await loadCachedUserPreferences();
});
</script>
@@ -226,6 +303,35 @@ onMounted(async () => {
</div>
</el-scrollbar>
<div :class="['mb-s', $style.runsHeader]">
<div>
<n8n-heading size="large" :bold="true"
>{{
locale.baseText('evaluation.listRuns.allTestCases', {
interpolate: {
count: filteredTestCases.length,
},
})
}}
</n8n-heading>
</div>
<div :class="$style.runsHeaderButtons">
<n8n-icon-button
:icon="isAllExpanded ? 'chevrons-down-up' : 'chevrons-up-down'"
type="secondary"
size="medium"
@click="toggleAllExpansion"
/>
<N8nTableHeaderControlsButton
size="medium"
icon-size="small"
:columns="orderedColumns"
@update:column-visibility="handleColumnVisibilityUpdate"
@update:column-order="handleColumnOrderUpdate"
/>
</div>
</div>
<n8n-callout
v-if="
!isLoading &&
@@ -251,13 +357,26 @@ onMounted(async () => {
:data="filteredTestCases"
:columns="columns"
:default-sort="{ prop: 'id', order: 'descending' }"
@row-click="handleRowClick"
:expanded-rows="expandedRows"
@row-click="toggleRowExpansion"
>
<template #id="{ row }">
<div style="display: flex; justify-content: space-between; gap: 10px">
{{ row.id }}
</div>
</template>
<template #index="{ row }">
<div>
<N8nExternalLink
v-if="row.executionId"
class="open-execution-link"
@click.stop.prevent="openRelatedExecution(row)"
>
#{{ row.index }}
</N8nExternalLink>
<span v-else :class="$style.deletedExecutionRowIndex">#{{ row.index }}</span>
</div>
</template>
<template #status="{ row }">
<div style="display: inline-flex; gap: 12px; align-items: center; max-width: 100%">
<N8nIcon
@@ -289,13 +408,21 @@ onMounted(async () => {
</div>
</template>
<style lang="scss" scoped>
/**
When hovering over link in row, ensure hover background is removed from row
*/
:global(tr:hover:has(.open-execution-link:hover)) {
--color-table-row-hover-background: transparent;
}
</style>
<style module lang="scss">
.container {
height: 100%;
width: 100%;
max-width: var(--content-container-width);
margin: auto;
padding: var(--spacing-l) var(--spacing-2xl) 0;
padding: var(--spacing-l) 0;
}
.header {
@@ -354,6 +481,19 @@ onMounted(async () => {
margin-bottom: var(--spacing-s);
}
.runsHeader {
display: flex;
> div:first-child {
flex: 1;
}
}
.runsHeaderButtons {
display: flex;
gap: var(--spacing-xs);
}
.loading {
display: flex;
justify-content: center;
@@ -432,4 +572,9 @@ onMounted(async () => {
font-size: var(--font-size-2xs);
line-height: 1.25;
}
.deletedExecutionRowIndex {
color: var(--color-text-base);
font-weight: var(--font-weight-regular);
}
</style>

View File

@@ -1,10 +1,77 @@
import type { TestTableColumn } from '@/components/Evaluations.ee/shared/TestTableBase.vue';
import type { TestCaseExecutionRecord } from '../../api/evaluation.ee';
import type { JsonValue } from 'n8n-workflow';
import type { TestCaseExecutionRecord, TestRunRecord } from '../../api/evaluation.ee';
import type { Column, Header } from './TestRunDetailView.vue';
export const SHORT_TABLE_CELL_MIN_WIDTH = 125;
const LONG_TABLE_CELL_MIN_WIDTH = 250;
const specialKeys = ['promptTokens', 'completionTokens', 'totalTokens', 'executionTime'];
export function getDefaultOrderedColumns(
run?: TestRunRecord,
filteredTestCases?: TestCaseExecutionRecord[],
) {
// Default sort order
// -> inputs, outputs, metrics, tokens, executionTime
const metricColumns = Object.keys(run?.metrics ?? {}).filter((key) => !specialKeys.includes(key));
const specialColumns = specialKeys.filter((key) => (run?.metrics ? key in run.metrics : false));
const inputColumns = getTestCasesColumns(filteredTestCases ?? [], 'inputs');
const outputColumns = getTestCasesColumns(filteredTestCases ?? [], 'outputs');
const defaultOrder: Column[] = [
...mapToColumns(inputColumns, 'inputs'),
...mapToColumns(outputColumns, 'outputs'),
...mapToColumns(metricColumns, 'metrics', true),
...mapToColumns(specialColumns, 'metrics', true),
];
return defaultOrder;
}
export function applyCachedVisibility(
columns: Column[],
visibility?: Record<string, boolean>,
): Column[] {
if (!visibility) {
return columns;
}
return columns.map((column) =>
column.disabled
? column
: {
...column,
visible: visibility.hasOwnProperty(column.key) ? visibility[column.key] : column.visible,
},
);
}
export function applyCachedSortOrder(defaultOrder: Column[], cachedOrder?: string[]): Column[] {
if (!cachedOrder?.length) {
return defaultOrder;
}
const result = cachedOrder.map((columnKey): Column => {
const matchingColumn = defaultOrder.find((col) => columnKey === col.key);
if (!matchingColumn) {
return {
key: columnKey,
disabled: true,
};
}
return matchingColumn;
});
// Add any missing columns from defaultOrder that aren't in the cached order
const missingColumns = defaultOrder.filter((defaultCol) => !cachedOrder.includes(defaultCol.key));
result.push(...missingColumns);
return result;
}
export function getTestCasesColumns(
cases: TestCaseExecutionRecord[],
columnType: 'inputs' | 'outputs',
): Array<TestTableColumn<TestCaseExecutionRecord & { index: number }>> {
): string[] {
const inputColumnNames = cases.reduce(
(set, testCase) => {
Object.keys(testCase[columnType] ?? {}).forEach((key) => set.add(key));
@@ -13,30 +80,72 @@ export function getTestCasesColumns(
new Set([] as string[]),
);
return Array.from(inputColumnNames.keys()).map((column) => ({
prop: `${columnType}.${column}`,
return Array.from(inputColumnNames.keys());
}
function mapToColumns(
columnNames: string[],
columnType: 'inputs' | 'outputs' | 'metrics',
numeric?: boolean,
): Column[] {
return columnNames.map((column) => ({
key: `${columnType}.${column}`,
label: column,
sortable: true,
filter: true,
showHeaderTooltip: true,
formatter: (row: TestCaseExecutionRecord) => {
const value = row[columnType]?.[column];
if (typeof value === 'object' && value !== null) {
return JSON.stringify(value, null, 2);
}
return `${value}`;
},
visible: true,
disabled: false,
columnType,
numeric,
}));
}
export function mapToNumericColumns(columnNames: string[]) {
return columnNames.map((metric) => ({
prop: `metrics.${metric}`,
label: metric,
sortable: true,
filter: true,
showHeaderTooltip: true,
formatter: (row: TestCaseExecutionRecord) => row.metrics?.[metric]?.toFixed(2) ?? '-',
}));
function formatValue(
key: string,
value?: JsonValue,
{ numeric }: { numeric?: boolean } = { numeric: false },
) {
let stringValue: string;
if (numeric && typeof value === 'number' && !specialKeys.includes(key)) {
stringValue = value.toFixed(2) ?? '-';
} else if (typeof value === 'object' && value !== null) {
stringValue = JSON.stringify(value, null, 2);
} else {
stringValue = `${value}`;
}
return stringValue;
}
export function getTestTableHeaders(
columnNames: Column[],
testCases: TestCaseExecutionRecord[],
): Header[] {
return columnNames
.filter((column): column is Column & { disabled: false } => !column.disabled && column.visible)
.map((column) => {
// Check if any content exceeds 10 characters
const hasLongContent = Boolean(
testCases.find((row) => {
const value = row[column.columnType]?.[column.label];
const formattedValue = formatValue(column.label, value, { numeric: column.numeric });
return formattedValue?.length > 10;
}),
);
return {
prop: column.key,
label: column.disabled ? column.key : column.label,
sortable: true,
filter: true,
showHeaderTooltip: true,
minWidth: hasLongContent ? LONG_TABLE_CELL_MIN_WIDTH : SHORT_TABLE_CELL_MIN_WIDTH,
formatter: (row: TestCaseExecutionRecord) => {
const value = row[column.columnType]?.[column.label];
const formattedValue = formatValue(column.label, value, { numeric: column.numeric });
return formattedValue;
},
};
});
}