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 { createTestingPinia } from '@pinia/testing';
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 = [
{ 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(),
finished: faker.datatype.boolean(),
mode: faker.helpers.arrayElement(['manual', 'trigger']),
@@ -55,6 +68,17 @@ const executionDataFactory = (): ExecutionSummaryWithScopes => ({
retryOf: generateUndefinedNullOrString(),
retrySuccessId: generateUndefinedNullOrString(),
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, {
@@ -89,7 +113,7 @@ describe('WorkflowExecutionsPreview.vue', () => {
settingsStore.settings.enterprise = {
...(settingsStore.settings.enterprise ?? {}),
[EnterpriseEditionFeature.DebugInEditor]: availability,
};
} as FrontendSettings['enterprise'];
workflowsStore.workflowsById[executionData.workflowId] = { scopes } as IWorkflowDb;
@@ -110,4 +134,119 @@ describe('WorkflowExecutionsPreview.vue', () => {
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>
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 WorkflowPreview from '@/components/WorkflowPreview.vue';
import { EnterpriseEditionFeature, MODAL_CONFIRM, VIEWS } from '@/constants';
import type { ExecutionSummary } from 'n8n-workflow';
import { useExecutionDebugging } from '@/composables/useExecutionDebugging';
import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import { useI18n } from '@/composables/useI18n';
import { useWorkflowsStore } from '@/stores/workflows.store';
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 { useMessage } from '@/composables/useMessage';
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>;
@@ -45,7 +44,6 @@ const settingsStore = useSettingsStore();
const testDefinitionStore = useTestDefinitionStore();
const executionsStore = useExecutionsStore();
const retryDropdownRef = ref<RetryDropdownRef | null>(null);
const actionToggleRef = ref<InstanceType<typeof ProjectCreateResource> | null>(null);
const workflowId = computed(() => route.params.name as string);
const workflowPermissions = computed(
() => getResourcePermissions(workflowsStore.getWorkflowById(workflowId.value)?.scopes).workflow,
@@ -58,11 +56,11 @@ const debugButtonData = computed(() =>
props.execution?.status === 'success'
? {
text: locale.baseText('executionsList.debug.button.copyToEditor'),
type: 'secondary',
type: 'secondary' as const,
}
: {
text: locale.baseText('executionsList.debug.button.debugInEditor'),
type: 'primary',
type: 'primary' as const,
},
);
const isRetriable = computed(
@@ -80,60 +78,116 @@ const hasAnnotation = computed(
);
const testDefinitions = computed(
() => testDefinitionStore.allTestDefinitionsByWorkflowId[workflowId.value],
() => testDefinitionStore.allTestDefinitionsByWorkflowId[workflowId.value] ?? [],
);
const testDefinition = computed(() =>
testDefinitions.value.find((test) => test.id === route.query.testId),
);
const addToTestActions = computed(() => {
const testAction = testDefinitions.value
.filter((test) => test.annotationTagId)
.map((test) => {
const isAlreadyAdded = isTagAlreadyAdded(test.annotationTagId ?? '');
return {
label: `${test.name}`,
value: test.annotationTagId ?? '',
disabled: !workflowPermissions.value.update || isAlreadyAdded,
};
});
const disableAddToTestTooltip = computed(() => {
if (props.execution.mode === 'evaluation') {
return locale.baseText('testDefinition.executions.tooltip.noExecutions');
}
const newTestAction = {
label: '+ New Test',
value: 'new',
disabled: !workflowPermissions.value.update,
};
if (props.execution.status !== 'success') {
return locale.baseText('testDefinition.executions.tooltip.onlySuccess');
}
return [newTestAction, ...testAction];
return '';
});
function getTestButtonLabel(isAdded: boolean): string {
if (isAdded) {
return locale.baseText('testDefinition.executions.addedTo', {
interpolate: { name: testDefinition.value?.name ?? '' },
});
}
return testDefinition.value
? locale.baseText('testDefinition.executions.addTo.existing', {
interpolate: { name: testDefinition.value.name },
})
: locale.baseText('testDefinition.executions.addTo.new');
}
type Command = {
type: 'addTag' | 'removeTag' | 'createTest';
id: string;
name: string;
};
const addTestButtonData = computed<{ label: string; type: ButtonType }>(() => {
const isAdded = isTagAlreadyAdded(route.query.tag as string);
return {
label: getTestButtonLabel(isAdded),
type: route.query.testId ? 'primary' : 'secondary',
disabled: !workflowPermissions.value.update || isAdded,
};
const getTagIds = (tags?: Array<{ id: string; name: string }>) => (tags ?? []).map((t) => t.id);
const addExecutionTag = async (annotationTagId: string) => {
const newTags = [...getTagIds(props.execution?.annotation?.tags), annotationTagId];
await executionsStore.annotateExecution(props.execution.id, { tags: newTags });
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) {
return Boolean(tagId && props.execution?.annotation?.tags.some((tag) => tag.id === tagId));
}
const executionHasTestTag = computed(() =>
isTagAlreadyAdded(testDefinition.value?.annotationTagId),
);
async function onDeleteExecution(): Promise<void> {
// Prepend the message with a note about annotations if they exist
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 () => {
await testDefinitionStore.fetchTestDefinitionsByWorkflowId(workflowId.value);
});
@@ -285,7 +309,7 @@ onMounted(async () => {
</N8nText>
<br /><N8nText v-if="execution.mode === 'retry'" color="text-base" size="medium">
{{ locale.baseText('executionDetails.retry') }}
<router-link
<RouterLink
:class="$style.executionLink"
:to="{
name: VIEWS.EXECUTION_PREVIEW,
@@ -296,24 +320,102 @@ onMounted(async () => {
}"
>
#{{ execution.retryOf }}
</router-link>
</RouterLink>
</N8nText>
</div>
<div :class="$style.actions">
<ProjectCreateResource
v-if="testDefinitions && testDefinitions.length"
ref="actionToggleRef"
:actions="addToTestActions"
:type="addTestButtonData.type"
@action="handleAddToTestAction"
<N8nTooltip
placement="top"
:content="disableAddToTestTooltip"
:disabled="!disableAddToTestTooltip"
>
<N8nButton
data-test-id="add-to-test-button"
v-bind="addTestButtonData"
@click="handleEvaluationButton"
/>
</ProjectCreateResource>
<router-link
<ElDropdown
trigger="click"
placement="bottom-end"
data-test-id="test-execution-crud"
@command="handleCommand"
>
<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="{
name: VIEWS.EXECUTION_DEBUG,
params: {
@@ -334,7 +436,7 @@ onMounted(async () => {
>{{ debugButtonData.text }}</span
>
</N8nButton>
</router-link>
</RouterLink>
<ElDropdown
v-if="isRetriable"
@@ -463,4 +565,41 @@ onMounted(async () => {
display: flex;
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>