diff --git a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue index c80ad9799..5e1c3ae20 100644 --- a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue +++ b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue @@ -7,6 +7,7 @@ import { MODAL_CONFIRM, PLACEHOLDER_EMPTY_WORKFLOW_ID, SOURCE_CONTROL_PUSH_MODAL_KEY, + VALID_WORKFLOW_IMPORT_URL_REGEX, VIEWS, WORKFLOW_MENU_ACTIONS, WORKFLOW_SETTINGS_MODAL_KEY, @@ -454,7 +455,7 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise { props.eventBus.on('fitView', onFitView); + props.eventBus.on('selectNodes', onSelectNodes); }); onUnmounted(() => { props.eventBus.off('fitView', onFitView); + props.eventBus.off('selectNodes', onSelectNodes); }); onPaneReady(async () => { diff --git a/packages/editor-ui/src/composables/__tests__/__snapshots__/useCanvasOperations.spec.ts.snap b/packages/editor-ui/src/composables/__tests__/__snapshots__/useCanvasOperations.spec.ts.snap new file mode 100644 index 000000000..875db0009 --- /dev/null +++ b/packages/editor-ui/src/composables/__tests__/__snapshots__/useCanvasOperations.spec.ts.snap @@ -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": {} +}", + ], +] +`; diff --git a/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts b/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts index 2b9a12ef1..4efaf8161 100644 --- a/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts +++ b/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts @@ -22,16 +22,22 @@ import { mock } from 'vitest-mock-extended'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useCredentialsStore } from '@/stores/credentials.store'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; +import { telemetry } from '@/plugins/telemetry'; +import { useClipboard } from '@/composables/useClipboard'; -vi.mock('vue-router', async () => { - const actual = await import('vue-router'); - +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal<{}>(); return { ...actual, useRouter: () => ({}), }; }); +vi.mock('@/composables/useClipboard', async () => { + const copySpy = vi.fn(); + return { useClipboard: vi.fn(() => ({ copy: copySpy })) }; +}); + describe('useCanvasOperations', () => { let workflowsStore: ReturnType; let uiStore: ReturnType; @@ -71,6 +77,7 @@ describe('useCanvasOperations', () => { await workflowHelpers.initState(workflow); canvasOperations = useCanvasOperations({ router, lastClickPosition }); + vi.clearAllMocks(); }); describe('addNode', () => { @@ -904,4 +911,71 @@ describe('useCanvasOperations', () => { 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; + }); +} diff --git a/packages/editor-ui/src/composables/useCanvasOperations.ts b/packages/editor-ui/src/composables/useCanvasOperations.ts index e3dc88581..f45b57599 100644 --- a/packages/editor-ui/src/composables/useCanvasOperations.ts +++ b/packages/editor-ui/src/composables/useCanvasOperations.ts @@ -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 & { 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 { + ): Promise { // 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> = []; 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(); + + 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, + ): 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, + ): Record { + const connections: Record = {}; + + 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, + includeNodeNames: Set, + ): 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, diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 683b4e9cf..ea3e5a966 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -403,6 +403,7 @@ export const MODAL_CLOSE = 'close'; 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,}))$/; +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_PIN_DATA_DISCOVERY_NDV_FLAG = 'N8N_PIN_DATA_DISCOVERY_NDV'; export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG = 'N8N_PIN_DATA_DISCOVERY_CANVAS'; diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index 59ebe1090..075e55268 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -64,7 +64,7 @@ import { useUIStore } from '@/stores/ui.store'; import { dataPinningEventBus } from '@/event-bus'; import { isObject } from '@/utils/objectUtils'; 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 { useNDVStore } from '@/stores/ndv.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); } + function getNodesByIds(nodeIds: string[]): INodeUi[] { + return nodeIds.map(getNodeById).filter(isPresent); + } + function getParametersLastUpdate(nodeName: string): number | undefined { return nodeMetadata.value[nodeName]?.parametersLastUpdatedAt; } @@ -1584,6 +1588,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { getWorkflowById, getNodeByName, getNodeById, + getNodesByIds, getParametersLastUpdate, isNodePristine, isNodeExecuting, diff --git a/packages/editor-ui/src/views/NodeView.v2.vue b/packages/editor-ui/src/views/NodeView.v2.vue index b349495a3..3ae627d1d 100644 --- a/packages/editor-ui/src/views/NodeView.v2.vue +++ b/packages/editor-ui/src/views/NodeView.v2.vue @@ -42,12 +42,13 @@ import { PLACEHOLDER_EMPTY_WORKFLOW_ID, START_NODE_TYPE, STICKY_NODE_TYPE, + VALID_WORKFLOW_IMPORT_URL_REGEX, VIEWS, } from '@/constants'; import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useNodeCreatorStore } from '@/stores/nodeCreator.store'; 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 { useToast } from '@/composables/useToast'; import { useSettingsStore } from '@/stores/settings.store'; @@ -83,6 +84,7 @@ import { tryToParseNumber } from '@/utils/typesUtils'; import { useTemplatesStore } from '@/stores/templates.store'; import { createEventBus } from 'n8n-design-system'; import type { PinDataSource } from '@/composables/usePinnedData'; +import { useClipboard } from '@/composables/useClipboard'; const NodeCreation = defineAsyncComponent( async () => await import('@/components/Node/NodeCreation.vue'), @@ -141,6 +143,9 @@ const { setNodeParameters, deleteNode, deleteNodes, + copyNodes, + cutNodes, + duplicateNodes, revertDeleteNode, addNodes, createConnection, @@ -156,6 +161,7 @@ const { editableWorkflowObject, } = useCanvasOperations({ router, lastClickPosition }); const { applyExecutionData } = useExecutionDebugging(); +useClipboard({ onPaste: onClipboardPaste }); const isLoading = ref(true); const isBlankRedirect = ref(false); @@ -475,16 +481,76 @@ function onSetNodeSelected(id?: string) { setNodeSelected(id); } -function onCopyNodes(_ids: string[]) { - // @TODO: implement this +async function onCopyNodes(ids: string[]) { + await copyNodes(ids); + + toast.showMessage({ title: i18n.baseText('generic.copiedToClipboard'), type: 'success' }); } -function onCutNodes(_ids: string[]) { - // @TODO: implement this +async function onClipboardPaste(plainTextData: string): Promise { + 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(plainTextData, { fallbackValue: null }); + } + + if (!workflowData) { + return; + } + + const result = await importWorkflowData(workflowData, 'paste', false); + selectNodes(result.nodes?.map((node) => node.id) ?? []); } -function onDuplicateNodes(_ids: string[]) { - // @TODO: implement this +async function onCutNodes(ids: string[]) { + 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) { @@ -666,9 +732,11 @@ async function importWorkflowExact({ workflow: workflowData }: { workflow: IWork } async function onImportWorkflowDataEvent(data: IDataObject) { - await importWorkflowData(data.data as IWorkflowDataUpdate, 'file'); + const workflowData = data.data as IWorkflowDataUpdate; + await importWorkflowData(workflowData, 'file'); fitView(); + selectNodes(workflowData.nodes?.map((node) => node.id) ?? []); } async function onImportWorkflowUrlEvent(data: IDataObject) { @@ -680,6 +748,7 @@ async function onImportWorkflowUrlEvent(data: IDataObject) { await importWorkflowData(workflowData, 'url'); fitView(); + selectNodes(workflowData.nodes?.map((node) => node.id) ?? []); } function addImportEventBindings() { @@ -972,12 +1041,16 @@ function removePostMessageEventBindings() { window.removeEventListener('message', onPostMessageReceived); } -async function onPostMessageReceived(message: MessageEvent) { - if (!message || typeof message.data !== 'string' || !message.data?.includes?.('"command"')) { +async function onPostMessageReceived(messageEvent: MessageEvent) { + if ( + !messageEvent || + typeof messageEvent.data !== 'string' || + !messageEvent.data?.includes?.('"command"') + ) { return; } try { - const json = JSON.parse(message.data); + const json = JSON.parse(messageEvent.data); if (json && json.command === 'openWorkflow') { try { await importWorkflowExact(json); @@ -1110,6 +1183,10 @@ function fitView() { setTimeout(() => canvasEventBus.emit('fitView')); } +function selectNodes(ids: string[]) { + setTimeout(() => canvasEventBus.emit('selectNodes', ids)); +} + /** * Mouse events */ @@ -1250,9 +1327,6 @@ onMounted(async () => { 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 void externalHooks.run('nodeView.mount').catch(() => {}); }); diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 65b148730..e97355f5a 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -241,6 +241,7 @@ import { DRAG_EVENT_DATA_KEY, UPDATE_WEBHOOK_ID_NODE_TYPES, CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT, + VALID_WORKFLOW_IMPORT_URL_REGEX, } from '@/constants'; import useGlobalLinkActions from '@/composables/useGlobalLinkActions'; @@ -2024,7 +2025,7 @@ export default defineComponent({ return; } // 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 if (!this.editAllowedCheck()) {