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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 400 additions and 42 deletions

View file

@ -7,6 +7,7 @@ import {
MODAL_CONFIRM, MODAL_CONFIRM,
PLACEHOLDER_EMPTY_WORKFLOW_ID, PLACEHOLDER_EMPTY_WORKFLOW_ID,
SOURCE_CONTROL_PUSH_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY,
VALID_WORKFLOW_IMPORT_URL_REGEX,
VIEWS, VIEWS,
WORKFLOW_MENU_ACTIONS, WORKFLOW_MENU_ACTIONS,
WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY,
@ -454,7 +455,7 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void
confirmButtonText: locale.baseText('mainSidebar.prompt.import'), confirmButtonText: locale.baseText('mainSidebar.prompt.import'),
cancelButtonText: locale.baseText('mainSidebar.prompt.cancel'), cancelButtonText: locale.baseText('mainSidebar.prompt.cancel'),
inputErrorMessage: locale.baseText('mainSidebar.prompt.invalidUrl'), inputErrorMessage: locale.baseText('mainSidebar.prompt.invalidUrl'),
inputPattern: /^http[s]?:\/\/.*\.json$/i, inputPattern: VALID_WORKFLOW_IMPORT_URL_REGEX,
}, },
); );

View file

@ -15,6 +15,7 @@ import { useKeybindings } from '@/composables/useKeybindings';
import ContextMenu from '@/components/ContextMenu/ContextMenu.vue'; import ContextMenu from '@/components/ContextMenu/ContextMenu.vue';
import type { NodeCreatorOpenSource } from '@/Interface'; import type { NodeCreatorOpenSource } from '@/Interface';
import type { PinDataSource } from '@/composables/usePinnedData'; import type { PinDataSource } from '@/composables/usePinnedData';
import { isPresent } from '@/utils/typesUtils';
const $style = useCssModule(); const $style = useCssModule();
@ -75,6 +76,7 @@ const {
project, project,
nodes: graphNodes, nodes: graphNodes,
onPaneReady, onPaneReady,
findNode,
} = useVueFlow({ id: props.id, deleteKeyCode: null }); } = useVueFlow({ id: props.id, deleteKeyCode: null });
useKeybindings({ useKeybindings({
@ -131,11 +133,20 @@ function onSetNodeActive(id: string) {
emit('update:node:active', id); emit('update:node:active', id);
} }
function clearSelectedNodes() {
removeSelectedNodes(selectedNodes.value);
}
function onSelectNode() { function onSelectNode() {
if (!lastSelectedNode.value) return; if (!lastSelectedNode.value) return;
emit('update:node:selected', lastSelectedNode.value.id); emit('update:node:selected', lastSelectedNode.value.id);
} }
function onSelectNodes(ids: string[]) {
clearSelectedNodes();
addSelectedNodes(ids.map(findNode).filter(isPresent));
}
function onToggleNodeEnabled(id: string) { function onToggleNodeEnabled(id: string) {
emit('update:node:enabled', id); emit('update:node:enabled', id);
} }
@ -288,7 +299,7 @@ function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) {
case 'select_all': case 'select_all':
return addSelectedNodes(graphNodes.value); return addSelectedNodes(graphNodes.value);
case 'deselect_all': case 'deselect_all':
return removeSelectedNodes(selectedNodes.value); return clearSelectedNodes();
case 'duplicate': case 'duplicate':
return emit('duplicate:nodes', nodeIds); return emit('duplicate:nodes', nodeIds);
case 'toggle_pin': case 'toggle_pin':
@ -310,10 +321,12 @@ function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) {
onMounted(() => { onMounted(() => {
props.eventBus.on('fitView', onFitView); props.eventBus.on('fitView', onFitView);
props.eventBus.on('selectNodes', onSelectNodes);
}); });
onUnmounted(() => { onUnmounted(() => {
props.eventBus.off('fitView', onFitView); props.eventBus.off('fitView', onFitView);
props.eventBus.off('selectNodes', onSelectNodes);
}); });
onPaneReady(async () => { onPaneReady(async () => {

View file

@ -0,0 +1,71 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`useCanvasOperations > copyNodes > should copy nodes 1`] = `
[
[
"{
"nodes": [
{
"parameters": {},
"id": "1",
"name": "Node 1",
"type": "type",
"position": [
40,
40
],
"typeVersion": 1
},
{
"parameters": {},
"id": "2",
"name": "Node 2",
"type": "type",
"position": [
40,
40
],
"typeVersion": 1
}
],
"connections": {},
"pinData": {}
}",
],
]
`;
exports[`useCanvasOperations > cutNodes > should copy and delete nodes 1`] = `
[
[
"{
"nodes": [
{
"parameters": {},
"id": "1",
"name": "Node 1",
"type": "type",
"position": [
40,
40
],
"typeVersion": 1
},
{
"parameters": {},
"id": "2",
"name": "Node 2",
"type": "type",
"position": [
40,
40
],
"typeVersion": 1
}
],
"connections": {},
"pinData": {}
}",
],
]
`;

View file

@ -22,16 +22,22 @@ import { mock } from 'vitest-mock-extended';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCredentialsStore } from '@/stores/credentials.store'; import { useCredentialsStore } from '@/stores/credentials.store';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { telemetry } from '@/plugins/telemetry';
import { useClipboard } from '@/composables/useClipboard';
vi.mock('vue-router', async () => { vi.mock('vue-router', async (importOriginal) => {
const actual = await import('vue-router'); const actual = await importOriginal<{}>();
return { return {
...actual, ...actual,
useRouter: () => ({}), useRouter: () => ({}),
}; };
}); });
vi.mock('@/composables/useClipboard', async () => {
const copySpy = vi.fn();
return { useClipboard: vi.fn(() => ({ copy: copySpy })) };
});
describe('useCanvasOperations', () => { describe('useCanvasOperations', () => {
let workflowsStore: ReturnType<typeof useWorkflowsStore>; let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let uiStore: ReturnType<typeof useUIStore>; let uiStore: ReturnType<typeof useUIStore>;
@ -71,6 +77,7 @@ describe('useCanvasOperations', () => {
await workflowHelpers.initState(workflow); await workflowHelpers.initState(workflow);
canvasOperations = useCanvasOperations({ router, lastClickPosition }); canvasOperations = useCanvasOperations({ router, lastClickPosition });
vi.clearAllMocks();
}); });
describe('addNode', () => { describe('addNode', () => {
@ -904,4 +911,71 @@ describe('useCanvasOperations', () => {
expect(addConnectionSpy).toHaveBeenCalledWith({ connection }); expect(addConnectionSpy).toHaveBeenCalledWith({ connection });
}); });
}); });
describe('duplicateNodes', () => {
it('should duplicate nodes', async () => {
nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]);
const telemetrySpy = vi.spyOn(telemetry, 'track');
const nodes = buildImportNodes();
workflowsStore.setNodes(nodes);
const duplicatedNodeIds = await canvasOperations.duplicateNodes(['1', '2']);
expect(duplicatedNodeIds.length).toBe(2);
expect(duplicatedNodeIds).not.toContain('1');
expect(duplicatedNodeIds).not.toContain('2');
expect(workflowsStore.workflow.nodes.length).toEqual(4);
expect(telemetrySpy).toHaveBeenCalledWith(
'User duplicated nodes',
expect.objectContaining({ node_graph_string: expect.any(String), workflow_id: 'test' }),
);
});
});
describe('copyNodes', () => {
it('should copy nodes', async () => {
nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]);
const telemetrySpy = vi.spyOn(telemetry, 'track');
const nodes = buildImportNodes();
workflowsStore.setNodes(nodes);
await canvasOperations.copyNodes(['1', '2']);
expect(useClipboard().copy).toHaveBeenCalledTimes(1);
expect(vi.mocked(useClipboard().copy).mock.calls).toMatchSnapshot();
expect(telemetrySpy).toHaveBeenCalledWith(
'User copied nodes',
expect.objectContaining({ node_types: ['type', 'type'], workflow_id: 'test' }),
);
});
});
describe('cutNodes', () => {
it('should copy and delete nodes', async () => {
nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]);
const telemetrySpy = vi.spyOn(telemetry, 'track');
const nodes = buildImportNodes();
workflowsStore.setNodes(nodes);
await canvasOperations.cutNodes(['1', '2']);
expect(useClipboard().copy).toHaveBeenCalledTimes(1);
expect(vi.mocked(useClipboard().copy).mock.calls).toMatchSnapshot();
expect(telemetrySpy).toHaveBeenCalledWith(
'User copied nodes',
expect.objectContaining({ node_types: ['type', 'type'], workflow_id: 'test' }),
);
expect(workflowsStore.getNodes().length).toBe(0);
});
});
}); });
function buildImportNodes() {
return [
mockNode({ id: '1', name: 'Node 1', type: 'type' }),
mockNode({ id: '2', name: 'Node 2', type: 'type' }),
].map((node) => {
// Setting position in mockNode will wrap it in a Proxy
// This causes deepCopy to remove position -> set position after instead
node.position = [40, 40];
return node;
});
}

