Add test definitions API and store

- Created evaluations API endpoints
- Implemented evaluations store
- Updated NewWorkflowEvaluationView
- RenamedRun Test to Save Test
This commit is contained in:
Oleg Ivaniv 2024-11-12 11:27:02 +01:00
parent 03542bfb83
commit 69f859b9ea
No known key found for this signature in database
7 changed files with 1039 additions and 409 deletions

View file

@ -0,0 +1,91 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
// Base interface for common properties
export interface ITestDefinitionBase {
name: string;
workflowId: string;
evaluationWorkflowId?: string;
description?: string;
annotationTagId?: string;
}
// Complete test definition with ID
export interface ITestDefinition extends ITestDefinitionBase {
id: number;
}
// Create params - requires name and workflowId, optional evaluationWorkflowId
export type CreateTestDefinitionParams = Pick<ITestDefinitionBase, 'name' | 'workflowId'> &
Partial<Pick<ITestDefinitionBase, 'evaluationWorkflowId'>>;
// All fields optional except ID
export type UpdateTestDefinitionParams = Partial<
Pick<ITestDefinitionBase, 'name' | 'evaluationWorkflowId' | 'annotationTagId'>
>;
// Query options type
export interface ITestDefinitionsQueryOptions {
includeScopes?: boolean;
}
export interface ITestDefinitionsApi {
getTestDefinitions: (
context: IRestApiContext,
options?: ITestDefinitionsQueryOptions,
) => Promise<{ count: number; testDefinitions: ITestDefinition[] }>;
getTestDefinition: (context: IRestApiContext, id: number) => Promise<ITestDefinition>;
createTestDefinition: (
context: IRestApiContext,
params: CreateTestDefinitionParams,
) => Promise<ITestDefinition>;
updateTestDefinition: (
context: IRestApiContext,
id: number,
params: UpdateTestDefinitionParams,
) => Promise<ITestDefinition>;
deleteTestDefinition: (context: IRestApiContext, id: number) => Promise<{ success: boolean }>;
}
export function createTestDefinitionsApi(): ITestDefinitionsApi {
const endpoint = '/evaluation/test-definitions';
return {
getTestDefinitions: async (
context: IRestApiContext,
options?: ITestDefinitionsQueryOptions,
): Promise<ITestDefinition[]> => {
return await makeRestApiRequest(context, 'GET', endpoint, options);
},
getTestDefinition: async (context: IRestApiContext, id: number): Promise<ITestDefinition> => {
return await makeRestApiRequest(context, 'GET', `${endpoint}/${id}`);
},
createTestDefinition: async (
context: IRestApiContext,
params: CreateTestDefinitionParams,
): Promise<ITestDefinition> => {
return await makeRestApiRequest(context, 'POST', endpoint, params);
},
updateTestDefinition: async (
context: IRestApiContext,
id: number,
params: UpdateTestDefinitionParams,
): Promise<ITestDefinition> => {
return await makeRestApiRequest(context, 'PATCH', `${endpoint}/${id}`, params);
},
deleteTestDefinition: async (
context: IRestApiContext,
id: number,
): Promise<{ success: boolean }> => {
return await makeRestApiRequest(context, 'DELETE', `${endpoint}/${id}`);
},
};
}

View file

