From c5ee969433591ea82ce1d03902ada516a5b70b81 Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Fri, 12 Sep 2025 11:52:08 +0300 Subject: [PATCH] fix(editor): Make inputs dragged to Python Code editor produce working code (#19415) --- .../CodeNodeEditor/CodeNodeEditor.vue | 12 +-- .../components/CodeNodeEditor/utils.test.ts | 86 ++++++++++++++++++- .../src/components/CodeNodeEditor/utils.ts | 44 ++++++++++ 3 files changed, 135 insertions(+), 7 deletions(-) diff --git a/packages/frontend/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue b/packages/frontend/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue index 5ca1e300c5..417b8c1b4a 100644 --- a/packages/frontend/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue +++ b/packages/frontend/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue @@ -20,6 +20,7 @@ import { useLinter } from './linter'; import { useSettingsStore } from '@/stores/settings.store'; import { dropInCodeEditor } from '@/plugins/codemirror/dragAndDrop'; import type { TargetNodeParameterContext } from '@/Interface'; +import { valueToInsert } from './utils'; export type CodeNodeLanguageOption = CodeNodeEditorLanguage | 'pythonNative'; @@ -203,12 +204,11 @@ function onAiLoadEnd() { async function onDrop(value: string, event: MouseEvent) { if (!editor.value) return; - const valueToInsert = - props.mode === 'runOnceForAllItems' - ? value.replace('$json', '$input.first().json').replace(/\$\((.*)\)\.item/, '$($1).first()') - : value; - - await dropInCodeEditor(toRaw(editor.value), event, valueToInsert); + await dropInCodeEditor( + toRaw(editor.value), + event, + valueToInsert(value, props.language, props.mode), + ); } defineExpose({ diff --git a/packages/frontend/editor-ui/src/components/CodeNodeEditor/utils.test.ts b/packages/frontend/editor-ui/src/components/CodeNodeEditor/utils.test.ts index a821403e4c..1a73870611 100644 --- a/packages/frontend/editor-ui/src/components/CodeNodeEditor/utils.test.ts +++ b/packages/frontend/editor-ui/src/components/CodeNodeEditor/utils.test.ts @@ -1,5 +1,5 @@ import * as esprima from 'esprima-next'; -import { walk } from './utils'; +import { valueToInsert, walk } from './utils'; describe('CodeNodeEditor utils', () => { describe('walk', () => { @@ -41,4 +41,88 @@ const y = f({ a: 'c' }) ]); }); }); + + describe('valueToInsert', () => { + describe('JavaScript', () => { + it('should convert input item correctly, runOnceForAllItems', () => { + expect( + valueToInsert('{{ $json.foo.bar[0].baz }}', 'javaScript', 'runOnceForAllItems'), + ).toBe('{{ $input.first().json.foo.bar[0].baz }}'); + }); + + it('should convert input item correctly, runOnceForEachItem', () => { + expect( + valueToInsert('{{ $json.foo.bar[0].baz }}', 'javaScript', 'runOnceForEachItem'), + ).toBe('{{ $json.foo.bar[0].baz }}'); + }); + + it('should convert previous node correctly, runOnceForAllItems', () => { + expect( + valueToInsert( + "{{ $('Some Previous Node').item.json.foo.bar[0].baz }}", + 'javaScript', + 'runOnceForAllItems', + ), + ).toBe("{{ $('Some Previous Node').first().json.foo.bar[0].baz }}"); + }); + + it('should convert previous node correctly, runOnceForEachItem', () => { + expect( + valueToInsert( + "{{ $('Some Previous Node').item.json.foo.bar[0].baz }}", + 'javaScript', + 'runOnceForEachItem', + ), + ).toBe("{{ $('Some Previous Node').item.json.foo.bar[0].baz }}"); + }); + }); + + describe('Python (Pyodide)', () => { + it('should convert input item correctly, runOnceForAllItems', () => { + expect(valueToInsert('{{ $json.foo.bar[0].baz }}', 'python', 'runOnceForAllItems')).toBe( + '{{ _input.first().json.foo.bar[0].baz }}', + ); + }); + + it('should convert input item correctly, runOnceForEachItem', () => { + expect(valueToInsert('{{ $json.foo.bar[0].baz }}', 'python', 'runOnceForEachItem')).toBe( + '{{ _input.item.json.foo.bar[0].baz }}', + ); + }); + + it('should convert previous node correctly, runOnceForAllItems', () => { + expect( + valueToInsert( + "{{ $('Some Previous Node').item.json.foo.bar[0].baz }}", + 'python', + 'runOnceForAllItems', + ), + ).toBe("{{ _('Some Previous Node').first().json.foo.bar[0].baz }}"); + }); + + it('should convert previous node correctly, runOnceForEachItem', () => { + expect( + valueToInsert( + "{{ $('Some Previous Node').item.json.foo.bar[0].baz }}", + 'python', + 'runOnceForEachItem', + ), + ).toBe("{{ _('Some Previous Node').item.json.foo.bar[0].baz }}"); + }); + }); + + describe('Python (Native)', () => { + it('should convert input item correctly, runOnceForAllItems', () => { + expect( + valueToInsert('{{ $json.foo.bar[0].baz }}', 'pythonNative', 'runOnceForAllItems'), + ).toBe('{{ _items[0]["json"]["foo"]["bar"][0]["baz"] }}'); + }); + + it('should convert input item correctly, runOnceForEachItem', () => { + expect( + valueToInsert('{{ $json.foo.bar[0].baz }}', 'pythonNative', 'runOnceForEachItem'), + ).toBe('{{ _item["json"]["foo"]["bar"][0]["baz"] }}'); + }); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/components/CodeNodeEditor/utils.ts b/packages/frontend/editor-ui/src/components/CodeNodeEditor/utils.ts index e75dfa927a..c980a19efa 100644 --- a/packages/frontend/editor-ui/src/components/CodeNodeEditor/utils.ts +++ b/packages/frontend/editor-ui/src/components/CodeNodeEditor/utils.ts @@ -3,6 +3,8 @@ import type { Completion } from '@codemirror/autocomplete'; import type { RangeNode } from './types'; import { sanitizeHtml } from '@/utils/htmlUtils'; import type { Node } from 'estree'; +import type { CodeNodeLanguageOption } from './CodeNodeEditor.vue'; +import type { CodeExecutionMode } from 'n8n-workflow'; export function walk( node: Node | esprima.Program, @@ -57,3 +59,45 @@ export const addInfoRenderer = (option: Completion): Completion => { } return option; }; + +const DOT_CHAINS = /((?:\.[A-Za-z_$][A-Za-z0-9_$]*)+)/g; +const DOT_KEY = /\.(?[A-Za-z_$][A-Za-z0-9_$]*)/g; + +// Convert dot notation ".a.b.c" chains -> ["a"]["b"]["c"] +const toBracketNotation = (input: string): string => { + return input.replace(DOT_CHAINS, (chain) => chain.replace(DOT_KEY, '["$"]')); +}; + +const pythonInsert = (value: string, mode: CodeExecutionMode): string => { + const base = + mode === 'runOnceForAllItems' + ? value.replace('$json', '_items[0]["json"]') + : value.replace('$json', '_item["json"]'); + + return toBracketNotation(base); +}; + +const pyodideInsert = (value: string, mode: CodeExecutionMode): string => { + return value + .replace('$json', mode === 'runOnceForAllItems' ? '_input.first().json' : '_input.item.json') + .replace(/\$\((.*)\)\.item/, mode === 'runOnceForAllItems' ? '_($1).first()' : '_($1).item'); +}; + +const jsInsertForAllItems = (value: string): string => { + return value.replace('$json', '$input.first().json').replace(/\$\((.*)\)\.item/, '$($1).first()'); +}; + +const isPyodide = (language: CodeNodeLanguageOption) => language === 'python'; +const isPython = (language: CodeNodeLanguageOption) => language === 'pythonNative'; + +export const valueToInsert = ( + value: string, + language: CodeNodeLanguageOption, + mode: CodeExecutionMode, +): string => { + if (isPython(language)) return pythonInsert(value, mode); + if (isPyodide(language)) return pyodideInsert(value, mode); + if (mode === 'runOnceForAllItems') return jsInsertForAllItems(value); + + return value; +};