feat(editor): Add stop current execution button in new canvas (no-changelog) (#9968)

This commit is contained in:
Alex Grozav
2024-07-10 11:53:27 +03:00
committed by GitHub
parent 90e3f56a9d
commit 2107de2f4a
15 changed files with 245 additions and 42 deletions

View File

@@ -0,0 +1,33 @@
import { createComponentRenderer } from '@/__tests__/render';
import CanvasExecuteWorkflowButton from './CanvasExecuteWorkflowButton.vue';
const renderComponent = createComponentRenderer(CanvasExecuteWorkflowButton);
describe('CanvasExecuteWorkflowButton', () => {
it('should render correctly', () => {
const wrapper = renderComponent();
expect(wrapper.html()).toMatchSnapshot();
});
it('should render different label when executing', () => {
const wrapper = renderComponent({
props: {
executing: true,
},
});
expect(wrapper.getAllByText('Executing workflow')).toHaveLength(2);
});
it('should render different label when executing and waiting for webhook', () => {
const wrapper = renderComponent({
props: {
executing: true,
waitingForWebhook: true,
},
});
expect(wrapper.getAllByText('Waiting for trigger event')).toHaveLength(2);
});
});

View File

@@ -2,31 +2,36 @@
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue'; import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useUIStore } from '@/stores/ui.store';
defineEmits<{ defineEmits<{
click: [event: MouseEvent]; click: [event: MouseEvent];
}>(); }>();
const uiStore = useUIStore(); const props = defineProps<{
const locale = useI18n(); waitingForWebhook: boolean;
executing: boolean;
}>();
const workflowRunning = computed(() => uiStore.isActionActive['workflowRunning']); const i18n = useI18n();
const runButtonText = computed(() => { const label = computed(() => {
if (!workflowRunning.value) { if (!props.executing) {
return locale.baseText('nodeView.runButtonText.executeWorkflow'); return i18n.baseText('nodeView.runButtonText.executeWorkflow');
} }
return locale.baseText('nodeView.runButtonText.executingWorkflow'); if (props.waitingForWebhook) {
return i18n.baseText('nodeView.runButtonText.waitingForTriggerEvent');
}
return i18n.baseText('nodeView.runButtonText.executingWorkflow');
}); });
</script> </script>
<template> <template>
<KeyboardShortcutTooltip :label="runButtonText" :shortcut="{ metaKey: true, keys: ['↵'] }"> <KeyboardShortcutTooltip :label="label" :shortcut="{ metaKey: true, keys: ['↵'] }">
<N8nButton <N8nButton
:loading="workflowRunning" :loading="executing"
:label="runButtonText" :label="label"
size="large" size="large"
icon="flask" icon="flask"
type="primary" type="primary"

View File

@@ -0,0 +1,22 @@
import { createComponentRenderer } from '@/__tests__/render';
import CanvasStopCurrentExecutionButton from './CanvasStopCurrentExecutionButton.vue';
const renderComponent = createComponentRenderer(CanvasStopCurrentExecutionButton);
describe('CanvasStopCurrentExecutionButton', () => {
it('should render correctly', () => {
const wrapper = renderComponent();
expect(wrapper.html()).toMatchSnapshot();
});
it('should render different title when loading', () => {
const wrapper = renderComponent({
props: {
stopping: true,
},
});
expect(wrapper.getByTitle('Stopping current execution')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
import { computed } from 'vue';
const props = defineProps<{
stopping?: boolean;
}>();
const i18n = useI18n();
const title = computed(() =>
props.stopping
? i18n.baseText('nodeView.stoppingCurrentExecution')
: i18n.baseText('nodeView.stopCurrentExecution'),
);
</script>
<template>
<n8n-icon-button
icon="stop"
size="large"
class="stop-execution"
type="secondary"
:title="title"
:loading="stopping"
data-test-id="stop-execution-button"
/>
</template>

View File

@@ -0,0 +1,12 @@
import { createComponentRenderer } from '@/__tests__/render';
import CanvasStopWaitingForWebhookButton from './CanvasStopWaitingForWebhookButton.vue';
const renderComponent = createComponentRenderer(CanvasStopWaitingForWebhookButton);
describe('CanvasStopCurrentExecutionButton', () => {
it('should render correctly', () => {
const wrapper = renderComponent();
expect(wrapper.html()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,15 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
const i18n = useI18n();
</script>
<template>
<n8n-icon-button
class="stop-execution"
icon="stop"
size="large"
:title="i18n.baseText('nodeView.stopWaitingForWebhookCall')"
type="secondary"
data-test-id="stop-execution-waiting-for-webhook-button"
/>
</template>

View File

@@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasExecuteWorkflowButton > 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>Test workflow</span></button>
<!--teleport start-->
<!--teleport end-->"
`;

View File

@@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasStopCurrentExecutionButton > should render correctly 1`] = `
"<button class="button button secondary large withIcon square stop-execution stop-execution" aria-live="polite" title="Stop current execution" data-test-id="stop-execution-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-stop fa-w-14 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="stop" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path class="" fill="currentColor" d="M400 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48z"></path></svg></span></span>
<!--v-if-->
</button>"
`;

View File

@@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasStopCurrentExecutionButton > should render correctly 1`] = `
"<button class="button button secondary large withIcon square stop-execution stop-execution" aria-live="polite" title="Stop waiting for webhook call" data-test-id="stop-execution-waiting-for-webhook-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-stop fa-w-14 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="stop" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path class="" fill="currentColor" d="M400 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48z"></path></svg></span></span>
<!--v-if-->
</button>"
`;

View File

@@ -1,6 +1,6 @@
import { createPinia, setActivePinia } from 'pinia'; import { createPinia, setActivePinia } from 'pinia';
import type { Connection } from '@vue-flow/core'; import type { Connection } from '@vue-flow/core';
import type { IConnection } from 'n8n-workflow'; import type { IConnection, Workflow } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
import { useCanvasOperations } from '@/composables/useCanvasOperations'; import { useCanvasOperations } from '@/composables/useCanvasOperations';
import type { CanvasElement } from '@/types'; import type { CanvasElement } from '@/types';
@@ -65,7 +65,7 @@ describe('useCanvasOperations', () => {
usedCredentials: [], usedCredentials: [],
}); });
workflowsStore.workflowsById[workflowId] = workflow; workflowsStore.workflowsById[workflowId] = workflow;
await workflowHelpers.initState(workflow, true); await workflowHelpers.initState(workflow);
canvasOperations = useCanvasOperations({ router, lastClickPosition }); canvasOperations = useCanvasOperations({ router, lastClickPosition });
}); });
@@ -248,8 +248,8 @@ describe('useCanvasOperations', () => {
it('should add nodes at current position when position is not specified', async () => { it('should add nodes at current position when position is not specified', async () => {
const nodeTypeName = 'type'; const nodeTypeName = 'type';
const nodes = [ const nodes = [
mockNode({ name: 'Node 1', type: nodeTypeName, position: [40, 40] }), mockNode({ name: 'Node 1', type: nodeTypeName, position: [120, 120] }),
mockNode({ name: 'Node 2', type: nodeTypeName, position: [100, 240] }), mockNode({ name: 'Node 2', type: nodeTypeName, position: [180, 320] }),
]; ];
const workflowStoreAddNodeSpy = vi.spyOn(workflowsStore, 'addNode'); const workflowStoreAddNodeSpy = vi.spyOn(workflowsStore, 'addNode');
@@ -292,9 +292,16 @@ describe('useCanvasOperations', () => {
}), }),
]); ]);
canvasOperations.editableWorkflowObject.value.getParentNodesByDepth = vi vi.spyOn(workflowsStore, 'getCurrentWorkflow').mockImplementation(() =>
.fn() mock<Workflow>({
.mockReturnValue(nodes.map((node) => node.name)); getParentNodesByDepth: () =>
nodes.map((node) => ({
name: node.name,
depth: 0,
indicies: [],
})),
}),
);
await canvasOperations.addNodes(nodes, {}); await canvasOperations.addNodes(nodes, {});

View File

@@ -11,13 +11,19 @@ import type {
INodeUpdatePropertiesInformation, INodeUpdatePropertiesInformation,
XYPosition, XYPosition,
} from '@/Interface'; } from '@/Interface';
import { QUICKSTART_NOTE_NAME, STICKY_NODE_TYPE } from '@/constants'; import {
FORM_TRIGGER_NODE_TYPE,
QUICKSTART_NOTE_NAME,
STICKY_NODE_TYPE,
WEBHOOK_NODE_TYPE,
} from '@/constants';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useHistoryStore } from '@/stores/history.store'; import { useHistoryStore } from '@/stores/history.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useExternalHooks } from '@/composables/useExternalHooks'; import { useExternalHooks } from '@/composables/useExternalHooks';
import { import {
AddNodeCommand,
MoveNodeCommand, MoveNodeCommand,
RemoveConnectionCommand, RemoveConnectionCommand,
RemoveNodeCommand, RemoveNodeCommand,
@@ -53,10 +59,8 @@ import type { useRouter } from 'vue-router';
import { useCanvasStore } from '@/stores/canvas.store'; import { useCanvasStore } from '@/stores/canvas.store';
import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useNodeHelpers } from '@/composables/useNodeHelpers';
type AddNodeData = { type AddNodeData = Partial<INodeUi> & {
name?: string;
type: string; type: string;
position?: XYPosition;
}; };
type AddNodeOptions = { type AddNodeOptions = {
@@ -266,13 +270,12 @@ export function useCanvasOperations({
) { ) {
let currentPosition = position; let currentPosition = position;
let lastAddedNode: INodeUi | undefined; let lastAddedNode: INodeUi | undefined;
for (const { type, name, position: nodePosition, isAutoAdd, openDetail } of nodes) { for (const { isAutoAdd, openDetail, ...nodeData } of nodes) {
try { try {
await createNode( await createNode(
{ {
name, ...nodeData,
type, position: nodeData.position ?? currentPosition,
position: nodePosition ?? currentPosition,
}, },
{ {
dragAndDrop, dragAndDrop,
@@ -328,14 +331,16 @@ export function useCanvasOperations({
workflowsStore.addNode(newNodeData); workflowsStore.addNode(newNodeData);
// @TODO Figure out why this is needed and if we can do better... nodeHelpers.matchCredentials(newNodeData);
// this.matchCredentials(node);
const lastSelectedNode = uiStore.getLastSelectedNode; const lastSelectedNode = uiStore.getLastSelectedNode;
const lastSelectedNodeOutputIndex = uiStore.lastSelectedNodeOutputIndex; const lastSelectedNodeOutputIndex = uiStore.lastSelectedNodeOutputIndex;
const lastSelectedNodeEndpointUuid = uiStore.lastSelectedNodeEndpointUuid; const lastSelectedNodeEndpointUuid = uiStore.lastSelectedNodeEndpointUuid;
historyStore.startRecordingUndo(); historyStore.startRecordingUndo();
if (options.trackHistory) {
historyStore.pushCommandToUndo(new AddNodeCommand(newNodeData));
}
const outputIndex = lastSelectedNodeOutputIndex ?? 0; const outputIndex = lastSelectedNodeOutputIndex ?? 0;
const targetEndpoint = lastSelectedNodeEndpointUuid ?? ''; const targetEndpoint = lastSelectedNodeEndpointUuid ?? '';
@@ -399,12 +404,14 @@ export function useCanvasOperations({
} }
const newNodeData: INodeUi = { const newNodeData: INodeUi = {
id: uuid(), ...node,
id: node.id ?? uuid(),
name: node.name ?? (nodeTypeDescription.defaults.name as string), name: node.name ?? (nodeTypeDescription.defaults.name as string),
type: nodeTypeDescription.name, type: nodeTypeDescription.name,
typeVersion: nodeVersion, typeVersion: nodeVersion,
position: node.position ?? [0, 0], position: node.position ?? [0, 0],
parameters: {}, disabled: node.disabled ?? false,
parameters: node.parameters ?? {},
}; };
await loadNodeTypesProperties([{ name: newNodeData.type, version: newNodeData.typeVersion }]); await loadNodeTypesProperties([{ name: newNodeData.type, version: newNodeData.typeVersion }]);
@@ -664,6 +671,14 @@ export function useCanvasOperations({
newNodeData.webhookId = uuid(); newNodeData.webhookId = uuid();
} }
// if it's a webhook and the path is empty set the UUID as the default path
if (
[WEBHOOK_NODE_TYPE, FORM_TRIGGER_NODE_TYPE].includes(newNodeData.type) &&
newNodeData.parameters.path === ''
) {
newNodeData.parameters.path = newNodeData.webhookId as string;
}
workflowsStore.setNodePristine(newNodeData.name, true); workflowsStore.setNodePristine(newNodeData.name, true);
uiStore.stateIsDirty = true; uiStore.stateIsDirty = true;

View File

@@ -1253,6 +1253,7 @@ export function useNodeHelpers() {
updateNodesCredentialsIssues, updateNodesCredentialsIssues,
getNodeInputData, getNodeInputData,
setSuccessOutput, setSuccessOutput,
matchCredentials,
isInsertingNodes, isInsertingNodes,
credentialsUpdated, credentialsUpdated,
isProductionExecutionPreview, isProductionExecutionPreview,

View File

@@ -435,10 +435,20 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
} }
} }
async function stopWaitingForWebhook() {
try {
await workflowsStore.removeTestWebhook(workflowsStore.workflowId);
} catch (error) {
toast.showError(error, i18n.baseText('nodeView.showError.stopWaitingForWebhook.title'));
return;
}
}
return { return {
consolidateRunDataAndStartNodes, consolidateRunDataAndStartNodes,
runWorkflow, runWorkflow,
runWorkflowApi, runWorkflowApi,
stopCurrentExecution, stopCurrentExecution,
stopWaitingForWebhook,
}; };
} }

View File

@@ -1050,12 +1050,8 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
} }
} }
async function initState(workflowData: IWorkflowDb, set = false): Promise<void> { async function initState(workflowData: IWorkflowDb): Promise<void> {
workflowsStore.addWorkflow(workflowData); workflowsStore.addWorkflow(workflowData);
if (set) {
workflowsStore.setWorkflow(workflowData);
}
workflowsStore.setActive(workflowData.active || false); workflowsStore.setActive(workflowData.active || false);
workflowsStore.setWorkflowId(workflowData.id); workflowsStore.setWorkflowId(workflowData.id);
workflowsStore.setWorkflowName({ workflowsStore.setWorkflowName({

View File

@@ -73,8 +73,10 @@ import { useUsersStore } from '@/stores/users.store';
import { sourceControlEventBus } from '@/event-bus/source-control'; import { sourceControlEventBus } from '@/event-bus/source-control';
import { useTagsStore } from '@/stores/tags.store'; import { useTagsStore } from '@/stores/tags.store';
import { usePushConnectionStore } from '@/stores/pushConnection.store'; import { usePushConnectionStore } from '@/stores/pushConnection.store';
import { getNodeViewTab } from '@/utils/canvasUtils';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { getNodeViewTab } from '@/utils/canvasUtils';
import CanvasStopCurrentExecutionButton from '@/components/canvas/elements/buttons/CanvasStopCurrentExecutionButton.vue';
import CanvasStopWaitingForWebhookButton from '@/components/canvas/elements/buttons/CanvasStopWaitingForWebhookButton.vue';
const NodeCreation = defineAsyncComponent( const NodeCreation = defineAsyncComponent(
async () => await import('@/components/Node/NodeCreation.vue'), async () => await import('@/components/Node/NodeCreation.vue'),
@@ -120,7 +122,7 @@ const ndvStore = useNDVStore();
const lastClickPosition = ref<XYPosition>([450, 450]); const lastClickPosition = ref<XYPosition>([450, 450]);
const { runWorkflow } = useRunWorkflow({ router }); const { runWorkflow, stopCurrentExecution, stopWaitingForWebhook } = useRunWorkflow({ router });
const { const {
updateNodePosition, updateNodePosition,
renameNode, renameNode,
@@ -147,7 +149,6 @@ const readOnlyNotification = ref<null | { visible: boolean }>(null);
const isProductionExecutionPreview = ref(false); const isProductionExecutionPreview = ref(false);
const isExecutionPreview = ref(false); const isExecutionPreview = ref(false);
const isExecutionWaitingForWebhook = ref(false);
const canOpenNDV = ref(true); const canOpenNDV = ref(true);
const hideNodeIssues = ref(false); const hideNodeIssues = ref(false);
@@ -348,7 +349,9 @@ async function openWorkflow(data: IWorkflowDb) {
resetWorkspace(); resetWorkspace();
await workflowHelpers.initState(data, true); await workflowHelpers.initState(data);
await addNodes(data.nodes);
workflowsStore.setConnections(data.connections);
if (data.sharedWithProjects) { if (data.sharedWithProjects) {
workflowsEEStore.setWorkflowSharedWith({ workflowsEEStore.setWorkflowSharedWith({
@@ -536,6 +539,18 @@ function onToggleNodeCreator(options: ToggleNodeCreatorOptions) {
* Executions * Executions
*/ */
const isStoppingExecution = ref(false);
const isWorkflowRunning = computed(() => uiStore.isActionActive.workflowRunning);
const isExecutionWaitingForWebhook = computed(() => workflowsStore.executionWaitingForWebhook);
const isStopExecutionButtonVisible = computed(
() => isWorkflowRunning.value && !isExecutionWaitingForWebhook.value,
);
const isStopWaitingForWebhookButtonVisible = computed(
() => isWorkflowRunning.value && isExecutionWaitingForWebhook.value,
);
async function onRunWorkflow() { async function onRunWorkflow() {
trackRunWorkflow(); trackRunWorkflow();
@@ -583,8 +598,18 @@ async function openExecution(_executionId: string) {
// @TODO // @TODO
} }
async function onStopExecution() {
isStoppingExecution.value = true;
await stopCurrentExecution();
isStoppingExecution.value = false;
}
async function onStopWaitingForWebhook() {
await stopWaitingForWebhook();
}
/** /**
* Keboard * Keyboard
*/ */
function addKeyboardEventBindings() { function addKeyboardEventBindings() {
@@ -938,7 +963,20 @@ onBeforeUnmount(() => {
@click:pane="onClickPane" @click:pane="onClickPane"
> >
<div :class="$style.executionButtons"> <div :class="$style.executionButtons">
<CanvasExecuteWorkflowButton @click="onRunWorkflow" /> <CanvasExecuteWorkflowButton
:waiting-for-webhook="isExecutionWaitingForWebhook"
:executing="isWorkflowRunning"
@click="onRunWorkflow"
/>
<CanvasStopCurrentExecutionButton
v-if="isStopExecutionButtonVisible"
:stopping="isStoppingExecution"
@click="onStopExecution"
/>
<CanvasStopWaitingForWebhookButton
v-if="isStopWaitingForWebhookButtonVisible"
@click="onStopWaitingForWebhook"
/>
</div> </div>
<Suspense> <Suspense>
<NodeCreation <NodeCreation
@@ -956,12 +994,12 @@ onBeforeUnmount(() => {
:is-production-execution-preview="isProductionExecutionPreview" :is-production-execution-preview="isProductionExecutionPreview"
:renaming="false" :renaming="false"
@value-changed="onRenameNode" @value-changed="onRenameNode"
@stop-execution="onStopExecution"
@switch-selected-node="onSwitchActiveNode" @switch-selected-node="onSwitchActiveNode"
@open-connection-node-creator="onOpenConnectionNodeCreator" @open-connection-node-creator="onOpenConnectionNodeCreator"
/> />
<!-- <!--
:renaming="renamingActive" :renaming="renamingActive"
@stop-execution="stopExecution"
@save-keyboard-shortcut="onSaveKeyboardShortcut" @save-keyboard-shortcut="onSaveKeyboardShortcut"
--> -->
</Suspense> </Suspense>