@ -18,8 +18,8 @@ import type { RouterMiddleware } from '@/types/router';
import { initializeAuthenticatedFeatures, initializeCore } from '@/init'; import { initializeAuthenticatedFeatures, initializeCore } from '@/init';
import { tryToParseNumber } from '@/utils/typesUtils'; import { tryToParseNumber } from '@/utils/typesUtils';
import { projectsRoutes } from '@/routes/projects.routes'; import { projectsRoutes } from '@/routes/projects.routes';
import WorkflowEvaluationView from './views/WorkflowEvaluationView.vue'; import ListEvaluations from './views/WorkflowEvaluation/ListEvaluations.vue';
import NewWorkflowEvaluationView from './views/NewWorkflowEvaluationView.vue'; import NewEvaluation from './views/WorkflowEvaluation/NewEvaluation.vue';
const ChangePasswordView = async () => await import('./views/ChangePasswordView.vue'); const ChangePasswordView = async () => await import('./views/ChangePasswordView.vue');
const ErrorView = async () => await import('./views/ErrorView.vue'); const ErrorView = async () => await import('./views/ErrorView.vue');
@ -254,7 +254,7 @@ export const routes: RouteRecordRaw[] = [
path: '/workflow/:name/evaluation', path: '/workflow/:name/evaluation',
name: VIEWS.WORKFLOW_EVALUATION, name: VIEWS.WORKFLOW_EVALUATION,
components: { components: {
default: WorkflowEvaluationView, default: ListEvaluations,
header: MainHeader, header: MainHeader,
sidebar: MainSidebar, sidebar: MainSidebar,
}, },
@ -291,7 +291,7 @@ export const routes: RouteRecordRaw[] = [
path: '/workflow/:name/evaluation/new', path: '/workflow/:name/evaluation/new',
name: VIEWS.NEW_WORKFLOW_EVALUATION, name: VIEWS.NEW_WORKFLOW_EVALUATION,
components: { components: {
default: NewWorkflowEvaluationView, default: NewEvaluation,
header: MainHeader, header: MainHeader,
sidebar: MainSidebar, sidebar: MainSidebar,
}, },

View file

@ -0,0 +1,145 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { useRootStore } from './root.store';
import { createTestDefinitionsApi } from '@/api/evaluations.ee';
import type { ITestDefinition } from '@/api/evaluations.ee';
export const useEvaluationsStore = defineStore(
'evaluations',
() => {
// State
const testDefinitionsById = ref<Record<number, ITestDefinition>>({});
const loading = ref(false);
const fetchedAll = ref(false);
// Store instances
const rootStore = useRootStore();
const testDefinitionsApi = createTestDefinitionsApi();
// Computed
const allTestDefinitions = computed(() => {
return Object.values(testDefinitionsById.value).sort((a, b) => a.name.localeCompare(b.name));
});
const isLoading = computed(() => loading.value);
const hasTestDefinitions = computed(() => Object.keys(testDefinitionsById.value).length > 0);
// Methods
const setAllTestDefinitions = (definitions: ITestDefinition[]) => {
console.log('🚀 ~ setAllTestDefinitions ~ definitions:', definitions);
testDefinitionsById.value = definitions.reduce(
(acc: Record<number, ITestDefinition>, def: ITestDefinition) => {
acc[def.id] = def;
return acc;
},
{},
);
fetchedAll.value = true;
};
const upsertTestDefinitions = (toUpsertDefinitions: ITestDefinition[]) => {
toUpsertDefinitions.forEach((toUpsertDef) => {
const defId = toUpsertDef.id;
const currentDef = testDefinitionsById.value[defId];
if (currentDef) {
testDefinitionsById.value = {
...testDefinitionsById.value,
[defId]: {
...currentDef,
...toUpsertDef,
},
};
} else {
testDefinitionsById.value = {
...testDefinitionsById.value,
[defId]: toUpsertDef,
};
}
});
};
const deleteTestDefinition = (id: number) => {
const { [id]: deleted, ...rest } = testDefinitionsById.value;
testDefinitionsById.value = rest;
};
const fetchAll = async (params?: { force?: boolean; includeScopes?: boolean }) => {
const { force = false, includeScopes = false } = params || {};
if (!force && fetchedAll.value) {
return Object.values(testDefinitionsById.value);
}
loading.value = true;
try {
const retrievedDefinitions = await testDefinitionsApi.getTestDefinitions(
rootStore.restApiContext,
{ includeScopes },
);
console.log('🚀 ~ fetchAll ~ retrievedDefinitions:', retrievedDefinitions);
setAllTestDefinitions(retrievedDefinitions.testDefinitions);
return retrievedDefinitions;
} finally {
loading.value = false;
}
};
const create = async (params: {
name: string;
workflowId: string;
evaluationWorkflowId?: string;
}) => {
const createdDefinition = await testDefinitionsApi.createTestDefinition(
rootStore.restApiContext,
params,
);
upsertTestDefinitions([createdDefinition]);
return createdDefinition;
};
const update = async (params: {
id: number;
name?: string;
evaluationWorkflowId?: string;
annotationTagId?: string;
}) => {
const { id, ...updateParams } = params;
const updatedDefinition = await testDefinitionsApi.updateTestDefinition(
rootStore.restApiContext,
id,
updateParams,
);
upsertTestDefinitions([updatedDefinition]);
return updatedDefinition;
};
const deleteById = async (id: number) => {
const result = await testDefinitionsApi.deleteTestDefinition(rootStore.restApiContext, id);
if (result.success) {
deleteTestDefinition(id);
}
return result.success;
};
return {
// State
testDefinitionsById,
// Computed
allTestDefinitions,
isLoading,
hasTestDefinitions,
// Methods
fetchAll,
create,
update,
deleteById,
upsertTestDefinitions,
deleteTestDefinition,
};
},
{},
);

View file

@ -1,405 +0,0 @@
<script setup lang="ts">
import type { ComponentPublicInstance } from 'vue';
import { ref, computed, useTemplateRef, nextTick } from 'vue';
import type AnnotationTagsDropdownEe from '@/components/AnnotationTagsDropdown.ee.vue';
import WorkflowSelectorParameterInput from '@/components/WorkflowSelectorParameterInput/WorkflowSelectorParameterInput.vue';
import { createEventBus, N8nInput } from 'n8n-design-system';
import type { INodeParameterResourceLocator } from 'n8n-workflow';
import { useAnnotationTagsStore } from '@/stores/tags.store';
const tagsEventBus = createEventBus();
const isPanelExpanded = ref(true);
const name = ref('My Test');
const tempName = ref('');
const isNameEditing = ref(false);
const description = ref('');
const tempDescription = ref('');
const isDescriptionEditing = ref(false);
const appliedTagIds = ref<string[]>([]);
const isTagsEditing = ref(false);
const metrics = ref<string[]>(['']);
const evaluationWorkflow = ref<INodeParameterResourceLocator>({
mode: 'list',
value: '',
__rl: true,
});
const helpText = computed(
() => 'Executions with this tag will be added as test cases to this test.',
);
const workflowHelpText = computed(() => 'This workflow will be called once for each test case.');
const metricsHelpText = computed(
() =>
'The output field of the last node in the evaluation workflow. Metrics will be averaged across all test cases.',
);
const containerStyle = computed(() => ({
width: isPanelExpanded.value ? '383px' : '50px',
transition: 'width 0.3s ease',
}));
type FieldRefs = {
nameInput: ComponentPublicInstance<typeof N8nInput>;
// description: ComponentPublicInstance<typeof N8nInput>;
tagsInput: ComponentPublicInstance<typeof AnnotationTagsDropdownEe>;
workflowInput: ComponentPublicInstance<typeof WorkflowSelectorParameterInput>;
};
const fields = {
nameInput: useTemplateRef<FieldRefs['nameInput']>('nameInput'),
tagsInput: useTemplateRef<FieldRefs['tagsInput']>('tagsInput'),
workflowInput: useTemplateRef<FieldRefs['workflowInput']>('workflowInput'),
} as const;
const tagsStore = useAnnotationTagsStore();
const allTags = computed(() => tagsStore.allTags);
const isLoading = computed(() => tagsStore.isLoading);
const tagsById = computed(() => tagsStore.tagsById);
function onClickEmptyStateButton() {
console.log('onClickEmptyStateButton');
}
function onWorkflowSelectorInput(value: INodeParameterResourceLocator) {
evaluationWorkflow.value = value;
}
function addNewMetric() {
metrics.value.push('');
}
function togglePanel() {
isPanelExpanded.value = !isPanelExpanded.value;
}
// Generic edit handling functions
async function startEditing(field: 'name' | 'description' | 'tags') {
switch (field) {
case 'name':
tempName.value = name.value;
isNameEditing.value = true;
await nextTick();
console.log('🚀 ~ startEditing ~ fields.nameInput.value:', fields.nameInput.value);
fields.nameInput.value?.focus();
break;
case 'description':
// tempDescription.value = description.value;
// isDescriptionEditing.value = true;
break;
case 'tags':
// tempTagName.value = '';
isTagsEditing.value = true;
break;
}
}
function saveChanges(field: 'name' | 'description' | 'tags') {
if (field === 'name') {
name.value = tempName.value;
console.log('🚀 ~ saveChanges ~ name.value:', name.value);
}
// switch (field) {
// case 'name':
// name.value = tempName.value;
// console.log("🚀 ~ saveChanges ~ name.value:", name.value, tempName.value);
// break;
// case 'description':
// description.value = tempDescription.value;
// break;
// case 'tags':
// // Handle tag saving logic here
// break;
// }
cancelEditing(field);
}
function cancelEditing(field: 'name' | 'description' | 'tags') {
console.log('Cancel editing', field);
switch (field) {
case 'name':
isNameEditing.value = false;
N: break;
case 'description':
isDescriptionEditing.value = false;
break;
case 'tags':
isTagsEditing.value = false;
break;
}
}
function handleKeydown(event: KeyboardEvent, field: 'name' | 'description' | 'tags') {
if (event.key === 'Escape') {
cancelEditing(field);
} else if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
saveChanges(field);
}
}
function updateMetric(index: number, value: string) {
metrics.value[index] = value;
}
function onTagUpdate(tags: string[]) {
// Only one tag can be applied at a time
appliedTagIds.value = tags[0] ? [tags[0]] : [];
}
function getTagName(tagId: string) {
return tagsById.value[tagId]?.name ?? '';
}
// Load tags
void tagsStore.fetchAll();
</script>
<template>
<div :class="$style.container" :style="containerStyle">
<template v-if="isPanelExpanded">
<!-- Back button -->
<div :class="$style.header">
<n8n-icon-button
icon="arrow-left"
:class="$style.backButton"
type="tertiary"
:title="$locale.baseText('common.back')"
@click="$router.back()"
/>
<h2 :class="$style.title">
<template v-if="!isNameEditing">
{{ name }}
<n8n-icon-button
:class="$style.editInputButton"
icon="pen"
type="tertiary"
@click="startEditing('name')"
/>
</template>
<N8nInput
v-else
ref="nameInput"
v-model="tempName"
type="text"
:placeholder="$locale.baseText('common.name')"
@blur="() => saveChanges('name')"
@keydown="(e) => handleKeydown(e, 'name')"
/>
</h2>
</div>
<!-- Description -->
<div :class="$style.formGroup">
<n8n-input-label label="Description" :bold="false" size="small">
<N8nInput
ref="description"
v-model="tempDescription"
type="textarea"
:placeholder="$locale.baseText('common.description')"
@blur="saveChanges('description')"
@keydown="(e) => handleKeydown(e, 'description')"
/>
</n8n-input-label>
</div>
<!-- Tags -->
<div :class="$style.formGroup">
<n8n-input-label label="Tag name" :bold="false" size="small">
<div v-if="!isTagsEditing" :class="$style.tagsRead" @click="startEditing('tags')">
appliedTagIds: {{ appliedTagIds }}
<n8n-text v-if="appliedTagIds.length === 0" size="small"> Select tag... </n8n-text>
<n8n-tag
v-for="tagId in appliedTagIds"
:key="tagId"
:text="getTagName(tagId)"
></n8n-tag>
<n8n-icon-button
:class="$style.editInputButton"
icon="pen"
type="tertiary"
size="small"
transparent
/>
</div>
<TagsDropdown
v-else
ref="tagsInput"
:model-value="appliedTagIds"
:placeholder="$locale.baseText('executionAnnotationView.chooseOrCreateATag')"
:create-enabled="false"
:event-bus="tagsEventBus"
:all-tags="allTags"
:is-loading="isLoading"
:tags-by-id="tagsById"
class="tags-edit"
data-test-id="workflow-tags-dropdown"
@update:model-value="onTagUpdate"
@esc="cancelEditing('tags')"
@blur="saveChanges('tags')"
/>
</n8n-input-label>
<n8n-text size="small" color="text-light">
{{ helpText }}
</n8n-text>
</div>
<!-- Evaluation Workflow -->
<div :class="$style.formGroup">
<n8n-input-label label="Evaluation workflow" :bold="false" size="small">
<WorkflowSelectorParameterInput
ref="workflowInput"
:parameter="{
displayName: 'Workflow',
name: 'workflowId',
type: 'workflowSelector',
default: '',
}"
:model-value="evaluationWorkflow"
:display-title="'Evaluation Workflow'"
:is-value-expression="false"
:expression-edit-dialog-visible="false"
:path="'workflows'"
allow-new
@update:model-value="onWorkflowSelectorInput"
/>
</n8n-input-label>
<n8n-text size="small" color="text-light">
{{ workflowHelpText }}
</n8n-text>
</div>
<!-- Metrics -->
<div :class="[$style.formGroup, $style.metrics]">
<n8n-text color="text-dark"> Metrics </n8n-text>
<hr :class="$style.metricsDivider" />
<n8n-text size="small" color="text-light">
{{ metricsHelpText }}
</n8n-text>
<n8n-input-label
label="Output field(s)"
:bold="false"
size="small"
:class="$style.metricField"
>
<div :class="$style.metricsContainer">
<div v-for="(metric, index) in metrics" :key="index">
<N8nInput
:ref="`metric_${index}`"
:model-value="metric"
:placeholder="'Enter metric name'"
@update:model-value="(value) => updateMetric(index, value)"
/>
</div>
<n8n-button
type="tertiary"
:label="'New metric'"
:class="$style.newMetricButton"
@click="addNewMetric"
/>
</div>
</n8n-input-label>
</div>
<!-- Run Test Button -->
<div :class="$style.footer">
<n8n-button type="primary" :label="'Run test'" @click="onClickEmptyStateButton" />
</div>
</template>
</div>
</template>
<style module lang="scss">
.container {
width: 383px;
height: 1015px;
padding: var(--spacing-s);
border: 1px solid var(--color-foreground-base);
background: var(--color-background-xlight);
// Pin the container to the left
margin-right: auto;
}
.header {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
margin-bottom: var(--spacing-l);
&:hover {
.editInputButton {
opacity: 1;
}
}
}
.title {
margin: 0;
flex-grow: 1;
font-size: var(--font-size-l);
font-weight: var(--font-weight-bold);
color: var(--color-text-dark);
}
.formGroup {
margin-bottom: var(--spacing-l);
:global(.n8n-input-label) {
margin-bottom: var(--spacing-2xs);
}
}
.readOnlyField {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2xs) var(--spacing-xs);
background-color: var(--color-background-light);
border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-small);
}
.metricsContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.metricField {
width: 100%;
margin-top: var(--spacing-xs);
}
.metricsDivider {
margin-top: var(--spacing-4xs);
margin-bottom: var(--spacing-3xs);
}
.newMetricButton {
align-self: flex-start;
margin-top: var(--spacing-2xs);
width: 100%;
background-color: var(--color-sticky-code-background);
border-color: var(--color-button-secondary-focus-outline);
color: var(--color-button-secondary-font);
}
.footer {
margin-top: var(--spacing-xl);
display: flex;
justify-content: flex-start;
}
.tagsRead {
&:hover .editInputButton {
opacity: 1;
}
}
.editInputButton {
opacity: 0;
border: none;
--button-font-color: var(--prim-gray-490);
}
.backButton {
border: none;
--button-font-color: var(--color-text-light);
}
</style>

