Skip to content

Commit ac1a1df

Browse files
authored
feat(editor): Make ‘Execute workflow’ a split button (#15933)
1 parent eb71c41 commit ac1a1df

File tree

20 files changed

+619
-70
lines changed

20 files changed

+619
-70
lines changed

cypress/e2e/19-execution.cy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,7 @@ describe('Execution', () => {
483483
cy.wait('@workflowRun').then((interception) => {
484484
expect(interception.request.body).to.have.property('runData').that.is.an('object');
485485

486-
const expectedKeys = ['Start Manually', 'Edit Fields', 'Process The Data'];
486+
const expectedKeys = ['Start on Schedule', 'Edit Fields', 'Process The Data'];
487487

488488
const { runData } = interception.request.body;
489489
expect(Object.keys(runData)).to.have.lengthOf(expectedKeys.length);

packages/@n8n/utils/src/string/truncate.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { truncate } from './truncate';
1+
import { truncateBeforeLast, truncate } from './truncate';
22

33
describe('truncate', () => {
44
it('should truncate text to 30 chars by default', () => {
@@ -13,3 +13,37 @@ describe('truncate', () => {
1313
);
1414
});
1515
});
16+
17+
describe(truncateBeforeLast, () => {
18+
it('should return unmodified text if the length does not exceed max length', () => {
19+
expect(truncateBeforeLast('I love nodemation', 25)).toBe('I love nodemation');
20+
expect(truncateBeforeLast('I ❤️ nodemation', 25)).toBe('I ❤️ nodemation');
21+
expect(truncateBeforeLast('Nodemation is cool', 25)).toBe('Nodemation is cool');
22+
expect(truncateBeforeLast('Internationalization', 25)).toBe('Internationalization');
23+
expect(truncateBeforeLast('I love 👨‍👩‍👧‍👦', 8)).toBe('I love 👨‍👩‍👧‍👦');
24+
});
25+
26+
it('should remove chars just before the last word, as long as the last word is under 15 chars', () => {
27+
expect(truncateBeforeLast('I love nodemation', 15)).toBe('I lo…nodemation');
28+
expect(truncateBeforeLast('I love "nodemation"', 15)).toBe('I …"nodemation"');
29+
expect(truncateBeforeLast('I ❤️ nodemation', 13)).toBe('I …nodemation');
30+
expect(truncateBeforeLast('Nodemation is cool', 15)).toBe('Nodemation…cool');
31+
expect(truncateBeforeLast('"Nodemation" is cool', 15)).toBe('"Nodematio…cool');
32+
expect(truncateBeforeLast('Is it fun to automate boring stuff?', 15)).toBe('Is it fu…stuff?');
33+
expect(truncateBeforeLast('Is internationalization fun?', 15)).toBe('Is interna…fun?');
34+
expect(truncateBeforeLast('I love 👨‍👩‍👧‍👦', 7)).toBe('I lov…👨‍👩‍👧‍👦');
35+
});
36+
37+
it('should preserve last 5 characters if the last word is longer than 15 characters', () => {
38+
expect(truncateBeforeLast('I love internationalization', 25)).toBe('I love internationa…ation');
39+
expect(truncateBeforeLast('I love "internationalization"', 25)).toBe(
40+
'I love "internation…tion"',
41+
);
42+
expect(truncateBeforeLast('I "love" internationalization', 25)).toBe(
43+
'I "love" internatio…ation',
44+
);
45+
expect(truncateBeforeLast('I ❤️ internationalization', 9)).toBe('I ❤️…ation');
46+
expect(truncateBeforeLast('I ❤️ internationalization', 8)).toBe('I …ation');
47+
expect(truncateBeforeLast('Internationalization', 15)).toBe('Internati…ation');
48+
});
49+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,44 @@
11
export const truncate = (text: string, length = 30): string =>
22
text.length > length ? text.slice(0, length) + '...' : text;
3+
4+
/**
5+
* Replace part of given text with ellipsis following the rules below:
6+
*
7+
* - Remove chars just before the last word, as long as the last word is under 15 chars
8+
* - Otherwise preserve the last 5 chars of the name and remove chars before that
9+
*/
10+
export function truncateBeforeLast(text: string, maxLength: number): string {
11+
const chars: string[] = [];
12+
13+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
14+
15+
for (const { segment } of segmenter.segment(text)) {
16+
chars.push(segment);
17+
}
18+
19+
if (chars.length <= maxLength) {
20+
return text;
21+
}
22+
23+
const lastWhitespaceIndex = chars.findLastIndex((ch) => ch.match(/^\s+$/));
24+
const lastWordIndex = lastWhitespaceIndex + 1;
25+
const lastWord = chars.slice(lastWordIndex);
26+
const ellipsis = '…';
27+
const ellipsisLength = ellipsis.length;
28+
29+
if (lastWord.length < 15) {
30+
const charsToRemove = chars.length - maxLength + ellipsisLength;
31+
const indexBeforeLastWord = lastWordIndex;
32+
const keepLength = indexBeforeLastWord - charsToRemove;
33+
34+
if (keepLength > 0) {
35+
return (
36+
chars.slice(0, keepLength).join('') + ellipsis + chars.slice(indexBeforeLastWord).join('')
37+
);
38+
}
39+
}
40+
41+
return (
42+
chars.slice(0, maxLength - 5 - ellipsisLength).join('') + ellipsis + chars.slice(-5).join('')
43+
);
44+
}

packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ const emit = defineEmits<{
5656
select: [action: string];
5757
visibleChange: [open: boolean];
5858
}>();
59+
60+
defineSlots<{
61+
activator: {};
62+
menuItem: (props: ActionDropdownItem) => void;
63+
}>();
64+
5965
const elementDropdown = ref<InstanceType<typeof ElDropdown>>();
6066
6167
const popperClass = computed(
@@ -115,7 +121,9 @@ defineExpose({ open, close });
115121
<N8nIcon :icon="item.icon" :size="iconSize" />
116122
</span>
117123
<span :class="$style.label">
118-
{{ item.label }}
124+
<slot name="menuItem" v-bind="item">
125+
{{ item.label }}
126+
</slot>
119127
</span>
120128
<N8nIcon v-if="item.checked" icon="check" :size="iconSize" />
121129
<span v-if="item.badge">

packages/frontend/@n8n/design-system/src/components/N8nButton/Button.scss

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@
2222
box-sizing: border-box;
2323
outline: none;
2424
margin: 0;
25-
transition: 0.3s;
25+
transition:
26+
all 0.3s,
27+
padding 0s,
28+
width 0s,
29+
height 0s;
2630

2731
@include utils-user-select(none);
2832

packages/frontend/@n8n/i18n/src/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1479,6 +1479,7 @@
14791479
"nodeView.runButtonText.executeWorkflow": "Execute workflow",
14801480
"nodeView.runButtonText.executingWorkflow": "Executing workflow",
14811481
"nodeView.runButtonText.waitingForTriggerEvent": "Waiting for trigger event",
1482+
"nodeView.runButtonText.from": "from {nodeName}",
14821483
"nodeView.showError.workflowError": "Workflow execution had an error",
14831484
"nodeView.showError.getWorkflowDataFromUrl.title": "Problem loading workflow",
14841485
"nodeView.showError.importWorkflowData.title": "Problem importing workflow",

packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.vue

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,13 @@ async function handleOpenNdv(treeNode: LogEntry) {
116116
ref="container"
117117
:class="$style.container"
118118
tabindex="-1"
119-
@keydown.esc.stop="select(undefined)"
120-
@keydown.j.stop="selectNext"
121-
@keydown.down.stop.prevent="selectNext"
122-
@keydown.k.stop="selectPrev"
123-
@keydown.up.stop.prevent="selectPrev"
124-
@keydown.space.stop="selected && toggleExpanded(selected)"
125-
@keydown.enter.stop="selected && handleOpenNdv(selected)"
119+
@keydown.esc.exact.stop="select(undefined)"
120+
@keydown.j.exact.stop="selectNext"
121+
@keydown.down.exact.stop.prevent="selectNext"
122+
@keydown.k.exact.stop="selectPrev"
123+
@keydown.up.exact.stop.prevent="selectPrev"
124+
@keydown.space.exact.stop="selected && toggleExpanded(selected)"
125+
@keydown.enter.exact.stop="selected && handleOpenNdv(selected)"
126126
>
127127
<N8nResizeWrapper
128128
v-if="hasChat && (!props.isReadOnly || messages.length > 0)"

packages/frontend/editor-ui/src/components/ParameterInput.test.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,8 @@ import { createMockEnterpriseSettings } from '@/__tests__/mocks';
1414
import { useWorkflowsStore } from '@/stores/workflows.store';
1515
import type { INodeParameterResourceLocator } from 'n8n-workflow';
1616

17-
let mockNdvState: Partial<ReturnType<typeof useNDVStore>>;
18-
let mockNodeTypesState: Partial<ReturnType<typeof useNodeTypesStore>>;
19-
let mockCompletionResult: Partial<CompletionResult>;
20-
21-
beforeEach(() => {
22-
mockNdvState = {
17+
function getNdvStateMock(): Partial<ReturnType<typeof useNDVStore>> {
18+
return {
2319
hasInputData: true,
2420
activeNode: {
2521
id: faker.string.uuid(),
@@ -32,9 +28,21 @@ beforeEach(() => {
3228
isInputPanelEmpty: false,
3329
isOutputPanelEmpty: false,
3430
};
35-
mockNodeTypesState = {
31+
}
32+
33+
function getNodeTypesStateMock(): Partial<ReturnType<typeof useNodeTypesStore>> {
34+
return {
3635
allNodeTypes: [],
3736
};
37+
}
38+
39+
let mockNdvState = getNdvStateMock();
40+
let mockNodeTypesState = getNodeTypesStateMock();
41+
let mockCompletionResult: Partial<CompletionResult> = {};
42+
43+
beforeEach(() => {
44+
mockNdvState = getNdvStateMock();
45+
mockNodeTypesState = getNodeTypesStateMock();
3846
mockCompletionResult = {};
3947
createAppModals();
4048
});

packages/frontend/editor-ui/src/components/canvas/elements/buttons/CanvasChatButton.vue

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<script lang="ts" setup>
2-
export interface Props {
3-
type: 'primary' | 'tertiary';
4-
label: string;
5-
}
2+
import { type ButtonProps } from '@n8n/design-system';
3+
4+
export type Props = Pick<ButtonProps, 'label' | 'type'>;
5+
66
defineProps<Props>();
77
</script>
88
<template>

packages/frontend/editor-ui/src/components/canvas/elements/buttons/CanvasRunWorkflowButton.test.ts

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
11
import { createComponentRenderer } from '@/__tests__/render';
22
import CanvasRunWorkflowButton from './CanvasRunWorkflowButton.vue';
33
import userEvent from '@testing-library/user-event';
4-
import { waitFor } from '@testing-library/vue';
5-
6-
const renderComponent = createComponentRenderer(CanvasRunWorkflowButton);
4+
import { fireEvent, waitFor } from '@testing-library/vue';
5+
import { createTestNode } from '@/__tests__/mocks';
6+
import {
7+
CHAT_TRIGGER_NODE_TYPE,
8+
MANUAL_CHAT_TRIGGER_NODE_TYPE,
9+
MANUAL_TRIGGER_NODE_TYPE,
10+
SCHEDULE_TRIGGER_NODE_TYPE,
11+
} from '@/constants';
712

813
describe('CanvasRunWorkflowButton', () => {
14+
const renderComponent = createComponentRenderer(CanvasRunWorkflowButton, {
15+
props: {
16+
triggerNodes: [createTestNode({ type: MANUAL_CHAT_TRIGGER_NODE_TYPE })],
17+
getNodeType: () => null,
18+
},
19+
});
20+
921
it('should render correctly', () => {
1022
const wrapper = renderComponent();
1123

@@ -50,4 +62,80 @@ describe('CanvasRunWorkflowButton', () => {
5062

5163
await waitFor(() => expect(isTooltipVisible(false)).toBeTruthy());
5264
});
65+
66+
it('should render split button if multiple triggers are available', () => {
67+
const wrapper = renderComponent({
68+
props: {
69+
selectedTriggerNodeName: 'A',
70+
triggerNodes: [
71+
createTestNode({ name: 'A', type: MANUAL_TRIGGER_NODE_TYPE }),
72+
createTestNode({ name: 'B', type: SCHEDULE_TRIGGER_NODE_TYPE }),
73+
],
74+
},
75+
});
76+
77+
expect(wrapper.container.textContent).toBe('Execute workflow from A');
78+
expect(wrapper.queryByLabelText('Select trigger node')).toBeInTheDocument();
79+
});
80+
81+
it('should not render split button if there is only one trigger that is not disabled nor a chat trigger', () => {
82+
const wrapper = renderComponent({
83+
props: {
84+
selectedTriggerNodeName: 'A',
85+
triggerNodes: [
86+
createTestNode({ name: 'A', type: MANUAL_TRIGGER_NODE_TYPE }),
87+
createTestNode({ name: 'B', type: MANUAL_TRIGGER_NODE_TYPE, disabled: true }),
88+
createTestNode({ name: 'C', type: CHAT_TRIGGER_NODE_TYPE }),
89+
],
90+
},
91+
});
92+
93+
expect(wrapper.container.textContent).toBe('Execute workflow ');
94+
expect(wrapper.queryByLabelText('Select trigger node')).not.toBeInTheDocument();
95+
});
96+
97+
it('should show available triggers in the ordering of coordinate on the canvas when chevron icon is clicked', async () => {
98+
const wrapper = renderComponent({
99+
props: {
100+
selectedTriggerNodeName: 'A',
101+
triggerNodes: [
102+
createTestNode({ name: 'A', type: MANUAL_TRIGGER_NODE_TYPE, position: [1, 1] }),
103+
createTestNode({ name: 'B', type: MANUAL_TRIGGER_NODE_TYPE, position: [1, 0] }),
104+
createTestNode({ name: 'C', type: MANUAL_TRIGGER_NODE_TYPE, position: [0, 0] }),
105+
createTestNode({ name: 'D', type: MANUAL_TRIGGER_NODE_TYPE, position: [0, 1] }),
106+
],
107+
},
108+
});
109+
110+
const chevron = (await wrapper.findAllByRole('button'))[1];
111+
112+
await fireEvent.click(chevron);
113+
114+
const menuItems = await wrapper.findAllByRole('menuitem');
115+
116+
expect(menuItems).toHaveLength(4);
117+
expect(menuItems[0]).toHaveTextContent('from C');
118+
expect(menuItems[1]).toHaveTextContent('from B');
119+
expect(menuItems[2]).toHaveTextContent('from D');
120+
expect(menuItems[3]).toHaveTextContent('from A');
121+
});
122+
123+
it('should allow to select and execute a different trigger', async () => {
124+
const wrapper = renderComponent({
125+
props: {
126+
selectedTriggerNodeName: 'A',
127+
triggerNodes: [
128+
createTestNode({ name: 'A', type: MANUAL_TRIGGER_NODE_TYPE }),
129+
createTestNode({ name: 'B', type: MANUAL_TRIGGER_NODE_TYPE }),
130+
],
131+
},
132+
});
133+
134+
const [executeButton, chevron] = await wrapper.findAllByRole('button');
135+
await fireEvent.click(chevron);
136+
const menuItems = await wrapper.findAllByRole('menuitem');
137+
await fireEvent.click(menuItems[1]);
138+
await fireEvent.click(executeButton);
139+
expect(wrapper.emitted('selectTriggerNode')).toEqual([['B']]);
140+
});
53141
});

0 commit comments

Comments
 (0)