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

@@ -483,7 +483,7 @@ describe('Execution', () => {
cy.wait('@workflowRun').then((interception) => {
expect(interception.request.body).to.have.property('runData').that.is.an('object');
const expectedKeys = ['Start Manually', 'Edit Fields', 'Process The Data'];
const expectedKeys = ['Start on Schedule', 'Edit Fields', 'Process The Data'];
const { runData } = interception.request.body;
expect(Object.keys(runData)).to.have.lengthOf(expectedKeys.length);

View File

@@ -1,4 +1,4 @@
import { truncate } from './truncate';
import { truncateBeforeLast, truncate } from './truncate';
describe('truncate', () => {
it('should truncate text to 30 chars by default', () => {
@@ -13,3 +13,37 @@ describe('truncate', () => {
);
});
});
describe(truncateBeforeLast, () => {
it('should return unmodified text if the length does not exceed max length', () => {
expect(truncateBeforeLast('I love nodemation', 25)).toBe('I love nodemation');
expect(truncateBeforeLast('I ❤️ nodemation', 25)).toBe('I ❤️ nodemation');
expect(truncateBeforeLast('Nodemation is cool', 25)).toBe('Nodemation is cool');
expect(truncateBeforeLast('Internationalization', 25)).toBe('Internationalization');
expect(truncateBeforeLast('I love 👨‍👩‍👧‍👦', 8)).toBe('I love 👨‍👩‍👧‍👦');
});
it('should remove chars just before the last word, as long as the last word is under 15 chars', () => {
expect(truncateBeforeLast('I love nodemation', 15)).toBe('I lo…nodemation');
expect(truncateBeforeLast('I love "nodemation"', 15)).toBe('I …"nodemation"');
expect(truncateBeforeLast('I ❤️ nodemation', 13)).toBe('I …nodemation');
expect(truncateBeforeLast('Nodemation is cool', 15)).toBe('Nodemation…cool');
expect(truncateBeforeLast('"Nodemation" is cool', 15)).toBe('"Nodematio…cool');
expect(truncateBeforeLast('Is it fun to automate boring stuff?', 15)).toBe('Is it fu…stuff?');
expect(truncateBeforeLast('Is internationalization fun?', 15)).toBe('Is interna…fun?');
expect(truncateBeforeLast('I love 👨‍👩‍👧‍👦', 7)).toBe('I lov…👨👩👧👦');
});
it('should preserve last 5 characters if the last word is longer than 15 characters', () => {
expect(truncateBeforeLast('I love internationalization', 25)).toBe('I love internationa…ation');
expect(truncateBeforeLast('I love "internationalization"', 25)).toBe(
'I love "internation…tion"',
);
expect(truncateBeforeLast('I "love" internationalization', 25)).toBe(
'I "love" internatio…ation',
);
expect(truncateBeforeLast('I ❤️ internationalization', 9)).toBe('I ❤…ation');
expect(truncateBeforeLast('I ❤️ internationalization', 8)).toBe('I …ation');
expect(truncateBeforeLast('Internationalization', 15)).toBe('Internati…ation');
});
});

View File

@@ -1,2 +1,44 @@
export const truncate = (text: string, length = 30): string =>
text.length > length ? text.slice(0, length) + '...' : text;
/**
* Replace part of given text with ellipsis following the rules below:
*
* - Remove chars just before the last word, as long as the last word is under 15 chars
* - Otherwise preserve the last 5 chars of the name and remove chars before that
*/
export function truncateBeforeLast(text: string, maxLength: number): string {
const chars: string[] = [];
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
for (const { segment } of segmenter.segment(text)) {
chars.push(segment);
}
if (chars.length <= maxLength) {
return text;
}
const lastWhitespaceIndex = chars.findLastIndex((ch) => ch.match(/^\s+$/));
const lastWordIndex = lastWhitespaceIndex + 1;
const lastWord = chars.slice(lastWordIndex);
const ellipsis = '…';
const ellipsisLength = ellipsis.length;
if (lastWord.length < 15) {
const charsToRemove = chars.length - maxLength + ellipsisLength;
const indexBeforeLastWord = lastWordIndex;
const keepLength = indexBeforeLastWord - charsToRemove;
if (keepLength > 0) {
return (
chars.slice(0, keepLength).join('') + ellipsis + chars.slice(indexBeforeLastWord).join('')
);
}
}
return (
chars.slice(0, maxLength - 5 - ellipsisLength).join('') + ellipsis + chars.slice(-5).join('')
);
}

View File

@@ -56,6 +56,12 @@ const emit = defineEmits<{
select: [action: string];
visibleChange: [open: boolean];
}>();
defineSlots<{
activator: {};
menuItem: (props: ActionDropdownItem) => void;
}>();
const elementDropdown = ref<InstanceType<typeof ElDropdown>>();
const popperClass = computed(
@@ -115,7 +121,9 @@ defineExpose({ open, close });
<N8nIcon :icon="item.icon" :size="iconSize" />
</span>
<span :class="$style.label">
{{ item.label }}
<slot name="menuItem" v-bind="item">
{{ item.label }}
</slot>
</span>
<N8nIcon v-if="item.checked" icon="check" :size="iconSize" />
<span v-if="item.badge">

View File

@@ -22,7 +22,11 @@
box-sizing: border-box;
outline: none;
margin: 0;
transition: 0.3s;
transition:
all 0.3s,
padding 0s,
width 0s,
height 0s;
@include utils-user-select(none);

View File

@@ -1479,6 +1479,7 @@
"nodeView.runButtonText.executeWorkflow": "Execute workflow",
"nodeView.runButtonText.executingWorkflow": "Executing workflow",
"nodeView.runButtonText.waitingForTriggerEvent": "Waiting for trigger event",
"nodeView.runButtonText.from": "from {nodeName}",
"nodeView.showError.workflowError": "Workflow execution had an error",
"nodeView.showError.getWorkflowDataFromUrl.title": "Problem loading workflow",
"nodeView.showError.importWorkflowData.title": "Problem importing workflow",

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>

View File

@@ -549,7 +549,9 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
void externalHooks.run('nodeView.onRunWorkflow', telemetryPayload);
});
void runWorkflow({ triggerNode });
void runWorkflow({
triggerNode: triggerNode ?? workflowsStore.selectedTriggerNodeName,
});
}
return {

View File

@@ -3,13 +3,13 @@ import * as workflowsApi from '@/api/workflows';
import {
DUPLICATE_POSTFFIX,
FORM_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
MAX_WORKFLOW_NAME_LENGTH,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
WAIT_NODE_TYPE,
} from '@/constants';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { IExecutionResponse, INodeUi, IWorkflowDb, IWorkflowSettings } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { deepCopy, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
import type {
@@ -31,7 +31,8 @@ import * as apiUtils from '@n8n/rest-api-client';
import { useSettingsStore } from '@/stores/settings.store';
import { useLocalStorage } from '@vueuse/core';
import { ref } from 'vue';
import { createTestNode } from '@/__tests__/mocks';
import { createTestNode, mockNodeTypeDescription } from '@/__tests__/mocks';
import { waitFor } from '@testing-library/vue';
vi.mock('@/stores/ndv.store', () => ({
useNDVStore: vi.fn(() => ({
@@ -179,11 +180,7 @@ describe('useWorkflowsStore', () => {
describe('workflowTriggerNodes', () => {
it('should return only nodes that are triggers', () => {
vi.mocked(useNodeTypesStore).mockReturnValueOnce({
getNodeType: vi.fn(() => ({
group: ['trigger'],
})),
} as unknown as ReturnType<typeof useNodeTypesStore>);
getNodeType.mockReturnValueOnce({ group: ['trigger'] });
workflowsStore.workflow.nodes = [
{ type: 'triggerNode', typeVersion: '1' },
@@ -1270,6 +1267,31 @@ describe('useWorkflowsStore', () => {
]);
});
});
describe('selectedTriggerNode', () => {
const n0 = createTestNode({ type: MANUAL_TRIGGER_NODE_TYPE, name: 'n0' });
const n1 = createTestNode({ type: MANUAL_TRIGGER_NODE_TYPE, name: 'n1' });
const n2 = createTestNode({ type: MANUAL_TRIGGER_NODE_TYPE, name: 'n2' });
beforeEach(() => {
workflowsStore.setNodes([n0, n1]);
getNodeType.mockImplementation(() => mockNodeTypeDescription({ group: ['trigger'] }));
});
it('should select newly added trigger node automatically', async () => {
await waitFor(() => expect(workflowsStore.selectedTriggerNodeName).toBe('n0'));
workflowsStore.addNode(n2);
await waitFor(() => expect(workflowsStore.selectedTriggerNodeName).toBe('n2'));
});
it('should re-select a trigger when selected trigger gets disabled or removed', async () => {
await waitFor(() => expect(workflowsStore.selectedTriggerNodeName).toBe('n0'));
workflowsStore.removeNode(n0);
await waitFor(() => expect(workflowsStore.selectedTriggerNodeName).toBe('n1'));
workflowsStore.setNodeValue({ name: 'n1', key: 'disabled', value: true });
await waitFor(() => expect(workflowsStore.selectedTriggerNodeName).toBe(undefined));
});
});
});
function getMockEditFieldsNode() {

View File

@@ -82,20 +82,25 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { getCredentialOnlyNodeTypeName } from '@/utils/credentialOnlyNodes';
import { i18n } from '@n8n/i18n';
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useProjectsStore } from '@/stores/projects.store';
import type { ProjectSharingData } from '@/types/projects.types';
import type { PushPayload } from '@n8n/api-types';
import { useTelemetry } from '@/composables/useTelemetry';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useSettingsStore } from './settings.store';
import { clearPopupWindowState, openFormPopupWindow } from '@/utils/executionUtils';
import {
clearPopupWindowState,
findTriggerNodeToAutoSelect,
openFormPopupWindow,
} from '@/utils/executionUtils';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useUsersStore } from '@/stores/users.store';
import { updateCurrentUserSettings } from '@/api/users';
import { useExecutingNode } from '@/composables/useExecutingNode';
import type { NodeExecuteBefore } from '@n8n/api-types/push/execution';
import { useLogsStore } from './logs.store';
import { isChatNode } from '@/components/CanvasChat/utils';
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
name: '',
@@ -131,6 +136,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const nodeHelpers = useNodeHelpers();
const usersStore = useUsersStore();
const logsStore = useLogsStore();
const nodeTypesStore = useNodeTypesStore();
const version = computed(() => settingsStore.partialExecutionVersion);
const workflow = ref<IWorkflowDb>(createEmptyWorkflow());
@@ -153,6 +159,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const isInDebugMode = ref(false);
const chatMessages = ref<string[]>([]);
const chatPartialExecutionDestinationNode = ref<string | null>(null);
const selectedTriggerNodeName = ref<string>();
const {
executingNode,
@@ -182,7 +189,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const workflowTriggerNodes = computed(() =>
workflow.value.nodes.filter((node: INodeUi) => {
const nodeTypesStore = useNodeTypesStore();
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
return nodeType && nodeType.group.includes('trigger');
}),
@@ -290,6 +296,25 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
Workflow.getConnectionsByDestination(workflow.value.connections),
);
const selectableTriggerNodes = computed(() =>
workflowTriggerNodes.value.filter((node) => !node.disabled && !isChatNode(node)),
);
const workflowExecutionTriggerNodeName = computed(() => {
if (!isWorkflowRunning.value) {
return undefined;
}
if (workflowExecutionData.value?.triggerNode) {
return workflowExecutionData.value.triggerNode;
}
// In case of partial execution, triggerNode is not set, so I'm trying to find from runData
return Object.keys(workflowExecutionData.value?.data?.resultData.runData ?? {}).find((name) =>
workflowTriggerNodes.value.some((node) => node.name === name),
);
});
/**
* Sets the active execution id
*
@@ -415,7 +440,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
nodeTypes: {},
init: async (): Promise<void> => {},
getByNameAndVersion: (nodeType: string, version?: number): INodeType | undefined => {
const nodeTypeDescription = useNodeTypesStore().getNodeType(nodeType, version);
const nodeTypeDescription = nodeTypesStore.getNodeType(nodeType, version);
if (nodeTypeDescription === null) {
return undefined;
@@ -1862,6 +1887,41 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
}
}
function setSelectedTriggerNodeName(value: string) {
selectedTriggerNodeName.value = value;
}
watch(
[selectableTriggerNodes, workflowExecutionTriggerNodeName],
([newSelectable, currentTrigger], [oldSelectable]) => {
if (currentTrigger !== undefined) {
selectedTriggerNodeName.value = currentTrigger;
return;
}
if (
selectedTriggerNodeName.value === undefined ||
newSelectable.every((node) => node.name !== selectedTriggerNodeName.value)
) {
selectedTriggerNodeName.value = findTriggerNodeToAutoSelect(
selectableTriggerNodes.value,
nodeTypesStore.getNodeType,
)?.name;
return;
}
const newTrigger = newSelectable?.find((node) =>
oldSelectable?.every((old) => old.name !== node.name),
);
if (newTrigger !== undefined) {
// Select newly added node
selectedTriggerNodeName.value = newTrigger.name;
}
},
{ immediate: true },
);
return {
workflow,
usedCredentials,
@@ -1908,6 +1968,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
getAllLoadedFinishedExecutions,
getWorkflowExecution,
getPastChatMessages,
selectedTriggerNodeName: computed(() => selectedTriggerNodeName.value),
workflowExecutionTriggerNodeName,
outgoingConnectionsByNodeName,
incomingConnectionsByNodeName,
nodeHasOutputConnection,
@@ -2013,6 +2075,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
findNodeByPartialId,
getPartialIdForNode,
getNewWorkflowDataAndMakeShareable,
setSelectedTriggerNodeName,
totalWorkflowCount,
};
});

View File

@@ -6,11 +6,19 @@ import {
waitingNodeTooltip,
getExecutionErrorMessage,
getExecutionErrorToastConfiguration,
findTriggerNodeToAutoSelect,
} from './executionUtils';
import type { INode, IRunData, IPinData, ExecutionError } from 'n8n-workflow';
import type { INode, IRunData, IPinData, ExecutionError, INodeTypeDescription } from 'n8n-workflow';
import { type INodeUi } from '../Interface';
import { CHAT_TRIGGER_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, GITHUB_NODE_TYPE } from '@/constants';
import { createTestNode } from '@/__tests__/mocks';
import {
CHAT_TRIGGER_NODE_TYPE,
CORE_NODES_CATEGORY,
FORM_TRIGGER_NODE_TYPE,
GITHUB_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
SCHEDULE_TRIGGER_NODE_TYPE,
} from '@/constants';
import { createTestNode, mockNodeTypeDescription } from '@/__tests__/mocks';
import type { VNode } from 'vue';
const WAIT_NODE_TYPE = 'waitNode';
@@ -504,3 +512,51 @@ describe('getExecutionErrorToastConfiguration', () => {
});
});
});
describe(findTriggerNodeToAutoSelect, () => {
const APP_TRIGGER_TYPE = 'app trigger';
function getNodeType(type: string): INodeTypeDescription {
return mockNodeTypeDescription({
name: type,
codex: { categories: type === APP_TRIGGER_TYPE ? [] : [CORE_NODES_CATEGORY] },
});
}
it('should return the first enabled node', () => {
expect(
findTriggerNodeToAutoSelect(
[
createTestNode({ name: 'A', disabled: true }),
createTestNode({ name: 'B', disabled: false }),
createTestNode({ name: 'C', disabled: false }),
],
getNodeType,
),
).toEqual(expect.objectContaining({ name: 'B' }));
});
it('should prioritize form trigger node than other node types', () => {
expect(
findTriggerNodeToAutoSelect(
[
createTestNode({ name: 'A', type: MANUAL_TRIGGER_NODE_TYPE }),
createTestNode({ name: 'B', type: FORM_TRIGGER_NODE_TYPE }),
],
getNodeType,
),
).toEqual(expect.objectContaining({ name: 'B' }));
});
it('should prioritize an app trigger than a scheduled trigger', () => {
expect(
findTriggerNodeToAutoSelect(
[
createTestNode({ name: 'A', type: SCHEDULE_TRIGGER_NODE_TYPE }),
createTestNode({ name: 'B', type: APP_TRIGGER_TYPE }),
],
getNodeType,
),
).toEqual(expect.objectContaining({ name: 'B' }));
});
});

View File

@@ -1,4 +1,8 @@
import { SEND_AND_WAIT_OPERATION, TRIMMED_TASK_DATA_CONNECTIONS_KEY } from 'n8n-workflow';
import {
MANUAL_TRIGGER_NODE_TYPE,
SEND_AND_WAIT_OPERATION,
TRIMMED_TASK_DATA_CONNECTIONS_KEY,
} from 'n8n-workflow';
import type {
ITaskData,
ExecutionStatus,
@@ -7,6 +11,7 @@ import type {
IPinData,
IRunData,
ExecutionError,
INodeTypeBaseDescription,
} from 'n8n-workflow';
import type {
ExecutionFilterType,
@@ -16,7 +21,16 @@ import type {
INodeUi,
} from '@/Interface';
import { isEmpty } from '@/utils/typesUtils';
import { FORM_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, GITHUB_NODE_TYPE } from '../constants';
import {
CORE_NODES_CATEGORY,
ERROR_TRIGGER_NODE_TYPE,
FORM_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
GITHUB_NODE_TYPE,
SCHEDULE_TRIGGER_NODE_TYPE,
WEBHOOK_NODE_TYPE,
WORKFLOW_TRIGGER_NODE_TYPE,
} from '../constants';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import { i18n } from '@n8n/i18n';
@@ -385,3 +399,33 @@ export function unflattenExecutionData(fullExecutionData: IExecutionFlattedRespo
return returnData;
}
export function findTriggerNodeToAutoSelect(
triggerNodes: INodeUi[],
getNodeType: (type: string, typeVersion: number) => INodeTypeBaseDescription | null,
) {
const autoSelectPriorities: Record<string, number | undefined> = {
[FORM_TRIGGER_NODE_TYPE]: 10,
[WEBHOOK_NODE_TYPE]: 9,
// ..."Other apps"
[SCHEDULE_TRIGGER_NODE_TYPE]: 7,
[MANUAL_TRIGGER_NODE_TYPE]: 6,
[WORKFLOW_TRIGGER_NODE_TYPE]: 5,
[ERROR_TRIGGER_NODE_TYPE]: 4,
};
function isCoreNode(node: INodeUi): boolean {
const nodeType = getNodeType(node.type, node.typeVersion);
return nodeType?.codex?.categories?.includes(CORE_NODES_CATEGORY) ?? false;
}
return triggerNodes
.toSorted((a, b) => {
const aPriority = autoSelectPriorities[a.type] ?? (isCoreNode(a) ? 0 : 8);
const bPriority = autoSelectPriorities[b.type] ?? (isCoreNode(b) ? 0 : 8);
return bPriority - aPriority;
})
.find((node) => !node.disabled);
}

View File

@@ -14,6 +14,7 @@ import type { TestRunRecord } from '@/api/evaluation.ee';
import { PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
import { useTelemetry } from '@/composables/useTelemetry';
import { EVALUATION_NODE_TYPE, EVALUATION_TRIGGER_NODE_TYPE, NodeHelpers } from 'n8n-workflow';
import { mockNodeTypeDescription } from '@/__tests__/mocks';
vi.mock('@/composables/useTelemetry', () => {
const track = vi.fn();
@@ -214,7 +215,9 @@ describe('EvaluationsRootView', () => {
// Mock dataset trigger node type exists
getNodeType.mockImplementation((nodeType) =>
nodeType === EVALUATION_TRIGGER_NODE_TYPE ? { name: EVALUATION_TRIGGER_NODE_TYPE } : null,
nodeType === EVALUATION_TRIGGER_NODE_TYPE
? mockNodeTypeDescription({ name: EVALUATION_TRIGGER_NODE_TYPE })
: null,
);
renderComponent({ props: { name: mockWorkflow.id } });
@@ -268,7 +271,9 @@ describe('EvaluationsRootView', () => {
// Mock evaluation node type exists
getNodeType.mockImplementation((nodeType) =>
nodeType === EVALUATION_NODE_TYPE ? { name: EVALUATION_NODE_TYPE } : null,
nodeType === EVALUATION_NODE_TYPE
? mockNodeTypeDescription({ name: EVALUATION_NODE_TYPE })
: null,
);
renderComponent({ props: { name: mockWorkflow.id } });
@@ -322,7 +327,9 @@ describe('EvaluationsRootView', () => {
// Mock evaluation node type exists
getNodeType.mockImplementation((nodeType) =>
nodeType === EVALUATION_NODE_TYPE ? { name: EVALUATION_NODE_TYPE } : null,
nodeType === EVALUATION_NODE_TYPE
? mockNodeTypeDescription({ name: EVALUATION_NODE_TYPE })
: null,
);
renderComponent({ props: { name: mockWorkflow.id } });

View File

@@ -2024,15 +2024,20 @@ onBeforeUnmount(() => {
:waiting-for-webhook="isExecutionWaitingForWebhook"
:disabled="isExecutionDisabled"
:executing="isWorkflowRunning"
:trigger-nodes="triggerNodes"
:get-node-type="nodeTypesStore.getNodeType"
:selected-trigger-node-name="workflowsStore.selectedTriggerNodeName"
@mouseenter="onRunWorkflowButtonMouseEnter"
@mouseleave="onRunWorkflowButtonMouseLeave"
@click="runEntireWorkflow('main')"
@execute="runEntireWorkflow('main')"
@select-trigger-node="workflowsStore.setSelectedTriggerNodeName"
/>
<template v-if="containsChatTriggerNodes">
<CanvasChatButton
v-if="isLogsPanelOpen"
type="tertiary"
:label="i18n.baseText('chat.hide')"
:class="$style.chatButton"
@click="logsStore.toggleOpen(false)"
/>
<KeyboardShortcutTooltip
@@ -2041,8 +2046,9 @@ onBeforeUnmount(() => {
:shortcut="{ keys: ['c'] }"
>
<CanvasChatButton
type="primary"
:type="isRunWorkflowButtonVisible ? 'secondary' : 'primary'"
:label="i18n.baseText('chat.open')"
:class="$style.chatButton"
@click="onOpenChat"
/>
</KeyboardShortcutTooltip>
@@ -2137,6 +2143,10 @@ onBeforeUnmount(() => {
}
}
}
.chatButton {
align-self: stretch;
}
}
.setupCredentialsButtonWrapper {