feat(editor): Add duplicate and copy/paste to canvas v2 (no-changelog) (#10112)

This commit is contained in:
Elias Meire
2024-07-19 14:49:52 +02:00
committed by GitHub
parent fc4184773a
commit 2e5c548452
9 changed files with 400 additions and 42 deletions

View File

@@ -7,6 +7,8 @@ import type {
AddedNodesAndConnections,
INodeUi,
ITag,
IUsedCredential,
IWorkflowData,
IWorkflowDataUpdate,
IWorkflowDb,
XYPosition,
@@ -20,6 +22,7 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import {
EnterpriseEditionFeature,
FORM_TRIGGER_NODE_TYPE,
QUICKSTART_NOTE_NAME,
STICKY_NODE_TYPE,
@@ -56,7 +59,6 @@ import {
} from '@/utils/canvasUtilsV2';
import * as NodeViewUtils from '@/utils/nodeViewUtils';
import { isValidNodeConnectionType } from '@/utils/typeGuards';
import { isPresent } from '@/utils/typesUtils';
import type { Connection } from '@vue-flow/core';
import type {
ConnectionTypes,
@@ -64,20 +66,24 @@ import type {
IConnections,
INode,
INodeConnections,
INodeCredentials,
INodeInputConfiguration,
INodeOutputConfiguration,
INodeTypeDescription,
INodeTypeNameVersion,
IPinData,
ITelemetryTrackProperties,
IWorkflowBase,
NodeParameterValueType,
Workflow,
} from 'n8n-workflow';
import { NodeConnectionType, NodeHelpers, TelemetryHelpers } from 'n8n-workflow';
import { deepCopy, NodeConnectionType, NodeHelpers, TelemetryHelpers } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import type { Ref } from 'vue';
import { computed, nextTick } from 'vue';
import type { useRouter } from 'vue-router';
import { useClipboard } from '@/composables/useClipboard';
import { isPresent } from '../utils/typesUtils';
type AddNodeData = Partial<INodeUi> & {
type: string;
@@ -116,6 +122,7 @@ export function useCanvasOperations({
const nodeHelpers = useNodeHelpers();
const telemetry = useTelemetry();
const externalHooks = useExternalHooks();
const clipboard = useClipboard();
const editableWorkflow = computed(() => workflowsStore.workflow);
const editableWorkflowObject = computed(() => workflowsStore.getCurrentWorkflow());
@@ -296,14 +303,14 @@ export function useCanvasOperations({
ids: string[],
{ trackHistory = true }: { trackHistory?: boolean } = {},
) {
const nodes = ids.map((id) => workflowsStore.getNodeById(id)).filter(isPresent);
const nodes = workflowsStore.getNodesByIds(ids);
nodeHelpers.disableNodes(nodes, trackHistory);
}
function toggleNodesPinned(ids: string[], source: PinDataSource) {
historyStore.startRecordingUndo();
const nodes = ids.map((id) => workflowsStore.getNodeById(id)).filter(isPresent);
const nodes = workflowsStore.getNodesByIds(ids);
const nextStatePinned = nodes.some((node) => !workflowsStore.pinDataByNodeName(node.name));
for (const node of nodes) {
@@ -1168,7 +1175,7 @@ export function useCanvasOperations({
// Get only the connections of the nodes that get created
const newConnections: IConnections = {};
const currentConnections = data.connections!;
const currentConnections = data.connections ?? {};
const createNodeNames = createNodes.map((node) => node.name);
let sourceNode, type, sourceIndex, connectionIndex, connectionData;
for (sourceNode of Object.keys(currentConnections)) {
@@ -1271,10 +1278,10 @@ export function useCanvasOperations({
workflowData: IWorkflowDataUpdate,
source: string,
importTags = true,
): Promise<void> {
): Promise<IWorkflowDataUpdate> {
// If it is JSON check if it looks on the first look like data we can use
if (!workflowData.hasOwnProperty('nodes') || !workflowData.hasOwnProperty('connections')) {
return;
return {};
}
try {
@@ -1346,10 +1353,6 @@ export function useCanvasOperations({
});
}
// By default we automatically deselect all the currently
// selected nodes and select the new ones
// this.deselectAllNodes();
// Fix the node position as it could be totally offscreen
// and the pasted nodes would so not be directly visible to
// the user
@@ -1360,17 +1363,14 @@ export function useCanvasOperations({
await addImportedNodesToWorkflow(workflowData);
// setTimeout(() => {
// (data?.nodes ?? []).forEach((node: INodeUi) => {
// this.nodeSelectedByName(node.name);
// });
// });
if (importTags && settingsStore.areTagsEnabled && Array.isArray(workflowData.tags)) {
await importWorkflowTags(workflowData);
}
return workflowData;
} catch (error) {
toast.showError(error, i18n.baseText('nodeView.showError.importWorkflowData.title'));
return {};
}
}
@@ -1383,9 +1383,9 @@ export function useCanvasOperations({
const creatingTagPromises: Array<Promise<ITag>> = [];
for (const tag of notFound) {
const creationPromise = tagsStore.create(tag.name).then((tag: ITag) => {
allTags.push(tag);
return tag;
const creationPromise = tagsStore.create(tag.name).then((newTag: ITag) => {
allTags.push(newTag);
return newTag;
});
creatingTagPromises.push(creationPromise);
@@ -1394,7 +1394,7 @@ export function useCanvasOperations({
await Promise.all(creatingTagPromises);
const tagIds = workflowTags.reduce((accu: string[], imported: ITag) => {
const tag = allTags.find((tag) => tag.name === imported.name);
const tag = allTags.find((t) => t.name === imported.name);
if (tag) {
accu.push(tag.id);
}
@@ -1424,6 +1424,121 @@ export function useCanvasOperations({
return workflowData;
}
function getNodesToSave(nodes: INode[]): IWorkflowData {
const data = {
nodes: [] as INodeUi[],
connections: {} as IConnections,
pinData: {} as IPinData,
} satisfies IWorkflowData;
const exportedNodeNames = new Set<string>();
for (const node of nodes) {
const nodeSaveData = workflowHelpers.getNodeDataToSave(node);
const pinDataForNode = workflowsStore.pinDataByNodeName(node.name);
if (pinDataForNode) {
data.pinData[node.name] = pinDataForNode;
}
if (
nodeSaveData.credentials &&
settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)
) {
nodeSaveData.credentials = filterAllowedCredentials(
nodeSaveData.credentials,
workflowsStore.usedCredentials,
);
}
data.nodes.push(nodeSaveData);
exportedNodeNames.add(node.name);
}
data.connections = getConnectionsForNodes(data.nodes, exportedNodeNames);
workflowHelpers.removeForeignCredentialsFromWorkflow(data, credentialsStore.allCredentials);
return data;
}
function filterAllowedCredentials(
credentials: INodeCredentials,
usedCredentials: Record<string, IUsedCredential>,
): INodeCredentials {
return Object.fromEntries(
Object.entries(credentials).filter(([, credential]) => {
return (
credential.id &&
(!usedCredentials[credential.id] || usedCredentials[credential.id]?.currentUserHasAccess)
);
}),
);
}
function getConnectionsForNodes(
nodes: INodeUi[],
includeNodeNames: Set<string>,
): Record<string, INodeConnections> {
const connections: Record<string, INodeConnections> = {};
for (const node of nodes) {
const outgoingConnections = workflowsStore.outgoingConnectionsByNodeName(node.name);
if (!Object.keys(outgoingConnections).length) continue;
const filteredConnections = filterConnectionsByNodes(outgoingConnections, includeNodeNames);
if (Object.keys(filteredConnections).length) {
connections[node.name] = filteredConnections;
}
}
return connections;
}
function filterConnectionsByNodes(
connections: Record<string, IConnection[][]>,
includeNodeNames: Set<string>,
): INodeConnections {
const filteredConnections: INodeConnections = {};
for (const [type, typeConnections] of Object.entries(connections)) {
const validConnections = typeConnections
.map((sourceConnections) =>
sourceConnections.filter((connection) => includeNodeNames.has(connection.node)),
)
.filter((sourceConnections) => sourceConnections.length > 0);
if (validConnections.length) {
filteredConnections[type] = validConnections;
}
}
return filteredConnections;
}
async function duplicateNodes(ids: string[]) {
const workflowData = deepCopy(getNodesToSave(workflowsStore.getNodesByIds(ids)));
const result = await importWorkflowData(workflowData, 'duplicate', false);
return result.nodes?.map((node) => node.id).filter(isPresent) ?? [];
}
async function copyNodes(ids: string[]) {
const workflowData = deepCopy(getNodesToSave(workflowsStore.getNodesByIds(ids)));
await clipboard.copy(JSON.stringify(workflowData, null, 2));
telemetry.track('User copied nodes', {
node_types: workflowData.nodes.map((node) => node.type),
workflow_id: workflowsStore.workflowId,
});
}
async function cutNodes(ids: string[]) {
await copyNodes(ids);
deleteNodes(ids);
}
return {
editableWorkflow,
editableWorkflowObject,
@@ -1441,6 +1556,9 @@ export function useCanvasOperations({
revertRenameNode,
deleteNode,
deleteNodes,
copyNodes,
cutNodes,
duplicateNodes,
revertDeleteNode,
addConnections,
createConnection,