mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +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) => {
|
||||
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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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' }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user