View file

@ -7,6 +7,8 @@ import type {
AddedNodesAndConnections, AddedNodesAndConnections,
INodeUi, INodeUi,
ITag, ITag,
IUsedCredential,
IWorkflowData,
IWorkflowDataUpdate, IWorkflowDataUpdate,
IWorkflowDb, IWorkflowDb,
XYPosition, XYPosition,
@ -20,6 +22,7 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { import {
EnterpriseEditionFeature,
FORM_TRIGGER_NODE_TYPE, FORM_TRIGGER_NODE_TYPE,
QUICKSTART_NOTE_NAME, QUICKSTART_NOTE_NAME,
STICKY_NODE_TYPE, STICKY_NODE_TYPE,
@ -56,7 +59,6 @@ import {
} from '@/utils/canvasUtilsV2'; } from '@/utils/canvasUtilsV2';
import * as NodeViewUtils from '@/utils/nodeViewUtils'; import * as NodeViewUtils from '@/utils/nodeViewUtils';
import { isValidNodeConnectionType } from '@/utils/typeGuards'; import { isValidNodeConnectionType } from '@/utils/typeGuards';
import { isPresent } from '@/utils/typesUtils';
import type { Connection } from '@vue-flow/core'; import type { Connection } from '@vue-flow/core';
import type { import type {
ConnectionTypes, ConnectionTypes,
@ -64,20 +66,24 @@ import type {
IConnections, IConnections,
INode, INode,
INodeConnections, INodeConnections,
INodeCredentials,
INodeInputConfiguration, INodeInputConfiguration,
INodeOutputConfiguration, INodeOutputConfiguration,
INodeTypeDescription, INodeTypeDescription,
INodeTypeNameVersion, INodeTypeNameVersion,
IPinData,
ITelemetryTrackProperties, ITelemetryTrackProperties,
IWorkflowBase, IWorkflowBase,
NodeParameterValueType, NodeParameterValueType,
Workflow, Workflow,
} from 'n8n-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 { v4 as uuid } from 'uuid';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import { computed, nextTick } from 'vue'; import { computed, nextTick } from 'vue';
import type { useRouter } from 'vue-router'; import type { useRouter } from 'vue-router';
import { useClipboard } from '@/composables/useClipboard';
import { isPresent } from '../utils/typesUtils';
type AddNodeData = Partial<INodeUi> & { type AddNodeData = Partial<INodeUi> & {
type: string; type: string;
@ -116,6 +122,7 @@ export function useCanvasOperations({
const nodeHelpers = useNodeHelpers(); const nodeHelpers = useNodeHelpers();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const externalHooks = useExternalHooks(); const externalHooks = useExternalHooks();
const clipboard = useClipboard();
const editableWorkflow = computed(() => workflowsStore.workflow); const editableWorkflow = computed(() => workflowsStore.workflow);
const editableWorkflowObject = computed(() => workflowsStore.getCurrentWorkflow()); const editableWorkflowObject = computed(() => workflowsStore.getCurrentWorkflow());
@ -296,14 +303,14 @@ export function useCanvasOperations({
ids: string[], ids: string[],
{ trackHistory = true }: { trackHistory?: boolean } = {}, { trackHistory = true }: { trackHistory?: boolean } = {},
) { ) {
const nodes = ids.map((id) => workflowsStore.getNodeById(id)).filter(isPresent); const nodes = workflowsStore.getNodesByIds(ids);
nodeHelpers.disableNodes(nodes, trackHistory); nodeHelpers.disableNodes(nodes, trackHistory);
} }
function toggleNodesPinned(ids: string[], source: PinDataSource) { function toggleNodesPinned(ids: string[], source: PinDataSource) {
historyStore.startRecordingUndo(); 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)); const nextStatePinned = nodes.some((node) => !workflowsStore.pinDataByNodeName(node.name));
for (const node of nodes) { for (const node of nodes) {
@ -1168,7 +1175,7 @@ export function useCanvasOperations({
// Get only the connections of the nodes that get created // Get only the connections of the nodes that get created
const newConnections: IConnections = {}; const newConnections: IConnections = {};
const currentConnections = data.connections!; const currentConnections = data.connections ?? {};
const createNodeNames = createNodes.map((node) => node.name); const createNodeNames = createNodes.map((node) => node.name);
let sourceNode, type, sourceIndex, connectionIndex, connectionData; let sourceNode, type, sourceIndex, connectionIndex, connectionData;
for (sourceNode of Object.keys(currentConnections)) { for (sourceNode of Object.keys(currentConnections)) {
@ -1271,10 +1278,10 @@ export function useCanvasOperations({
workflowData: IWorkflowDataUpdate, workflowData: IWorkflowDataUpdate,
source: string, source: string,
importTags = true, importTags = true,
): Promise<void> { ): Promise<IWorkflowDataUpdate> {
// If it is JSON check if it looks on the first look like data we can use // If it is JSON check if it looks on the first look like data we can use
if (!workflowData.hasOwnProperty('nodes') || !workflowData.hasOwnProperty('connections')) { if (!workflowData.hasOwnProperty('nodes') || !workflowData.hasOwnProperty('connections')) {
return; return {};
} }
try { 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 // Fix the node position as it could be totally offscreen
// and the pasted nodes would so not be directly visible to // and the pasted nodes would so not be directly visible to
// the user // the user
@ -1360,17 +1363,14 @@ export function useCanvasOperations({
await addImportedNodesToWorkflow(workflowData); await addImportedNodesToWorkflow(workflowData);
// setTimeout(() => {
// (data?.nodes ?? []).forEach((node: INodeUi) => {
// this.nodeSelectedByName(node.name);
// });
// });
if (importTags && settingsStore.areTagsEnabled && Array.isArray(workflowData.tags)) { if (importTags && settingsStore.areTagsEnabled && Array.isArray(workflowData.tags)) {
await importWorkflowTags(workflowData); await importWorkflowTags(workflowData);
} }
return workflowData;
} catch (error) { } catch (error) {
toast.showError(error, i18n.baseText('nodeView.showError.importWorkflowData.title')); toast.showError(error, i18n.baseText('nodeView.showError.importWorkflowData.title'));
return {};
} }
} }
@ -1383,9 +1383,9 @@ export function useCanvasOperations({
const creatingTagPromises: Array<Promise<ITag>> = []; const creatingTagPromises: Array<Promise<ITag>> = [];
for (const tag of notFound) { for (const tag of notFound) {
const creationPromise = tagsStore.create(tag.name).then((tag: ITag) => { const creationPromise = tagsStore.create(tag.name).then((newTag: ITag) => {
allTags.push(tag); allTags.push(newTag);
return tag; return newTag;
}); });
creatingTagPromises.push(creationPromise); creatingTagPromises.push(creationPromise);
@ -1394,7 +1394,7 @@ export function useCanvasOperations({
await Promise.all(creatingTagPromises); await Promise.all(creatingTagPromises);
const tagIds = workflowTags.reduce((accu: string[], imported: ITag) => { 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) { if (tag) {
accu.push(tag.id); accu.push(tag.id);
} }
@ -1424,6 +1424,121 @@ export function useCanvasOperations({
return workflowData; 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 { return {
editableWorkflow, editableWorkflow,
editableWorkflowObject, editableWorkflowObject,
@ -1441,6 +1556,9 @@ export function useCanvasOperations({
revertRenameNode, revertRenameNode,
deleteNode, deleteNode,
deleteNodes, deleteNodes,
copyNodes,
cutNodes,
duplicateNodes,
revertDeleteNode, revertDeleteNode,
addConnections, addConnections,
createConnection, createConnection,

View file

@ -403,6 +403,7 @@ export const MODAL_CLOSE = 'close';
export const VALID_EMAIL_REGEX = export const VALID_EMAIL_REGEX =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
export const VALID_WORKFLOW_IMPORT_URL_REGEX = /^http[s]?:\/\/.*\.json$/i;
export const LOCAL_STORAGE_ACTIVATION_FLAG = 'N8N_HIDE_ACTIVATION_ALERT'; export const LOCAL_STORAGE_ACTIVATION_FLAG = 'N8N_HIDE_ACTIVATION_ALERT';
export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG = 'N8N_PIN_DATA_DISCOVERY_NDV'; export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG = 'N8N_PIN_DATA_DISCOVERY_NDV';
export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG = 'N8N_PIN_DATA_DISCOVERY_CANVAS'; export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG = 'N8N_PIN_DATA_DISCOVERY_CANVAS';

View file

@ -64,7 +64,7 @@ import { useUIStore } from '@/stores/ui.store';
import { dataPinningEventBus } from '@/event-bus'; import { dataPinningEventBus } from '@/event-bus';
import { isObject } from '@/utils/objectUtils'; import { isObject } from '@/utils/objectUtils';
import { getPairedItemsMapping } from '@/utils/pairedItemUtils'; import { getPairedItemsMapping } from '@/utils/pairedItemUtils';
import { isJsonKeyObject, isEmpty, stringSizeInBytes } from '@/utils/typesUtils'; import { isJsonKeyObject, isEmpty, stringSizeInBytes, isPresent } from '@/utils/typesUtils';
import { makeRestApiRequest, unflattenExecutionData, ResponseError } from '@/utils/apiUtils'; import { makeRestApiRequest, unflattenExecutionData, ResponseError } from '@/utils/apiUtils';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
@ -250,6 +250,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
return workflow.value.nodes.find((node) => node.id === nodeId); return workflow.value.nodes.find((node) => node.id === nodeId);
} }
function getNodesByIds(nodeIds: string[]): INodeUi[] {
return nodeIds.map(getNodeById).filter(isPresent);
}
function getParametersLastUpdate(nodeName: string): number | undefined { function getParametersLastUpdate(nodeName: string): number | undefined {
return nodeMetadata.value[nodeName]?.parametersLastUpdatedAt; return nodeMetadata.value[nodeName]?.parametersLastUpdatedAt;
} }
@ -1584,6 +1588,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
getWorkflowById, getWorkflowById,
getNodeByName, getNodeByName,
getNodeById, getNodeById,
getNodesByIds,
getParametersLastUpdate, getParametersLastUpdate,
isNodePristine, isNodePristine,
isNodeExecuting, isNodeExecuting,

View file

@ -42,12 +42,13 @@ import {
PLACEHOLDER_EMPTY_WORKFLOW_ID, PLACEHOLDER_EMPTY_WORKFLOW_ID,
START_NODE_TYPE, START_NODE_TYPE,
STICKY_NODE_TYPE, STICKY_NODE_TYPE,
VALID_WORKFLOW_IMPORT_URL_REGEX,
VIEWS, VIEWS,
} from '@/constants'; } from '@/constants';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store'; import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { useExternalHooks } from '@/composables/useExternalHooks'; import { useExternalHooks } from '@/composables/useExternalHooks';
import { TelemetryHelpers, NodeConnectionType } from 'n8n-workflow'; import { TelemetryHelpers, NodeConnectionType, jsonParse } from 'n8n-workflow';
import type { IDataObject, ExecutionSummary, IConnection, IWorkflowBase } from 'n8n-workflow'; import type { IDataObject, ExecutionSummary, IConnection, IWorkflowBase } from 'n8n-workflow';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
@ -83,6 +84,7 @@ import { tryToParseNumber } from '@/utils/typesUtils';
import { useTemplatesStore } from '@/stores/templates.store'; import { useTemplatesStore } from '@/stores/templates.store';
import { createEventBus } from 'n8n-design-system'; import { createEventBus } from 'n8n-design-system';
import type { PinDataSource } from '@/composables/usePinnedData'; import type { PinDataSource } from '@/composables/usePinnedData';
import { useClipboard } from '@/composables/useClipboard';
const NodeCreation = defineAsyncComponent( const NodeCreation = defineAsyncComponent(
async () => await import('@/components/Node/NodeCreation.vue'), async () => await import('@/components/Node/NodeCreation.vue'),
@ -141,6 +143,9 @@ const {
setNodeParameters, setNodeParameters,
deleteNode, deleteNode,
deleteNodes, deleteNodes,
copyNodes,
cutNodes,
duplicateNodes,
revertDeleteNode, revertDeleteNode,
addNodes, addNodes,
createConnection, createConnection,
@ -156,6 +161,7 @@ const {
editableWorkflowObject, editableWorkflowObject,
} = useCanvasOperations({ router, lastClickPosition }); } = useCanvasOperations({ router, lastClickPosition });
const { applyExecutionData } = useExecutionDebugging(); const { applyExecutionData } = useExecutionDebugging();
useClipboard({ onPaste: onClipboardPaste });
const isLoading = ref(true); const isLoading = ref(true);
const isBlankRedirect = ref(false); const isBlankRedirect = ref(false);
@ -475,16 +481,76 @@ function onSetNodeSelected(id?: string) {
setNodeSelected(id); setNodeSelected(id);
} }
function onCopyNodes(_ids: string[]) { async function onCopyNodes(ids: string[]) {
// @TODO: implement this await copyNodes(ids);
toast.showMessage({ title: i18n.baseText('generic.copiedToClipboard'), type: 'success' });
} }
function onCutNodes(_ids: string[]) { async function onClipboardPaste(plainTextData: string): Promise<void> {
// @TODO: implement this if (getNodeViewTab(route) !== MAIN_HEADER_TABS.WORKFLOW) {
return;
}
if (!checkIfEditingIsAllowed()) {
return;
}
let workflowData: IWorkflowDataUpdate | null | undefined = null;
// Check if it is an URL which could contain workflow data
if (plainTextData.match(VALID_WORKFLOW_IMPORT_URL_REGEX)) {
const importConfirm = await message.confirm(
i18n.baseText('nodeView.confirmMessage.onClipboardPasteEvent.message', {
interpolate: { plainTextData },
}),
i18n.baseText('nodeView.confirmMessage.onClipboardPasteEvent.headline'),
{
type: 'warning',
confirmButtonText: i18n.baseText(
'nodeView.confirmMessage.onClipboardPasteEvent.confirmButtonText',
),
cancelButtonText: i18n.baseText(
'nodeView.confirmMessage.onClipboardPasteEvent.cancelButtonText',
),
dangerouslyUseHTMLString: true,
},
);
if (importConfirm !== MODAL_CONFIRM) {
return;
}
workflowData = await fetchWorkflowDataFromUrl(plainTextData);
} else {
// Pasted data is is possible workflow data
workflowData = jsonParse<IWorkflowDataUpdate | null>(plainTextData, { fallbackValue: null });
}
if (!workflowData) {
return;
}
const result = await importWorkflowData(workflowData, 'paste', false);
selectNodes(result.nodes?.map((node) => node.id) ?? []);
} }
function onDuplicateNodes(_ids: string[]) { async function onCutNodes(ids: string[]) {
// @TODO: implement this if (isCanvasReadOnly.value) {
await copyNodes(ids);
} else {
await cutNodes(ids);
}
}
async function onDuplicateNodes(ids: string[]) {
if (!checkIfEditingIsAllowed()) {
return;
}
const newIds = await duplicateNodes(ids);
selectNodes(newIds);
} }
function onPinNodes(ids: string[], source: PinDataSource) { function onPinNodes(ids: string[], source: PinDataSource) {
@ -666,9 +732,11 @@ async function importWorkflowExact({ workflow: workflowData }: { workflow: IWork
} }
async function onImportWorkflowDataEvent(data: IDataObject) { async function onImportWorkflowDataEvent(data: IDataObject) {
await importWorkflowData(data.data as IWorkflowDataUpdate, 'file'); const workflowData = data.data as IWorkflowDataUpdate;
await importWorkflowData(workflowData, 'file');
fitView(); fitView();
selectNodes(workflowData.nodes?.map((node) => node.id) ?? []);
} }
async function onImportWorkflowUrlEvent(data: IDataObject) { async function onImportWorkflowUrlEvent(data: IDataObject) {
@ -680,6 +748,7 @@ async function onImportWorkflowUrlEvent(data: IDataObject) {
await importWorkflowData(workflowData, 'url'); await importWorkflowData(workflowData, 'url');
fitView(); fitView();
selectNodes(workflowData.nodes?.map((node) => node.id) ?? []);
} }
function addImportEventBindings() { function addImportEventBindings() {
@ -972,12 +1041,16 @@ function removePostMessageEventBindings() {
window.removeEventListener('message', onPostMessageReceived); window.removeEventListener('message', onPostMessageReceived);
} }
async function onPostMessageReceived(message: MessageEvent) { async function onPostMessageReceived(messageEvent: MessageEvent) {
if (!message || typeof message.data !== 'string' || !message.data?.includes?.('"command"')) { if (
!messageEvent ||
typeof messageEvent.data !== 'string' ||
!messageEvent.data?.includes?.('"command"')
) {
return; return;
} }
try { try {
const json = JSON.parse(message.data); const json = JSON.parse(messageEvent.data);
if (json && json.command === 'openWorkflow') { if (json && json.command === 'openWorkflow') {
try { try {
await importWorkflowExact(json); await importWorkflowExact(json);
@ -1110,6 +1183,10 @@ function fitView() {
setTimeout(() => canvasEventBus.emit('fitView')); setTimeout(() => canvasEventBus.emit('fitView'));
} }
function selectNodes(ids: string[]) {
setTimeout(() => canvasEventBus.emit('selectNodes', ids));
}
/** /**
* Mouse events * Mouse events
*/ */
@ -1250,9 +1327,6 @@ onMounted(async () => {
registerCustomActions(); registerCustomActions();
// @TODO Implement this
// this.clipboard.onPaste.value = this.onClipboardPasteEvent;
// @TODO: This currently breaks since front-end hooks are still not updated to work with pinia store // @TODO: This currently breaks since front-end hooks are still not updated to work with pinia store
void externalHooks.run('nodeView.mount').catch(() => {}); void externalHooks.run('nodeView.mount').catch(() => {});
}); });

View file

@ -241,6 +241,7 @@ import {
DRAG_EVENT_DATA_KEY, DRAG_EVENT_DATA_KEY,
UPDATE_WEBHOOK_ID_NODE_TYPES, UPDATE_WEBHOOK_ID_NODE_TYPES,
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT, CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT,
VALID_WORKFLOW_IMPORT_URL_REGEX,
} from '@/constants'; } from '@/constants';
import useGlobalLinkActions from '@/composables/useGlobalLinkActions'; import useGlobalLinkActions from '@/composables/useGlobalLinkActions';
@ -2024,7 +2025,7 @@ export default defineComponent({
return; return;
} }
// Check if it is an URL which could contain workflow data // Check if it is an URL which could contain workflow data
if (plainTextData.match(/^http[s]?:\/\/.*\.json$/i)) { if (plainTextData.match(VALID_WORKFLOW_IMPORT_URL_REGEX)) {
// Pasted data points to a possible workflow JSON file // Pasted data points to a possible workflow JSON file
if (!this.editAllowedCheck()) { if (!this.editAllowedCheck()) {