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:
@@ -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)"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>"
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user