mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
feat(editor): Add node context menu (#7620)

This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
@mousedown="mouseDown"
|
||||
v-touch:tap="touchTap"
|
||||
@mouseup="mouseUp"
|
||||
@contextmenu="contextMenu.open"
|
||||
@wheel="canvasStore.wheelScroll"
|
||||
>
|
||||
<div
|
||||
@@ -44,11 +45,9 @@
|
||||
/>
|
||||
<node
|
||||
v-for="nodeData in nodesToRender"
|
||||
@duplicateNode="duplicateNode"
|
||||
@deselectAllNodes="deselectAllNodes"
|
||||
@deselectNode="nodeDeselectedByName"
|
||||
@nodeSelected="nodeSelectedByName"
|
||||
@removeNode="(name) => removeNode(name, true)"
|
||||
@runWorkflow="onRunNode"
|
||||
@moved="onNodeMoved"
|
||||
@run="onNodeRun"
|
||||
@@ -104,23 +103,30 @@
|
||||
<Suspense>
|
||||
<CanvasControls />
|
||||
</Suspense>
|
||||
<Suspense>
|
||||
<ContextMenu @action="onContextMenuAction" />
|
||||
</Suspense>
|
||||
<div class="workflow-execute-wrapper" v-if="!isReadOnlyRoute && !readOnlyEnv">
|
||||
<span
|
||||
@mouseenter="showTriggerMissingToltip(true)"
|
||||
@mouseleave="showTriggerMissingToltip(false)"
|
||||
@click="onRunContainerClick"
|
||||
>
|
||||
<n8n-button
|
||||
@click.stop="onRunWorkflow"
|
||||
:loading="workflowRunning"
|
||||
<keyboard-shortcut-tooltip
|
||||
:label="runButtonText"
|
||||
:title="$locale.baseText('nodeView.executesTheWorkflowFromATriggerNode')"
|
||||
size="large"
|
||||
icon="play-circle"
|
||||
type="primary"
|
||||
:disabled="isExecutionDisabled"
|
||||
data-test-id="execute-workflow-button"
|
||||
/>
|
||||
:shortcut="{ metaKey: true, keys: ['↵'] }"
|
||||
>
|
||||
<n8n-button
|
||||
@click.stop="onRunWorkflow"
|
||||
:loading="workflowRunning"
|
||||
:label="runButtonText"
|
||||
size="large"
|
||||
icon="play-circle"
|
||||
type="primary"
|
||||
:disabled="isExecutionDisabled"
|
||||
data-test-id="execute-workflow-button"
|
||||
/>
|
||||
</keyboard-shortcut-tooltip>
|
||||
</span>
|
||||
|
||||
<n8n-button
|
||||
@@ -229,6 +235,7 @@ import { copyPaste } from '@/mixins/copyPaste';
|
||||
import { externalHooks } from '@/mixins/externalHooks';
|
||||
import { genericHelpers } from '@/mixins/genericHelpers';
|
||||
import { moveNodeWorkflow } from '@/mixins/moveNodeWorkflow';
|
||||
import { nodeHelpers } from '@/mixins/nodeHelpers';
|
||||
import {
|
||||
useGlobalLinkActions,
|
||||
useCanvasMouseSelect,
|
||||
@@ -236,17 +243,22 @@ import {
|
||||
useToast,
|
||||
useTitleChange,
|
||||
useExecutionDebugging,
|
||||
useContextMenu,
|
||||
type ContextMenuAction,
|
||||
useDataSchema,
|
||||
} from '@/composables';
|
||||
import { useUniqueNodeName } from '@/composables/useUniqueNodeName';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
||||
import { workflowRun } from '@/mixins/workflowRun';
|
||||
import { pinData } from '@/mixins/pinData';
|
||||
import { type PinDataSource, pinData } from '@/mixins/pinData';
|
||||
|
||||
import NodeDetailsView from '@/components/NodeDetailsView.vue';
|
||||
import ContextMenu from '@/components/ContextMenu/ContextMenu.vue';
|
||||
import Node from '@/components/Node.vue';
|
||||
import Sticky from '@/components/Sticky.vue';
|
||||
import CanvasAddButton from './CanvasAddButton.vue';
|
||||
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type {
|
||||
IConnection,
|
||||
@@ -368,6 +380,7 @@ export default defineComponent({
|
||||
workflowHelpers,
|
||||
workflowRun,
|
||||
debounceHelper,
|
||||
nodeHelpers,
|
||||
pinData,
|
||||
],
|
||||
components: {
|
||||
@@ -375,14 +388,20 @@ export default defineComponent({
|
||||
Node,
|
||||
Sticky,
|
||||
CanvasAddButton,
|
||||
KeyboardShortcutTooltip,
|
||||
NodeCreation,
|
||||
CanvasControls,
|
||||
ContextMenu,
|
||||
},
|
||||
setup(props) {
|
||||
const locale = useI18n();
|
||||
const contextMenu = useContextMenu();
|
||||
const dataSchema = useDataSchema();
|
||||
|
||||
return {
|
||||
locale,
|
||||
contextMenu,
|
||||
dataSchema,
|
||||
...useCanvasMouseSelect(),
|
||||
...useGlobalLinkActions(),
|
||||
...useTitleChange(),
|
||||
@@ -1079,6 +1098,8 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
async keyDown(e: KeyboardEvent) {
|
||||
this.contextMenu.close();
|
||||
|
||||
if (e.key === 's' && this.isCtrlKeyPressed(e)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
@@ -1127,18 +1148,41 @@ export default defineComponent({
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'd') {
|
||||
void this.callDebounced('deactivateSelectedNode', { debounceTime: 350 });
|
||||
const selectedNodes = this.uiStore.getSelectedNodes
|
||||
.map((node) => node && this.workflowsStore.getNodeByName(node.name))
|
||||
.filter((node) => !!node) as INode[];
|
||||
|
||||
if (e.key === 'd' && !this.isCtrlKeyPressed(e)) {
|
||||
void this.callDebounced('toggleActivationNodes', { debounceTime: 350 }, selectedNodes);
|
||||
} else if (e.key === 'd' && this.isCtrlKeyPressed(e)) {
|
||||
if (selectedNodes.length > 0) {
|
||||
e.preventDefault();
|
||||
void this.duplicateNodes(selectedNodes);
|
||||
}
|
||||
} else if (e.key === 'p' && !this.isCtrlKeyPressed(e)) {
|
||||
if (selectedNodes.length > 0) {
|
||||
e.preventDefault();
|
||||
this.togglePinNodes(selectedNodes, 'keyboard-shortcut');
|
||||
}
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
void this.callDebounced('deleteSelectedNodes', { debounceTime: 500 });
|
||||
void this.callDebounced('deleteNodes', { debounceTime: 500 }, selectedNodes);
|
||||
} else if (e.key === 'Tab') {
|
||||
this.onToggleNodeCreator({
|
||||
source: NODE_CREATOR_OPEN_SOURCES.TAB,
|
||||
createNodeActive: !this.createNodeActive && !this.isReadOnlyRoute && !this.readOnlyEnv,
|
||||
});
|
||||
} else if (
|
||||
e.key === 'Enter' &&
|
||||
this.isCtrlKeyPressed(e) &&
|
||||
!this.isReadOnlyRoute &&
|
||||
!this.readOnlyEnv
|
||||
) {
|
||||
void this.onRunWorkflow();
|
||||
} else if (e.key === 'S' && e.shiftKey && !this.isReadOnlyRoute && !this.readOnlyEnv) {
|
||||
void this.onAddNodes({ nodes: [{ type: STICKY_NODE_TYPE }], connections: [] });
|
||||
} else if (e.key === this.controlKeyCode) {
|
||||
this.ctrlKeyPressed = true;
|
||||
} else if (e.key === ' ') {
|
||||
@@ -1159,13 +1203,13 @@ export default defineComponent({
|
||||
|
||||
void this.callDebounced('selectAllNodes', { debounceTime: 1000 });
|
||||
} else if (e.key === 'c' && this.isCtrlKeyPressed(e)) {
|
||||
void this.callDebounced('copySelectedNodes', { debounceTime: 1000 });
|
||||
void this.callDebounced('copyNodes', { debounceTime: 1000 }, selectedNodes);
|
||||
} else if (e.key === 'x' && this.isCtrlKeyPressed(e)) {
|
||||
// Cut nodes
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
void this.callDebounced('cutSelectedNodes', { debounceTime: 1000 });
|
||||
void this.callDebounced('cutNodes', { debounceTime: 1000 }, selectedNodes);
|
||||
} else if (e.key === 'n' && this.isCtrlKeyPressed(e) && e.altKey) {
|
||||
// Create a new workflow
|
||||
e.stopPropagation();
|
||||
@@ -1333,23 +1377,46 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
|
||||
deactivateSelectedNode() {
|
||||
toggleActivationNodes(nodes: INode[]) {
|
||||
if (!this.editAllowedCheck()) {
|
||||
return;
|
||||
}
|
||||
this.disableNodes(this.uiStore.getSelectedNodes, true);
|
||||
|
||||
this.disableNodes(nodes, true);
|
||||
},
|
||||
|
||||
deleteSelectedNodes() {
|
||||
togglePinNodes(nodes: INode[], source: PinDataSource) {
|
||||
if (!this.editAllowedCheck()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.historyStore.startRecordingUndo();
|
||||
|
||||
const nextStatePinned = nodes.some(
|
||||
(node) => !this.workflowsStore.pinDataByNodeName(node.name),
|
||||
);
|
||||
|
||||
for (const node of nodes) {
|
||||
if (nextStatePinned) {
|
||||
const dataToPin = this.dataSchema.getInputDataWithPinned(node);
|
||||
if (dataToPin.length !== 0) {
|
||||
this.setPinData(node, dataToPin, source);
|
||||
}
|
||||
} else {
|
||||
this.unsetPinData(node, source);
|
||||
}
|
||||
}
|
||||
|
||||
this.historyStore.stopRecordingUndo();
|
||||
},
|
||||
|
||||
deleteNodes(nodes: INode[]) {
|
||||
// Copy "selectedNodes" as the nodes get deleted out of selection
|
||||
// when they get deleted and if we would use original it would mess
|
||||
// with the index and would so not delete all nodes
|
||||
const nodesToDelete: string[] = this.uiStore.getSelectedNodes.map((node: INodeUi) => {
|
||||
return node.name;
|
||||
});
|
||||
this.historyStore.startRecordingUndo();
|
||||
nodesToDelete.forEach((nodeName: string) => {
|
||||
this.removeNode(nodeName, true, false);
|
||||
nodes.forEach((node) => {
|
||||
this.removeNode(node.name, true, false);
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.historyStore.stopRecordingUndo();
|
||||
@@ -1437,16 +1504,16 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
|
||||
cutSelectedNodes() {
|
||||
cutNodes(nodes: INode[]) {
|
||||
const deleteCopiedNodes = !this.isReadOnlyRoute && !this.readOnlyEnv;
|
||||
this.copySelectedNodes(deleteCopiedNodes);
|
||||
this.copyNodes(nodes, deleteCopiedNodes);
|
||||
if (deleteCopiedNodes) {
|
||||
this.deleteSelectedNodes();
|
||||
this.deleteNodes(nodes);
|
||||
}
|
||||
},
|
||||
|
||||
copySelectedNodes(isCut: boolean) {
|
||||
void this.getSelectedNodesToSave().then((data) => {
|
||||
copyNodes(nodes: INode[], isCut = false) {
|
||||
void this.getNodesToSave(nodes).then((data) => {
|
||||
const workflowToCopy: IWorkflowToShare = {
|
||||
meta: {
|
||||
instanceId: this.rootStore.instanceId,
|
||||
@@ -1708,6 +1775,11 @@ export default defineComponent({
|
||||
workflow_id: this.workflowsStore.workflowId,
|
||||
node_graph_string: nodeGraph,
|
||||
});
|
||||
} else if (source === 'duplicate') {
|
||||
this.$telemetry.track('User duplicated nodes', {
|
||||
workflow_id: this.workflowsStore.workflowId,
|
||||
node_graph_string: nodeGraph,
|
||||
});
|
||||
} else {
|
||||
this.$telemetry.track('User imported workflow', {
|
||||
source,
|
||||
@@ -3211,84 +3283,13 @@ export default defineComponent({
|
||||
this.workflowsStore.removeConnection({ connection: connectionInfo });
|
||||
}
|
||||
},
|
||||
async duplicateNode(nodeName: string) {
|
||||
async duplicateNodes(nodes: INode[]): Promise<void> {
|
||||
if (!this.editAllowedCheck()) {
|
||||
return;
|
||||
}
|
||||
const node = this.workflowsStore.getNodeByName(nodeName);
|
||||
|
||||
if (node) {
|
||||
const nodeTypeData = this.nodeTypesStore.getNodeType(node.type, node.typeVersion);
|
||||
|
||||
if (
|
||||
nodeTypeData?.maxNodes !== undefined &&
|
||||
this.getNodeTypeCount(node.type) >= nodeTypeData.maxNodes
|
||||
) {
|
||||
this.showMaxNodeTypeError(nodeTypeData);
|
||||
return;
|
||||
}
|
||||
|
||||
// Deep copy the data so that data on lower levels of the node-properties do
|
||||
// not share objects
|
||||
const newNodeData = deepCopy(this.getNodeDataToSave(node));
|
||||
newNodeData.id = uuid();
|
||||
|
||||
const localizedName = this.locale.localizeNodeName(newNodeData.name, newNodeData.type);
|
||||
|
||||
newNodeData.name = this.uniqueNodeName(localizedName);
|
||||
|
||||
newNodeData.position = NodeViewUtils.getNewNodePosition(
|
||||
this.nodes,
|
||||
[node.position[0], node.position[1] + 140],
|
||||
[0, 140],
|
||||
);
|
||||
|
||||
if (newNodeData.webhookId) {
|
||||
// Make sure that the node gets a new unique webhook-ID
|
||||
newNodeData.webhookId = uuid();
|
||||
}
|
||||
|
||||
if (
|
||||
newNodeData.credentials &&
|
||||
this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)
|
||||
) {
|
||||
const usedCredentials = this.workflowsStore.usedCredentials;
|
||||
newNodeData.credentials = Object.fromEntries(
|
||||
Object.entries(newNodeData.credentials).filter(([_, credential]) => {
|
||||
return (
|
||||
credential.id &&
|
||||
(!usedCredentials[credential.id] ||
|
||||
usedCredentials[credential.id]?.currentUserHasAccess)
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await this.addNodes([newNodeData], [], true);
|
||||
|
||||
const pinDataForNode = this.workflowsStore.pinDataByNodeName(nodeName);
|
||||
if (pinDataForNode?.length) {
|
||||
try {
|
||||
this.setPinData(newNodeData, pinDataForNode, 'duplicate-node');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
this.uiStore.stateIsDirty = true;
|
||||
|
||||
// Automatically deselect all nodes and select the current one and also active
|
||||
// current node
|
||||
this.deselectAllNodes();
|
||||
setTimeout(() => {
|
||||
this.nodeSelectedByName(newNodeData.name, false);
|
||||
});
|
||||
|
||||
this.$telemetry.track('User duplicated node', {
|
||||
node_type: node.type,
|
||||
workflow_id: this.workflowsStore.workflowId,
|
||||
});
|
||||
}
|
||||
const workflowData = deepCopy(await this.getNodesToSave(nodes));
|
||||
await this.importWorkflowData(workflowData, 'duplicate', false);
|
||||
},
|
||||
getJSPlumbConnection(
|
||||
sourceNodeName: string,
|
||||
@@ -4036,21 +4037,43 @@ export default defineComponent({
|
||||
connections: tempWorkflow.connectionsBySourceNode,
|
||||
};
|
||||
},
|
||||
async getSelectedNodesToSave(): Promise<IWorkflowData> {
|
||||
async getNodesToSave(nodes: INode[]): Promise<IWorkflowData> {
|
||||
const data: IWorkflowData = {
|
||||
nodes: [],
|
||||
connections: {},
|
||||
pinData: {},
|
||||
};
|
||||
|
||||
// Get data of all the selected noes
|
||||
let nodeData;
|
||||
const exportNodeNames: string[] = [];
|
||||
|
||||
for (const node of this.uiStore.getSelectedNodes) {
|
||||
for (const node of nodes) {
|
||||
nodeData = this.getNodeDataToSave(node);
|
||||
exportNodeNames.push(node.name);
|
||||
|
||||
data.nodes.push(nodeData);
|
||||
|
||||
const pinDataForNode = this.workflowsStore.pinDataByNodeName(node.name);
|
||||
if (pinDataForNode) {
|
||||
data.pinData![node.name] = pinDataForNode;
|
||||
}
|
||||
|
||||
if (
|
||||
nodeData.credentials &&
|
||||
this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)
|
||||
) {
|
||||
const usedCredentials = this.workflowsStore.usedCredentials;
|
||||
nodeData.credentials = Object.fromEntries(
|
||||
Object.entries(nodeData.credentials).filter(([_, credential]) => {
|
||||
return (
|
||||
credential.id &&
|
||||
(!usedCredentials[credential.id] ||
|
||||
usedCredentials[credential.id]?.currentUserHasAccess)
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get only connections of exported nodes and ignore all other ones
|
||||
@@ -4418,6 +4441,49 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
},
|
||||
onContextMenuAction(action: ContextMenuAction, nodes: INode[]): void {
|
||||
switch (action) {
|
||||
case 'copy':
|
||||
this.copyNodes(nodes);
|
||||
break;
|
||||
case 'delete':
|
||||
this.deleteNodes(nodes);
|
||||
break;
|
||||
case 'duplicate':
|
||||
void this.duplicateNodes(nodes);
|
||||
break;
|
||||
case 'execute':
|
||||
this.onRunNode(nodes[0].name, 'NodeView.onContextMenuAction');
|
||||
break;
|
||||
case 'open':
|
||||
this.ndvStore.activeNodeName = nodes[0].name;
|
||||
break;
|
||||
case 'rename':
|
||||
void this.renameNodePrompt(nodes[0].name);
|
||||
break;
|
||||
case 'toggle_activation':
|
||||
this.toggleActivationNodes(nodes);
|
||||
break;
|
||||
case 'toggle_pin':
|
||||
this.togglePinNodes(nodes, 'context-menu');
|
||||
break;
|
||||
case 'add_node':
|
||||
this.onToggleNodeCreator({
|
||||
source: NODE_CREATOR_OPEN_SOURCES.CONTEXT_MENU,
|
||||
createNodeActive: !this.isReadOnlyRoute && !this.readOnlyEnv,
|
||||
});
|
||||
break;
|
||||
case 'add_sticky':
|
||||
void this.onAddNodes({ nodes: [{ type: STICKY_NODE_TYPE }], connections: [] });
|
||||
break;
|
||||
case 'select_all':
|
||||
this.selectAllNodes();
|
||||
break;
|
||||
case 'deselect_all':
|
||||
this.deselectAllNodes();
|
||||
break;
|
||||
}
|
||||
},
|
||||
},
|
||||
async onSourceControlPull() {
|
||||
let workflowId = null as string | null;
|
||||
|
||||
Reference in New Issue
Block a user