feat(editor): Make ‘Execute workflow’ a split button (#15933)

This commit is contained in:
Suguru Inoue
2025-06-06 13:05:53 +02:00
committed by GitHub
parent eb71c41e93
commit ac1a1dfbc2
20 changed files with 619 additions and 70 deletions

View File

@@ -116,13 +116,13 @@ async function handleOpenNdv(treeNode: LogEntry) {
ref="container"
:class="$style.container"
tabindex="-1"
@keydown.esc.stop="select(undefined)"
@keydown.j.stop="selectNext"
@keydown.down.stop.prevent="selectNext"
@keydown.k.stop="selectPrev"
@keydown.up.stop.prevent="selectPrev"
@keydown.space.stop="selected && toggleExpanded(selected)"
@keydown.enter.stop="selected && handleOpenNdv(selected)"
@keydown.esc.exact.stop="select(undefined)"
@keydown.j.exact.stop="selectNext"
@keydown.down.exact.stop.prevent="selectNext"
@keydown.k.exact.stop="selectPrev"
@keydown.up.exact.stop.prevent="selectPrev"
@keydown.space.exact.stop="selected && toggleExpanded(selected)"
@keydown.enter.exact.stop="selected && handleOpenNdv(selected)"
>
<N8nResizeWrapper
v-if="hasChat && (!props.isReadOnly || messages.length > 0)"

View File

@@ -14,12 +14,8 @@ import { createMockEnterpriseSettings } from '@/__tests__/mocks';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { INodeParameterResourceLocator } from 'n8n-workflow';
let mockNdvState: Partial<ReturnType<typeof useNDVStore>>;
let mockNodeTypesState: Partial<ReturnType<typeof useNodeTypesStore>>;
let mockCompletionResult: Partial<CompletionResult>;
beforeEach(() => {
mockNdvState = {
function getNdvStateMock(): Partial<ReturnType<typeof useNDVStore>> {
return {
hasInputData: true,
activeNode: {
id: faker.string.uuid(),
@@ -32,9 +28,21 @@ beforeEach(() => {
isInputPanelEmpty: false,
isOutputPanelEmpty: false,
};
mockNodeTypesState = {
}
function getNodeTypesStateMock(): Partial<ReturnType<typeof useNodeTypesStore>> {
return {
allNodeTypes: [],
};
}
let mockNdvState = getNdvStateMock();
let mockNodeTypesState = getNodeTypesStateMock();
let mockCompletionResult: Partial<CompletionResult> = {};
beforeEach(() => {
mockNdvState = getNdvStateMock();
mockNodeTypesState = getNodeTypesStateMock();
mockCompletionResult = {};
createAppModals();
});

View File

@@ -1,8 +1,8 @@
<script lang="ts" setup>
export interface Props {
type: 'primary' | 'tertiary';
label: string;
}
import { type ButtonProps } from '@n8n/design-system';
export type Props = Pick<ButtonProps, 'label' | 'type'>;
defineProps<Props>();
</script>
<template>

View File

@@ -1,11 +1,23 @@
import { createComponentRenderer } from '@/__tests__/render';
import CanvasRunWorkflowButton from './CanvasRunWorkflowButton.vue';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/vue';
const renderComponent = createComponentRenderer(CanvasRunWorkflowButton);
import { fireEvent, waitFor } from '@testing-library/vue';
import { createTestNode } from '@/__tests__/mocks';
import {
CHAT_TRIGGER_NODE_TYPE,
MANUAL_CHAT_TRIGGER_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
SCHEDULE_TRIGGER_NODE_TYPE,
} from '@/constants';
describe('CanvasRunWorkflowButton', () => {
const renderComponent = createComponentRenderer(CanvasRunWorkflowButton, {
props: {
triggerNodes: [createTestNode({ type: MANUAL_CHAT_TRIGGER_NODE_TYPE })],
getNodeType: () => null,
},
});
it('should render correctly', () => {
const wrapper = renderComponent();
@@ -50,4 +62,80 @@ describe('CanvasRunWorkflowButton', () => {
await waitFor(() => expect(isTooltipVisible(false)).toBeTruthy());
});
it('should render split button if multiple triggers are available', () => {
const wrapper = renderComponent({
props: {
selectedTriggerNodeName: 'A',
triggerNodes: [
createTestNode({ name: 'A', type: MANUAL_TRIGGER_NODE_TYPE }),
createTestNode({ name: 'B', type: SCHEDULE_TRIGGER_NODE_TYPE }),
],
},
});
expect(wrapper.container.textContent).toBe('Execute workflow from A');
expect(wrapper.queryByLabelText('Select trigger node')).toBeInTheDocument();
});
it('should not render split button if there is only one trigger that is not disabled nor a chat trigger', () => {
const wrapper = renderComponent({
props: {
selectedTriggerNodeName: 'A',
triggerNodes: [
createTestNode({ name: 'A', type: MANUAL_TRIGGER_NODE_TYPE }),
createTestNode({ name: 'B', type: MANUAL_TRIGGER_NODE_TYPE, disabled: true }),
createTestNode({ name: 'C', type: CHAT_TRIGGER_NODE_TYPE }),
],
},
});
expect(wrapper.container.textContent).toBe('Execute workflow ');
expect(wrapper.queryByLabelText('Select trigger node')).not.toBeInTheDocument();
});
it('should show available triggers in the ordering of coordinate on the canvas when chevron icon is clicked', async () => {
const wrapper = renderComponent({
props: {
selectedTriggerNodeName: 'A',
triggerNodes: [
createTestNode({ name: 'A', type: MANUAL_TRIGGER_NODE_TYPE, position: [1, 1] }),
createTestNode({ name: 'B', type: MANUAL_TRIGGER_NODE_TYPE, position: [1, 0] }),
createTestNode({ name: 'C', type: MANUAL_TRIGGER_NODE_TYPE, position: [0, 0] }),
createTestNode({ name: 'D', type: MANUAL_TRIGGER_NODE_TYPE, position: [0, 1] }),
],
},
});
const chevron = (await wrapper.findAllByRole('button'))[1];
await fireEvent.click(chevron);
const menuItems = await wrapper.findAllByRole('menuitem');
expect(menuItems).toHaveLength(4);
expect(menuItems[0]).toHaveTextContent('from C');
expect(menuItems[1]).toHaveTextContent('from B');
expect(menuItems[2]).toHaveTextContent('from D');
expect(menuItems[3]).toHaveTextContent('from A');
});
it('should allow to select and execute a different trigger', async () => {
const wrapper = renderComponent({
props: {
selectedTriggerNodeName: 'A',
triggerNodes: [
createTestNode({ name: 'A', type: MANUAL_TRIGGER_NODE_TYPE }),
createTestNode({ name: 'B', type: MANUAL_TRIGGER_NODE_TYPE }),
],
},
});
const [executeButton, chevron] = await wrapper.findAllByRole('button');
await fireEvent.click(chevron);
const menuItems = await wrapper.findAllByRole('menuitem');
await fireEvent.click(menuItems[1]);
await fireEvent.click(executeButton);
expect(wrapper.emitted('selectTriggerNode')).toEqual([['B']]);
});
});

View File

@@ -1,22 +1,34 @@
<script setup lang="ts">
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { type INodeUi } from '@/Interface';
import { truncateBeforeLast } from '@n8n/utils/string/truncate';
import { type ActionDropdownItem, N8nActionDropdown, N8nButton, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { type INodeTypeDescription } from 'n8n-workflow';
import { computed } from 'vue';
import { isChatNode } from '@/components/CanvasChat/utils';
defineEmits<{
const emit = defineEmits<{
mouseenter: [event: MouseEvent];
mouseleave: [event: MouseEvent];
click: [event: MouseEvent];
execute: [];
selectTriggerNode: [name: string];
}>();
const props = defineProps<{
selectedTriggerNodeName?: string;
triggerNodes: INodeUi[];
waitingForWebhook?: boolean;
executing?: boolean;
disabled?: boolean;
getNodeType: (type: string, typeVersion: number) => INodeTypeDescription | null;
}>();
const i18n = useI18n();
const selectableTriggerNodes = computed(() =>
props.triggerNodes.filter((node) => !node.disabled && !isChatNode(node)),
);
const label = computed(() => {
if (!props.executing) {
return i18n.baseText('nodeView.runButtonText.executeWorkflow');
@@ -28,25 +40,163 @@ const label = computed(() => {
return i18n.baseText('nodeView.runButtonText.executingWorkflow');
});
const actions = computed(() =>
props.triggerNodes
.filter((node) => !isChatNode(node))
.toSorted((a, b) => {
const [aX, aY] = a.position;
const [bX, bY] = b.position;
return aY === bY ? aX - bX : aY - bY;
})
.map<ActionDropdownItem>((node) => ({
label: truncateBeforeLast(node.name, 25),
disabled: !!node.disabled,
id: node.name,
checked: props.selectedTriggerNodeName === node.name,
})),
);
const isSplitButton = computed(
() => selectableTriggerNodes.value.length > 1 && props.selectedTriggerNodeName !== undefined,
);
function getNodeTypeByName(name: string): INodeTypeDescription | null {
const node = props.triggerNodes.find((trigger) => trigger.name === name);
if (!node) {
return null;
}
return props.getNodeType(node.type, node.typeVersion);
}
</script>
<template>
<KeyboardShortcutTooltip
:label="label"
:shortcut="{ metaKey: true, keys: ['↵'] }"
:disabled="executing"
>
<N8nButton
:loading="executing"
<div :class="[$style.component, isSplitButton ? $style.split : '']">
<KeyboardShortcutTooltip
:label="label"
:disabled="disabled"
size="large"
icon="flask"
type="primary"
data-test-id="execute-workflow-button"
@mouseenter="$emit('mouseenter', $event)"
@mouseleave="$emit('mouseleave', $event)"
@click.stop="$emit('click', $event)"
/>
</KeyboardShortcutTooltip>
:shortcut="{ metaKey: true, keys: ['↵'] }"
:disabled="executing"
>
<N8nButton
:class="$style.button"
:loading="executing"
:disabled="disabled"
size="large"
icon="flask"
type="primary"
data-test-id="execute-workflow-button"
@mouseenter="$emit('mouseenter', $event)"
@mouseleave="$emit('mouseleave', $event)"
@click="emit('execute')"
>
<span :class="$style.buttonContent">
{{ label }}
<N8nText v-if="isSplitButton" :class="$style.subText" :bold="false">
<I18nT keypath="nodeView.runButtonText.from">
<template #nodeName>
<N8nText bold size="mini">
{{ truncateBeforeLast(props.selectedTriggerNodeName ?? '', 25) }}
</N8nText>
</template>
</I18nT>
</N8nText>
</span>
</N8nButton>
</KeyboardShortcutTooltip>
<template v-if="isSplitButton">
<N8nActionDropdown
:class="$style.menu"
:items="actions"
:disabled="disabled"
placement="top"
@select="emit('selectTriggerNode', $event)"
>
<template #activator>
<N8nButton
type="primary"
size="large"
:disabled="disabled || executing"
:class="$style.chevron"
aria-label="Select trigger node"
icon="angle-down"
/>
</template>
<template #menuItem="item">
<div :class="[$style.menuItem, item.disabled ? $style.disabled : '']">
<NodeIcon :class="$style.menuIcon" :size="16" :node-type="getNodeTypeByName(item.id)" />
<span>
<I18nT keypath="nodeView.runButtonText.from">
<template #nodeName>
<N8nText bold size="small">{{ item.label }}</N8nText>
</template>
</I18nT>
</span>
</div>
</template>
</N8nActionDropdown>
</template>
</div>
</template>
<style lang="scss" module>
.component {
position: relative;
display: flex;
align-items: stretch;
}
.button {
.split & {
height: var(--spacing-2xl);
padding-inline-start: var(--spacing-xs);
padding-inline-end: var(--spacing-xl);
padding-block: 0;
}
}
.chevron {
position: absolute;
right: var(--spacing-3xs);
top: var(--spacing-3xs);
width: 18px;
height: calc(100% - var(--spacing-3xs) * 2);
border: none;
padding: 0;
background-color: transparent;
&:not(:disabled):hover {
background-color: var(--prim-color-primary-tint-100);
}
&:disabled {
background-color: transparent !important;
}
}
.menu :global(.el-dropdown) {
height: 100%;
}
.menuItem {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
}
.menuItem.disabled .menuIcon {
opacity: 0.2;
}
.buttonContent {
display: flex;
flex-direction: column;
align-items: flex-start !important;
gap: var(--spacing-5xs);
}
.subText {
font-size: var(--font-size-2xs);
}
</style>

View File

@@ -1,7 +1,9 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasRunWorkflowButton > should render correctly 1`] = `
"<button class="button button primary large withIcon el-tooltip__trigger el-tooltip__trigger" aria-live="polite" data-test-id="execute-workflow-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-flask fa-w-14 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="flask" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path class="" fill="currentColor" d="M437.2 403.5L320 215V64h8c13.3 0 24-10.7 24-24V24c0-13.3-10.7-24-24-24H120c-13.3 0-24 10.7-24 24v16c0 13.3 10.7 24 24 24h8v151L10.8 403.5C-18.5 450.6 15.3 512 70.9 512h306.2c55.7 0 89.4-61.5 60.1-108.5zM137.9 320l48.2-77.6c3.7-5.2 5.8-11.6 5.8-18.4V64h64v160c0 6.9 2.2 13.2 5.8 18.4l48.2 77.6h-172z"></path></svg></span></span><span>Execute workflow</span></button>
<!--teleport start-->
<!--teleport end-->"
"<div class="component"><button class="button button primary large withIcon button el-tooltip__trigger el-tooltip__trigger button el-tooltip__trigger el-tooltip__trigger" aria-live="polite" data-test-id="execute-workflow-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-flask fa-w-14 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="flask" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path class="" fill="currentColor" d="M437.2 403.5L320 215V64h8c13.3 0 24-10.7 24-24V24c0-13.3-10.7-24-24-24H120c-13.3 0-24 10.7-24 24v16c0 13.3 10.7 24 24 24h8v151L10.8 403.5C-18.5 450.6 15.3 512 70.9 512h306.2c55.7 0 89.4-61.5 60.1-108.5zM137.9 320l48.2-77.6c3.7-5.2 5.8-11.6 5.8-18.4V64h64v160c0 6.9 2.2 13.2 5.8 18.4l48.2 77.6h-172z"></path></svg></span></span><span class="buttonContent">Execute workflow <!--v-if--></span></button>
<!--teleport start-->
<!--teleport end-->
<!--v-if-->
</div>"
`;

View File

@@ -44,6 +44,11 @@ const { startChat } = useCanvasOperations();
const isChatOpen = computed(() => logsStore.isOpen);
const isExecuting = computed(() => workflowsStore.isWorkflowRunning);
const testId = computed(() => `execute-workflow-button-${name}`);
async function handleClickExecute() {
workflowsStore.setSelectedTriggerNodeName(name);
await runEntireWorkflow('node', name);
}
</script>
<template>
@@ -59,6 +64,7 @@ const testId = computed(() => `execute-workflow-button-${name}`);
<N8nButton
v-if="isChatOpen"
type="secondary"
icon="comment"
size="large"
:disabled="isExecuting"
:data-test-id="testId"
@@ -72,6 +78,7 @@ const testId = computed(() => `execute-workflow-button-${name}`);
>
<N8nButton
type="primary"
icon="comment"
size="large"
:disabled="isExecuting"
:data-test-id="testId"
@@ -83,11 +90,12 @@ const testId = computed(() => `execute-workflow-button-${name}`);
<N8nButton
v-else
type="primary"
icon="flask"
size="large"
:disabled="isExecuting"
:data-test-id="testId"
:label="i18n.baseText('nodeView.runButtonText.executeWorkflow')"
@click.capture="runEntireWorkflow('node', name)"
@click.capture="handleClickExecute"
/>
</template>
</div>