mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-14 16:44:07 -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 { tryToParseNumber } from '@/utils/typesUtils';
|
||||
import { projectsRoutes } from '@/routes/projects.routes';
|
||||
import WorkflowEvaluationView from './views/WorkflowEvaluationView.vue';
|
||||
import NewWorkflowEvaluationView from './views/NewWorkflowEvaluationView.vue';
|
||||
import ListEvaluations from './views/WorkflowEvaluation/ListEvaluations.vue';
|
||||
import NewEvaluation from './views/WorkflowEvaluation/NewEvaluation.vue';
|
||||
|
||||
const ChangePasswordView = async () => await import('./views/ChangePasswordView.vue');
|
||||
const ErrorView = async () => await import('./views/ErrorView.vue');
|
||||
|
@ -254,7 +254,7 @@ export const routes: RouteRecordRaw[] = [
|
|||
path: '/workflow/:name/evaluation',
|
||||
name: VIEWS.WORKFLOW_EVALUATION,
|
||||
components: {
|
||||
default: WorkflowEvaluationView,
|
||||
default: ListEvaluations,
|
||||
header: MainHeader,
|
||||
sidebar: MainSidebar,
|
||||
},
|
||||
|
@ -291,7 +291,7 @@ export const routes: RouteRecordRaw[] = [
|
|||
path: '/workflow/:name/evaluation/new',
|
||||
name: VIEWS.NEW_WORKFLOW_EVALUATION,
|
||||
components: {
|
||||
default: NewWorkflowEvaluationView,
|
||||
default: NewEvaluation,
|
||||
header: MainHeader,
|
||||
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