mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Make ‘Execute workflow’ a split button (#15933)
This commit is contained in:
@@ -483,7 +483,7 @@ describe('Execution', () => {
|
|||||||
cy.wait('@workflowRun').then((interception) => {
|
cy.wait('@workflowRun').then((interception) => {
|
||||||
expect(interception.request.body).to.have.property('runData').that.is.an('object');
|
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;
|
const { runData } = interception.request.body;
|
||||||
expect(Object.keys(runData)).to.have.lengthOf(expectedKeys.length);
|
expect(Object.keys(runData)).to.have.lengthOf(expectedKeys.length);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { truncate } from './truncate';
|
import { truncateBeforeLast, truncate } from './truncate';
|
||||||
|
|
||||||
describe('truncate', () => {
|
describe('truncate', () => {
|
||||||
it('should truncate text to 30 chars by default', () => {
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,2 +1,44 @@
|
|||||||
export const truncate = (text: string, length = 30): string =>
|
export const truncate = (text: string, length = 30): string =>
|
||||||
text.length > length ? text.slice(0, length) + '...' : text;
|
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('')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ const emit = defineEmits<{
|
|||||||
select: [action: string];
|
select: [action: string];
|
||||||
visibleChange: [open: boolean];
|
visibleChange: [open: boolean];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
defineSlots<{
|
||||||
|
activator: {};
|
||||||
|
menuItem: (props: ActionDropdownItem) => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
const elementDropdown = ref<InstanceType<typeof ElDropdown>>();
|
const elementDropdown = ref<InstanceType<typeof ElDropdown>>();
|
||||||
|
|
||||||
const popperClass = computed(
|
const popperClass = computed(
|
||||||
@@ -115,7 +121,9 @@ defineExpose({ open, close });
|
|||||||
<N8nIcon :icon="item.icon" :size="iconSize" />
|
<N8nIcon :icon="item.icon" :size="iconSize" />
|
||||||
</span>
|
</span>
|
||||||
<span :class="$style.label">
|
<span :class="$style.label">
|
||||||
|
<slot name="menuItem" v-bind="item">
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
|
</slot>
|
||||||
</span>
|
</span>
|
||||||
<N8nIcon v-if="item.checked" icon="check" :size="iconSize" />
|
<N8nIcon v-if="item.checked" icon="check" :size="iconSize" />
|
||||||
<span v-if="item.badge">
|
<span v-if="item.badge">
|
||||||
|
|||||||
@@ -22,7 +22,11 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
outline: none;
|
outline: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
transition: 0.3s;
|
transition:
|
||||||
|
all 0.3s,
|
||||||
|
padding 0s,
|
||||||
|
width 0s,
|
||||||
|
height 0s;
|
||||||
|
|
||||||
@include utils-user-select(none);
|
@include utils-user-select(none);
|
||||||
|
|
||||||
|
|||||||
@@ -1479,6 +1479,7 @@
|
|||||||
"nodeView.runButtonText.executeWorkflow": "Execute workflow",
|
"nodeView.runButtonText.executeWorkflow": "Execute workflow",
|
||||||
"nodeView.runButtonText.executingWorkflow": "Executing workflow",
|
"nodeView.runButtonText.executingWorkflow": "Executing workflow",
|
||||||
"nodeView.runButtonText.waitingForTriggerEvent": "Waiting for trigger event",
|
"nodeView.runButtonText.waitingForTriggerEvent": "Waiting for trigger event",
|
||||||
|
"nodeView.runButtonText.from": "from {nodeName}",
|
||||||
"nodeView.showError.workflowError": "Workflow execution had an error",
|
"nodeView.showError.workflowError": "Workflow execution had an error",
|
||||||
"nodeView.showError.getWorkflowDataFromUrl.title": "Problem loading workflow",
|
"nodeView.showError.getWorkflowDataFromUrl.title": "Problem loading workflow",
|
||||||
"nodeView.showError.importWorkflowData.title": "Problem importing workflow",
|
"nodeView.showError.importWorkflowData.title": "Problem importing workflow",
|
||||||
|
|||||||
@@ -116,13 +116,13 @@ async function handleOpenNdv(treeNode: LogEntry) {
|
|||||||
ref="container"
|
ref="container"
|
||||||
:class="$style.container"
|
:class="$style.container"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@keydown.esc.stop="select(undefined)"
|
@keydown.esc.exact.stop="select(undefined)"
|
||||||
@keydown.j.stop="selectNext"
|
@keydown.j.exact.stop="selectNext"
|
||||||
@keydown.down.stop.prevent="selectNext"
|
@keydown.down.exact.stop.prevent="selectNext"
|
||||||
@keydown.k.stop="selectPrev"
|
@keydown.k.exact.stop="selectPrev"
|
||||||
@keydown.up.stop.prevent="selectPrev"
|
@keydown.up.exact.stop.prevent="selectPrev"
|
||||||
@keydown.space.stop="selected && toggleExpanded(selected)"
|
@keydown.space.exact.stop="selected && toggleExpanded(selected)"
|
||||||
@keydown.enter.stop="selected && handleOpenNdv(selected)"
|
@keydown.enter.exact.stop="selected && handleOpenNdv(selected)"
|
||||||
>
|
>
|
||||||
<N8nResizeWrapper
|
<N8nResizeWrapper
|
||||||
v-if="hasChat && (!props.isReadOnly || messages.length > 0)"
|
v-if="hasChat && (!props.isReadOnly || messages.length > 0)"
|
||||||
|
|||||||
@@ -14,12 +14,8 @@ import { createMockEnterpriseSettings } from '@/__tests__/mocks';
|
|||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import type { INodeParameterResourceLocator } from 'n8n-workflow';
|
import type { INodeParameterResourceLocator } from 'n8n-workflow';
|
||||||
|
|
||||||
let mockNdvState: Partial<ReturnType<typeof useNDVStore>>;
|
function getNdvStateMock(): Partial<ReturnType<typeof useNDVStore>> {
|
||||||
let mockNodeTypesState: Partial<ReturnType<typeof useNodeTypesStore>>;
|
return {
|
||||||
let mockCompletionResult: Partial<CompletionResult>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockNdvState = {
|
|
||||||
hasInputData: true,
|
hasInputData: true,
|
||||||
activeNode: {
|
activeNode: {
|
||||||
id: faker.string.uuid(),
|
id: faker.string.uuid(),
|
||||||
@@ -32,9 +28,21 @@ beforeEach(() => {
|
|||||||
isInputPanelEmpty: false,
|
isInputPanelEmpty: false,
|
||||||
isOutputPanelEmpty: false,
|
isOutputPanelEmpty: false,
|
||||||
};
|
};
|
||||||
mockNodeTypesState = {
|
}
|
||||||
|
|
||||||
|
function getNodeTypesStateMock(): Partial<ReturnType<typeof useNodeTypesStore>> {
|
||||||
|
return {
|
||||||
allNodeTypes: [],
|
allNodeTypes: [],
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let mockNdvState = getNdvStateMock();
|
||||||
|
let mockNodeTypesState = getNodeTypesStateMock();
|
||||||
|
let mockCompletionResult: Partial<CompletionResult> = {};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockNdvState = getNdvStateMock();
|
||||||
|
mockNodeTypesState = getNodeTypesStateMock();
|
||||||
mockCompletionResult = {};
|
mockCompletionResult = {};
|
||||||
createAppModals();
|
createAppModals();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
export interface Props {
|
import { type ButtonProps } from '@n8n/design-system';
|
||||||
type: 'primary' | 'tertiary';
|
|
||||||
label: string;
|
export type Props = Pick<ButtonProps, 'label' | 'type'>;
|
||||||
}
|
|
||||||
defineProps<Props>();
|
defineProps<Props>();
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import CanvasRunWorkflowButton from './CanvasRunWorkflowButton.vue';
|
import CanvasRunWorkflowButton from './CanvasRunWorkflowButton.vue';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { waitFor } from '@testing-library/vue';
|
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||||
|
import { createTestNode } from '@/__tests__/mocks';
|
||||||
const renderComponent = createComponentRenderer(CanvasRunWorkflowButton);
|
import {
|
||||||
|
CHAT_TRIGGER_NODE_TYPE,
|
||||||
|
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
||||||
|
MANUAL_TRIGGER_NODE_TYPE,
|
||||||
|
SCHEDULE_TRIGGER_NODE_TYPE,
|
||||||
|
} from '@/constants';
|
||||||
|
|
||||||
describe('CanvasRunWorkflowButton', () => {
|
describe('CanvasRunWorkflowButton', () => {
|
||||||
|
const renderComponent = createComponentRenderer(CanvasRunWorkflowButton, {
|
||||||
|
props: {
|
||||||
|
triggerNodes: [createTestNode({ type: MANUAL_CHAT_TRIGGER_NODE_TYPE })],
|
||||||
|
getNodeType: () => null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
it('should render correctly', () => {
|
it('should render correctly', () => {
|
||||||
const wrapper = renderComponent();
|
const wrapper = renderComponent();
|
||||||
|
|
||||||
@@ -50,4 +62,80 @@ describe('CanvasRunWorkflowButton', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(isTooltipVisible(false)).toBeTruthy());
|
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']]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
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 { useI18n } from '@n8n/i18n';
|
||||||
|
import { type INodeTypeDescription } from 'n8n-workflow';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { isChatNode } from '@/components/CanvasChat/utils';
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
mouseenter: [event: MouseEvent];
|
mouseenter: [event: MouseEvent];
|
||||||
mouseleave: [event: MouseEvent];
|
mouseleave: [event: MouseEvent];
|
||||||
click: [event: MouseEvent];
|
execute: [];
|
||||||
|
selectTriggerNode: [name: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
selectedTriggerNodeName?: string;
|
||||||
|
triggerNodes: INodeUi[];
|
||||||
waitingForWebhook?: boolean;
|
waitingForWebhook?: boolean;
|
||||||
executing?: boolean;
|
executing?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
getNodeType: (type: string, typeVersion: number) => INodeTypeDescription | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
const selectableTriggerNodes = computed(() =>
|
||||||
|
props.triggerNodes.filter((node) => !node.disabled && !isChatNode(node)),
|
||||||
|
);
|
||||||
const label = computed(() => {
|
const label = computed(() => {
|
||||||
if (!props.executing) {
|
if (!props.executing) {
|
||||||
return i18n.baseText('nodeView.runButtonText.executeWorkflow');
|
return i18n.baseText('nodeView.runButtonText.executeWorkflow');
|
||||||
@@ -28,17 +40,47 @@ const label = computed(() => {
|
|||||||
|
|
||||||
return i18n.baseText('nodeView.runButtonText.executingWorkflow');
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<div :class="[$style.component, isSplitButton ? $style.split : '']">
|
||||||
<KeyboardShortcutTooltip
|
<KeyboardShortcutTooltip
|
||||||
:label="label"
|
:label="label"
|
||||||
:shortcut="{ metaKey: true, keys: ['↵'] }"
|
:shortcut="{ metaKey: true, keys: ['↵'] }"
|
||||||
:disabled="executing"
|
:disabled="executing"
|
||||||
>
|
>
|
||||||
<N8nButton
|
<N8nButton
|
||||||
|
:class="$style.button"
|
||||||
:loading="executing"
|
:loading="executing"
|
||||||
:label="label"
|
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
size="large"
|
size="large"
|
||||||
icon="flask"
|
icon="flask"
|
||||||
@@ -46,7 +88,115 @@ const label = computed(() => {
|
|||||||
data-test-id="execute-workflow-button"
|
data-test-id="execute-workflow-button"
|
||||||
@mouseenter="$emit('mouseenter', $event)"
|
@mouseenter="$emit('mouseenter', $event)"
|
||||||
@mouseleave="$emit('mouseleave', $event)"
|
@mouseleave="$emit('mouseleave', $event)"
|
||||||
@click.stop="$emit('click', $event)"
|
@click="emit('execute')"
|
||||||
/>
|
>
|
||||||
</KeyboardShortcutTooltip>
|
<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>
|
</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>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
exports[`CanvasRunWorkflowButton > should render correctly 1`] = `
|
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>
|
"<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 start-->
|
||||||
<!--teleport end-->"
|
<!--teleport end-->
|
||||||
|
<!--v-if-->
|
||||||
|
</div>"
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ const { startChat } = useCanvasOperations();
|
|||||||
const isChatOpen = computed(() => logsStore.isOpen);
|
const isChatOpen = computed(() => logsStore.isOpen);
|
||||||
const isExecuting = computed(() => workflowsStore.isWorkflowRunning);
|
const isExecuting = computed(() => workflowsStore.isWorkflowRunning);
|
||||||
const testId = computed(() => `execute-workflow-button-${name}`);
|
const testId = computed(() => `execute-workflow-button-${name}`);
|
||||||
|
|
||||||
|
async function handleClickExecute() {
|
||||||
|
workflowsStore.setSelectedTriggerNodeName(name);
|
||||||
|
await runEntireWorkflow('node', name);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -59,6 +64,7 @@ const testId = computed(() => `execute-workflow-button-${name}`);
|
|||||||
<N8nButton
|
<N8nButton
|
||||||
v-if="isChatOpen"
|
v-if="isChatOpen"
|
||||||
type="secondary"
|
type="secondary"
|
||||||
|
icon="comment"
|
||||||
size="large"
|
size="large"
|
||||||
:disabled="isExecuting"
|
:disabled="isExecuting"
|
||||||
:data-test-id="testId"
|
:data-test-id="testId"
|
||||||
@@ -72,6 +78,7 @@ const testId = computed(() => `execute-workflow-button-${name}`);
|
|||||||
>
|
>
|
||||||
<N8nButton
|
<N8nButton
|
||||||
type="primary"
|
type="primary"
|
||||||
|
icon="comment"
|
||||||
size="large"
|
size="large"
|
||||||
:disabled="isExecuting"
|
:disabled="isExecuting"
|
||||||
:data-test-id="testId"
|
:data-test-id="testId"
|
||||||
@@ -83,11 +90,12 @@ const testId = computed(() => `execute-workflow-button-${name}`);
|
|||||||
<N8nButton
|
<N8nButton
|
||||||
v-else
|
v-else
|
||||||
type="primary"
|
type="primary"
|
||||||
|
icon="flask"
|
||||||
size="large"
|
size="large"
|
||||||
:disabled="isExecuting"
|
:disabled="isExecuting"
|
||||||
:data-test-id="testId"
|
:data-test-id="testId"
|
||||||
:label="i18n.baseText('nodeView.runButtonText.executeWorkflow')"
|
:label="i18n.baseText('nodeView.runButtonText.executeWorkflow')"
|
||||||
@click.capture="runEntireWorkflow('node', name)"
|
@click.capture="handleClickExecute"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -549,7 +549,9 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
|||||||
void externalHooks.run('nodeView.onRunWorkflow', telemetryPayload);
|
void externalHooks.run('nodeView.onRunWorkflow', telemetryPayload);
|
||||||
});
|
});
|
||||||
|
|
||||||
void runWorkflow({ triggerNode });
|
void runWorkflow({
|
||||||
|
triggerNode: triggerNode ?? workflowsStore.selectedTriggerNodeName,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import * as workflowsApi from '@/api/workflows';
|
|||||||
import {
|
import {
|
||||||
DUPLICATE_POSTFFIX,
|
DUPLICATE_POSTFFIX,
|
||||||
FORM_NODE_TYPE,
|
FORM_NODE_TYPE,
|
||||||
|
MANUAL_TRIGGER_NODE_TYPE,
|
||||||
MAX_WORKFLOW_NAME_LENGTH,
|
MAX_WORKFLOW_NAME_LENGTH,
|
||||||
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||||
WAIT_NODE_TYPE,
|
WAIT_NODE_TYPE,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import type { IExecutionResponse, INodeUi, IWorkflowDb, IWorkflowSettings } from '@/Interface';
|
import type { IExecutionResponse, INodeUi, IWorkflowDb, IWorkflowSettings } from '@/Interface';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
|
||||||
|
|
||||||
import { deepCopy, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
|
import { deepCopy, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
@@ -31,7 +31,8 @@ import * as apiUtils from '@n8n/rest-api-client';
|
|||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useLocalStorage } from '@vueuse/core';
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
import { ref } from 'vue';
|
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', () => ({
|
vi.mock('@/stores/ndv.store', () => ({
|
||||||
useNDVStore: vi.fn(() => ({
|
useNDVStore: vi.fn(() => ({
|
||||||
@@ -179,11 +180,7 @@ describe('useWorkflowsStore', () => {
|
|||||||
|
|
||||||
describe('workflowTriggerNodes', () => {
|
describe('workflowTriggerNodes', () => {
|
||||||
it('should return only nodes that are triggers', () => {
|
it('should return only nodes that are triggers', () => {
|
||||||
vi.mocked(useNodeTypesStore).mockReturnValueOnce({
|
getNodeType.mockReturnValueOnce({ group: ['trigger'] });
|
||||||
getNodeType: vi.fn(() => ({
|
|
||||||
group: ['trigger'],
|
|
||||||
})),
|
|
||||||
} as unknown as ReturnType<typeof useNodeTypesStore>);
|
|
||||||
|
|
||||||
workflowsStore.workflow.nodes = [
|
workflowsStore.workflow.nodes = [
|
||||||
{ type: 'triggerNode', typeVersion: '1' },
|
{ 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() {
|
function getMockEditFieldsNode() {
|
||||||
|
|||||||
@@ -82,20 +82,25 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
|||||||
import { getCredentialOnlyNodeTypeName } from '@/utils/credentialOnlyNodes';
|
import { getCredentialOnlyNodeTypeName } from '@/utils/credentialOnlyNodes';
|
||||||
import { i18n } from '@n8n/i18n';
|
import { i18n } from '@n8n/i18n';
|
||||||
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import type { ProjectSharingData } from '@/types/projects.types';
|
import type { ProjectSharingData } from '@/types/projects.types';
|
||||||
import type { PushPayload } from '@n8n/api-types';
|
import type { PushPayload } from '@n8n/api-types';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||||
import { useSettingsStore } from './settings.store';
|
import { useSettingsStore } from './settings.store';
|
||||||
import { clearPopupWindowState, openFormPopupWindow } from '@/utils/executionUtils';
|
import {
|
||||||
|
clearPopupWindowState,
|
||||||
|
findTriggerNodeToAutoSelect,
|
||||||
|
openFormPopupWindow,
|
||||||
|
} from '@/utils/executionUtils';
|
||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import { updateCurrentUserSettings } from '@/api/users';
|
import { updateCurrentUserSettings } from '@/api/users';
|
||||||
import { useExecutingNode } from '@/composables/useExecutingNode';
|
import { useExecutingNode } from '@/composables/useExecutingNode';
|
||||||
import type { NodeExecuteBefore } from '@n8n/api-types/push/execution';
|
import type { NodeExecuteBefore } from '@n8n/api-types/push/execution';
|
||||||
import { useLogsStore } from './logs.store';
|
import { useLogsStore } from './logs.store';
|
||||||
|
import { isChatNode } from '@/components/CanvasChat/utils';
|
||||||
|
|
||||||
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
|
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -131,6 +136,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
const nodeHelpers = useNodeHelpers();
|
const nodeHelpers = useNodeHelpers();
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
const logsStore = useLogsStore();
|
const logsStore = useLogsStore();
|
||||||
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
|
|
||||||
const version = computed(() => settingsStore.partialExecutionVersion);
|
const version = computed(() => settingsStore.partialExecutionVersion);
|
||||||
const workflow = ref<IWorkflowDb>(createEmptyWorkflow());
|
const workflow = ref<IWorkflowDb>(createEmptyWorkflow());
|
||||||
@@ -153,6 +159,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
const isInDebugMode = ref(false);
|
const isInDebugMode = ref(false);
|
||||||
const chatMessages = ref<string[]>([]);
|
const chatMessages = ref<string[]>([]);
|
||||||
const chatPartialExecutionDestinationNode = ref<string | null>(null);
|
const chatPartialExecutionDestinationNode = ref<string | null>(null);
|
||||||
|
const selectedTriggerNodeName = ref<string>();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
executingNode,
|
executingNode,
|
||||||
@@ -182,7 +189,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
|
|
||||||
const workflowTriggerNodes = computed(() =>
|
const workflowTriggerNodes = computed(() =>
|
||||||
workflow.value.nodes.filter((node: INodeUi) => {
|
workflow.value.nodes.filter((node: INodeUi) => {
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
|
||||||
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
|
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
|
||||||
return nodeType && nodeType.group.includes('trigger');
|
return nodeType && nodeType.group.includes('trigger');
|
||||||
}),
|
}),
|
||||||
@@ -290,6 +296,25 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
Workflow.getConnectionsByDestination(workflow.value.connections),
|
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
|
* Sets the active execution id
|
||||||
*
|
*
|
||||||
@@ -415,7 +440,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
nodeTypes: {},
|
nodeTypes: {},
|
||||||
init: async (): Promise<void> => {},
|
init: async (): Promise<void> => {},
|
||||||
getByNameAndVersion: (nodeType: string, version?: number): INodeType | undefined => {
|
getByNameAndVersion: (nodeType: string, version?: number): INodeType | undefined => {
|
||||||
const nodeTypeDescription = useNodeTypesStore().getNodeType(nodeType, version);
|
const nodeTypeDescription = nodeTypesStore.getNodeType(nodeType, version);
|
||||||
|
|
||||||
if (nodeTypeDescription === null) {
|
if (nodeTypeDescription === null) {
|
||||||
return undefined;
|
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 {
|
return {
|
||||||
workflow,
|
workflow,
|
||||||
usedCredentials,
|
usedCredentials,
|
||||||
@@ -1908,6 +1968,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
getAllLoadedFinishedExecutions,
|
getAllLoadedFinishedExecutions,
|
||||||
getWorkflowExecution,
|
getWorkflowExecution,
|
||||||
getPastChatMessages,
|
getPastChatMessages,
|
||||||
|
selectedTriggerNodeName: computed(() => selectedTriggerNodeName.value),
|
||||||
|
workflowExecutionTriggerNodeName,
|
||||||
outgoingConnectionsByNodeName,
|
outgoingConnectionsByNodeName,
|
||||||
incomingConnectionsByNodeName,
|
incomingConnectionsByNodeName,
|
||||||
nodeHasOutputConnection,
|
nodeHasOutputConnection,
|
||||||
@@ -2013,6 +2075,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
findNodeByPartialId,
|
findNodeByPartialId,
|
||||||
getPartialIdForNode,
|
getPartialIdForNode,
|
||||||
getNewWorkflowDataAndMakeShareable,
|
getNewWorkflowDataAndMakeShareable,
|
||||||
|
setSelectedTriggerNodeName,
|
||||||
totalWorkflowCount,
|
totalWorkflowCount,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,11 +6,19 @@ import {
|
|||||||
waitingNodeTooltip,
|
waitingNodeTooltip,
|
||||||
getExecutionErrorMessage,
|
getExecutionErrorMessage,
|
||||||
getExecutionErrorToastConfiguration,
|
getExecutionErrorToastConfiguration,
|
||||||
|
findTriggerNodeToAutoSelect,
|
||||||
} from './executionUtils';
|
} 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 { type INodeUi } from '../Interface';
|
||||||
import { CHAT_TRIGGER_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, GITHUB_NODE_TYPE } from '@/constants';
|
import {
|
||||||
import { createTestNode } from '@/__tests__/mocks';
|
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';
|
import type { VNode } from 'vue';
|
||||||
|
|
||||||
const WAIT_NODE_TYPE = 'waitNode';
|
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' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 {
|
import type {
|
||||||
ITaskData,
|
ITaskData,
|
||||||
ExecutionStatus,
|
ExecutionStatus,
|
||||||
@@ -7,6 +11,7 @@ import type {
|
|||||||
IPinData,
|
IPinData,
|
||||||
IRunData,
|
IRunData,
|
||||||
ExecutionError,
|
ExecutionError,
|
||||||
|
INodeTypeBaseDescription,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
ExecutionFilterType,
|
ExecutionFilterType,
|
||||||
@@ -16,7 +21,16 @@ import type {
|
|||||||
INodeUi,
|
INodeUi,
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
import { isEmpty } from '@/utils/typesUtils';
|
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 { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||||
import { i18n } from '@n8n/i18n';
|
import { i18n } from '@n8n/i18n';
|
||||||
@@ -385,3 +399,33 @@ export function unflattenExecutionData(fullExecutionData: IExecutionFlattedRespo
|
|||||||
|
|
||||||
return returnData;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { TestRunRecord } from '@/api/evaluation.ee';
|
|||||||
import { PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
|
import { PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { EVALUATION_NODE_TYPE, EVALUATION_TRIGGER_NODE_TYPE, NodeHelpers } from 'n8n-workflow';
|
import { EVALUATION_NODE_TYPE, EVALUATION_TRIGGER_NODE_TYPE, NodeHelpers } from 'n8n-workflow';
|
||||||
|
import { mockNodeTypeDescription } from '@/__tests__/mocks';
|
||||||
|
|
||||||
vi.mock('@/composables/useTelemetry', () => {
|
vi.mock('@/composables/useTelemetry', () => {
|
||||||
const track = vi.fn();
|
const track = vi.fn();
|
||||||
@@ -214,7 +215,9 @@ describe('EvaluationsRootView', () => {
|
|||||||
|
|
||||||
// Mock dataset trigger node type exists
|
// Mock dataset trigger node type exists
|
||||||
getNodeType.mockImplementation((nodeType) =>
|
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 } });
|
renderComponent({ props: { name: mockWorkflow.id } });
|
||||||
@@ -268,7 +271,9 @@ describe('EvaluationsRootView', () => {
|
|||||||
|
|
||||||
// Mock evaluation node type exists
|
// Mock evaluation node type exists
|
||||||
getNodeType.mockImplementation((nodeType) =>
|
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 } });
|
renderComponent({ props: { name: mockWorkflow.id } });
|
||||||
@@ -322,7 +327,9 @@ describe('EvaluationsRootView', () => {
|
|||||||
|
|
||||||
// Mock evaluation node type exists
|
// Mock evaluation node type exists
|
||||||
getNodeType.mockImplementation((nodeType) =>
|
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 } });
|
renderComponent({ props: { name: mockWorkflow.id } });
|
||||||
|
|||||||
@@ -2024,15 +2024,20 @@ onBeforeUnmount(() => {
|
|||||||
:waiting-for-webhook="isExecutionWaitingForWebhook"
|
:waiting-for-webhook="isExecutionWaitingForWebhook"
|
||||||
:disabled="isExecutionDisabled"
|
:disabled="isExecutionDisabled"
|
||||||
:executing="isWorkflowRunning"
|
:executing="isWorkflowRunning"
|
||||||
|
:trigger-nodes="triggerNodes"
|
||||||
|
:get-node-type="nodeTypesStore.getNodeType"
|
||||||
|
:selected-trigger-node-name="workflowsStore.selectedTriggerNodeName"
|
||||||
@mouseenter="onRunWorkflowButtonMouseEnter"
|
@mouseenter="onRunWorkflowButtonMouseEnter"
|
||||||
@mouseleave="onRunWorkflowButtonMouseLeave"
|
@mouseleave="onRunWorkflowButtonMouseLeave"
|
||||||
@click="runEntireWorkflow('main')"
|
@execute="runEntireWorkflow('main')"
|
||||||
|
@select-trigger-node="workflowsStore.setSelectedTriggerNodeName"
|
||||||
/>
|
/>
|
||||||
<template v-if="containsChatTriggerNodes">
|
<template v-if="containsChatTriggerNodes">
|
||||||
<CanvasChatButton
|
<CanvasChatButton
|
||||||
v-if="isLogsPanelOpen"
|
v-if="isLogsPanelOpen"
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
:label="i18n.baseText('chat.hide')"
|
:label="i18n.baseText('chat.hide')"
|
||||||
|
:class="$style.chatButton"
|
||||||
@click="logsStore.toggleOpen(false)"
|
@click="logsStore.toggleOpen(false)"
|
||||||
/>
|
/>
|
||||||
<KeyboardShortcutTooltip
|
<KeyboardShortcutTooltip
|
||||||
@@ -2041,8 +2046,9 @@ onBeforeUnmount(() => {
|
|||||||
:shortcut="{ keys: ['c'] }"
|
:shortcut="{ keys: ['c'] }"
|
||||||
>
|
>
|
||||||
<CanvasChatButton
|
<CanvasChatButton
|
||||||
type="primary"
|
:type="isRunWorkflowButtonVisible ? 'secondary' : 'primary'"
|
||||||
:label="i18n.baseText('chat.open')"
|
:label="i18n.baseText('chat.open')"
|
||||||
|
:class="$style.chatButton"
|
||||||
@click="onOpenChat"
|
@click="onOpenChat"
|
||||||
/>
|
/>
|
||||||
</KeyboardShortcutTooltip>
|
</KeyboardShortcutTooltip>
|
||||||
@@ -2137,6 +2143,10 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatButton {
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.setupCredentialsButtonWrapper {
|
.setupCredentialsButtonWrapper {
|
||||||
|
|||||||
Reference in New Issue
Block a user