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) <noreply@anthropic.com>
This commit is contained in:
Guillaume Jacquart 2026-10-16 13:25:51 +00:00 committed by GitHub
parent f92b4c11d6
commit dfec6dee81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 36 additions and 26 deletions

View File

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

View File

@ -3696,10 +3696,8 @@ export function useCanvasOperations() {
return result.nodes?.map((node) => node.id).filter(isPresent) ?? [];
}
async function copyNodes(ids: string[]): Promise<boolean> {
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 });
}

View File

@ -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' }),
);
});

View File

@ -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' });
};