mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-15 00:54:06 -08:00
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:
parent
03542bfb83
commit
69f859b9ea
91
packages/editor-ui/src/api/evaluations.ee.ts
Normal file
91
packages/editor-ui/src/api/evaluations.ee.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
145
packages/editor-ui/src/stores/evaluations.store.ee.ts
Normal file
145
packages/editor-ui/src/stores/evaluations.store.ee.ts
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
|
@ -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>
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
Loading…
Reference in a new issue