From 69f859b9ea72ae9b1e72d3f784c88c92f8b9a682 Mon Sep 17 00:00:00 2001 From: Oleg Ivaniv Date: Tue, 12 Nov 2024 11:27:02 +0100 Subject: [PATCH] Add test definitions API and store - Created evaluations API endpoints - Implemented evaluations store - Updated NewWorkflowEvaluationView - RenamedRun Test to Save Test --- packages/editor-ui/src/api/evaluations.ee.ts | 91 ++++ packages/editor-ui/src/router.ts | 8 +- .../src/stores/evaluations.store.ee.ts | 145 +++++++ .../src/views/NewWorkflowEvaluationView.vue | 405 ------------------ .../WorkflowEvaluation/ListEvaluations.vue | 271 ++++++++++++ .../WorkflowEvaluation/NewEvaluation.vue | 318 ++++++++++++++ .../composables/useEvaluationForm.ts | 210 +++++++++ 7 files changed, 1039 insertions(+), 409 deletions(-) create mode 100644 packages/editor-ui/src/api/evaluations.ee.ts create mode 100644 packages/editor-ui/src/stores/evaluations.store.ee.ts delete mode 100644 packages/editor-ui/src/views/NewWorkflowEvaluationView.vue create mode 100644 packages/editor-ui/src/views/WorkflowEvaluation/ListEvaluations.vue create mode 100644 packages/editor-ui/src/views/WorkflowEvaluation/NewEvaluation.vue create mode 100644 packages/editor-ui/src/views/WorkflowEvaluation/composables/useEvaluationForm.ts diff --git a/packages/editor-ui/src/api/evaluations.ee.ts b/packages/editor-ui/src/api/evaluations.ee.ts new file mode 100644 index 0000000000..0e88152731 --- /dev/null +++ b/packages/editor-ui/src/api/evaluations.ee.ts @@ -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 & + Partial>; + +// All fields optional except ID +export type UpdateTestDefinitionParams = Partial< + Pick +>; + +// 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; + + createTestDefinition: ( + context: IRestApiContext, + params: CreateTestDefinitionParams, + ) => Promise; + + updateTestDefinition: ( + context: IRestApiContext, + id: number, + params: UpdateTestDefinitionParams, + ) => Promise; + + 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 => { + return await makeRestApiRequest(context, 'GET', endpoint, options); + }, + + getTestDefinition: async (context: IRestApiContext, id: number): Promise => { + return await makeRestApiRequest(context, 'GET', `${endpoint}/${id}`); + }, + + createTestDefinition: async ( + context: IRestApiContext, + params: CreateTestDefinitionParams, + ): Promise => { + return await makeRestApiRequest(context, 'POST', endpoint, params); + }, + + updateTestDefinition: async ( + context: IRestApiContext, + id: number, + params: UpdateTestDefinitionParams, + ): Promise => { + return await makeRestApiRequest(context, 'PATCH', `${endpoint}/${id}`, params); + }, + + deleteTestDefinition: async ( + context: IRestApiContext, + id: number, + ): Promise<{ success: boolean }> => { + return await makeRestApiRequest(context, 'DELETE', `${endpoint}/${id}`); + }, + }; +} diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index a64c36819d..2de320ac7d 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -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, }, diff --git a/packages/editor-ui/src/stores/evaluations.store.ee.ts b/packages/editor-ui/src/stores/evaluations.store.ee.ts new file mode 100644 index 0000000000..fdea0ac845 --- /dev/null +++ b/packages/editor-ui/src/stores/evaluations.store.ee.ts @@ -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>({}); + 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, 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, + }; + }, + {}, +); diff --git a/packages/editor-ui/src/views/NewWorkflowEvaluationView.vue b/packages/editor-ui/src/views/NewWorkflowEvaluationView.vue deleted file mode 100644 index 89b57be360..0000000000 --- a/packages/editor-ui/src/views/NewWorkflowEvaluationView.vue +++ /dev/null @@ -1,405 +0,0 @@ - - - - diff --git a/packages/editor-ui/src/views/WorkflowEvaluation/ListEvaluations.vue b/packages/editor-ui/src/views/WorkflowEvaluation/ListEvaluations.vue new file mode 100644 index 0000000000..a946257edf --- /dev/null +++ b/packages/editor-ui/src/views/WorkflowEvaluation/ListEvaluations.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/packages/editor-ui/src/views/WorkflowEvaluation/NewEvaluation.vue b/packages/editor-ui/src/views/WorkflowEvaluation/NewEvaluation.vue new file mode 100644 index 0000000000..7f2f07fb91 --- /dev/null +++ b/packages/editor-ui/src/views/WorkflowEvaluation/NewEvaluation.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/packages/editor-ui/src/views/WorkflowEvaluation/composables/useEvaluationForm.ts b/packages/editor-ui/src/views/WorkflowEvaluation/composables/useEvaluationForm.ts new file mode 100644 index 0000000000..bb45676461 --- /dev/null +++ b/packages/editor-ui/src/views/WorkflowEvaluation/composables/useEvaluationForm.ts @@ -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; + tagsInput: ComponentPublicInstance; +}; + +export function useEvaluationForm(testId?: number) { + // Stores + const tagsStore = useAnnotationTagsStore(); + const evaluationsStore = useEvaluationsStore(); + + // Form state + const state = ref({ + 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({} 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, + }; +}