fix(editor): Ai 695 update executions view ux (no-changelog) (#13531)

This commit is contained in:
Raúl Gómez Morales
2025-03-12 12:07:57 +01:00
committed by GitHub
parent 9057ee69df
commit b2fcfe9d69
5 changed files with 493 additions and 111 deletions

View File

@@ -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],
});
});
});
}); });

View File

@@ -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>

View File

@@ -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:",

View File

@@ -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>

View File

@@ -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'],
}),
);
});
});