mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
fix(editor): Ai 695 update executions view ux (no-changelog) (#13531)
This commit is contained in:
committed by
GitHub
parent
9057ee69df
commit
b2fcfe9d69
@@ -11,6 +11,17 @@ import type { ExecutionSummaryWithScopes, IWorkflowDb } from '@/Interface';
|
|||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
|
import type { FrontendSettings } from '@n8n/api-types';
|
||||||
|
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||||
|
import { useExecutionsStore } from '@/stores/executions.store';
|
||||||
|
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
|
||||||
|
|
||||||
|
const showMessage = vi.fn();
|
||||||
|
const showError = vi.fn();
|
||||||
|
const showToast = vi.fn();
|
||||||
|
vi.mock('@/composables/useToast', () => ({
|
||||||
|
useToast: () => ({ showMessage, showError, showToast }),
|
||||||
|
}));
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/', name: 'home', component: { template: '<div></div>' } },
|
{ path: '/', name: 'home', component: { template: '<div></div>' } },
|
||||||
@@ -41,7 +52,9 @@ const generateUndefinedNullOrString = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const executionDataFactory = (): ExecutionSummaryWithScopes => ({
|
const executionDataFactory = (
|
||||||
|
tags: Array<{ id: string; name: string }> = [],
|
||||||
|
): ExecutionSummaryWithScopes => ({
|
||||||
id: faker.string.uuid(),
|
id: faker.string.uuid(),
|
||||||
finished: faker.datatype.boolean(),
|
finished: faker.datatype.boolean(),
|
||||||
mode: faker.helpers.arrayElement(['manual', 'trigger']),
|
mode: faker.helpers.arrayElement(['manual', 'trigger']),
|
||||||
@@ -55,6 +68,17 @@ const executionDataFactory = (): ExecutionSummaryWithScopes => ({
|
|||||||
retryOf: generateUndefinedNullOrString(),
|
retryOf: generateUndefinedNullOrString(),
|
||||||
retrySuccessId: generateUndefinedNullOrString(),
|
retrySuccessId: generateUndefinedNullOrString(),
|
||||||
scopes: ['workflow:update'],
|
scopes: ['workflow:update'],
|
||||||
|
annotation: { tags, vote: 'up' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCaseFactory = (workflowId: string, annotationTagId?: string): TestDefinitionRecord => ({
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
createdAt: faker.date.past().toString(),
|
||||||
|
updatedAt: faker.date.past().toString(),
|
||||||
|
evaluationWorkflowId: null,
|
||||||
|
annotationTagId,
|
||||||
|
workflowId,
|
||||||
|
name: `My test ${faker.number.int()}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(WorkflowExecutionsPreview, {
|
const renderComponent = createComponentRenderer(WorkflowExecutionsPreview, {
|
||||||
@@ -89,7 +113,7 @@ describe('WorkflowExecutionsPreview.vue', () => {
|
|||||||
settingsStore.settings.enterprise = {
|
settingsStore.settings.enterprise = {
|
||||||
...(settingsStore.settings.enterprise ?? {}),
|
...(settingsStore.settings.enterprise ?? {}),
|
||||||
[EnterpriseEditionFeature.DebugInEditor]: availability,
|
[EnterpriseEditionFeature.DebugInEditor]: availability,
|
||||||
};
|
} as FrontendSettings['enterprise'];
|
||||||
|
|
||||||
workflowsStore.workflowsById[executionData.workflowId] = { scopes } as IWorkflowDb;
|
workflowsStore.workflowsById[executionData.workflowId] = { scopes } as IWorkflowDb;
|
||||||
|
|
||||||
@@ -110,4 +134,119 @@ describe('WorkflowExecutionsPreview.vue', () => {
|
|||||||
|
|
||||||
expect(getByTestId('stop-execution')).toBeDisabled();
|
expect(getByTestId('stop-execution')).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('test execution crud', () => {
|
||||||
|
it('should add an execution to a testcase', async () => {
|
||||||
|
const tag = { id: 'tag_id', name: 'tag_name' };
|
||||||
|
const execution = executionDataFactory([]);
|
||||||
|
const testCase = testCaseFactory(execution.workflowId, tag.id);
|
||||||
|
|
||||||
|
const testDefinitionStore = mockedStore(useTestDefinitionStore);
|
||||||
|
const executionsStore = mockedStore(useExecutionsStore);
|
||||||
|
const settingsStore = mockedStore(useSettingsStore);
|
||||||
|
|
||||||
|
testDefinitionStore.allTestDefinitionsByWorkflowId[execution.workflowId] = [testCase];
|
||||||
|
|
||||||
|
settingsStore.isEnterpriseFeatureEnabled = {
|
||||||
|
advancedExecutionFilters: true,
|
||||||
|
} as FrontendSettings['enterprise'];
|
||||||
|
|
||||||
|
const { getByTestId } = renderComponent({
|
||||||
|
props: { execution: { ...execution, status: 'success' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await router.push({ params: { name: execution.workflowId }, query: { testId: testCase.id } });
|
||||||
|
|
||||||
|
expect(getByTestId('test-execution-crud')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('test-execution-add')).toBeVisible();
|
||||||
|
|
||||||
|
await userEvent.click(getByTestId('test-execution-add'));
|
||||||
|
|
||||||
|
expect(executionsStore.annotateExecution).toHaveBeenCalledWith(execution.id, {
|
||||||
|
tags: [testCase.annotationTagId],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove an execution from a testcase', async () => {
|
||||||
|
const tag = { id: 'tag_id', name: 'tag_name' };
|
||||||
|
const execution = executionDataFactory([tag]);
|
||||||
|
const testCase = testCaseFactory(execution.workflowId, tag.id);
|
||||||
|
|
||||||
|
const testDefinitionStore = mockedStore(useTestDefinitionStore);
|
||||||
|
const executionsStore = mockedStore(useExecutionsStore);
|
||||||
|
const settingsStore = mockedStore(useSettingsStore);
|
||||||
|
|
||||||
|
testDefinitionStore.allTestDefinitionsByWorkflowId[execution.workflowId] = [testCase];
|
||||||
|
|
||||||
|
settingsStore.isEnterpriseFeatureEnabled = {
|
||||||
|
advancedExecutionFilters: true,
|
||||||
|
} as FrontendSettings['enterprise'];
|
||||||
|
|
||||||
|
const { getByTestId } = renderComponent({
|
||||||
|
props: { execution: { ...execution, status: 'success' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await router.push({ params: { name: execution.workflowId }, query: { testId: testCase.id } });
|
||||||
|
|
||||||
|
expect(getByTestId('test-execution-crud')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('test-execution-remove')).toBeVisible();
|
||||||
|
|
||||||
|
await userEvent.click(getByTestId('test-execution-remove'));
|
||||||
|
|
||||||
|
expect(executionsStore.annotateExecution).toHaveBeenCalledWith(execution.id, {
|
||||||
|
tags: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toggle an execution', async () => {
|
||||||
|
const tag1 = { id: 'tag_id', name: 'tag_name' };
|
||||||
|
const tag2 = { id: 'tag_id_2', name: 'tag_name_2' };
|
||||||
|
const execution = executionDataFactory([tag1]);
|
||||||
|
const testCase1 = testCaseFactory(execution.workflowId, tag1.id);
|
||||||
|
const testCase2 = testCaseFactory(execution.workflowId, tag2.id);
|
||||||
|
|
||||||
|
const testDefinitionStore = mockedStore(useTestDefinitionStore);
|
||||||
|
const executionsStore = mockedStore(useExecutionsStore);
|
||||||
|
const settingsStore = mockedStore(useSettingsStore);
|
||||||
|
|
||||||
|
testDefinitionStore.allTestDefinitionsByWorkflowId[execution.workflowId] = [
|
||||||
|
testCase1,
|
||||||
|
testCase2,
|
||||||
|
];
|
||||||
|
|
||||||
|
settingsStore.isEnterpriseFeatureEnabled = {
|
||||||
|
advancedExecutionFilters: true,
|
||||||
|
} as FrontendSettings['enterprise'];
|
||||||
|
|
||||||
|
const { getByTestId, queryAllByTestId, rerender } = renderComponent({
|
||||||
|
props: { execution: { ...execution, status: 'success' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await router.push({ params: { name: execution.workflowId } });
|
||||||
|
|
||||||
|
expect(getByTestId('test-execution-crud')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('test-execution-toggle')).toBeVisible();
|
||||||
|
|
||||||
|
// add
|
||||||
|
await userEvent.click(getByTestId('test-execution-toggle'));
|
||||||
|
await userEvent.click(queryAllByTestId('test-execution-add-to')[1]);
|
||||||
|
expect(executionsStore.annotateExecution).toHaveBeenCalledWith(execution.id, {
|
||||||
|
tags: [tag1.id, tag2.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
const executionWithBothTags = executionDataFactory([tag1, tag2]);
|
||||||
|
await rerender({ execution: { ...executionWithBothTags, status: 'success' } });
|
||||||
|
|
||||||
|
// remove
|
||||||
|
await userEvent.click(getByTestId('test-execution-toggle'));
|
||||||
|
await userEvent.click(queryAllByTestId('test-execution-add-to')[1]);
|
||||||
|
expect(executionsStore.annotateExecution).toHaveBeenLastCalledWith(executionWithBothTags.id, {
|
||||||
|
tags: [tag1.id],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref, onMounted } from 'vue';
|
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
|
||||||
import { ElDropdown } from 'element-plus';
|
|
||||||
import { useExecutionDebugging } from '@/composables/useExecutionDebugging';
|
|
||||||
import { useMessage } from '@/composables/useMessage';
|
|
||||||
import WorkflowExecutionAnnotationPanel from '@/components/executions/workflow/WorkflowExecutionAnnotationPanel.ee.vue';
|
import WorkflowExecutionAnnotationPanel from '@/components/executions/workflow/WorkflowExecutionAnnotationPanel.ee.vue';
|
||||||
import WorkflowPreview from '@/components/WorkflowPreview.vue';
|
import WorkflowPreview from '@/components/WorkflowPreview.vue';
|
||||||
import { EnterpriseEditionFeature, MODAL_CONFIRM, VIEWS } from '@/constants';
|
import { useExecutionDebugging } from '@/composables/useExecutionDebugging';
|
||||||
import type { ExecutionSummary } from 'n8n-workflow';
|
|
||||||
import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
|
import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
|
||||||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useMessage } from '@/composables/useMessage';
|
||||||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
|
||||||
import { getResourcePermissions } from '@/permissions';
|
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
|
||||||
import type { ButtonType } from '@n8n/design-system';
|
|
||||||
import { useExecutionsStore } from '@/stores/executions.store';
|
|
||||||
import ProjectCreateResource from '@/components/Projects/ProjectCreateResource.vue';
|
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { EnterpriseEditionFeature, MODAL_CONFIRM, VIEWS } from '@/constants';
|
||||||
|
import { getResourcePermissions } from '@/permissions';
|
||||||
|
import { useExecutionsStore } from '@/stores/executions.store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus';
|
||||||
|
import { N8nButton, N8nIcon, N8nIconButton, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||||
|
import type { ExecutionSummary } from 'n8n-workflow';
|
||||||
|
import { computed, h, onMounted, ref } from 'vue';
|
||||||
|
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
type RetryDropdownRef = InstanceType<typeof ElDropdown>;
|
type RetryDropdownRef = InstanceType<typeof ElDropdown>;
|
||||||
|
|
||||||
@@ -45,7 +44,6 @@ const settingsStore = useSettingsStore();
|
|||||||
const testDefinitionStore = useTestDefinitionStore();
|
const testDefinitionStore = useTestDefinitionStore();
|
||||||
const executionsStore = useExecutionsStore();
|
const executionsStore = useExecutionsStore();
|
||||||
const retryDropdownRef = ref<RetryDropdownRef | null>(null);
|
const retryDropdownRef = ref<RetryDropdownRef | null>(null);
|
||||||
const actionToggleRef = ref<InstanceType<typeof ProjectCreateResource> | null>(null);
|
|
||||||
const workflowId = computed(() => route.params.name as string);
|
const workflowId = computed(() => route.params.name as string);
|
||||||
const workflowPermissions = computed(
|
const workflowPermissions = computed(
|
||||||
() => getResourcePermissions(workflowsStore.getWorkflowById(workflowId.value)?.scopes).workflow,
|
() => getResourcePermissions(workflowsStore.getWorkflowById(workflowId.value)?.scopes).workflow,
|
||||||
@@ -58,11 +56,11 @@ const debugButtonData = computed(() =>
|
|||||||
props.execution?.status === 'success'
|
props.execution?.status === 'success'
|
||||||
? {
|
? {
|
||||||
text: locale.baseText('executionsList.debug.button.copyToEditor'),
|
text: locale.baseText('executionsList.debug.button.copyToEditor'),
|
||||||
type: 'secondary',
|
type: 'secondary' as const,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
text: locale.baseText('executionsList.debug.button.debugInEditor'),
|
text: locale.baseText('executionsList.debug.button.debugInEditor'),
|
||||||
type: 'primary',
|
type: 'primary' as const,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const isRetriable = computed(
|
const isRetriable = computed(
|
||||||
@@ -80,60 +78,116 @@ const hasAnnotation = computed(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const testDefinitions = computed(
|
const testDefinitions = computed(
|
||||||
() => testDefinitionStore.allTestDefinitionsByWorkflowId[workflowId.value],
|
() => testDefinitionStore.allTestDefinitionsByWorkflowId[workflowId.value] ?? [],
|
||||||
);
|
);
|
||||||
|
|
||||||
const testDefinition = computed(() =>
|
const testDefinition = computed(() =>
|
||||||
testDefinitions.value.find((test) => test.id === route.query.testId),
|
testDefinitions.value.find((test) => test.id === route.query.testId),
|
||||||
);
|
);
|
||||||
|
|
||||||
const addToTestActions = computed(() => {
|
const disableAddToTestTooltip = computed(() => {
|
||||||
const testAction = testDefinitions.value
|
if (props.execution.mode === 'evaluation') {
|
||||||
.filter((test) => test.annotationTagId)
|
return locale.baseText('testDefinition.executions.tooltip.noExecutions');
|
||||||
.map((test) => {
|
}
|
||||||
const isAlreadyAdded = isTagAlreadyAdded(test.annotationTagId ?? '');
|
|
||||||
return {
|
|
||||||
label: `${test.name}`,
|
|
||||||
value: test.annotationTagId ?? '',
|
|
||||||
disabled: !workflowPermissions.value.update || isAlreadyAdded,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const newTestAction = {
|
if (props.execution.status !== 'success') {
|
||||||
label: '+ New Test',
|
return locale.baseText('testDefinition.executions.tooltip.onlySuccess');
|
||||||
value: 'new',
|
}
|
||||||
disabled: !workflowPermissions.value.update,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [newTestAction, ...testAction];
|
return '';
|
||||||
});
|
});
|
||||||
|
|
||||||
function getTestButtonLabel(isAdded: boolean): string {
|
type Command = {
|
||||||
if (isAdded) {
|
type: 'addTag' | 'removeTag' | 'createTest';
|
||||||
return locale.baseText('testDefinition.executions.addedTo', {
|
id: string;
|
||||||
interpolate: { name: testDefinition.value?.name ?? '' },
|
name: string;
|
||||||
});
|
};
|
||||||
}
|
|
||||||
return testDefinition.value
|
|
||||||
? locale.baseText('testDefinition.executions.addTo.existing', {
|
|
||||||
interpolate: { name: testDefinition.value.name },
|
|
||||||
})
|
|
||||||
: locale.baseText('testDefinition.executions.addTo.new');
|
|
||||||
}
|
|
||||||
|
|
||||||
const addTestButtonData = computed<{ label: string; type: ButtonType }>(() => {
|
const getTagIds = (tags?: Array<{ id: string; name: string }>) => (tags ?? []).map((t) => t.id);
|
||||||
const isAdded = isTagAlreadyAdded(route.query.tag as string);
|
|
||||||
return {
|
const addExecutionTag = async (annotationTagId: string) => {
|
||||||
label: getTestButtonLabel(isAdded),
|
const newTags = [...getTagIds(props.execution?.annotation?.tags), annotationTagId];
|
||||||
type: route.query.testId ? 'primary' : 'secondary',
|
await executionsStore.annotateExecution(props.execution.id, { tags: newTags });
|
||||||
disabled: !workflowPermissions.value.update || isAdded,
|
toast.showToast({
|
||||||
};
|
title: locale.baseText('testDefinition.executions.toast.addedTo.title'),
|
||||||
|
message: h(
|
||||||
|
N8nText,
|
||||||
|
{
|
||||||
|
color: 'primary',
|
||||||
|
style: { cursor: 'pointer ' },
|
||||||
|
},
|
||||||
|
() => locale.baseText('testDefinition.executions.toast.closeTab'),
|
||||||
|
),
|
||||||
|
closeOnClick: false,
|
||||||
|
onClick() {
|
||||||
|
window.close();
|
||||||
|
},
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeExecutionTag = async (annotationTagId: string) => {
|
||||||
|
const newTags = getTagIds(props.execution?.annotation?.tags).filter(
|
||||||
|
(id) => id !== annotationTagId,
|
||||||
|
);
|
||||||
|
await executionsStore.annotateExecution(props.execution.id, { tags: newTags });
|
||||||
|
toast.showMessage({
|
||||||
|
title: locale.baseText('testDefinition.executions.toast.removedFrom.title'),
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestForExecution = async (id: string) => {
|
||||||
|
await router.push({
|
||||||
|
name: VIEWS.NEW_TEST_DEFINITION,
|
||||||
|
params: {
|
||||||
|
name: workflowId.value,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
executionId: id,
|
||||||
|
annotationTags: getTagIds(props.execution?.annotation?.tags),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const commandCallbacks = {
|
||||||
|
addTag: addExecutionTag,
|
||||||
|
removeTag: removeExecutionTag,
|
||||||
|
createTest: createTestForExecution,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const handleCommand = async (command: Command) => {
|
||||||
|
const action = commandCallbacks[command.type];
|
||||||
|
return await action(command.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const testList = computed(() => {
|
||||||
|
return testDefinitions.value.reduce<
|
||||||
|
Array<{ label: string; value: string; added: boolean; command: Command }>
|
||||||
|
>((acc, test) => {
|
||||||
|
if (!test.annotationTagId) return acc;
|
||||||
|
|
||||||
|
const added = isTagAlreadyAdded(test.annotationTagId);
|
||||||
|
|
||||||
|
acc.push({
|
||||||
|
label: test.name,
|
||||||
|
value: test.annotationTagId,
|
||||||
|
added,
|
||||||
|
command: { type: added ? 'removeTag' : 'addTag', id: test.annotationTagId, name: test.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
function isTagAlreadyAdded(tagId?: string | null) {
|
function isTagAlreadyAdded(tagId?: string | null) {
|
||||||
return Boolean(tagId && props.execution?.annotation?.tags.some((tag) => tag.id === tagId));
|
return Boolean(tagId && props.execution?.annotation?.tags.some((tag) => tag.id === tagId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const executionHasTestTag = computed(() =>
|
||||||
|
isTagAlreadyAdded(testDefinition.value?.annotationTagId),
|
||||||
|
);
|
||||||
|
|
||||||
async function onDeleteExecution(): Promise<void> {
|
async function onDeleteExecution(): Promise<void> {
|
||||||
// Prepend the message with a note about annotations if they exist
|
// Prepend the message with a note about annotations if they exist
|
||||||
const confirmationText = [
|
const confirmationText = [
|
||||||
@@ -173,36 +227,6 @@ function onRetryButtonBlur(event: FocusEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddToTestAction(actionValue: string) {
|
|
||||||
if (actionValue === 'new') {
|
|
||||||
await router.push({
|
|
||||||
name: VIEWS.NEW_TEST_DEFINITION,
|
|
||||||
params: {
|
|
||||||
name: workflowId.value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const currentTags = props.execution?.annotation?.tags ?? [];
|
|
||||||
const newTags = [...currentTags.map((t) => t.id), actionValue];
|
|
||||||
await executionsStore.annotateExecution(props.execution.id, { tags: newTags });
|
|
||||||
toast.showMessage({
|
|
||||||
title: locale.baseText('testDefinition.executions.toast.addedTo.title'),
|
|
||||||
message: locale.baseText('testDefinition.executions.toast.addedTo', {
|
|
||||||
interpolate: { name: testDefinition.value?.name ?? '' },
|
|
||||||
}),
|
|
||||||
type: 'success',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEvaluationButton() {
|
|
||||||
if (!testDefinition.value) {
|
|
||||||
actionToggleRef.value?.openActionToggle(true);
|
|
||||||
} else {
|
|
||||||
await handleAddToTestAction(route.query.tag as string);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await testDefinitionStore.fetchTestDefinitionsByWorkflowId(workflowId.value);
|
await testDefinitionStore.fetchTestDefinitionsByWorkflowId(workflowId.value);
|
||||||
});
|
});
|
||||||
@@ -285,7 +309,7 @@ onMounted(async () => {
|
|||||||
</N8nText>
|
</N8nText>
|
||||||
<br /><N8nText v-if="execution.mode === 'retry'" color="text-base" size="medium">
|
<br /><N8nText v-if="execution.mode === 'retry'" color="text-base" size="medium">
|
||||||
{{ locale.baseText('executionDetails.retry') }}
|
{{ locale.baseText('executionDetails.retry') }}
|
||||||
<router-link
|
<RouterLink
|
||||||
:class="$style.executionLink"
|
:class="$style.executionLink"
|
||||||
:to="{
|
:to="{
|
||||||
name: VIEWS.EXECUTION_PREVIEW,
|
name: VIEWS.EXECUTION_PREVIEW,
|
||||||
@@ -296,24 +320,102 @@ onMounted(async () => {
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
#{{ execution.retryOf }}
|
#{{ execution.retryOf }}
|
||||||
</router-link>
|
</RouterLink>
|
||||||
</N8nText>
|
</N8nText>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.actions">
|
<div :class="$style.actions">
|
||||||
<ProjectCreateResource
|
<N8nTooltip
|
||||||
v-if="testDefinitions && testDefinitions.length"
|
placement="top"
|
||||||
ref="actionToggleRef"
|
:content="disableAddToTestTooltip"
|
||||||
:actions="addToTestActions"
|
:disabled="!disableAddToTestTooltip"
|
||||||
:type="addTestButtonData.type"
|
|
||||||
@action="handleAddToTestAction"
|
|
||||||
>
|
>
|
||||||
<N8nButton
|
<ElDropdown
|
||||||
data-test-id="add-to-test-button"
|
trigger="click"
|
||||||
v-bind="addTestButtonData"
|
placement="bottom-end"
|
||||||
@click="handleEvaluationButton"
|
data-test-id="test-execution-crud"
|
||||||
/>
|
@command="handleCommand"
|
||||||
</ProjectCreateResource>
|
>
|
||||||
<router-link
|
<div v-if="testDefinition" :class="$style.buttonGroup">
|
||||||
|
<N8nButton
|
||||||
|
v-if="executionHasTestTag"
|
||||||
|
:disabled="!!disableAddToTestTooltip"
|
||||||
|
type="secondary"
|
||||||
|
data-test-id="test-execution-remove"
|
||||||
|
@click.stop="removeExecutionTag(testDefinition.annotationTagId!)"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
locale.baseText('testDefinition.executions.removeFrom', {
|
||||||
|
interpolate: { name: testDefinition.name },
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</N8nButton>
|
||||||
|
|
||||||
|
<N8nButton
|
||||||
|
v-else
|
||||||
|
:disabled="!!disableAddToTestTooltip"
|
||||||
|
type="primary"
|
||||||
|
data-test-id="test-execution-add"
|
||||||
|
@click.stop="addExecutionTag(testDefinition.annotationTagId!)"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
locale.baseText('testDefinition.executions.addTo.existing', {
|
||||||
|
interpolate: { name: testDefinition.name },
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</N8nButton>
|
||||||
|
<N8nIconButton
|
||||||
|
:disabled="!!disableAddToTestTooltip"
|
||||||
|
icon="angle-down"
|
||||||
|
:type="executionHasTestTag ? 'secondary' : 'primary'"
|
||||||
|
data-test-id="test-execution-toggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<N8nButton
|
||||||
|
v-else
|
||||||
|
:disabled="!!disableAddToTestTooltip"
|
||||||
|
type="secondary"
|
||||||
|
data-test-id="test-execution-toggle"
|
||||||
|
>
|
||||||
|
{{ locale.baseText('testDefinition.executions.addTo.new') }}
|
||||||
|
<N8nIcon icon="angle-down" size="small" class="ml-2xs" />
|
||||||
|
</N8nButton>
|
||||||
|
|
||||||
|
<template #dropdown>
|
||||||
|
<ElDropdownMenu :class="$style.testDropdownMenu">
|
||||||
|
<div :class="$style.testDropdownMenuScroll">
|
||||||
|
<ElDropdownItem
|
||||||
|
v-for="test in testList"
|
||||||
|
:key="test.value"
|
||||||
|
:command="test.command"
|
||||||
|
data-test-id="test-execution-add-to"
|
||||||
|
>
|
||||||
|
<N8nText
|
||||||
|
:color="test.added ? 'primary' : 'text-dark'"
|
||||||
|
:class="$style.fontMedium"
|
||||||
|
>
|
||||||
|
<N8nIcon v-if="test.added" icon="check" color="primary" />
|
||||||
|
{{ test.label }}
|
||||||
|
</N8nText>
|
||||||
|
</ElDropdownItem>
|
||||||
|
</div>
|
||||||
|
<ElDropdownItem
|
||||||
|
:class="$style.createTestButton"
|
||||||
|
:command="{ type: 'createTest', id: execution.id }"
|
||||||
|
:disabled="!workflowPermissions.update"
|
||||||
|
data-test-id="test-execution-create"
|
||||||
|
>
|
||||||
|
<N8nText :class="$style.fontMedium">
|
||||||
|
<N8nIcon icon="plus" />
|
||||||
|
{{ locale.baseText('testDefinition.executions.tooltip.addTo') }}
|
||||||
|
</N8nText>
|
||||||
|
</ElDropdownItem>
|
||||||
|
</ElDropdownMenu>
|
||||||
|
</template>
|
||||||
|
</ElDropdown>
|
||||||
|
</N8nTooltip>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
:to="{
|
:to="{
|
||||||
name: VIEWS.EXECUTION_DEBUG,
|
name: VIEWS.EXECUTION_DEBUG,
|
||||||
params: {
|
params: {
|
||||||
@@ -334,7 +436,7 @@ onMounted(async () => {
|
|||||||
>{{ debugButtonData.text }}</span
|
>{{ debugButtonData.text }}</span
|
||||||
>
|
>
|
||||||
</N8nButton>
|
</N8nButton>
|
||||||
</router-link>
|
</RouterLink>
|
||||||
|
|
||||||
<ElDropdown
|
<ElDropdown
|
||||||
v-if="isRetriable"
|
v-if="isRetriable"
|
||||||
@@ -463,4 +565,41 @@ onMounted(async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-xs);
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.testDropdownMenu {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testDropdownMenuScroll {
|
||||||
|
max-height: 274px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createTestButton {
|
||||||
|
border-top: 1px solid var(--color-foreground-base);
|
||||||
|
background-color: var(--color-background-light-base);
|
||||||
|
border-bottom-left-radius: var(--border-radius-base);
|
||||||
|
border-bottom-right-radius: var(--border-radius-base);
|
||||||
|
&:not(.is-disabled):hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fontMedium {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonGroup {
|
||||||
|
display: inline-flex;
|
||||||
|
:global(.button:first-child) {
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
:global(.button:last-child) {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2997,10 +2997,17 @@
|
|||||||
"testDefinition.executions.addTo.new": "Add to Test",
|
"testDefinition.executions.addTo.new": "Add to Test",
|
||||||
"testDefinition.executions.addTo.existing": "Add to \"{name}\"",
|
"testDefinition.executions.addTo.existing": "Add to \"{name}\"",
|
||||||
"testDefinition.executions.addedTo": "Added to \"{name}\"",
|
"testDefinition.executions.addedTo": "Added to \"{name}\"",
|
||||||
"testDefinition.executions.toast.addedTo": "1 past execution added as a test case to \"{name}\"",
|
"testDefinition.executions.removeFrom": "Remove from \"{name}\"",
|
||||||
|
"testDefinition.executions.removedFrom": "Execution removed from \"{name}\"",
|
||||||
|
"testDefinition.executions.toast.addedTo": "Go back to \"{name}\"",
|
||||||
|
"testDefinition.executions.tooltip.addTo": "Add to new test",
|
||||||
|
"testDefinition.executions.tooltip.noExecutions": "Evaluation executions can not be added to tests",
|
||||||
|
"testDefinition.executions.tooltip.onlySuccess": "Only successful executions can be added to tests",
|
||||||
"testDefinition.workflow.createNew": "Create new evaluation workflow",
|
"testDefinition.workflow.createNew": "Create new evaluation workflow",
|
||||||
"testDefinition.workflow.createNew.or": "or use existing evaluation sub-workflow",
|
"testDefinition.workflow.createNew.or": "or use existing evaluation sub-workflow",
|
||||||
"testDefinition.executions.toast.addedTo.title": "Added to test",
|
"testDefinition.executions.toast.addedTo.title": "Execution added to test ",
|
||||||
|
"testDefinition.executions.toast.closeTab": "Close this tab",
|
||||||
|
"testDefinition.executions.toast.removedFrom.title": "Execution removed from test ",
|
||||||
"freeAi.credits.callout.claim.title": "Get {credits} free OpenAI API credits",
|
"freeAi.credits.callout.claim.title": "Get {credits} free OpenAI API credits",
|
||||||
"freeAi.credits.callout.claim.button.label": "Claim credits",
|
"freeAi.credits.callout.claim.button.label": "Claim credits",
|
||||||
"freeAi.credits.callout.success.title.part1": "Claimed {credits} free OpenAI API credits! Please note these free credits are only for the following models:",
|
"freeAi.credits.callout.success.title.part1": "Claimed {credits} free OpenAI API credits! Please note these free credits are only for the following models:",
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useTestDefinitionForm } from '@/components/TestDefinition/composables/useTestDefinitionForm';
|
import { useTestDefinitionForm } from '@/components/TestDefinition/composables/useTestDefinitionForm';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
import { useExecutionsStore } from '@/stores/executions.store';
|
||||||
|
import { useRootStore } from '@/stores/root.store';
|
||||||
import { useAnnotationTagsStore } from '@/stores/tags.store';
|
import { useAnnotationTagsStore } from '@/stores/tags.store';
|
||||||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { N8nLoading } from '@n8n/design-system';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useRootStore } from '@/stores/root.store';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { VIEWS } from '@/constants';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -16,9 +18,11 @@ const { state, createTest, updateTest } = useTestDefinitionForm();
|
|||||||
const testDefinitionStore = useTestDefinitionStore();
|
const testDefinitionStore = useTestDefinitionStore();
|
||||||
|
|
||||||
const tagsStore = useAnnotationTagsStore();
|
const tagsStore = useAnnotationTagsStore();
|
||||||
|
const executionsStore = useExecutionsStore();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
function generateTagFromName(name: string): string {
|
function generateTagFromName(name: string): string {
|
||||||
let tag = name.toLowerCase().replace(/\s+/g, '_');
|
let tag = name.toLowerCase().replace(/\s+/g, '_');
|
||||||
@@ -46,11 +50,16 @@ void createTest(props.name).then(async (test) => {
|
|||||||
throw new Error('no test found');
|
throw new Error('no test found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tag = generateTagFromName(state.value.name.value);
|
const tag = generateTagFromName(test.name);
|
||||||
|
|
||||||
const testTag = await createTag(tag);
|
const testTag = await createTag(tag);
|
||||||
state.value.tags.value = [testTag.id];
|
state.value.tags.value = [testTag.id];
|
||||||
|
|
||||||
|
if (typeof route.query?.executionId === 'string' && Array.isArray(route.query.annotationTags)) {
|
||||||
|
const newTags = [...(route.query.annotationTags as string[]), testTag.id];
|
||||||
|
await executionsStore.annotateExecution(route.query.executionId, { tags: newTags });
|
||||||
|
}
|
||||||
|
|
||||||
await updateTest(test.id);
|
await updateTest(test.id);
|
||||||
testDefinitionStore.updateRunFieldIssues(test.id);
|
testDefinitionStore.updateRunFieldIssues(test.id);
|
||||||
|
|
||||||
@@ -74,5 +83,5 @@ void createTest(props.name).then(async (test) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>creating {{ name }}</div>
|
<N8nLoading loading :rows="3" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import TestDefinitionNewView from '@/views/TestDefinition/TestDefinitionNewView.vue';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
|
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||||
|
import { useAnnotationTagsStore } from '@/stores/tags.store';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useExecutionsStore } from '@/stores/executions.store';
|
||||||
|
import { waitFor } from '@testing-library/vue';
|
||||||
|
|
||||||
|
const workflowId = 'workflow_id';
|
||||||
|
const testId = 'test_id';
|
||||||
|
|
||||||
|
const mockedForm = {
|
||||||
|
state: ref({ tags: { value: [] }, name }),
|
||||||
|
createTest: vi.fn().mockResolvedValue({
|
||||||
|
id: testId,
|
||||||
|
name: 'test_name',
|
||||||
|
workflowId,
|
||||||
|
createdAt: '',
|
||||||
|
}),
|
||||||
|
updateTest: vi.fn().mockResolvedValue({}),
|
||||||
|
};
|
||||||
|
vi.mock('@/components/TestDefinition/composables/useTestDefinitionForm', () => ({
|
||||||
|
useTestDefinitionForm: vi.fn().mockImplementation(() => mockedForm),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockReplace = vi.fn();
|
||||||
|
vi.mock('vue-router', async (importOriginal) => ({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||||
|
...(await importOriginal<typeof import('vue-router')>()),
|
||||||
|
useRoute: vi.fn().mockReturnValue({}),
|
||||||
|
useRouter: vi.fn(() => ({
|
||||||
|
replace: mockReplace,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('TestDefinitionRootView', () => {
|
||||||
|
const renderComponent = createComponentRenderer(TestDefinitionNewView);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
createTestingPinia();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a test adn redirect', async () => {
|
||||||
|
const testDefinitionStore = mockedStore(useTestDefinitionStore);
|
||||||
|
const annotationTagsStore = mockedStore(useAnnotationTagsStore);
|
||||||
|
|
||||||
|
annotationTagsStore.create.mockResolvedValueOnce({ id: 'tag_id', name: 'tag_name' });
|
||||||
|
renderComponent({ props: { name: workflowId } });
|
||||||
|
|
||||||
|
expect(mockedForm.createTest).toHaveBeenCalledWith(workflowId);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(testDefinitionStore.updateRunFieldIssues).toHaveBeenCalledWith(testId),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockReplace).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
params: {
|
||||||
|
testId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should assign an execution to a test', async () => {
|
||||||
|
(useRoute as Mock).mockReturnValue({
|
||||||
|
query: { executionId: 'execution_id', annotationTags: ['2', '3'] },
|
||||||
|
});
|
||||||
|
const annotationTagsStore = mockedStore(useAnnotationTagsStore);
|
||||||
|
const executionsStore = mockedStore(useExecutionsStore);
|
||||||
|
|
||||||
|
annotationTagsStore.create.mockResolvedValueOnce({ id: 'tag_id', name: 'tag_name' });
|
||||||
|
renderComponent({ props: { name: workflowId } });
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(executionsStore.annotateExecution).toHaveBeenCalledWith('execution_id', {
|
||||||
|
tags: ['2', '3', 'tag_id'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user