View file

@ -0,0 +1,271 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { VIEWS } from '@/constants';
import { useEvaluationsStore } from '@/stores/evaluations.store.ee';
import { useToast } from '@/composables/useToast';
import { useI18n } from '@/composables/useI18n';
interface TestMetrics {
metric1: number | null;
metric2: number | null;
metric3: number | null;
}
interface TestExecution {
lastRun: string | null;
errorRate: number | null;
metrics: TestMetrics;
}
const router = useRouter();
const evaluationsStore = useEvaluationsStore();
const isLoading = ref(false);
const toast = useToast();
const locale = useI18n();
// Computed properties for test data
const tests = computed(() => {
return evaluationsStore.allTestDefinitions.map((test) => ({
id: test.id,
name: test.name,
tagName: test.annotationTagId ? getTagName(test.annotationTagId) : '',
testCases: 0, // This should come from the API
execution: getTestExecution(test.id),
}));
});
const hasTests = computed(() => tests.value.length > 0);
// Mock function to get tag name - replace with actual tag lookup
function getTagName(tagId: string) {
const tags = {
tag1: 'marketing',
tag2: 'SupportOps',
};
return tags[tagId] || '';
}
// Mock function to get test execution data - replace with actual API call
function getTestExecution(testId: number): TestExecution {
// Mock data - replace with actual data from your API
const mockExecutions = {
12: {
lastRun: 'an hour ago',
errorRate: 0,
metrics: { metric1: 0.12, metric2: 0.99, metric3: 0.87 },
},
};
return (
mockExecutions[testId] || {
lastRun: null,
errorRate: null,
metrics: { metric1: null, metric2: null, metric3: null },
}
);
}
// Action handlers
function onCreateTest() {
void router.push({ name: VIEWS.NEW_WORKFLOW_EVALUATION });
}
function onRunTest(testId: number) {
console.log('Running test:', testId);
// Implement test run logic
}
function onViewDetails(testId: number) {
console.log('Viewing details for test:', testId);
// Implement navigation to test details
}
function onEditTest(testId: number) {
console.log('Editing test:', testId);
// Implement edit navigation
}
async function onDeleteTest(testId: number) {
console.log('Deleting test:', testId);
// Implement delete logic
await evaluationsStore.deleteById(testId);
toast.showMessage({
title: locale.baseText('generic.deleted'),
type: 'success',
});
}
// Load initial data
async function loadTests() {
isLoading.value = true;
try {
await evaluationsStore.fetchAll();
} finally {
isLoading.value = false;
}
}
// Load tests on mount
void loadTests();
</script>
<template>
<div :class="$style.container">
<div v-if="isLoading" :class="$style.loading">
<n8n-loading :loading="true" :rows="3" />
</div>
<template v-else>
<!-- Empty State -->
<template v-if="!hasTests">
<div :class="$style.header">
<h1>Tests</h1>
<n8n-button type="primary" label="Create new test" @click="onCreateTest" />
</div>
<n8n-action-box
v-if="!hasTests"
heading="Get confidence your workflow is working as expected"
description="Tests run your workflow and compare the results to expected ones. Create your first test from a past execution. More info"
button-text="Choose Execution(s)"
@click:button="onCreateTest"
/>
</template>
<!-- Tests List -->
<div v-else :class="$style.testsList">
<div :class="$style.testsHeader">
<n8n-button size="small" type="tertiary" label="Create new test" @click="onCreateTest" />
</div>
<!-- Test Items -->
<div v-for="test in tests" :key="test.id" :class="$style.testItem">
<div :class="$style.testInfo">
<div :class="$style.testName">
{{ test.name }}
<n8n-tag v-if="test.tagName" :text="test.tagName" />
</div>
<div :class="$style.testCases">
{{ test.testCases }} test case(s)
<n8n-loading v-if="!test.execution.lastRun" :loading="true" :rows="1" />
<span v-else>Ran {{ test.execution.lastRun }}</span>
</div>
</div>
<div :class="$style.metrics">
<div :class="$style.metric">Error rate: {{ test.execution.errorRate ?? '-' }}</div>
<div :class="$style.metric">Metric 1: {{ test.execution.metrics.metric1 ?? '-' }}</div>
<div :class="$style.metric">Metric 2: {{ test.execution.metrics.metric2 ?? '-' }}</div>
<div :class="$style.metric">Metric 3: {{ test.execution.metrics.metric3 ?? '-' }}</div>
</div>
<div :class="$style.actions">
<n8n-icon-button icon="play" type="tertiary" size="small" @click="onRunTest(test.id)" />
<n8n-icon-button
icon="list"
type="tertiary"
size="small"
@click="onViewDetails(test.id)"
/>
<n8n-icon-button icon="pen" type="tertiary" size="small" @click="onEditTest(test.id)" />
<n8n-icon-button
icon="trash"
type="tertiary"
size="small"
@click="onDeleteTest(test.id)"
/>
</div>
</div>
</div>
</template>
</div>
</template>
<style module lang="scss">
.container {
padding: var(--spacing-xl) var(--spacing-l);
height: 100%;
width: 100%;
max-width: var(--content-container-width);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
h1 {
margin: 0;
}
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
.testsList {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
}
.testsHeader {
margin-bottom: var(--spacing-m);
}
.testItem {
display: flex;
align-items: center;
padding: var(--spacing-s) var(--spacing-m);
border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-base);
background-color: var(--color-background-light);
&:hover {
background-color: var(--color-background-base);
}
}
.testInfo {
flex: 1;
min-width: 0;
}
.testName {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
font-weight: var(--font-weight-bold);
margin-bottom: var(--spacing-4xs);
}
.testCases {
color: var(--color-text-base);
font-size: var(--font-size-2xs);
display: flex;
align-items: center;
gap: var(--spacing-2xs);
}
.metrics {
display: flex;
gap: var(--spacing-l);
margin: 0 var(--spacing-l);
}
.metric {
font-size: var(--font-size-2xs);
color: var(--color-text-base);
white-space: nowrap;
}
.actions {
display: flex;
gap: var(--spacing-4xs);
}
</style>

