feat(editor): Add Python to Code actions (#18668)

This commit is contained in:
Iván Ovejero
2025-08-26 14:29:50 +02:00
committed by GitHub
parent b73f2393b4
commit 38f25d74eb
16 changed files with 185 additions and 59 deletions

View File

@@ -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 CHAT_TRIGGER_NODE_DISPLAY_NAME = 'When chat message received';
export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger'; export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger';
export const CODE_NODE_NAME = 'Code'; 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 SET_NODE_NAME = 'Set';
export const EDIT_FIELDS_SET_NODE_NAME = 'Edit Fields'; export const EDIT_FIELDS_SET_NODE_NAME = 'Edit Fields';
export const LOOP_OVER_ITEMS_NODE_NAME = 'Loop Over Items'; export const LOOP_OVER_ITEMS_NODE_NAME = 'Loop Over Items';

View File

@@ -1,10 +1,10 @@
import { getCanvasNodes } from '../composables/workflow'; import { getCanvasNodes } from '../composables/workflow';
import { import {
SCHEDULE_TRIGGER_NODE_NAME, SCHEDULE_TRIGGER_NODE_NAME,
CODE_NODE_NAME,
SET_NODE_NAME, SET_NODE_NAME,
MANUAL_TRIGGER_NODE_NAME, MANUAL_TRIGGER_NODE_NAME,
MANUAL_TRIGGER_NODE_DISPLAY_NAME, MANUAL_TRIGGER_NODE_DISPLAY_NAME,
CODE_NODE_DISPLAY_NAME,
} from '../constants'; } from '../constants';
import { NDV } from '../pages/ndv'; import { NDV } from '../pages/ndv';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
@@ -19,9 +19,9 @@ describe('Undo/Redo', () => {
it('should undo/redo deleting node using context menu', () => { it('should undo/redo deleting node using context menu', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addCodeNodeToCanvas();
WorkflowPage.actions.zoomToFit(); WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_NAME, { WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_DISPLAY_NAME, {
method: 'right-click', method: 'right-click',
anchor: 'topLeft', anchor: 'topLeft',
}); });
@@ -37,8 +37,8 @@ describe('Undo/Redo', () => {
it('should undo/redo deleting node using keyboard shortcut', () => { it('should undo/redo deleting node using keyboard shortcut', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addCodeNodeToCanvas();
WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).click(); WorkflowPage.getters.canvasNodeByName(CODE_NODE_DISPLAY_NAME).click();
cy.get('body').type('{backspace}'); cy.get('body').type('{backspace}');
WorkflowPage.getters.canvasNodes().should('have.have.length', 1); WorkflowPage.getters.canvasNodes().should('have.have.length', 1);
WorkflowPage.getters.nodeConnections().should('have.length', 0); WorkflowPage.getters.nodeConnections().should('have.length', 0);
@@ -52,9 +52,9 @@ describe('Undo/Redo', () => {
it('should undo/redo deleting node between two connected nodes', () => { it('should undo/redo deleting node between two connected nodes', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addCodeNodeToCanvas();
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).click(); WorkflowPage.getters.canvasNodeByName(CODE_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.zoomToFit(); WorkflowPage.actions.zoomToFit();
cy.get('body').type('{backspace}'); cy.get('body').type('{backspace}');
WorkflowPage.getters.canvasNodes().should('have.have.length', 2); WorkflowPage.getters.canvasNodes().should('have.have.length', 2);
@@ -69,7 +69,7 @@ describe('Undo/Redo', () => {
it('should undo/redo deleting whole workflow', () => { it('should undo/redo deleting whole workflow', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); 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}');
cy.get('body').type('{esc}'); cy.get('body').type('{esc}');
WorkflowPage.actions.hitDeleteAllNodes(); WorkflowPage.actions.hitDeleteAllNodes();
@@ -85,7 +85,7 @@ describe('Undo/Redo', () => {
it('should undo/redo moving nodes', () => { it('should undo/redo moving nodes', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addCodeNodeToCanvas();
WorkflowPage.actions.zoomToFit(); WorkflowPage.actions.zoomToFit();
@@ -131,8 +131,8 @@ describe('Undo/Redo', () => {
it('should undo/redo deleting a connection using context menu', () => { it('should undo/redo deleting a connection using context menu', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addCodeNodeToCanvas();
WorkflowPage.actions.deleteNodeBetweenNodes(SCHEDULE_TRIGGER_NODE_NAME, CODE_NODE_NAME); WorkflowPage.actions.deleteNodeBetweenNodes(SCHEDULE_TRIGGER_NODE_NAME, CODE_NODE_DISPLAY_NAME);
WorkflowPage.getters.nodeConnections().should('have.length', 0); WorkflowPage.getters.nodeConnections().should('have.length', 0);
WorkflowPage.actions.hitUndo(); WorkflowPage.actions.hitUndo();
WorkflowPage.getters.nodeConnections().should('have.length', 1); WorkflowPage.getters.nodeConnections().should('have.length', 1);
@@ -142,8 +142,8 @@ describe('Undo/Redo', () => {
it('should undo/redo disabling a node using context menu', () => { it('should undo/redo disabling a node using context menu', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addCodeNodeToCanvas();
WorkflowPage.actions.disableNode(CODE_NODE_NAME); WorkflowPage.actions.disableNode(CODE_NODE_DISPLAY_NAME);
WorkflowPage.getters.disabledNodes().should('have.length', 1); WorkflowPage.getters.disabledNodes().should('have.length', 1);
WorkflowPage.actions.hitUndo(); WorkflowPage.actions.hitUndo();
WorkflowPage.getters.disabledNodes().should('have.length', 0); WorkflowPage.getters.disabledNodes().should('have.length', 0);
@@ -153,7 +153,7 @@ describe('Undo/Redo', () => {
it('should undo/redo disabling a node using keyboard shortcut', () => { it('should undo/redo disabling a node using keyboard shortcut', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addCodeNodeToCanvas();
WorkflowPage.getters.canvasNodes().last().click(); WorkflowPage.getters.canvasNodes().last().click();
WorkflowPage.actions.hitDisableNodeShortcut(); WorkflowPage.actions.hitDisableNodeShortcut();
WorkflowPage.getters.disabledNodes().should('have.length', 1); WorkflowPage.getters.disabledNodes().should('have.length', 1);
@@ -165,7 +165,7 @@ describe('Undo/Redo', () => {
it('should undo/redo disabling multiple nodes', () => { it('should undo/redo disabling multiple nodes', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); 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}');
cy.get('body').type('{esc}'); cy.get('body').type('{esc}');
WorkflowPage.actions.hitSelectAll(); WorkflowPage.actions.hitSelectAll();
@@ -179,8 +179,8 @@ describe('Undo/Redo', () => {
it('should undo/redo duplicating a node', () => { it('should undo/redo duplicating a node', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addCodeNodeToCanvas();
WorkflowPage.actions.duplicateNode(CODE_NODE_NAME); WorkflowPage.actions.duplicateNode(CODE_NODE_DISPLAY_NAME);
WorkflowPage.actions.hitUndo(); WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.length', 2); WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.actions.hitRedo(); WorkflowPage.actions.hitRedo();

View File

@@ -6,6 +6,7 @@ import {
EDIT_FIELDS_SET_NODE_NAME, EDIT_FIELDS_SET_NODE_NAME,
SWITCH_NODE_NAME, SWITCH_NODE_NAME,
MERGE_NODE_NAME, MERGE_NODE_NAME,
CODE_NODE_DISPLAY_NAME,
} from './../constants'; } from './../constants';
import { import {
clickContextMenuAction, clickContextMenuAction,
@@ -165,7 +166,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.zoomToFit(); WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_NAME, { WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_DISPLAY_NAME, {
method: 'right-click', method: 'right-click',
anchor: 'topLeft', anchor: 'topLeft',
}); });
@@ -176,7 +177,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
it('should delete node using keyboard shortcut', () => { it('should delete node using keyboard shortcut', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_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}'); cy.get('body').type('{backspace}');
WorkflowPage.getters.canvasNodes().should('have.length', 1); WorkflowPage.getters.canvasNodes().should('have.length', 1);
WorkflowPage.getters.nodeConnections().should('have.length', 0); 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.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
WorkflowPage.getters.canvasNodes().should('have.length', 3); WorkflowPage.getters.canvasNodes().should('have.length', 3);
WorkflowPage.getters.nodeConnections().should('have.length', 2); 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(); WorkflowPage.actions.zoomToFit();
cy.get('body').type('{backspace}'); cy.get('body').type('{backspace}');
WorkflowPage.getters.canvasNodes().should('have.length', 2); WorkflowPage.getters.canvasNodes().should('have.length', 2);
@@ -318,7 +319,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.actions.hitDisableNodeShortcut(); WorkflowPage.actions.hitDisableNodeShortcut();
WorkflowPage.getters.disabledNodes().should('have.length', 1); 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); 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.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); 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.canvasNodes().should('have.length', 3);
WorkflowPage.getters.nodeConnections().should('have.length', 1); WorkflowPage.getters.nodeConnections().should('have.length', 1);

View File

@@ -390,7 +390,7 @@ describe('NDV', () => {
}); });
it('should not push NDV header out with a lot of code in Code node editor', () => { 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}'); ndv.getters.parameterInput('jsCode').get('.cm-content').type('{selectall}').type('{backspace}');
cy.fixture('Dummy_javascript.txt').then((code) => { cy.fixture('Dummy_javascript.txt').then((code) => {
ndv.getters.parameterInput('jsCode').get('.cm-content').paste(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', () => { it('should allow editing code in fullscreen in the code editors', () => {
// Code (JavaScript) // Code (JavaScript)
workflowPage.actions.addInitialNodeToCanvas('Code', { keepNdvOpen: true }); workflowPage.actions.addInitialCodeNodeToCanvas({ keepNdvOpen: true });
ndv.actions.openCodeEditorFullscreen(); ndv.actions.openCodeEditorFullscreen();
ndv.getters.codeEditorFullscreen().type('{selectall}').type('{backspace}').type('foo()'); ndv.getters.codeEditorFullscreen().type('{selectall}').type('{backspace}').type('foo()');
@@ -725,8 +725,7 @@ describe('NDV', () => {
}); });
it('should properly show node execution indicator', () => { it('should properly show node execution indicator', () => {
workflowPage.actions.addInitialNodeToCanvas('Code'); workflowPage.actions.addInitialCodeNodeToCanvas();
workflowPage.actions.openNode('Code');
// Should not show run info before execution // Should not show run info before execution
ndv.getters.nodeRunSuccessIndicator().should('not.exist'); ndv.getters.nodeRunSuccessIndicator().should('not.exist');
ndv.getters.nodeRunErrorIndicator().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', () => { it('should properly show node execution indicator for multiple nodes', () => {
workflowPage.actions.addInitialNodeToCanvas('Code'); workflowPage.actions.addInitialCodeNodeToCanvas();
workflowPage.actions.openNode('Code');
ndv.actions.typeIntoParameterInput('jsCode', 'testets'); ndv.actions.typeIntoParameterInput('jsCode', 'testets');
ndv.getters.backToCanvas().click(); ndv.getters.backToCanvas().click();
workflowPage.actions.executeWorkflow(); workflowPage.actions.executeWorkflow();

View File

@@ -1,7 +1,7 @@
import { BasePage } from './base'; import { BasePage } from './base';
import { NodeCreator } from './features/node-creator'; import { NodeCreator } from './features/node-creator';
import { clickContextMenuAction, getCanvasPane, openContextMenu } from '../composables/workflow'; 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 type { OpenContextMenuOptions } from '../types';
import { getVisibleSelect } from '../utils'; import { getVisibleSelect } from '../utils';
import { getUniqueWorkflowName } from '../utils/workflowUtils'; import { getUniqueWorkflowName } from '../utils/workflowUtils';
@@ -179,6 +179,12 @@ export class WorkflowPage extends BasePage {
win.preventNodeViewBeforeUnload = preventNodeViewUnload; win.preventNodeViewBeforeUnload = preventNodeViewUnload;
}); });
}, },
addInitialCodeNodeToCanvas(opts: { keepNdvOpen: boolean } = { keepNdvOpen: false }) {
this.addInitialNodeToCanvas(CODE_NODE_NAME, {
action: CODE_NODE_ACTION,
keepNdvOpen: opts.keepNdvOpen,
});
},
addInitialNodeToCanvas: ( addInitialNodeToCanvas: (
nodeDisplayName: string, nodeDisplayName: string,
opts?: { keepNdvOpen?: boolean; action?: string; isTrigger?: boolean }, opts?: { keepNdvOpen?: boolean; action?: string; isTrigger?: boolean },
@@ -202,6 +208,9 @@ export class WorkflowPage extends BasePage {
cy.get('body').type('{esc}'); cy.get('body').type('{esc}');
} }
}, },
addCodeNodeToCanvas(plusButtonClick = true, preventNdvClose?: boolean) {
this.addNodeToCanvas(CODE_NODE_NAME, plusButtonClick, preventNdvClose, CODE_NODE_ACTION);
},
addNodeToCanvas: ( addNodeToCanvas: (
nodeDisplayName: string, nodeDisplayName: string,
plusButtonClick = true, plusButtonClick = true,

View File

@@ -733,6 +733,7 @@ export type NodeTypeSelectedPayload = {
parameters?: { parameters?: {
resource?: string; resource?: string;
operation?: string; operation?: string;
language?: string;
}; };
actionName?: string; actionName?: string;
}; };

View File

@@ -185,6 +185,11 @@ export const useActions = () => {
actionName: actionData.name, actionName: actionData.name,
}; };
if (typeof actionData.value.language === 'string') {
result.parameters = { language: actionData.value.language };
return result;
}
if ( if (
typeof actionData.value.resource === 'string' || typeof actionData.value.resource === 'string' ||
typeof actionData.value.operation === 'string' typeof actionData.value.operation === 'string'

View File

@@ -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) { function getNodeTypeBase(nodeTypeDescription: INodeTypeDescription, label?: string) {
@@ -79,6 +98,21 @@ function getNodeTypeBase(nodeTypeDescription: INodeTypeDescription, label?: stri
function operationsCategory(nodeTypeDescription: INodeTypeDescription): ActionTypeDescription[] { function operationsCategory(nodeTypeDescription: INodeTypeDescription): ActionTypeDescription[] {
if (nodeTypeDescription.properties.find((property) => property.name === 'resource')) return []; 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( const matchedProperty = nodeTypeDescription.properties.find(
(property) => property.name?.toLowerCase() === 'operation', (property) => property.name?.toLowerCase() === 'operation',
); );

View File

@@ -41,10 +41,12 @@ const getV2LanguageProperty = (): INodeProperties => {
{ {
name: 'JavaScript', name: 'JavaScript',
value: 'javaScript', value: 'javaScript',
action: 'Code in JavaScript',
}, },
{ {
name: 'Python (Beta)', name: 'Python (Beta)',
value: 'python', value: 'python',
action: 'Code in Python (Beta)',
}, },
]; ];
@@ -52,6 +54,7 @@ const getV2LanguageProperty = (): INodeProperties => {
options.push({ options.push({
name: 'Python (Native) (Beta)', name: 'Python (Native) (Beta)',
value: 'pythonNative', value: 'pythonNative',
action: 'Code in Python (Native) (Beta)',
}); });
} }

View File

@@ -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 CHAT_TRIGGER_NODE_DISPLAY_NAME = 'When chat message received';
export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger'; export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger';
export const CODE_NODE_NAME = 'Code'; export const CODE_NODE_NAME = 'Code';
export const CODE_NODE_DISPLAY_NAME = 'Code in JavaScript';
export const SET_NODE_NAME = 'Set'; export const SET_NODE_NAME = 'Set';
export const EDIT_FIELDS_SET_NODE_NAME = 'Edit Fields (Set)'; export const EDIT_FIELDS_SET_NODE_NAME = 'Edit Fields (Set)';
export const LOOP_OVER_ITEMS_NODE_NAME = 'Loop Over Items'; export const LOOP_OVER_ITEMS_NODE_NAME = 'Loop Over Items';

View File

@@ -473,4 +473,9 @@ export class CanvasPage extends BasePage {
await this.page.goto('/workflow/new'); await this.page.goto('/workflow/new');
} }
async addNodeWithSubItem(searchText: string, subItemText: string): Promise<void> {
await this.addNode(searchText);
await this.nodeCreatorSubItem(subItemText).click();
}
} }

View File

@@ -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'; import { test, expect } from '../../fixtures/base';
test.describe('Canvas Node Actions', () => { 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.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_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.page.keyboard.press('Enter');
await n8n.canvas.nodeCreatorSubItem(CODE_NODE_DISPLAY_NAME).click();
await n8n.page.keyboard.press('Escape'); await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); 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 }) => { test('should add disconnected node when nothing selected', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.deselectAll(); 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.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(0); await expect(n8n.canvas.nodeConnections()).toHaveCount(0);
}); });

View File

@@ -3,6 +3,7 @@ import {
MANUAL_TRIGGER_NODE_DISPLAY_NAME, MANUAL_TRIGGER_NODE_DISPLAY_NAME,
CODE_NODE_NAME, CODE_NODE_NAME,
HTTP_REQUEST_NODE_NAME, HTTP_REQUEST_NODE_NAME,
CODE_NODE_DISPLAY_NAME,
} from '../../config/constants'; } from '../../config/constants';
import { test, expect } from '../../fixtures/base'; 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.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
await n8n.canvas.fillNodeCreatorSearchBar(CODE_NODE_NAME); await n8n.canvas.fillNodeCreatorSearchBar(CODE_NODE_NAME);
await n8n.page.keyboard.press('Enter'); await n8n.page.keyboard.press('Enter');
await n8n.canvas.nodeCreatorSubItem(CODE_NODE_DISPLAY_NAME).click();
await n8n.page.keyboard.press('Escape'); await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); 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.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME); await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
await n8n.canvas.fillNodeCreatorSearchBar(CODE_NODE_NAME); await n8n.canvas.fillNodeCreatorSearchBar(CODE_NODE_NAME);
await n8n.page.keyboard.press('Enter');
const sourceElement = n8n.canvas await n8n.canvas
.nodeCreatorNodeItems() .nodeCreatorSubItem(CODE_NODE_DISPLAY_NAME)
.filter({ hasText: CODE_NODE_NAME }) .dragTo(n8n.canvas.canvasPane(), { targetPosition: { x: 100, y: 100 } });
.first();
await sourceElement.dragTo(n8n.canvas.canvasPane(), { targetPosition: { x: 100, y: 100 } });
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(1); 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 }) => { test('should add disconnected node if nothing is selected', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.deselectAll(); 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.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(0); 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 }) => { test('should add node between two connected nodes', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); 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.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeConnections()).toHaveCount(1); await expect(n8n.canvas.nodeConnections()).toHaveCount(1);
await n8n.canvas.addNodeBetweenNodes( await n8n.canvas.addNodeBetweenNodes(
MANUAL_TRIGGER_NODE_DISPLAY_NAME, MANUAL_TRIGGER_NODE_DISPLAY_NAME,
CODE_NODE_NAME, CODE_NODE_DISPLAY_NAME,
HTTP_REQUEST_NODE_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 }) => { test('should delete connections by clicking on the delete button', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); 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.deleteConnectionBetweenNodes(MANUAL_TRIGGER_NODE_DISPLAY_NAME, CODE_NODE_NAME); await n8n.canvas.deleteConnectionBetweenNodes(
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
CODE_NODE_DISPLAY_NAME,
);
await expect(n8n.canvas.nodeConnections()).toHaveCount(0); await expect(n8n.canvas.nodeConnections()).toHaveCount(0);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
@@ -116,9 +119,9 @@ test.describe('Canvas Actions', () => {
test('should disable and enable node', async ({ n8n }) => { test('should disable and enable node', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); 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 disableButton.click();
await expect(n8n.canvas.disabledNodes()).toHaveCount(1); await expect(n8n.canvas.disabledNodes()).toHaveCount(1);
@@ -130,8 +133,8 @@ test.describe('Canvas Actions', () => {
test('should delete node', async ({ n8n }) => { test('should delete node', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); 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.deleteNodeByName(CODE_NODE_NAME); await n8n.canvas.deleteNodeByName(CODE_NODE_DISPLAY_NAME);
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1); await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1);
await expect(n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME)).toBeVisible(); 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 }) => { test('should copy selected nodes', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); 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.canvasComposer.selectAllAndCopy();
await n8n.canvas.nodeByName(CODE_NODE_NAME).click(); await n8n.canvas.nodeByName(CODE_NODE_DISPLAY_NAME).click();
await n8n.canvasComposer.copySelectedNodesWithToast(); await n8n.canvasComposer.copySelectedNodesWithToast();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
@@ -150,7 +153,7 @@ test.describe('Canvas Actions', () => {
test('should select/deselect all nodes', async ({ n8n }) => { test('should select/deselect all nodes', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); 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 n8n.canvas.selectAll();
await expect(n8n.canvas.selectedNodes()).toHaveCount(2); await expect(n8n.canvas.selectedNodes()).toHaveCount(2);
@@ -162,7 +165,7 @@ test.describe('Canvas Actions', () => {
test('should select nodes using arrow keys', async ({ n8n }) => { test('should select nodes using arrow keys', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); 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.getCanvasNodes().first().waitFor();
await n8n.canvas.navigateNodesWithArrows('left'); await n8n.canvas.navigateNodesWithArrows('left');
@@ -177,7 +180,7 @@ test.describe('Canvas Actions', () => {
test('should select nodes using shift and arrow keys', async ({ n8n }) => { test('should select nodes using shift and arrow keys', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); 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.getCanvasNodes().first().waitFor();
await n8n.canvas.extendSelectionWithArrows('left'); await n8n.canvas.extendSelectionWithArrows('left');

View File

@@ -1,6 +1,10 @@
import { nanoid } from 'nanoid'; 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'; import { test, expect } from '../../fixtures/base';
test.describe('Code node', () => { test.describe('Code node', () => {
@@ -9,7 +13,7 @@ test.describe('Code node', () => {
await n8n.goHome(); await n8n.goHome();
await n8n.workflows.clickAddWorkflowButton(); await n8n.workflows.clickAddWorkflowButton();
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); 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 }) => { 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.getCodeEditor().fill("console.log('code node 1')");
await n8n.ndv.close(); 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.getCodeEditor().fill("console.log('code node 2')");
await n8n.ndv.close(); 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 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')"); await expect(n8n.ndv.getCodeEditor()).toContainText("console.log('code node 1')");
}); });
@@ -112,7 +116,10 @@ return []
await n8n.goHome(); await n8n.goHome();
await n8n.workflows.clickAddWorkflowButton(); await n8n.workflows.clickAddWorkflowButton();
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); 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 }) => { test('tab should exist if experiment selected and be selectable', async ({ n8n }) => {

View File

@@ -1586,6 +1586,15 @@ function resolveResourceAndOperation(
nodeParameters: INodeParameters, nodeParameters: INodeParameters,
nodeTypeDescription: INodeTypeDescription, 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 resource = nodeParameters.resource as string;
const operation = nodeParameters.operation as string; const operation = nodeParameters.operation as string;
const nodeTypeOperation = nodeTypeDescription.properties.find( const nodeTypeOperation = nodeTypeDescription.properties.find(

View File

@@ -5630,6 +5630,44 @@ describe('NodeHelpers', () => {
// Assert // Assert
expect(result).toBe('Create user in Test Node'); 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', () => { describe('isTool', () => {
it('should return true for a node with AiTool output', () => { it('should return true for a node with AiTool output', () => {