feat(editor-ui): Add test metrics management and simplify tags input

- Add API endpoints for test metrics CRUD operations
- Implement metrics input component with add/delete functionality
- Simplify tags input component to use single tag selection
This commit is contained in:
Oleg Ivaniv 2024-12-04 09:01:02 +01:00
parent 5e97cfbcd3
commit b247103e38
No known key found for this signature in database
8 changed files with 304 additions and 122 deletions

View file

@ -35,6 +35,7 @@ export interface UpdateTestResponse {
}
const endpoint = '/evaluation/test-definitions';
const getMetricsEndpoint = (testDefinitionId: string) => `${endpoint}/${testDefinitionId}/metrics`;
export async function getTestDefinitions(context: IRestApiContext) {
return await makeRestApiRequest<{ count: number; testDefinitions: TestDefinitionRecord[] }>(
@ -71,3 +72,84 @@ export async function updateTestDefinition(
export async function deleteTestDefinition(context: IRestApiContext, id: string) {
return await makeRestApiRequest<{ success: boolean }>(context, 'DELETE', `${endpoint}/${id}`);
}
// Metrics
export interface TestMetricRecord {
id: string;
name: string;
testDefinitionId: string;
createdAt?: string;
updatedAt?: string;
}
export interface CreateTestMetricParams {
testDefinitionId: string;
name: string;
}
export interface UpdateTestMetricParams {
name: string;
id: string;
testDefinitionId: string;
}
export interface DeleteTestMetricParams {
testDefinitionId: string;
id: string;
}
export const getTestMetrics = async (context: IRestApiContext, testDefinitionId: string) => {
return await makeRestApiRequest<TestMetricRecord[]>(
context,
'GET',
getMetricsEndpoint(testDefinitionId),
);
};
export const getTestMetric = async (
context: IRestApiContext,
testDefinitionId: string,
id: string,
) => {
return await makeRestApiRequest<TestMetricRecord>(
context,
'GET',
`${getMetricsEndpoint(testDefinitionId)}/${id}`,
);
};
export const createTestMetric = async (
context: IRestApiContext,
params: CreateTestMetricParams,
) => {
return await makeRestApiRequest<TestMetricRecord>(
context,
'POST',
getMetricsEndpoint(params.testDefinitionId),
{ name: params.name },
);
};
export const updateTestMetric = async (
context: IRestApiContext,
params: UpdateTestMetricParams,
) => {
return await makeRestApiRequest<TestMetricRecord>(
context,
'PATCH',
`${getMetricsEndpoint(params.testDefinitionId)}/${params.id}`,
{ name: params.name },
);
};
export const deleteTestMetric = async (
context: IRestApiContext,
params: DeleteTestMetricParams,
) => {
return await makeRestApiRequest(
context,
'DELETE',
`${getMetricsEndpoint(params.testDefinitionId)}/${params.id}`,
);
};

View file

@ -1,22 +1,30 @@
<script setup lang="ts">
import type { TestMetricRecord } from '@/api/testDefinition.ee';
import { useI18n } from '@/composables/useI18n';
export interface MetricsInputProps {
modelValue: string[];
modelValue: Array<Partial<TestMetricRecord>>;
}
const props = defineProps<MetricsInputProps>();
const emit = defineEmits<{ 'update:modelValue': [value: MetricsInputProps['modelValue']] }>();
const emit = defineEmits<{
'update:modelValue': [value: MetricsInputProps['modelValue']];
deleteMetric: [metric: Partial<TestMetricRecord>];
}>();
const locale = useI18n();
function addNewMetric() {
emit('update:modelValue', [...props.modelValue, '']);
emit('update:modelValue', [...props.modelValue, { name: '' }]);
}
function updateMetric(index: number, value: string) {
function updateMetric(index: number, name: string) {
const newMetrics = [...props.modelValue];
newMetrics[index] = value;
newMetrics[index].name = name;
emit('update:modelValue', newMetrics);
}
function onDeleteMetric(metric: Partial<TestMetricRecord>) {
emit('deleteMetric', metric);
}
</script>
<template>
@ -27,14 +35,26 @@ function updateMetric(index: number, value: string) {
:class="$style.metricField"
>
<div :class="$style.metricsContainer">
<div v-for="(metric, index) in modelValue" :key="index">
<div v-for="(metric, index) in modelValue" :key="index" :class="$style.metricItem">
<N8nInput
:ref="`metric_${index}`"
data-test-id="evaluation-metric-item"
:model-value="metric"
:model-value="metric.name"
:placeholder="locale.baseText('testDefinition.edit.metricsPlaceholder')"
@update:model-value="(value: string) => updateMetric(index, value)"
/>
<n8n-icon-button
:class="{ [$style.sendButton]: true }"
icon="trash"
type="text"
@click="onDeleteMetric(metric)"
/>
<!-- <n8n-button
type="tertiary"
:label="locale.baseText('testDefinition.edit.deleteMetric')"
:class="$style.newMetricButton"
@click="onDeleteMetric(metric)"
/> -->
</div>
<n8n-button
type="tertiary"
@ -54,6 +74,11 @@ function updateMetric(index: number, value: string) {
gap: var(--spacing-xs);
}
.metricItem {
display: flex;
align-items: center;
}
.metricField {
width: 100%;
margin-top: var(--spacing-xs);

View file

@ -1,89 +1,32 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { useMessage } from '@/composables/useMessage';
import type { ITag } from '@/Interface';
import { createEventBus } from 'n8n-design-system';
import { computed } from 'vue';
export interface TagsInputProps {
modelValue?: {
isEditing: boolean;
appliedTagIds: string[];
};
allTags: ITag[];
tagsById: Record<string, ITag>;
isLoading: boolean;
startEditing: (field: string) => void;
saveChanges: (field: string) => void;
cancelEditing: (field: string) => void;
selectedTags: ITag[];
}
const props = withDefaults(defineProps<TagsInputProps>(), {
modelValue: () => ({
isEditing: false,
appliedTagIds: [],
}),
selectedTags: () => [],
});
const emit = defineEmits<{ 'update:modelValue': [value: TagsInputProps['modelValue']] }>();
const locale = useI18n();
const tagsEventBus = createEventBus();
const getTagName = computed(() => (tagId: string) => {
return props.tagsById[tagId]?.name ?? '';
});
function updateTags(tags: string[]) {
const newTags = tags[0] ? [tags[0]] : [];
emit('update:modelValue', {
...props.modelValue,
appliedTagIds: newTags,
});
}
const emit = defineEmits<{
addTag: [];
}>();
</script>
<template>
<div data-test-id="workflow-tags-field">
<n8n-input-label
:label="locale.baseText('testDefinition.edit.tagName')"
:bold="false"
size="small"
>
<div v-if="!modelValue.isEditing" :class="$style.tagsRead" @click="startEditing('tags')">
<n8n-text v-if="modelValue.appliedTagIds.length === 0" size="small">
{{ locale.baseText('testDefinition.edit.selectTag') }}
</n8n-text>
<n8n-tag
v-for="tagId in modelValue.appliedTagIds"
:key="tagId"
:text="getTagName(tagId)"
data-test-id="evaluation-tag-field"
/>
<n8n-icon-button
:class="$style.editInputButton"
icon="pen"
type="tertiary"
size="small"
transparent
/>
<n8n-input-label :bold="false" size="small">
<div :class="$style.tagsRead">
Use all executions that are tagged with {{ selectedTags[0]?.name }}
<br />
<br />
<n8n-button type="primary" size="small" transparent @click="emit('addTag')"
>Select Executions</n8n-button
>
</div>
<TagsDropdown
v-else
:model-value="modelValue.appliedTagIds"
:placeholder="locale.baseText('executionAnnotationView.chooseOrCreateATag')"
:create-enabled="false"
:all-tags="allTags"
:is-loading="isLoading"
:tags-by-id="tagsById"
data-test-id="workflow-tags-dropdown"
:event-bus="tagsEventBus"
@update:model-value="updateTags"
@esc="cancelEditing('tags')"
@blur="saveChanges('tags')"
/>
</n8n-input-label>
<n8n-text size="small" color="text-light">{{
locale.baseText('testDefinition.edit.tagsHelpText')
}}</n8n-text>
</div>
</template>

View file

@ -4,7 +4,10 @@ import type { INodeParameterResourceLocator } from 'n8n-workflow';
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';
import type { TestMetricRecord, UpdateTestDefinitionParams } from '@/api/testDefinition.ee';
import type { ITag } from '@/Interface';
import { useAnnotationTagsStore } from '@/stores/tags.store';
// import type { TestMetricRecord } from '@/api/testMetric.ee';
interface EditableField {
value: string;
@ -15,12 +18,9 @@ interface EditableField {
export interface IEvaluationFormState {
name: EditableField;
description: string;
tags: {
isEditing: boolean;
appliedTagIds: string[];
};
tags: ITag[];
evaluationWorkflow: INodeParameterResourceLocator;
metrics: string[];
metrics: TestMetricRecord[];
}
type FormRefs = {
@ -31,25 +31,23 @@ type FormRefs = {
export function useTestDefinitionForm() {
// Stores
const evaluationsStore = useTestDefinitionStore();
const tagsStore = useAnnotationTagsStore();
// Form state
const state = ref<IEvaluationFormState>({
description: '',
name: {
value: `My Test [${new Date().toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' })}]`,
value: `My Test ${evaluationsStore.allTestDefinitions.length + 1}`,
isEditing: false,
tempValue: '',
},
tags: {
isEditing: false,
appliedTagIds: [],
},
tags: [],
evaluationWorkflow: {
mode: 'list',
value: '',
__rl: true,
},
metrics: [''],
metrics: [],
});
// Loading states
@ -59,6 +57,9 @@ export function useTestDefinitionForm() {
// Field refs
const fields = ref<FormRefs>({} as FormRefs);
const tagIdToITag = (tagId: string) => {
return tagsStore.tagsById[tagId];
};
// Methods
const loadTestData = async (testId: string) => {
try {
@ -66,6 +67,10 @@ export function useTestDefinitionForm() {
const testDefinition = evaluationsStore.testDefinitionsById[testId];
if (testDefinition) {
// Fetch metrics for this test definition
const metrics = await evaluationsStore.fetchMetrics(testId);
console.log('Loaded metrics:', metrics);
state.value = {
description: testDefinition.description ?? '',
name: {
@ -73,20 +78,16 @@ export function useTestDefinitionForm() {
isEditing: false,
tempValue: '',
},
tags: {
isEditing: false,
appliedTagIds: testDefinition.annotationTagId ? [testDefinition.annotationTagId] : [],
},
tags: testDefinition.annotationTagId ? [tagIdToITag(testDefinition.annotationTagId)] : [],
evaluationWorkflow: {
mode: 'list',
value: testDefinition.evaluationWorkflowId ?? '',
__rl: true,
},
metrics: [''],
metrics, // Use the fetched metrics
};
}
} catch (error) {
// TODO: Throw better errors
console.error('Failed to load test data', error);
}
};
@ -114,6 +115,33 @@ export function useTestDefinitionForm() {
}
};
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 updatePromises = 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(updatePromises);
};
const updateTest = async (testId: string) => {
if (isSaving.value) return;
@ -135,12 +163,14 @@ export function useTestDefinitionForm() {
params.evaluationWorkflowId = state.value.evaluationWorkflow.value.toString();
}
const annotationTagId = state.value.tags.appliedTagIds[0];
const annotationTagId = state.value.tags?.[0]?.id;
if (annotationTagId) {
params.annotationTagId = annotationTagId;
}
// Update the existing test
return await evaluationsStore.update({ ...params, id: testId });
const updatedTest = await evaluationsStore.update({ ...params, id: testId });
return updatedTest;
} catch (error) {
throw error;
} finally {
@ -152,8 +182,6 @@ export function useTestDefinitionForm() {
if (field === 'name') {
state.value.name.tempValue = state.value.name.value;
state.value.name.isEditing = true;
} else {
state.value.tags.isEditing = true;
}
};
@ -161,8 +189,6 @@ export function useTestDefinitionForm() {
if (field === 'name') {
state.value.name.value = state.value.name.tempValue;
state.value.name.isEditing = false;
} else {
state.value.tags.isEditing = false;
}
};
@ -170,8 +196,6 @@ export function useTestDefinitionForm() {
if (field === 'name') {
state.value.name.isEditing = false;
state.value.name.tempValue = '';
} else {
state.value.tags.isEditing = false;
}
};
@ -189,6 +213,8 @@ export function useTestDefinitionForm() {
fields,
isSaving: computed(() => isSaving.value),
fieldsIssues: computed(() => fieldsIssues.value),
deleteMetric,
updateMetrics,
loadTestData,
createTest,
updateTest,

View file

@ -2741,7 +2741,7 @@
"testDefinition.edit.description": "Description",
"testDefinition.edit.tagName": "Tag name",
"testDefinition.edit.step.intro": "When running a test",
"testDefinition.edit.step.executions": "Fetch 5 past executions",
"testDefinition.edit.step.executions": "Fetch past executions | Fetch {count} past execution | Fetch {count} past executions",
"testDefinition.edit.step.nodes": "Mock nodes",
"testDefinition.edit.step.mockedNodes": "No nodes mocked | {count} node mocked | {count} nodes mocked",
"testDefinition.edit.step.reRunExecutions": "Re-run executions",

View file

@ -153,6 +153,7 @@ export const useExecutionsStore = defineStore('executions', () => {
executionsCount.value = data.count;
executionsCountEstimated.value = data.estimated;
return data;
} catch (e) {
throw e;
} finally {

View file

@ -13,6 +13,7 @@ export const useTestDefinitionStore = defineStore(
const testDefinitionsById = ref<Record<string, TestDefinitionRecord>>({});
const loading = ref(false);
const fetchedAll = ref(false);
const metricsById = ref<Record<string, testDefinitionsApi.TestMetricRecord>>({});
// Store instances
const posthogStore = usePostHog();
@ -34,6 +35,12 @@ export const useTestDefinitionStore = defineStore(
const hasTestDefinitions = computed(() => Object.keys(testDefinitionsById.value).length > 0);
const getMetricsByTestId = computed(() => (testId: string) => {
return Object.values(metricsById.value).filter(
(metric) => metric.testDefinitionId === testId,
);
});
// Methods
const setAllTestDefinitions = (definitions: TestDefinitionRecord[]) => {
testDefinitionsById.value = definitions.reduce(
@ -147,6 +154,44 @@ export const useTestDefinitionStore = defineStore(
return result.success;
};
const fetchMetrics = async (testId: string) => {
loading.value = true;
try {
const metrics = await testDefinitionsApi.getTestMetrics(rootStore.restApiContext, testId);
metrics.forEach((metric) => {
metricsById.value[metric.id] = metric;
});
return metrics;
} finally {
loading.value = false;
}
};
const createMetric = async (params: {
name: string;
testDefinitionId: string;
}): Promise<testDefinitionsApi.TestMetricRecord> => {
const metric = await testDefinitionsApi.createTestMetric(rootStore.restApiContext, params);
metricsById.value[metric.id] = metric;
return metric;
};
const updateMetric = async (
params: testDefinitionsApi.TestMetricRecord,
): Promise<testDefinitionsApi.TestMetricRecord> => {
const metric = await testDefinitionsApi.updateTestMetric(rootStore.restApiContext, params);
metricsById.value[metric.id] = metric;
return metric;
};
const deleteMetric = async (
params: testDefinitionsApi.DeleteTestMetricParams,
): Promise<void> => {
await testDefinitionsApi.deleteTestMetric(rootStore.restApiContext, params);
const { [params.id]: deleted, ...rest } = metricsById.value;
metricsById.value = rest;
};
return {
// State
fetchedAll,
@ -157,6 +202,8 @@ export const useTestDefinitionStore = defineStore(
isLoading,
hasTestDefinitions,
isFeatureEnabled,
metricsById,
getMetricsByTestId,
// Methods
fetchAll,
@ -165,6 +212,10 @@ export const useTestDefinitionStore = defineStore(
deleteById,
upsertTestDefinitions,
deleteTestDefinition,
fetchMetrics,
createMetric,
updateMetric,
deleteMetric,
};
},
{},

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { VIEWS } from '@/constants';
import { useToast } from '@/composables/useToast';
@ -14,6 +14,10 @@ import EvaluationStep from '@/components/TestDefinition/EditDefinition/Evaluatio
import TagsInput from '@/components/TestDefinition/EditDefinition/TagsInput.vue';
import WorkflowSelector from '@/components/TestDefinition/EditDefinition/WorkflowSelector.vue';
import MetricsInput from '@/components/TestDefinition/EditDefinition/MetricsInput.vue';
import type { TestMetricRecord } from '@/api/testDefinition.ee';
import { useMessage } from '@/composables/useMessage';
import { useExecutionsStore } from '@/stores/executions.store';
import { IExecutionsListResponse } from '@/Interface';
const props = defineProps<{
testId?: string;
@ -24,16 +28,20 @@ const route = useRoute();
const locale = useI18n();
const { debounce } = useDebounce();
const toast = useToast();
const { isLoading, allTags, tagsById, fetchAll } = useAnnotationTagsStore();
const { isLoading, allTags, tagsById, fetchAll, create: createTag } = useAnnotationTagsStore();
const { fetchExecutions } = useExecutionsStore();
const testId = computed(() => props.testId ?? (route.params.testId as string));
const currentWorkflowId = computed(() => route.params.name as string);
const buttonLabel = computed(() =>
testId.value
? locale.baseText('testDefinition.edit.updateTest')
: locale.baseText('testDefinition.edit.saveTest'),
);
const matchedExecutions = ref<IExecutionsListResponse['results']>([]);
const {
state,
fieldsIssues,
@ -43,14 +51,18 @@ const {
updateTest,
startEditing,
saveChanges,
cancelEditing,
handleKeydown,
deleteMetric,
updateMetrics,
} = useTestDefinitionForm();
onMounted(async () => {
await fetchAll();
if (testId.value) {
await loadTestData(testId.value);
if (state.value.tags.length > 0) {
await fetchSelectedExecutions();
}
} else {
await onSaveTest();
}
@ -64,7 +76,7 @@ async function onSaveTest() {
} else {
savedTest = await createTest(currentWorkflowId.value);
}
if (savedTest && route.name === VIEWS.TEST_DEFINITION_EDIT) {
if (savedTest && route.name === VIEWS.NEW_TEST_DEFINITION) {
await router.replace({
name: VIEWS.TEST_DEFINITION_EDIT,
params: { testId: savedTest.id },
@ -83,7 +95,50 @@ function hasIssues(key: string) {
return fieldsIssues.value.some((issue) => issue.field === key);
}
watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: true });
async function onDeleteMetric(deletedMetric: Partial<TestMetricRecord>) {
if (deletedMetric.id) {
await deleteMetric(deletedMetric.id, testId.value);
}
}
async function fetchSelectedExecutions() {
const executionsForTags = await fetchExecutions({
annotationTags: state.value.tags.map((tag) => tag.id),
});
matchedExecutions.value = executionsForTags.results;
}
async function onAddTag() {
const currentTags = state.value.tags;
if (currentTags.length === 0) {
const { prompt } = useMessage();
const tagName = await prompt('Enter tag name');
const newTag = await createTag(tagName.value);
state.value.tags = [newTag];
console.log('🚀 ~ onSelectExecutions ~ newWindow:', newTag);
}
const newWindow = window.open(`/workflow/${currentWorkflowId.value}/executions`, '_blank');
if (newWindow) {
newWindow.onload = () =>
(newWindow.onbeforeunload = async () => {
await fetchSelectedExecutions();
});
}
}
watch(
[() => state.value.name, () => state.value.description, () => state.value.evaluationWorkflow],
debounce(onSaveTest, { debounceTime: 400 }),
{ deep: true },
);
watch(
[() => state.value.metrics],
debounce(async () => await updateMetrics(testId.value), { debounceTime: 400 }),
{ deep: true },
);
</script>
<template>
@ -113,20 +168,15 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
<div :class="$style.panelBlock">
<EvaluationStep
:class="$style.step"
:title="locale.baseText('testDefinition.edit.step.executions')"
:title="
locale.baseText('testDefinition.edit.step.executions', {
adjustToNumber: matchedExecutions.length,
})
"
>
<template #icon><font-awesome-icon icon="history" size="lg" /></template>
<template #cardContent>
<TagsInput
v-model="state.tags"
:class="{ 'has-issues': hasIssues('tags') }"
:all-tags="allTags"
:tags-by-id="tagsById"
:is-loading="isLoading"
:start-editing="startEditing"
:save-changes="saveChanges"
:cancel-editing="cancelEditing"
/>
<TagsInput v-model="state.tags" :selected-tags="state.tags" @add-tag="onAddTag" />
</template>
</EvaluationStep>
<div :class="$style.evaluationArrows">
@ -172,7 +222,11 @@ watch(() => state.value, debounce(onSaveTest, { debounceTime: 400 }), { deep: tr
>
<template #icon><font-awesome-icon icon="chart-bar" size="lg" /></template>
<template #cardContent>
<MetricsInput v-model="state.metrics" :class="{ 'has-issues': hasIssues('metrics') }" />
<MetricsInput
v-model="state.metrics"
:class="{ 'has-issues': hasIssues('metrics') }"
@delete-metric="onDeleteMetric"
/>
</template>
</EvaluationStep>
</div>