View file

@ -0,0 +1,318 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useEvaluationForm } from './composables/useEvaluationForm';
import { useRouter } from 'vue-router';
import { VIEWS } from '@/constants';
import { useToast } from '@/composables/useToast';
const props = defineProps<{
testId?: number;
}>();
const router = useRouter();
const toast = useToast();
const {
state,
isEditing,
isLoading,
isSaving,
allTags,
tagsById,
init,
saveTest,
startEditing,
saveChanges,
cancelEditing,
handleKeydown,
updateMetrics,
onTagUpdate,
onWorkflowUpdate,
} = useEvaluationForm(props.testId);
// Help texts
const helpText = computed(
() => 'Executions with this tag will be added as test cases to this test.',
);
const workflowHelpText = computed(() => 'This workflow will be called once for each test case.');
const metricsHelpText = computed(
() =>
'The output field of the last node in the evaluation workflow. Metrics will be averaged across all test cases.',
);
function getTagName(tagId: string) {
return tagsById.value[tagId]?.name ?? '';
}
onMounted(() => {
void init();
});
// Utility functions specific to the UI
function addNewMetric() {
updateMetrics([...state.value.metrics, '']);
}
function updateMetric(index: number, value: string) {
const newMetrics = [...state.value.metrics];
newMetrics[index] = value;
updateMetrics(newMetrics);
}
async function onSaveTest() {
try {
await saveTest();
toast.showMessage({ title: 'Test saved', type: 'success' });
void router.push({ name: VIEWS.WORKFLOW_EVALUATION });
} catch (e: unknown) {
toast.showError(e, 'Failed to save test');
}
}
</script>
<template>
<div :class="$style.container">
<div :class="$style.header">
<n8n-icon-button
icon="arrow-left"
:class="$style.backButton"
type="tertiary"
:title="$locale.baseText('common.back')"
@click="$router.back()"
/>
<h2 :class="$style.title">
<template v-if="!state.name.isEditing">
{{ state.name.value }}
<n8n-icon-button
:class="$style.editInputButton"
icon="pen"
type="tertiary"
@click="startEditing('name')"
/>
</template>
<N8nInput
v-else
ref="nameInput"
v-model="state.name.tempValue"
type="text"
:placeholder="$locale.baseText('common.name')"
@blur="() => saveChanges('name')"
@keydown="(e) => handleKeydown(e, 'name')"
/>
</h2>
</div>
<!-- Description -->
<div :class="[$style.formGroup, $style.metrics]">
<n8n-input-label label="Description" :bold="false" size="small" :class="$style.metricField">
<N8nInput
v-model="state.description"
type="textarea"
:placeholder="'Enter evaluation description'"
/>
</n8n-input-label>
</div>
<!-- Tags -->
<div :class="$style.formGroup">
<n8n-input-label label="Tag name" :bold="false" size="small">
<div v-if="!state.tags.isEditing" :class="$style.tagsRead" @click="startEditing('tags')">
<n8n-text v-if="state.tags.appliedTagIds.length === 0" size="small"
>Select tag...</n8n-text
>
<n8n-tag
v-for="tagId in state.tags.appliedTagIds"
:key="tagId"
:text="getTagName(tagId)"
/>
<n8n-icon-button
:class="$style.editInputButton"
icon="pen"
type="tertiary"
size="small"
transparent
/>
</div>
<TagsDropdown
v-else
ref="tagsInput"
:model-value="state.tags.appliedTagIds"
:placeholder="$locale.baseText('executionAnnotationView.chooseOrCreateATag')"
:create-enabled="false"
:all-tags="allTags"
:is-loading="isLoading"
:tags-by-id="tagsById"
class="tags-edit"
data-test-id="workflow-tags-dropdown"
@update:model-value="onTagUpdate"
@esc="cancelEditing('tags')"
@blur="saveChanges('tags')"
/>
</n8n-input-label>
<n8n-text size="small" color="text-light">{{ helpText }}</n8n-text>
</div>
<!-- Evaluation Workflow -->
<div :class="$style.formGroup">
<n8n-input-label label="Evaluation workflow" :bold="false" size="small">
<WorkflowSelectorParameterInput
ref="workflowInput"
:parameter="{
displayName: 'Workflow',
name: 'workflowId',
type: 'workflowSelector',
default: '',
}"
:model-value="state.evaluationWorkflow"
:display-title="'Evaluation Workflow'"
:is-value-expression="false"
:expression-edit-dialog-visible="false"
:path="'workflows'"
allow-new
@update:model-value="onWorkflowUpdate"
/>
</n8n-input-label>
<n8n-text size="small" color="text-light">
{{ workflowHelpText }}
</n8n-text>
</div>
<!-- Metrics -->
<div :class="[$style.formGroup, $style.metrics]">
<n8n-text color="text-dark"> Metrics </n8n-text>
<hr :class="$style.metricsDivider" />
<n8n-text size="small" color="text-light">
{{ metricsHelpText }}
</n8n-text>
<n8n-input-label
label="Output field(s)"
:bold="false"
size="small"
:class="$style.metricField"
>
<div :class="$style.metricsContainer">
<div v-for="(metric, index) in state.metrics" :key="index">
<N8nInput
:ref="`metric_${index}`"
:model-value="metric"
:placeholder="'Enter metric name'"
@update:model-value="(value: string) => updateMetric(index, value)"
/>
</div>
<n8n-button
type="tertiary"
:label="'New metric'"
:class="$style.newMetricButton"
@click="addNewMetric"
/>
</div>
</n8n-input-label>
</div>
<!-- Save Test Button -->
<div :class="$style.footer">
<n8n-button
type="primary"
:label="isEditing ? 'Update Test' : 'Save Test'"
:loading="isSaving"
@click="onSaveTest"
/>
</div>
</div>
</template>
<style module lang="scss">
.container {
width: 383px;
height: 100%;
padding: var(--spacing-s);
border-right: 1px solid var(--color-foreground-base);
// border-top-color: transparent;
// border-left-color: transparent;
background: var(--color-background-xlight);
// Pin the container to the left
margin-right: auto;
}
.header {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
margin-bottom: var(--spacing-l);
&:hover {
.editInputButton {
opacity: 1;
}
}
}
.title {
margin: 0;
flex-grow: 1;
font-size: var(--font-size-l);
font-weight: var(--font-weight-bold);
color: var(--color-text-dark);
}
.formGroup {
margin-bottom: var(--spacing-l);
:global(.n8n-input-label) {
margin-bottom: var(--spacing-2xs);
}
}
.readOnlyField {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2xs) var(--spacing-xs);
background-color: var(--color-background-light);
border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-small);
}
.metricsContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.metricField {
width: 100%;
margin-top: var(--spacing-xs);
}
.metricsDivider {
margin-top: var(--spacing-4xs);
margin-bottom: var(--spacing-3xs);
}
.newMetricButton {
align-self: flex-start;
margin-top: var(--spacing-2xs);
width: 100%;
background-color: var(--color-sticky-code-background);
border-color: var(--color-button-secondary-focus-outline);
color: var(--color-button-secondary-font);
}
.footer {
margin-top: var(--spacing-xl);
display: flex;
justify-content: flex-start;
}
.tagsRead {
&:hover .editInputButton {
opacity: 1;
}
}
.editInputButton {
opacity: 0;
border: none;
--button-font-color: var(--prim-gray-490);
}
.backButton {
border: none;
--button-font-color: var(--color-text-light);
}
</style>

