From dfec6dee81d9b23516f0e51b76958a4768bd01c0 Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Fri, 16 Oct 2026 13:25:51 +0000 Subject: [PATCH] fix(editor): Allow copy and cut of a selection with a restricted node (no-changelog) (#41276) Co-authored-by: Claude Opus 5.5 (1M context) --- .../composables/useCanvasOperations.test.ts | 38 ++++++++++++++----- .../app/composables/useCanvasOperations.ts | 9 +---- .../editor-ui/src/app/views/NodeView.test.ts | 13 ++----- .../editor-ui/src/app/views/NodeView.vue | 2 +- 4 files changed, 36 insertions(+), 26 deletions(-) diff --git a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts index 79057ef144b..46eefa2562c 100644 --- a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts @@ -5143,26 +5143,24 @@ describe('useCanvasOperations', () => { expect(copiedData.nodeGroups).toBeUndefined(); }); - it('should not copy a selection that contains a restricted node type', async () => { + it('should copy a selection that contains a restricted node type', async () => { const toast = useToast(); const nodes = buildImportNodes(); nodes[1].type = 'n8n-nodes-base.slack'; workflowDocumentStoreInstance.allNodes = nodes; vi.spyOn(workflowDocumentStoreInstance, 'getNodesByIds').mockReturnValue(nodes); + vi.mocked(workflowDocumentStoreInstance.outgoingConnectionsByNodeName).mockReturnValue({}); mockedStore(useTypeAvailabilityPoliciesStore).getNodeTypeAvailability.mockImplementation( (name) => ({ name, available: name !== 'n8n-nodes-base.slack' }), ); const { copyNodes } = useCanvasOperations(); - const copied = await copyNodes(['1', '2']); + await copyNodes(['1', '2']); - expect(copied).toBe(false); - expect(useClipboard().copy).not.toHaveBeenCalled(); - expect(toast.showMessage).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'warning', - message: expect.stringContaining('n8n-nodes-base.slack'), - }), + const copiedData = JSON.parse(vi.mocked(useClipboard().copy).mock.calls[0][0] as string); + expect(copiedData.nodes.map((node: INodeUi) => node.type)).toContain('n8n-nodes-base.slack'); + expect(toast.showMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'warning' }), ); }); @@ -5371,6 +5369,28 @@ describe('useCanvasOperations', () => { expect(vi.mocked(useClipboard().copy).mock.calls).toMatchSnapshot(); }); + it('should cut a selection that contains a restricted node type', async () => { + const nodes = buildImportNodes(); + nodes[1].type = 'n8n-nodes-base.slack'; + workflowDocumentStoreInstance.allNodes = nodes; + vi.spyOn(workflowDocumentStoreInstance, 'getNodesByIds').mockReturnValue(nodes); + vi.mocked(workflowDocumentStoreInstance.outgoingConnectionsByNodeName).mockReturnValue({}); + mockedStore(useTypeAvailabilityPoliciesStore).getNodeTypeAvailability.mockImplementation( + (name) => ({ name, available: name !== 'n8n-nodes-base.slack' }), + ); + vi.spyOn(workflowDocumentStoreInstance, 'getNodeById').mockImplementation((id) => + nodes.find((node) => node.id === id), + ); + vi.mocked(workflowDocumentStoreInstance.incomingConnectionsByNodeName).mockReturnValue({}); + const removeNodeByIdSpy = vi.spyOn(workflowDocumentStoreInstance, 'removeNodeById'); + + const { cutNodes } = useCanvasOperations(); + await cutNodes(['1', '2']); + + expect(useClipboard().copy).toHaveBeenCalledTimes(1); + expect(removeNodeByIdSpy).toHaveBeenCalledWith('2'); + }); + it('does not restore an anchor when cutting an explicitly selected group', async () => { const nodeTypesStore = useNodeTypesStore(); const nodeTypeDescription = mockNodeTypeDescription({ name: SET_NODE_TYPE }); diff --git a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts index 792e578215a..8fd54a655d9 100644 --- a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts +++ b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts @@ -3696,10 +3696,8 @@ export function useCanvasOperations() { return result.nodes?.map((node) => node.id).filter(isPresent) ?? []; } - async function copyNodes(ids: string[]): Promise { + async function copyNodes(ids: string[]) { const nodes = workflowDocumentStore.value.getNodesByIds(ids); - if (refuseRestrictedNodes(nodes)) return false; - const workflowData = deepCopy(getNodesToSave(nodes)); if (!emptyCanvasGroupsEnabled.value) { removeEmptyCanvasGroupsFromWorkflowData(workflowData, nodes); @@ -3716,13 +3714,10 @@ export function useCanvasOperations() { node_types: workflowData.nodes.map((node) => node.type), workflow_id: workflowDocumentStore.value.workflowId, }); - - return true; } async function cutNodes(ids: string[], deleteWholeGroupIds: string[] = []) { - if (!(await copyNodes(ids))) return; - + await copyNodes(ids); deleteNodes(ids, { deleteWholeGroupIds }); } diff --git a/packages/frontend/editor-ui/src/app/views/NodeView.test.ts b/packages/frontend/editor-ui/src/app/views/NodeView.test.ts index bd5e06a2885..04e39002229 100644 --- a/packages/frontend/editor-ui/src/app/views/NodeView.test.ts +++ b/packages/frontend/editor-ui/src/app/views/NodeView.test.ts @@ -1143,7 +1143,7 @@ describe('NodeView', () => { expect(copiedText[0]).toContain('"name": "Existing"'); }); - it('refuses to copy a restricted node and does not claim success', async () => { + it('copies a restricted node and confirms the copy', async () => { vi.spyOn(useTypeAvailabilityPoliciesStore(), 'getNodeTypeAvailability').mockReturnValue({ name: MANUAL_TRIGGER_NODE_TYPE, available: false, @@ -1158,15 +1158,10 @@ describe('NodeView', () => { ); await deferred?.(); - expect(copiedText).toEqual([]); - expect(mockShowMessage).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'warning', - message: expect.stringContaining(MANUAL_TRIGGER_NODE_TYPE), - }), - ); + expect(copiedText).toHaveLength(1); + expect(mockShowMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })); expect(mockShowMessage).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'success' }), + expect.objectContaining({ type: 'warning' }), ); }); diff --git a/packages/frontend/editor-ui/src/app/views/NodeView.vue b/packages/frontend/editor-ui/src/app/views/NodeView.vue index 7937e0b0f68..8df947c0374 100644 --- a/packages/frontend/editor-ui/src/app/views/NodeView.vue +++ b/packages/frontend/editor-ui/src/app/views/NodeView.vue @@ -625,7 +625,7 @@ async function onCopyNodes(ids: string[]) { return; } - if (!(await copyNodes(ids))) return; + await copyNodes(ids); toast.showMessage({ title: i18n.baseText('generic.copiedToClipboard'), type: 'success' }); };