feat(editor): Add workflow evaluation run views (no-changelog) (#12258)

This commit is contained in:
oleg
2025-01-07 12:52:44 +01:00
committed by GitHub
parent ecabe34705
commit 3d990eb555
41 changed files with 3811 additions and 579 deletions

View File

@@ -1,27 +1,10 @@
import { ref, computed } from 'vue';
import type { ComponentPublicInstance } from 'vue';
import type { INodeParameterResourceLocator } from 'n8n-workflow';
import type { ComponentPublicInstance, ComputedRef } from 'vue';
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
import type AnnotationTagsDropdownEe from '@/components/AnnotationTagsDropdown.ee.vue';
import type { N8nInput } from 'n8n-design-system';
import type { UpdateTestDefinitionParams } from '@/api/testDefinition.ee';
interface EditableField {
value: string;
isEditing: boolean;
tempValue: string;
}
export interface IEvaluationFormState {
name: EditableField;
description: string;
tags: {
isEditing: boolean;
appliedTagIds: string[];
};
evaluationWorkflow: INodeParameterResourceLocator;
metrics: string[];
}
import type { EditableField, EditableFormState, EvaluationFormState } from '../types';
type FormRefs = {
nameInput: ComponentPublicInstance<typeof N8nInput>;
@@ -29,64 +12,75 @@ type FormRefs = {
};
export function useTestDefinitionForm() {
// Stores
const evaluationsStore = useTestDefinitionStore();
// Form state
const state = ref<IEvaluationFormState>({
description: '',
// State initialization
const state = ref<EvaluationFormState>({
name: {
value: `My Test [${new Date().toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' })}]`,
isEditing: false,
value: `My Test ${evaluationsStore.allTestDefinitions.length + 1}`,
tempValue: '',
isEditing: false,
},
tags: {
value: [],
tempValue: [],
isEditing: false,
appliedTagIds: [],
},
description: '',
evaluationWorkflow: {
mode: 'list',
value: '',
__rl: true,
},
metrics: [''],
metrics: [],
mockedNodes: [],
});
// Loading states
const isSaving = ref(false);
const fieldsIssues = ref<Array<{ field: string; message: string }>>([]);
// Field refs
const fields = ref<FormRefs>({} as FormRefs);
// Methods
// A computed mapping of editable fields to their states
// This ensures TS knows the exact type of each field.
const editableFields: ComputedRef<{
name: EditableField<string>;
tags: EditableField<string[]>;
}> = computed(() => ({
name: state.value.name,
tags: state.value.tags,
}));
/**
* Load test data including metrics.
*/
const loadTestData = async (testId: string) => {
try {
await evaluationsStore.fetchAll({ force: true });
const testDefinition = evaluationsStore.testDefinitionsById[testId];
if (testDefinition) {
state.value = {
description: testDefinition.description ?? '',
name: {
value: testDefinition.name ?? '',
isEditing: false,
tempValue: '',
},
tags: {
isEditing: false,
appliedTagIds: testDefinition.annotationTagId ? [testDefinition.annotationTagId] : [],
},
evaluationWorkflow: {
mode: 'list',
value: testDefinition.evaluationWorkflowId ?? '',
__rl: true,
},
metrics: [''],
const metrics = await evaluationsStore.fetchMetrics(testId);
state.value.description = testDefinition.description ?? '';
state.value.name = {
value: testDefinition.name ?? '',
isEditing: false,
tempValue: '',
};
state.value.tags = {
isEditing: false,
value: testDefinition.annotationTagId ? [testDefinition.annotationTagId] : [],
tempValue: [],
};
state.value.evaluationWorkflow = {
mode: 'list',
value: testDefinition.evaluationWorkflowId ?? '',
__rl: true,
};
state.value.metrics = metrics;
state.value.mockedNodes = testDefinition.mockedNodes ?? [];
}
} catch (error) {
// TODO: Throw better errors
console.error('Failed to load test data', error);
}
};
@@ -98,22 +92,43 @@ export function useTestDefinitionForm() {
fieldsIssues.value = [];
try {
// Prepare parameters for creating a new test
const params = {
name: state.value.name.value,
workflowId,
description: state.value.description,
};
const newTest = await evaluationsStore.create(params);
return newTest;
} catch (error) {
throw error;
return await evaluationsStore.create(params);
} finally {
isSaving.value = false;
}
};
const deleteMetric = async (metricId: string, testId: string) => {
await evaluationsStore.deleteMetric({ id: metricId, testDefinitionId: testId });
state.value.metrics = state.value.metrics.filter((metric) => metric.id !== metricId);
};
const updateMetrics = async (testId: string) => {
const promises = state.value.metrics.map(async (metric) => {
if (!metric.name) return;
if (!metric.id) {
const createdMetric = await evaluationsStore.createMetric({
name: metric.name,
testDefinitionId: testId,
});
metric.id = createdMetric.id;
} else {
await evaluationsStore.updateMetric({
name: metric.name,
id: metric.id,
testDefinitionId: testId,
});
}
});
await Promise.all(promises);
};
const updateTest = async (testId: string) => {
if (isSaving.value) return;
@@ -121,74 +136,93 @@ export function useTestDefinitionForm() {
fieldsIssues.value = [];
try {
// Check if the test ID is provided
if (!testId) {
throw new Error('Test ID is required for updating a test');
}
// Prepare parameters for updating the existing test
const params: UpdateTestDefinitionParams = {
name: state.value.name.value,
description: state.value.description,
};
if (state.value.evaluationWorkflow.value) {
params.evaluationWorkflowId = state.value.evaluationWorkflow.value.toString();
}
const annotationTagId = state.value.tags.appliedTagIds[0];
const annotationTagId = state.value.tags.value[0];
if (annotationTagId) {
params.annotationTagId = annotationTagId;
}
// Update the existing test
if (state.value.mockedNodes.length > 0) {
params.mockedNodes = state.value.mockedNodes;
}
return await evaluationsStore.update({ ...params, id: testId });
} catch (error) {
throw error;
} finally {
isSaving.value = false;
}
};
const startEditing = async (field: string) => {
if (field === 'name') {
state.value.name.tempValue = state.value.name.value;
state.value.name.isEditing = true;
} else {
state.value.tags.isEditing = true;
/**
* Start editing an editable field by copying `value` to `tempValue`.
*/
function startEditing<T extends keyof EditableFormState>(field: T) {
const fieldObj = editableFields.value[field];
if (fieldObj.isEditing) {
// Already editing, do nothing
return;
}
};
const saveChanges = (field: string) => {
if (field === 'name') {
state.value.name.value = state.value.name.tempValue;
state.value.name.isEditing = false;
if (Array.isArray(fieldObj.value)) {
fieldObj.tempValue = [...fieldObj.value];
} else {
state.value.tags.isEditing = false;
fieldObj.tempValue = fieldObj.value;
}
};
fieldObj.isEditing = true;
}
/**
* Save changes by copying `tempValue` back into `value`.
*/
function saveChanges<T extends keyof EditableFormState>(field: T) {
const fieldObj = editableFields.value[field];
fieldObj.value = Array.isArray(fieldObj.tempValue)
? [...fieldObj.tempValue]
: fieldObj.tempValue;
fieldObj.isEditing = false;
}
const cancelEditing = (field: string) => {
if (field === 'name') {
state.value.name.isEditing = false;
state.value.name.tempValue = '';
/**
* Cancel editing and revert `tempValue` from `value`.
*/
function cancelEditing<T extends keyof EditableFormState>(field: T) {
const fieldObj = editableFields.value[field];
if (Array.isArray(fieldObj.value)) {
fieldObj.tempValue = [...fieldObj.value];
} else {
state.value.tags.isEditing = false;
fieldObj.tempValue = fieldObj.value;
}
};
fieldObj.isEditing = false;
}
const handleKeydown = (event: KeyboardEvent, field: string) => {
/**
* Handle keyboard events during editing.
*/
function handleKeydown<T extends keyof EditableFormState>(event: KeyboardEvent, field: T) {
if (event.key === 'Escape') {
cancelEditing(field);
} else if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
saveChanges(field);
}
};
}
return {
state,
fields,
isSaving: computed(() => isSaving.value),
fieldsIssues: computed(() => fieldsIssues.value),
deleteMetric,
updateMetrics,
loadTestData,
createTest,
updateTest,