View file

@ -0,0 +1,210 @@
import { ref, computed } from 'vue';
import type { ComponentPublicInstance } from 'vue';
import type { INodeParameterResourceLocator } from 'n8n-workflow';
import { useAnnotationTagsStore } from '@/stores/tags.store';
import { useEvaluationsStore } from '@/stores/evaluations.store.ee';
import type AnnotationTagsDropdownEe from '@/components/AnnotationTagsDropdown.ee.vue';
import type { N8nInput } from 'n8n-design-system';
import { VIEWS } from '@/constants';
interface EditableField {
value: string;
isEditing: boolean;
tempValue: string;
}
export interface IEvaluationFormState {
name: EditableField;
description?: string;
tags: {
isEditing: boolean;
appliedTagIds: string[];
};
evaluationWorkflow: INodeParameterResourceLocator;
metrics: string[];
}
type FormRefs = {
nameInput: ComponentPublicInstance<typeof N8nInput>;
tagsInput: ComponentPublicInstance<typeof AnnotationTagsDropdownEe>;
};
export function useEvaluationForm(testId?: number) {
// Stores
const tagsStore = useAnnotationTagsStore();
const evaluationsStore = useEvaluationsStore();
// Form state
const state = ref<IEvaluationFormState>({
description: '',
name: {
value: 'My Test',
isEditing: false,
tempValue: '',
},
tags: {
isEditing: false,
appliedTagIds: [],
},
evaluationWorkflow: {
mode: 'list',
value: '',
__rl: true,
},
metrics: [''],
});
// Loading states
const isSaving = ref(false);
const isLoading = computed(() => tagsStore.isLoading);
// Computed
const isEditing = computed(() => !!testId);
const allTags = computed(() => tagsStore.allTags);
const tagsById = computed(() => tagsStore.tagsById);
// Field refs
const fields = ref<FormRefs>({} as FormRefs);
// Methods
const loadTestData = async () => {
if (!testId) return;
try {
await evaluationsStore.fetchAll();
const testDefinition = evaluationsStore.testDefinitionsById[testId];
if (testDefinition) {
state.value = {
name: {
value: testDefinition.name,
isEditing: false,
tempValue: '',
},
tags: {
isEditing: false,
appliedTagIds: testDefinition.annotationTagId ? [testDefinition.annotationTagId] : [],
},
evaluationWorkflow: {
mode: 'list',
value: testDefinition.evaluationWorkflowId || '',
__rl: true,
},
metrics: [''],
};
}
} catch (error) {
console.error('Failed to load test data', error);
}
};
const saveTest = async () => {
console.log('Saving Test');
if (isSaving.value) return;
isSaving.value = true;
try {
const params = {
name: state.value.name.value,
...(state.value.tags.appliedTagIds[0] && {
annotationTagId: state.value.tags.appliedTagIds[0],
}),
...(state.value.evaluationWorkflow.value && {
evaluationWorkflowId: state.value.evaluationWorkflow.value as string,
}),
};
console.log('Saving Test with params', params, 'isEditing', isEditing.value);
if (isEditing.value && testId) {
await evaluationsStore.update({
id: testId,
...params,
});
} else {
await evaluationsStore.create({
...params,
workflowId: state.value.evaluationWorkflow.value as string,
});
}
} catch (e) {
console.error(e);
throw e;
} finally {
isSaving.value = false;
}
};
const startEditing = async (field: 'name' | 'tags') => {
if (field === 'name') {
state.value.name.tempValue = state.value.name.value;
state.value.name.isEditing = true;
} else {
state.value.tags.isEditing = true;
}
};
const saveChanges = (field: 'name' | 'tags') => {
if (field === 'name') {
state.value.name.value = state.value.name.tempValue;
state.value.name.isEditing = false;
} else {
state.value.tags.isEditing = false;
}
};
const cancelEditing = (field: 'name' | 'tags') => {
if (field === 'name') {
state.value.name.isEditing = false;
} else {
state.value.tags.isEditing = false;
}
};
const handleKeydown = (event: KeyboardEvent, field: 'name' | 'tags') => {
if (event.key === 'Escape') {
cancelEditing(field);
} else if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
saveChanges(field);
}
};
const updateMetrics = (metrics: string[]) => {
state.value.metrics = metrics;
};
const onTagUpdate = (tags: string[]) => {
state.value.tags.appliedTagIds = tags[0] ? [tags[0]] : [];
};
const onWorkflowUpdate = (value: INodeParameterResourceLocator) => {
state.value.evaluationWorkflow = value;
};
// Initialize
const init = async () => {
await tagsStore.fetchAll();
if (testId) {
await loadTestData();
}
};
return {
state,
fields,
isEditing,
isLoading,
isSaving,
allTags,
tagsById,
init,
saveTest,
startEditing,
saveChanges,
cancelEditing,
handleKeydown,
updateMetrics,
onTagUpdate,
onWorkflowUpdate,
};
}