mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-15 00:54:06 -08:00
Refactor workflow evaluation components and improve code organization
• Move components to dedicated files • Create new EvaluationEditView component • Update router configuration • Relocate useEvaluationForm composable • Add new types for evaluations
This commit is contained in:
parent
49bd5c6acc
commit
fd94fe3ce4
|
@ -0,0 +1,39 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
modelValue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: 'Change me Description Default',
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits<{ 'update:modelValue': [value: string] }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="[$style.formGroup, $style.description]">
|
||||||
|
<n8n-input-label label="Description" :bold="false" size="small" :class="$style.field">
|
||||||
|
<N8nInput
|
||||||
|
:model-value="modelValue"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="Enter evaluation description"
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)"
|
||||||
|
/>
|
||||||
|
</n8n-input-label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.formGroup {
|
||||||
|
margin-bottom: var(--spacing-l);
|
||||||
|
|
||||||
|
:global(.n8n-input-label) {
|
||||||
|
margin-bottom: var(--spacing-2xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,82 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
export interface EvaluationHeaderProps {
|
||||||
|
modelValue: {
|
||||||
|
value: string;
|
||||||
|
isEditing: boolean;
|
||||||
|
tempValue: string;
|
||||||
|
};
|
||||||
|
startEditing: (field: string) => void;
|
||||||
|
saveChanges: (field: string) => void;
|
||||||
|
handleKeydown: (e: KeyboardEvent, field: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineEmits<{ 'update:modelValue': [value: EvaluationHeaderProps['modelValue']] }>();
|
||||||
|
defineProps<EvaluationHeaderProps>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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="!modelValue.isEditing">
|
||||||
|
{{ modelValue.value }}
|
||||||
|
<n8n-icon-button
|
||||||
|
:class="$style.editInputButton"
|
||||||
|
icon="pen"
|
||||||
|
type="tertiary"
|
||||||
|
@click="startEditing('name')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<N8nInput
|
||||||
|
v-else
|
||||||
|
ref="nameInput"
|
||||||
|
:model-value="modelValue.tempValue"
|
||||||
|
type="text"
|
||||||
|
:placeholder="$locale.baseText('common.name')"
|
||||||
|
@update:model-value="$emit('update:modelValue', { ...modelValue, tempValue: $event })"
|
||||||
|
@blur="() => saveChanges('name')"
|
||||||
|
@keydown="(e: KeyboardEvent) => handleKeydown(e, 'name')"
|
||||||
|
/>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,81 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
export interface MetricsInputProps {
|
||||||
|
modelValue: string[];
|
||||||
|
helpText: string;
|
||||||
|
}
|
||||||
|
const props = defineProps<MetricsInputProps>();
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: MetricsInputProps['modelValue']] }>();
|
||||||
|
|
||||||
|
function addNewMetric() {
|
||||||
|
emit('update:modelValue', [...props.modelValue, '']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMetric(index: number, value: string) {
|
||||||
|
const newMetrics = [...props.modelValue];
|
||||||
|
newMetrics[index] = value;
|
||||||
|
emit('update:modelValue', newMetrics);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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">
|
||||||
|
{{ helpText }}
|
||||||
|
</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 modelValue" :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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.formGroup {
|
||||||
|
margin-bottom: var(--spacing-l);
|
||||||
|
|
||||||
|
:global(.n8n-input-label) {
|
||||||
|
margin-bottom: var(--spacing-2xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,94 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ITag } from '@/Interface';
|
||||||
|
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;
|
||||||
|
helpText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<TagsInputProps>(), {
|
||||||
|
modelValue: () => ({
|
||||||
|
isEditing: false,
|
||||||
|
appliedTagIds: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: TagsInputProps['modelValue']] }>();
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="$style.formGroup">
|
||||||
|
<n8n-input-label label="Tag name" :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">Select tag...</n8n-text>
|
||||||
|
<n8n-tag v-for="tagId in modelValue.appliedTagIds" :key="tagId" :text="getTagName(tagId)" />
|
||||||
|
<n8n-icon-button
|
||||||
|
:class="$style.editInputButton"
|
||||||
|
icon="pen"
|
||||||
|
type="tertiary"
|
||||||
|
size="small"
|
||||||
|
transparent
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
class="tags-edit"
|
||||||
|
data-test-id="workflow-tags-dropdown"
|
||||||
|
@update:model-value="updateTags"
|
||||||
|
@esc="cancelEditing('tags')"
|
||||||
|
@blur="saveChanges('tags')"
|
||||||
|
/>
|
||||||
|
</n8n-input-label>
|
||||||
|
<n8n-text size="small" color="text-light">{{ helpText }}</n8n-text>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.formGroup {
|
||||||
|
margin-bottom: var(--spacing-l);
|
||||||
|
|
||||||
|
:global(.n8n-input-label) {
|
||||||
|
margin-bottom: var(--spacing-2xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagsRead {
|
||||||
|
&:hover .editInputButton {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editInputButton {
|
||||||
|
opacity: 0;
|
||||||
|
border: none;
|
||||||
|
--button-font-color: var(--prim-gray-490);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,52 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { INodeParameterResourceLocator } from 'n8n-workflow';
|
||||||
|
interface WorkflowSelectorProps {
|
||||||
|
modelValue: INodeParameterResourceLocator;
|
||||||
|
helpText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<WorkflowSelectorProps>(), {
|
||||||
|
modelValue: () => ({
|
||||||
|
mode: 'id',
|
||||||
|
value: 'Test Workflow?',
|
||||||
|
__rl: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits<{ 'update:modelValue': [value: WorkflowSelectorProps['modelValue']] }>();
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<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="modelValue"
|
||||||
|
:display-title="'Evaluation Workflow'"
|
||||||
|
:is-value-expression="false"
|
||||||
|
:expression-edit-dialog-visible="false"
|
||||||
|
:path="'workflows'"
|
||||||
|
allow-new
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)"
|
||||||
|
/>
|
||||||
|
</n8n-input-label>
|
||||||
|
<n8n-text size="small" color="text-light">
|
||||||
|
{{ helpText }}
|
||||||
|
</n8n-text>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.formGroup {
|
||||||
|
margin-bottom: var(--spacing-l);
|
||||||
|
|
||||||
|
:global(.n8n-input-label) {
|
||||||
|
margin-bottom: var(--spacing-2xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -5,7 +5,6 @@ import { useAnnotationTagsStore } from '@/stores/tags.store';
|
||||||
import { useEvaluationsStore } from '@/stores/evaluations.store.ee';
|
import { useEvaluationsStore } from '@/stores/evaluations.store.ee';
|
||||||
import type AnnotationTagsDropdownEe from '@/components/AnnotationTagsDropdown.ee.vue';
|
import type AnnotationTagsDropdownEe from '@/components/AnnotationTagsDropdown.ee.vue';
|
||||||
import type { N8nInput } from 'n8n-design-system';
|
import type { N8nInput } from 'n8n-design-system';
|
||||||
import { VIEWS } from '@/constants';
|
|
||||||
|
|
||||||
interface EditableField {
|
interface EditableField {
|
||||||
value: string;
|
value: string;
|
||||||
|
@ -15,7 +14,7 @@ interface EditableField {
|
||||||
|
|
||||||
export interface IEvaluationFormState {
|
export interface IEvaluationFormState {
|
||||||
name: EditableField;
|
name: EditableField;
|
||||||
description?: string;
|
description: string;
|
||||||
tags: {
|
tags: {
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
appliedTagIds: string[];
|
appliedTagIds: string[];
|
||||||
|
@ -71,11 +70,12 @@ export function useEvaluationForm(testId?: number) {
|
||||||
if (!testId) return;
|
if (!testId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await evaluationsStore.fetchAll();
|
await evaluationsStore.fetchAll({ force: true });
|
||||||
const testDefinition = evaluationsStore.testDefinitionsById[testId];
|
const testDefinition = evaluationsStore.testDefinitionsById[testId];
|
||||||
|
|
||||||
if (testDefinition) {
|
if (testDefinition) {
|
||||||
state.value = {
|
state.value = {
|
||||||
|
description: '',
|
||||||
name: {
|
name: {
|
||||||
value: testDefinition.name,
|
value: testDefinition.name,
|
||||||
isEditing: false,
|
isEditing: false,
|
||||||
|
@ -134,7 +134,7 @@ export function useEvaluationForm(testId?: number) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startEditing = async (field: 'name' | 'tags') => {
|
const startEditing = async (field: string) => {
|
||||||
if (field === 'name') {
|
if (field === 'name') {
|
||||||
state.value.name.tempValue = state.value.name.value;
|
state.value.name.tempValue = state.value.name.value;
|
||||||
state.value.name.isEditing = true;
|
state.value.name.isEditing = true;
|
||||||
|
@ -143,7 +143,7 @@ export function useEvaluationForm(testId?: number) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveChanges = (field: 'name' | 'tags') => {
|
const saveChanges = (field: string) => {
|
||||||
if (field === 'name') {
|
if (field === 'name') {
|
||||||
state.value.name.value = state.value.name.tempValue;
|
state.value.name.value = state.value.name.tempValue;
|
||||||
state.value.name.isEditing = false;
|
state.value.name.isEditing = false;
|
||||||
|
@ -152,7 +152,7 @@ export function useEvaluationForm(testId?: number) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEditing = (field: 'name' | 'tags') => {
|
const cancelEditing = (field: string) => {
|
||||||
if (field === 'name') {
|
if (field === 'name') {
|
||||||
state.value.name.isEditing = false;
|
state.value.name.isEditing = false;
|
||||||
} else {
|
} else {
|
||||||
|
@ -160,7 +160,7 @@ export function useEvaluationForm(testId?: number) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeydown = (event: KeyboardEvent, field: 'name' | 'tags') => {
|
const handleKeydown = (event: KeyboardEvent, field: string) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
cancelEditing(field);
|
cancelEditing(field);
|
||||||
} else if (event.key === 'Enter' && !event.shiftKey) {
|
} else if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
@ -169,18 +169,6 @@ export function useEvaluationForm(testId?: number) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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
|
// Initialize
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
await tagsStore.fetchAll();
|
await tagsStore.fetchAll();
|
||||||
|
@ -203,8 +191,5 @@ export function useEvaluationForm(testId?: number) {
|
||||||
saveChanges,
|
saveChanges,
|
||||||
cancelEditing,
|
cancelEditing,
|
||||||
handleKeydown,
|
handleKeydown,
|
||||||
updateMetrics,
|
|
||||||
onTagUpdate,
|
|
||||||
onWorkflowUpdate,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
export interface TestExecution {
|
||||||
|
lastRun: string | null;
|
||||||
|
errorRate: number | null;
|
||||||
|
metrics: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestListItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
tagName: string;
|
||||||
|
testCases: number;
|
||||||
|
execution: TestExecution;
|
||||||
|
}
|
|
@ -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 ListEvaluations from './views/WorkflowEvaluation/ListEvaluations.vue';
|
import EvaluationListView from './views/WorkflowEvaluation/EvaluationListView.vue';
|
||||||
import NewEvaluation from './views/WorkflowEvaluation/NewEvaluation.vue';
|
import EvaluationEditView from './views/WorkflowEvaluation/EvaluationEditView.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');
|
||||||
|
@ -253,8 +253,16 @@ export const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: '/workflow/:name/evaluation',
|
path: '/workflow/:name/evaluation',
|
||||||
name: VIEWS.WORKFLOW_EVALUATION,
|
name: VIEWS.WORKFLOW_EVALUATION,
|
||||||
|
meta: {
|
||||||
|
keepWorkflowAlive: true,
|
||||||
|
middleware: ['authenticated'],
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: VIEWS.WORKFLOW_EVALUATION,
|
||||||
components: {
|
components: {
|
||||||
default: ListEvaluations,
|
default: EvaluationListView,
|
||||||
header: MainHeader,
|
header: MainHeader,
|
||||||
sidebar: MainSidebar,
|
sidebar: MainSidebar,
|
||||||
},
|
},
|
||||||
|
@ -262,36 +270,12 @@ export const routes: RouteRecordRaw[] = [
|
||||||
keepWorkflowAlive: true,
|
keepWorkflowAlive: true,
|
||||||
middleware: ['authenticated'],
|
middleware: ['authenticated'],
|
||||||
},
|
},
|
||||||
// children: [
|
|
||||||
// {
|
|
||||||
// path: '',
|
|
||||||
// name: VIEWS.EXECUTION_HOME,
|
|
||||||
// components: {
|
|
||||||
// executionPreview: WorkflowExecutionsLandingPage,
|
|
||||||
// },
|
|
||||||
// meta: {
|
|
||||||
// keepWorkflowAlive: true,
|
|
||||||
// middleware: ['authenticated'],
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// path: ':executionId',
|
|
||||||
// name: VIEWS.EXECUTION_PREVIEW,
|
|
||||||
// components: {
|
|
||||||
// executionPreview: WorkflowExecutionsPreview,
|
|
||||||
// },
|
|
||||||
// meta: {
|
|
||||||
// keepWorkflowAlive: true,
|
|
||||||
// middleware: ['authenticated'],
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/workflow/:name/evaluation/new',
|
path: 'new',
|
||||||
name: VIEWS.NEW_WORKFLOW_EVALUATION,
|
name: VIEWS.NEW_WORKFLOW_EVALUATION,
|
||||||
components: {
|
components: {
|
||||||
default: NewEvaluation,
|
default: EvaluationEditView,
|
||||||
header: MainHeader,
|
header: MainHeader,
|
||||||
sidebar: MainSidebar,
|
sidebar: MainSidebar,
|
||||||
},
|
},
|
||||||
|
@ -299,6 +283,21 @@ export const routes: RouteRecordRaw[] = [
|
||||||
keepWorkflowAlive: true,
|
keepWorkflowAlive: true,
|
||||||
middleware: ['authenticated'],
|
middleware: ['authenticated'],
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':testId',
|
||||||
|
name: VIEWS.WORKFLOW_EVALUATION_EDIT,
|
||||||
|
components: {
|
||||||
|
default: EvaluationEditView,
|
||||||
|
header: MainHeader,
|
||||||
|
sidebar: MainSidebar,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
keepWorkflowAlive: true,
|
||||||
|
middleware: ['authenticated'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
// children: [
|
// children: [
|
||||||
// {
|
// {
|
||||||
// path: '',
|
// path: '',
|
||||||
|
|
|
@ -0,0 +1,114 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import EvaluationHeader from '@/components/WorkflowEvaluation/EditEvaluation/EvaluationHeader.vue';
|
||||||
|
import DescriptionInput from '@/components/WorkflowEvaluation/EditEvaluation/DescriptionInput.vue';
|
||||||
|
import TagsInput from '@/components/WorkflowEvaluation/EditEvaluation/TagsInput.vue';
|
||||||
|
import WorkflowSelector from '@/components/WorkflowEvaluation/EditEvaluation/WorkflowSelector.vue';
|
||||||
|
import MetricsInput from '@/components/WorkflowEvaluation/EditEvaluation/MetricsInput.vue';
|
||||||
|
import { useEvaluationForm } from '@/components/WorkflowEvaluation/composables/useEvaluationForm';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
testId?: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const testId = props.testId ?? (route.params.testId as unknown as number);
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
isEditing,
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
|
allTags,
|
||||||
|
tagsById,
|
||||||
|
init,
|
||||||
|
saveTest,
|
||||||
|
startEditing,
|
||||||
|
saveChanges,
|
||||||
|
cancelEditing,
|
||||||
|
handleKeydown,
|
||||||
|
} = useEvaluationForm(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.',
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void init();
|
||||||
|
});
|
||||||
|
|
||||||
|
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">
|
||||||
|
<EvaluationHeader
|
||||||
|
v-model="state.name"
|
||||||
|
:start-editing="startEditing"
|
||||||
|
:save-changes="saveChanges"
|
||||||
|
:handle-keydown="handleKeydown"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DescriptionInput v-model="state.description" />
|
||||||
|
|
||||||
|
<TagsInput
|
||||||
|
v-model="state.tags"
|
||||||
|
:all-tags="allTags"
|
||||||
|
:tags-by-id="tagsById"
|
||||||
|
:is-loading="isLoading"
|
||||||
|
:start-editing="startEditing"
|
||||||
|
:save-changes="saveChanges"
|
||||||
|
:cancel-editing="cancelEditing"
|
||||||
|
:help-text="helpText"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WorkflowSelector v-model="state.evaluationWorkflow" :help-text="workflowHelpText" />
|
||||||
|
|
||||||
|
<MetricsInput v-model="state.metrics" :help-text="metricsHelpText" />
|
||||||
|
|
||||||
|
<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);
|
||||||
|
background: var(--color-background-xlight);
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,318 +0,0 @@
|
||||||
<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>
|
|
Loading…
Reference in a new issue