From 38f25d74eb662456f9a0857d2f24762f58648ae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 26 Aug 2025 14:29:50 +0200 Subject: [PATCH] feat(editor): Add Python to Code actions (#18668) --- cypress/constants.ts | 2 + cypress/e2e/10-undo-redo.cy.ts | 34 +++++++-------- cypress/e2e/12-canvas.cy.ts | 11 ++--- cypress/e2e/5-ndv.cy.ts | 10 ++--- cypress/pages/workflow.ts | 11 ++++- packages/frontend/editor-ui/src/Interface.ts | 1 + .../NodeCreator/composables/useActions.ts | 5 +++ .../composables/useActionsGeneration.ts | 34 +++++++++++++++ packages/nodes-base/nodes/Code/Code.node.ts | 3 ++ .../testing/playwright/config/constants.ts | 1 + .../testing/playwright/pages/CanvasPage.ts | 5 +++ .../tests/ui/02-canvas-actions.spec.ts | 16 +++++-- .../tests/ui/12-canvas-actions.spec.ts | 43 ++++++++++--------- .../playwright/tests/ui/6-code-node.spec.ts | 21 ++++++--- packages/workflow/src/node-helpers.ts | 9 ++++ packages/workflow/test/node-helpers.test.ts | 38 ++++++++++++++++ 16 files changed, 185 insertions(+), 59 deletions(-) diff --git a/cypress/constants.ts b/cypress/constants.ts index 94ebb442ef..674e3b2b7d 100644 --- a/cypress/constants.ts +++ b/cypress/constants.ts @@ -40,6 +40,8 @@ export const MANUAL_CHAT_TRIGGER_NODE_NAME = 'Chat Trigger'; export const CHAT_TRIGGER_NODE_DISPLAY_NAME = 'When chat message received'; export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger'; export const CODE_NODE_NAME = 'Code'; +export const CODE_NODE_DISPLAY_NAME = 'Code in JavaScript'; +export const CODE_NODE_ACTION = CODE_NODE_DISPLAY_NAME; export const SET_NODE_NAME = 'Set'; export const EDIT_FIELDS_SET_NODE_NAME = 'Edit Fields'; export const LOOP_OVER_ITEMS_NODE_NAME = 'Loop Over Items'; diff --git a/cypress/e2e/10-undo-redo.cy.ts b/cypress/e2e/10-undo-redo.cy.ts index c0a5359a1a..6388f9b561 100644 --- a/cypress/e2e/10-undo-redo.cy.ts +++ b/cypress/e2e/10-undo-redo.cy.ts @@ -1,10 +1,10 @@ import { getCanvasNodes } from '../composables/workflow'; import { SCHEDULE_TRIGGER_NODE_NAME, - CODE_NODE_NAME, SET_NODE_NAME, MANUAL_TRIGGER_NODE_NAME, MANUAL_TRIGGER_NODE_DISPLAY_NAME, + CODE_NODE_DISPLAY_NAME, } from '../constants'; import { NDV } from '../pages/ndv'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; @@ -19,9 +19,9 @@ describe('Undo/Redo', () => { it('should undo/redo deleting node using context menu', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + WorkflowPage.actions.addCodeNodeToCanvas(); WorkflowPage.actions.zoomToFit(); - WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_NAME, { + WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_DISPLAY_NAME, { method: 'right-click', anchor: 'topLeft', }); @@ -37,8 +37,8 @@ describe('Undo/Redo', () => { it('should undo/redo deleting node using keyboard shortcut', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).click(); + WorkflowPage.actions.addCodeNodeToCanvas(); + WorkflowPage.getters.canvasNodeByName(CODE_NODE_DISPLAY_NAME).click(); cy.get('body').type('{backspace}'); WorkflowPage.getters.canvasNodes().should('have.have.length', 1); WorkflowPage.getters.nodeConnections().should('have.length', 0); @@ -52,9 +52,9 @@ describe('Undo/Redo', () => { it('should undo/redo deleting node between two connected nodes', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + WorkflowPage.actions.addCodeNodeToCanvas(); WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME); - WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).click(); + WorkflowPage.getters.canvasNodeByName(CODE_NODE_DISPLAY_NAME).click(); WorkflowPage.actions.zoomToFit(); cy.get('body').type('{backspace}'); WorkflowPage.getters.canvasNodes().should('have.have.length', 2); @@ -69,7 +69,7 @@ describe('Undo/Redo', () => { it('should undo/redo deleting whole workflow', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + WorkflowPage.actions.addCodeNodeToCanvas(); cy.get('body').type('{esc}'); cy.get('body').type('{esc}'); WorkflowPage.actions.hitDeleteAllNodes(); @@ -85,7 +85,7 @@ describe('Undo/Redo', () => { it('should undo/redo moving nodes', () => { WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + WorkflowPage.actions.addCodeNodeToCanvas(); WorkflowPage.actions.zoomToFit(); @@ -131,8 +131,8 @@ describe('Undo/Redo', () => { it('should undo/redo deleting a connection using context menu', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.actions.deleteNodeBetweenNodes(SCHEDULE_TRIGGER_NODE_NAME, CODE_NODE_NAME); + WorkflowPage.actions.addCodeNodeToCanvas(); + WorkflowPage.actions.deleteNodeBetweenNodes(SCHEDULE_TRIGGER_NODE_NAME, CODE_NODE_DISPLAY_NAME); WorkflowPage.getters.nodeConnections().should('have.length', 0); WorkflowPage.actions.hitUndo(); WorkflowPage.getters.nodeConnections().should('have.length', 1); @@ -142,8 +142,8 @@ describe('Undo/Redo', () => { it('should undo/redo disabling a node using context menu', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.actions.disableNode(CODE_NODE_NAME); + WorkflowPage.actions.addCodeNodeToCanvas(); + WorkflowPage.actions.disableNode(CODE_NODE_DISPLAY_NAME); WorkflowPage.getters.disabledNodes().should('have.length', 1); WorkflowPage.actions.hitUndo(); WorkflowPage.getters.disabledNodes().should('have.length', 0); @@ -153,7 +153,7 @@ describe('Undo/Redo', () => { it('should undo/redo disabling a node using keyboard shortcut', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + WorkflowPage.actions.addCodeNodeToCanvas(); WorkflowPage.getters.canvasNodes().last().click(); WorkflowPage.actions.hitDisableNodeShortcut(); WorkflowPage.getters.disabledNodes().should('have.length', 1); @@ -165,7 +165,7 @@ describe('Undo/Redo', () => { it('should undo/redo disabling multiple nodes', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + WorkflowPage.actions.addCodeNodeToCanvas(); cy.get('body').type('{esc}'); cy.get('body').type('{esc}'); WorkflowPage.actions.hitSelectAll(); @@ -179,8 +179,8 @@ describe('Undo/Redo', () => { it('should undo/redo duplicating a node', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.actions.duplicateNode(CODE_NODE_NAME); + WorkflowPage.actions.addCodeNodeToCanvas(); + WorkflowPage.actions.duplicateNode(CODE_NODE_DISPLAY_NAME); WorkflowPage.actions.hitUndo(); WorkflowPage.getters.canvasNodes().should('have.length', 2); WorkflowPage.actions.hitRedo(); diff --git a/cypress/e2e/12-canvas.cy.ts b/cypress/e2e/12-canvas.cy.ts index acd5884ffc..f54ac4aa2c 100644 --- a/cypress/e2e/12-canvas.cy.ts +++ b/cypress/e2e/12-canvas.cy.ts @@ -6,6 +6,7 @@ import { EDIT_FIELDS_SET_NODE_NAME, SWITCH_NODE_NAME, MERGE_NODE_NAME, + CODE_NODE_DISPLAY_NAME, } from './../constants'; import { clickContextMenuAction, @@ -165,7 +166,7 @@ describe('Canvas Node Manipulation and Navigation', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.zoomToFit(); - WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_NAME, { + WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_DISPLAY_NAME, { method: 'right-click', anchor: 'topLeft', }); @@ -176,7 +177,7 @@ describe('Canvas Node Manipulation and Navigation', () => { it('should delete node using keyboard shortcut', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).click(); + WorkflowPage.getters.canvasNodeByName(CODE_NODE_DISPLAY_NAME).click(); cy.get('body').type('{backspace}'); WorkflowPage.getters.canvasNodes().should('have.length', 1); WorkflowPage.getters.nodeConnections().should('have.length', 0); @@ -188,7 +189,7 @@ describe('Canvas Node Manipulation and Navigation', () => { WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); WorkflowPage.getters.canvasNodes().should('have.length', 3); WorkflowPage.getters.nodeConnections().should('have.length', 2); - WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).click(); + WorkflowPage.getters.canvasNodeByName(CODE_NODE_DISPLAY_NAME).click(); WorkflowPage.actions.zoomToFit(); cy.get('body').type('{backspace}'); WorkflowPage.getters.canvasNodes().should('have.length', 2); @@ -318,7 +319,7 @@ describe('Canvas Node Manipulation and Navigation', () => { WorkflowPage.actions.hitDisableNodeShortcut(); WorkflowPage.getters.disabledNodes().should('have.length', 1); - WorkflowPage.actions.disableNode(CODE_NODE_NAME); + WorkflowPage.actions.disableNode(CODE_NODE_DISPLAY_NAME); WorkflowPage.getters.disabledNodes().should('have.length', 0); }); @@ -394,7 +395,7 @@ describe('Canvas Node Manipulation and Navigation', () => { WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.actions.duplicateNode(CODE_NODE_NAME); + WorkflowPage.actions.duplicateNode(CODE_NODE_DISPLAY_NAME); WorkflowPage.getters.canvasNodes().should('have.length', 3); WorkflowPage.getters.nodeConnections().should('have.length', 1); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 23223aa0bf..6dad7d1619 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -390,7 +390,7 @@ describe('NDV', () => { }); it('should not push NDV header out with a lot of code in Code node editor', () => { - workflowPage.actions.addInitialNodeToCanvas('Code', { keepNdvOpen: true }); + workflowPage.actions.addInitialCodeNodeToCanvas({ keepNdvOpen: true }); ndv.getters.parameterInput('jsCode').get('.cm-content').type('{selectall}').type('{backspace}'); cy.fixture('Dummy_javascript.txt').then((code) => { ndv.getters.parameterInput('jsCode').get('.cm-content').paste(code); @@ -400,7 +400,7 @@ describe('NDV', () => { it('should allow editing code in fullscreen in the code editors', () => { // Code (JavaScript) - workflowPage.actions.addInitialNodeToCanvas('Code', { keepNdvOpen: true }); + workflowPage.actions.addInitialCodeNodeToCanvas({ keepNdvOpen: true }); ndv.actions.openCodeEditorFullscreen(); ndv.getters.codeEditorFullscreen().type('{selectall}').type('{backspace}').type('foo()'); @@ -725,8 +725,7 @@ describe('NDV', () => { }); it('should properly show node execution indicator', () => { - workflowPage.actions.addInitialNodeToCanvas('Code'); - workflowPage.actions.openNode('Code'); + workflowPage.actions.addInitialCodeNodeToCanvas(); // Should not show run info before execution ndv.getters.nodeRunSuccessIndicator().should('not.exist'); ndv.getters.nodeRunErrorIndicator().should('not.exist'); @@ -737,8 +736,7 @@ describe('NDV', () => { }); it('should properly show node execution indicator for multiple nodes', () => { - workflowPage.actions.addInitialNodeToCanvas('Code'); - workflowPage.actions.openNode('Code'); + workflowPage.actions.addInitialCodeNodeToCanvas(); ndv.actions.typeIntoParameterInput('jsCode', 'testets'); ndv.getters.backToCanvas().click(); workflowPage.actions.executeWorkflow(); diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index 6b486f807f..7105088455 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -1,7 +1,7 @@ import { BasePage } from './base'; import { NodeCreator } from './features/node-creator'; import { clickContextMenuAction, getCanvasPane, openContextMenu } from '../composables/workflow'; -import { META_KEY } from '../constants'; +import { CODE_NODE_ACTION, CODE_NODE_NAME, META_KEY } from '../constants'; import type { OpenContextMenuOptions } from '../types'; import { getVisibleSelect } from '../utils'; import { getUniqueWorkflowName } from '../utils/workflowUtils'; @@ -179,6 +179,12 @@ export class WorkflowPage extends BasePage { win.preventNodeViewBeforeUnload = preventNodeViewUnload; }); }, + addInitialCodeNodeToCanvas(opts: { keepNdvOpen: boolean } = { keepNdvOpen: false }) { + this.addInitialNodeToCanvas(CODE_NODE_NAME, { + action: CODE_NODE_ACTION, + keepNdvOpen: opts.keepNdvOpen, + }); + }, addInitialNodeToCanvas: ( nodeDisplayName: string, opts?: { keepNdvOpen?: boolean; action?: string; isTrigger?: boolean }, @@ -202,6 +208,9 @@ export class WorkflowPage extends BasePage { cy.get('body').type('{esc}'); } }, + addCodeNodeToCanvas(plusButtonClick = true, preventNdvClose?: boolean) { + this.addNodeToCanvas(CODE_NODE_NAME, plusButtonClick, preventNdvClose, CODE_NODE_ACTION); + }, addNodeToCanvas: ( nodeDisplayName: string, plusButtonClick = true, diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index c1c9e8cefe..aff685ac23 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -733,6 +733,7 @@ export type NodeTypeSelectedPayload = { parameters?: { resource?: string; operation?: string; + language?: string; }; actionName?: string; }; diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/composables/useActions.ts b/packages/frontend/editor-ui/src/components/Node/NodeCreator/composables/useActions.ts index b8c6035048..e07821ea7f 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/composables/useActions.ts +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/composables/useActions.ts @@ -185,6 +185,11 @@ export const useActions = () => { actionName: actionData.name, }; + if (typeof actionData.value.language === 'string') { + result.parameters = { language: actionData.value.language }; + return result; + } + if ( typeof actionData.value.resource === 'string' || typeof actionData.value.operation === 'string' diff --git a/packages/frontend/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts b/packages/frontend/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts index c22d5aca06..19ced16919 100644 --- a/packages/frontend/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts +++ b/packages/frontend/editor-ui/src/components/Node/NodeCreator/composables/useActionsGeneration.ts @@ -54,6 +54,25 @@ const customNodeActionsParsers: { }), ); }, + ['n8n-nodes-base.code']: (matchedProperty, nodeTypeDescription) => { + if (matchedProperty.name !== 'language') return; + + const languageOptions = matchedProperty.options as INodePropertyOptions[] | undefined; + if (!languageOptions) return; + + return languageOptions.map( + (option): ActionTypeDescription => ({ + ...getNodeTypeBase(nodeTypeDescription), + actionKey: `language_${option.value}`, + displayName: `Code in ${option.name}`, + description: `Run custom ${option.name} code`, + displayOptions: matchedProperty.displayOptions, + values: { + language: option.value, + }, + }), + ); + }, }; function getNodeTypeBase(nodeTypeDescription: INodeTypeDescription, label?: string) { @@ -79,6 +98,21 @@ function getNodeTypeBase(nodeTypeDescription: INodeTypeDescription, label?: stri function operationsCategory(nodeTypeDescription: INodeTypeDescription): ActionTypeDescription[] { if (nodeTypeDescription.properties.find((property) => property.name === 'resource')) return []; + if (nodeTypeDescription.name === 'n8n-nodes-base.code') { + const languageProperty = nodeTypeDescription.properties.find( + (property) => + property.name === 'language' && property.displayOptions?.show?.['@version']?.[0] === 2, + ); + + if (languageProperty) { + const customParsedItems = customNodeActionsParsers[nodeTypeDescription.name]?.( + languageProperty, + nodeTypeDescription, + ); + if (customParsedItems) return customParsedItems; + } + } + const matchedProperty = nodeTypeDescription.properties.find( (property) => property.name?.toLowerCase() === 'operation', ); diff --git a/packages/nodes-base/nodes/Code/Code.node.ts b/packages/nodes-base/nodes/Code/Code.node.ts index 409504cd5e..4a22182849 100644 --- a/packages/nodes-base/nodes/Code/Code.node.ts +++ b/packages/nodes-base/nodes/Code/Code.node.ts @@ -41,10 +41,12 @@ const getV2LanguageProperty = (): INodeProperties => { { name: 'JavaScript', value: 'javaScript', + action: 'Code in JavaScript', }, { name: 'Python (Beta)', value: 'python', + action: 'Code in Python (Beta)', }, ]; @@ -52,6 +54,7 @@ const getV2LanguageProperty = (): INodeProperties => { options.push({ name: 'Python (Native) (Beta)', value: 'pythonNative', + action: 'Code in Python (Native) (Beta)', }); } diff --git a/packages/testing/playwright/config/constants.ts b/packages/testing/playwright/config/constants.ts index 061eb788cd..547dd536cd 100644 --- a/packages/testing/playwright/config/constants.ts +++ b/packages/testing/playwright/config/constants.ts @@ -9,6 +9,7 @@ export const MANUAL_CHAT_TRIGGER_NODE_NAME = 'Chat Trigger'; export const CHAT_TRIGGER_NODE_DISPLAY_NAME = 'When chat message received'; export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger'; export const CODE_NODE_NAME = 'Code'; +export const CODE_NODE_DISPLAY_NAME = 'Code in JavaScript'; export const SET_NODE_NAME = 'Set'; export const EDIT_FIELDS_SET_NODE_NAME = 'Edit Fields (Set)'; export const LOOP_OVER_ITEMS_NODE_NAME = 'Loop Over Items'; diff --git a/packages/testing/playwright/pages/CanvasPage.ts b/packages/testing/playwright/pages/CanvasPage.ts index fb13c22d65..a8970797df 100644 --- a/packages/testing/playwright/pages/CanvasPage.ts +++ b/packages/testing/playwright/pages/CanvasPage.ts @@ -473,4 +473,9 @@ export class CanvasPage extends BasePage { await this.page.goto('/workflow/new'); } + + async addNodeWithSubItem(searchText: string, subItemText: string): Promise { + await this.addNode(searchText); + await this.nodeCreatorSubItem(subItemText).click(); + } } diff --git a/packages/testing/playwright/tests/ui/02-canvas-actions.spec.ts b/packages/testing/playwright/tests/ui/02-canvas-actions.spec.ts index 166feb70b0..d7f0e4ab02 100644 --- a/packages/testing/playwright/tests/ui/02-canvas-actions.spec.ts +++ b/packages/testing/playwright/tests/ui/02-canvas-actions.spec.ts @@ -1,4 +1,9 @@ -import { MANUAL_TRIGGER_NODE_NAME, MANUAL_TRIGGER_NODE_DISPLAY_NAME } from '../../config/constants'; +import { + MANUAL_TRIGGER_NODE_NAME, + MANUAL_TRIGGER_NODE_DISPLAY_NAME, + CODE_NODE_NAME, + CODE_NODE_DISPLAY_NAME, +} from '../../config/constants'; import { test, expect } from '../../fixtures/base'; test.describe('Canvas Node Actions', () => { @@ -52,8 +57,9 @@ test.describe('Canvas Node Actions', () => { await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME); - await n8n.canvas.fillNodeCreatorSearchBar('Code'); + await n8n.canvas.fillNodeCreatorSearchBar(CODE_NODE_NAME); await n8n.page.keyboard.press('Enter'); + await n8n.canvas.nodeCreatorSubItem(CODE_NODE_DISPLAY_NAME).click(); await n8n.page.keyboard.press('Escape'); await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); @@ -63,7 +69,11 @@ test.describe('Canvas Node Actions', () => { test('should add disconnected node when nothing selected', async ({ n8n }) => { await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); await n8n.canvas.deselectAll(); - await n8n.canvas.addNode('Code', { closeNDV: true }); + await n8n.canvas.clickNodeCreatorPlusButton(); + await n8n.canvas.fillNodeCreatorSearchBar(CODE_NODE_NAME); + await n8n.page.keyboard.press('Enter'); + await n8n.canvas.nodeCreatorSubItem(CODE_NODE_DISPLAY_NAME).click(); + await n8n.page.keyboard.press('Escape'); await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); await expect(n8n.canvas.nodeConnections()).toHaveCount(0); }); diff --git a/packages/testing/playwright/tests/ui/12-canvas-actions.spec.ts b/packages/testing/playwright/tests/ui/12-canvas-actions.spec.ts index 96f18dc3b7..6ccb749f11 100644 --- a/packages/testing/playwright/tests/ui/12-canvas-actions.spec.ts +++ b/packages/testing/playwright/tests/ui/12-canvas-actions.spec.ts @@ -3,6 +3,7 @@ import { MANUAL_TRIGGER_NODE_DISPLAY_NAME, CODE_NODE_NAME, HTTP_REQUEST_NODE_NAME, + CODE_NODE_DISPLAY_NAME, } from '../../config/constants'; import { test, expect } from '../../fixtures/base'; @@ -22,6 +23,7 @@ test.describe('Canvas Actions', () => { await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME); await n8n.canvas.fillNodeCreatorSearchBar(CODE_NODE_NAME); await n8n.page.keyboard.press('Enter'); + await n8n.canvas.nodeCreatorSubItem(CODE_NODE_DISPLAY_NAME).click(); await n8n.page.keyboard.press('Escape'); await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); @@ -32,12 +34,10 @@ test.describe('Canvas Actions', () => { await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME); await n8n.canvas.fillNodeCreatorSearchBar(CODE_NODE_NAME); - - const sourceElement = n8n.canvas - .nodeCreatorNodeItems() - .filter({ hasText: CODE_NODE_NAME }) - .first(); - await sourceElement.dragTo(n8n.canvas.canvasPane(), { targetPosition: { x: 100, y: 100 } }); + await n8n.page.keyboard.press('Enter'); + await n8n.canvas + .nodeCreatorSubItem(CODE_NODE_DISPLAY_NAME) + .dragTo(n8n.canvas.canvasPane(), { targetPosition: { x: 100, y: 100 } }); await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); await expect(n8n.canvas.nodeConnections()).toHaveCount(1); @@ -61,7 +61,7 @@ test.describe('Canvas Actions', () => { test('should add disconnected node if nothing is selected', async ({ n8n }) => { await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); await n8n.canvas.deselectAll(); - await n8n.canvas.addNode(CODE_NODE_NAME, { closeNDV: true }); + await n8n.canvas.addNode(CODE_NODE_NAME, { action: CODE_NODE_DISPLAY_NAME, closeNDV: true }); await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); await expect(n8n.canvas.nodeConnections()).toHaveCount(0); @@ -70,14 +70,14 @@ test.describe('Canvas Actions', () => { test('should add node between two connected nodes', async ({ n8n }) => { await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); - await n8n.canvas.addNode(CODE_NODE_NAME, { closeNDV: true }); + await n8n.canvas.addNode(CODE_NODE_NAME, { action: CODE_NODE_DISPLAY_NAME, closeNDV: true }); await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); await expect(n8n.canvas.nodeConnections()).toHaveCount(1); await n8n.canvas.addNodeBetweenNodes( MANUAL_TRIGGER_NODE_DISPLAY_NAME, - CODE_NODE_NAME, + CODE_NODE_DISPLAY_NAME, HTTP_REQUEST_NODE_NAME, ); @@ -96,8 +96,11 @@ test.describe('Canvas Actions', () => { test('should delete connections by clicking on the delete button', async ({ n8n }) => { await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); - await n8n.canvas.addNode(CODE_NODE_NAME, { closeNDV: true }); - await n8n.canvas.deleteConnectionBetweenNodes(MANUAL_TRIGGER_NODE_DISPLAY_NAME, CODE_NODE_NAME); + await n8n.canvas.addNode(CODE_NODE_NAME, { action: CODE_NODE_DISPLAY_NAME, closeNDV: true }); + await n8n.canvas.deleteConnectionBetweenNodes( + MANUAL_TRIGGER_NODE_DISPLAY_NAME, + CODE_NODE_DISPLAY_NAME, + ); await expect(n8n.canvas.nodeConnections()).toHaveCount(0); await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); @@ -116,9 +119,9 @@ test.describe('Canvas Actions', () => { test('should disable and enable node', async ({ n8n }) => { await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); - await n8n.canvas.addNode(CODE_NODE_NAME, { closeNDV: true }); + await n8n.canvas.addNode(CODE_NODE_NAME, { action: CODE_NODE_DISPLAY_NAME, closeNDV: true }); - const disableButton = n8n.canvas.nodeDisableButton(CODE_NODE_NAME); + const disableButton = n8n.canvas.nodeDisableButton(CODE_NODE_DISPLAY_NAME); await disableButton.click(); await expect(n8n.canvas.disabledNodes()).toHaveCount(1); @@ -130,8 +133,8 @@ test.describe('Canvas Actions', () => { test('should delete node', async ({ n8n }) => { await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); - await n8n.canvas.addNode(CODE_NODE_NAME, { closeNDV: true }); - await n8n.canvas.deleteNodeByName(CODE_NODE_NAME); + await n8n.canvas.addNode(CODE_NODE_NAME, { action: CODE_NODE_DISPLAY_NAME, closeNDV: true }); + await n8n.canvas.deleteNodeByName(CODE_NODE_DISPLAY_NAME); await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1); await expect(n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME)).toBeVisible(); @@ -140,9 +143,9 @@ test.describe('Canvas Actions', () => { test('should copy selected nodes', async ({ n8n }) => { await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); - await n8n.canvas.addNode(CODE_NODE_NAME, { closeNDV: true }); + await n8n.canvas.addNode(CODE_NODE_NAME, { action: CODE_NODE_DISPLAY_NAME, closeNDV: true }); await n8n.canvasComposer.selectAllAndCopy(); - await n8n.canvas.nodeByName(CODE_NODE_NAME).click(); + await n8n.canvas.nodeByName(CODE_NODE_DISPLAY_NAME).click(); await n8n.canvasComposer.copySelectedNodesWithToast(); await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); @@ -150,7 +153,7 @@ test.describe('Canvas Actions', () => { test('should select/deselect all nodes', async ({ n8n }) => { await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); - await n8n.canvas.addNode(CODE_NODE_NAME, { closeNDV: true }); + await n8n.canvas.addNode(CODE_NODE_NAME, { action: CODE_NODE_DISPLAY_NAME, closeNDV: true }); await n8n.canvas.selectAll(); await expect(n8n.canvas.selectedNodes()).toHaveCount(2); @@ -162,7 +165,7 @@ test.describe('Canvas Actions', () => { test('should select nodes using arrow keys', async ({ n8n }) => { await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); - await n8n.canvas.addNode(CODE_NODE_NAME, { closeNDV: true }); + await n8n.canvas.addNode(CODE_NODE_NAME, { action: CODE_NODE_DISPLAY_NAME, closeNDV: true }); await n8n.canvas.getCanvasNodes().first().waitFor(); await n8n.canvas.navigateNodesWithArrows('left'); @@ -177,7 +180,7 @@ test.describe('Canvas Actions', () => { test('should select nodes using shift and arrow keys', async ({ n8n }) => { await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); - await n8n.canvas.addNode(CODE_NODE_NAME, { closeNDV: true }); + await n8n.canvas.addNode(CODE_NODE_NAME, { action: CODE_NODE_DISPLAY_NAME, closeNDV: true }); await n8n.canvas.getCanvasNodes().first().waitFor(); await n8n.canvas.extendSelectionWithArrows('left'); diff --git a/packages/testing/playwright/tests/ui/6-code-node.spec.ts b/packages/testing/playwright/tests/ui/6-code-node.spec.ts index ba16e8a95b..36cbb5df4f 100644 --- a/packages/testing/playwright/tests/ui/6-code-node.spec.ts +++ b/packages/testing/playwright/tests/ui/6-code-node.spec.ts @@ -1,6 +1,10 @@ import { nanoid } from 'nanoid'; -import { CODE_NODE_NAME, MANUAL_TRIGGER_NODE_NAME } from '../../config/constants'; +import { + CODE_NODE_DISPLAY_NAME, + CODE_NODE_NAME, + MANUAL_TRIGGER_NODE_NAME, +} from '../../config/constants'; import { test, expect } from '../../fixtures/base'; test.describe('Code node', () => { @@ -9,7 +13,7 @@ test.describe('Code node', () => { await n8n.goHome(); await n8n.workflows.clickAddWorkflowButton(); await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); - await n8n.canvas.addNode(CODE_NODE_NAME); + await n8n.canvas.addNodeWithSubItem(CODE_NODE_NAME, CODE_NODE_DISPLAY_NAME); }); test('should show correct placeholders switching modes', async ({ n8n }) => { @@ -53,17 +57,17 @@ test.describe('Code node', () => { await n8n.ndv.getCodeEditor().fill("console.log('code node 1')"); await n8n.ndv.close(); - await n8n.canvas.addNode(CODE_NODE_NAME); + await n8n.canvas.addNodeWithSubItem(CODE_NODE_NAME, CODE_NODE_DISPLAY_NAME); await n8n.ndv.getCodeEditor().fill("console.log('code node 2')"); await n8n.ndv.close(); - await n8n.canvas.openNode(CODE_NODE_NAME); + await n8n.canvas.openNode(CODE_NODE_DISPLAY_NAME); - await n8n.ndv.clickFloatingNode('Code1'); + await n8n.ndv.clickFloatingNode(CODE_NODE_DISPLAY_NAME + '1'); await expect(n8n.ndv.getCodeEditor()).toContainText("console.log('code node 2')"); - await n8n.ndv.clickFloatingNode('Code'); + await n8n.ndv.clickFloatingNode(CODE_NODE_DISPLAY_NAME); await expect(n8n.ndv.getCodeEditor()).toContainText("console.log('code node 1')"); }); @@ -112,7 +116,10 @@ return [] await n8n.goHome(); await n8n.workflows.clickAddWorkflowButton(); await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); - await n8n.canvas.addNode(CODE_NODE_NAME); + await n8n.canvas.clickNodeCreatorPlusButton(); + await n8n.canvas.fillNodeCreatorSearchBar(CODE_NODE_NAME); + await n8n.page.keyboard.press('Enter'); + await n8n.canvas.clickNodeCreatorItemName(CODE_NODE_DISPLAY_NAME); }); test('tab should exist if experiment selected and be selectable', async ({ n8n }) => { diff --git a/packages/workflow/src/node-helpers.ts b/packages/workflow/src/node-helpers.ts index 721484c323..14ae8819c9 100644 --- a/packages/workflow/src/node-helpers.ts +++ b/packages/workflow/src/node-helpers.ts @@ -1586,6 +1586,15 @@ function resolveResourceAndOperation( nodeParameters: INodeParameters, nodeTypeDescription: INodeTypeDescription, ) { + if (nodeTypeDescription.name === 'n8n-nodes-base.code') { + const language = nodeParameters.language as string; + const langProp = nodeTypeDescription.properties.find((p) => p.name === 'language'); + if (langProp?.options && isINodePropertyOptionsList(langProp.options)) { + const found = langProp.options.find((o) => o.value === language); + if (found?.action) return { action: found.action }; + } + } + const resource = nodeParameters.resource as string; const operation = nodeParameters.operation as string; const nodeTypeOperation = nodeTypeDescription.properties.find( diff --git a/packages/workflow/test/node-helpers.test.ts b/packages/workflow/test/node-helpers.test.ts index c7ba791caa..e5c3e9a622 100644 --- a/packages/workflow/test/node-helpers.test.ts +++ b/packages/workflow/test/node-helpers.test.ts @@ -5630,6 +5630,44 @@ describe('NodeHelpers', () => { // Assert expect(result).toBe('Create user in Test Node'); }); + + test.each([ + ['javaScript', 'Code in JavaScript'], + ['python', 'Code in Python (Beta)'], + ['pythonNative', 'Code in Python (Native) (Beta)'], + ])( + 'should return action-based name for Code node with %s language', + (language, expectedAction) => { + mockNodeTypeDescription.name = 'n8n-nodes-base.code'; + mockNodeTypeDescription.properties = [ + { + displayName: 'Language', + name: 'language', + type: 'options', + options: [ + { + name: 'JavaScript', + value: 'javaScript', + action: 'Code in JavaScript', + }, + { + name: 'Python (Beta)', + value: 'python', + action: 'Code in Python (Beta)', + }, + { + name: 'Python (Native) (Beta)', + value: 'pythonNative', + action: 'Code in Python (Native) (Beta)', + }, + ], + default: 'javaScript', + }, + ]; + const result = makeNodeName({ language }, mockNodeTypeDescription); + expect(result).toBe(expectedAction); + }, + ); }); describe('isTool', () => { it('should return true for a node with AiTool output', () => {