mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Add dragging and hiding for evaluation table columns (#17587)
This commit is contained in:
@@ -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>
|
||||
|
||||
368
packages/frontend/editor-ui/src/plugins/cache.test.ts
Normal file
368
packages/frontend/editor-ui/src/plugins/cache.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user