feat(editor): Add node context menu (#7620)

![image](https://github.com/n8n-io/n8n/assets/8850410/5a601fae-cb8e-41bb-beca-ac9ab7065b75)
This commit is contained in:
Elias Meire
2023-11-20 14:37:12 +01:00
committed by GitHub
parent 4dbae0e2e9
commit 8d12c1ad8d
46 changed files with 1612 additions and 373 deletions

View File

@@ -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;