diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 9ecc6b10346..de1a686f96f 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1616,6 +1616,7 @@ "expressionEdit.expression": "Expression", "expressionEdit.resultOfItem1": "Result of item 1", "expressionEditor.uncalledFunction": "[this is a function, please add ()]", + "expressionEditor.deprecated.getPairedItem": "$getPairedItem is deprecated and will be removed", "expressionModalInput.empty": "[empty]", "expressionModalInput.undefined": "[undefined]", "expressionModalInput.null": "null", diff --git a/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.test.ts b/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.test.ts index ae0d5f79283..5939c1e749c 100644 --- a/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.test.ts +++ b/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.test.ts @@ -142,6 +142,31 @@ describe('useExpressionEditor', () => { }); }); + test('surfaces deprecated $getPairedItem as an error segment', async () => { + mockResolveExpression(); + + const { + expressionEditor: { segments }, + } = await renderExpressionEditor({ + editorValue: '{{ $getPairedItem }}', + extensions: [n8nLang()], + }); + + await waitFor(() => { + expect(toValue(segments.resolvable)).toEqual([ + { + error: expect.any(Error), + from: 0, + kind: 'resolvable', + resolvable: '{{ $getPairedItem }}', + resolved: '[$getPairedItem is deprecated and will be removed]', + state: 'invalid', + to: 20, + }, + ]); + }); + }); + test('render [empty] when expression evaluates to an empty string', async () => { mockResolveExpression().mockReturnValueOnce(''); diff --git a/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.ts b/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.ts index be19e766b26..334d19b994d 100644 --- a/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.ts +++ b/packages/frontend/editor-ui/src/features/shared/editors/composables/useExpressionEditor.ts @@ -33,6 +33,7 @@ import { closeCursorInfoBox } from '../plugins/codemirror/tooltips/InfoBoxToolti import type { Html, Plaintext, RawSegment, Resolvable, Segment } from '@/app/types/expressions'; import { getExpressionErrorMessage, getResolvableState } from '@/app/utils/expressions'; import { isCredentialsModalOpen } from '../plugins/codemirror/completions/utils'; +import { usesDeprecatedExpressionFunction } from '../plugins/codemirror/expressionDeprecations'; import { closeCompletion, completionStatus } from '@codemirror/autocomplete'; import { Compartment, @@ -384,6 +385,12 @@ export const useExpressionEditor = ({ }; try { + // Deprecated functions still resolve on the backend, but we surface them + // as an error in the editor preview to steer users off them. + if (usesDeprecatedExpressionFunction(resolvable)) { + throw new Error(i18n.baseText('expressionEditor.deprecated.getPairedItem')); + } + if (expressionLocalResolveContext.value) { result.resolved = await workflowHelpers.resolveExpression('=' + resolvable, undefined, { ...expressionLocalResolveContext.value, diff --git a/packages/frontend/editor-ui/src/features/shared/editors/plugins/codemirror/expressionDeprecations.test.ts b/packages/frontend/editor-ui/src/features/shared/editors/plugins/codemirror/expressionDeprecations.test.ts new file mode 100644 index 00000000000..8504ef29fa0 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/shared/editors/plugins/codemirror/expressionDeprecations.test.ts @@ -0,0 +1,19 @@ +import { usesDeprecatedExpressionFunction } from './expressionDeprecations'; + +describe('usesDeprecatedExpressionFunction', () => { + it('detects $getPairedItem used as a bare reference', () => { + expect(usesDeprecatedExpressionFunction('{{ $getPairedItem }}')).toBe(true); + }); + + it('detects $getPairedItem used as a call', () => { + expect(usesDeprecatedExpressionFunction('{{ $getPairedItem("Node", null, {}) }}')).toBe(true); + }); + + it('does not flag benign expressions', () => { + expect(usesDeprecatedExpressionFunction('{{ $json.foo }}')).toBe(false); + }); + + it('does not flag $getPairedItem inside a string literal', () => { + expect(usesDeprecatedExpressionFunction('{{ "$getPairedItem" }}')).toBe(false); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/shared/editors/plugins/codemirror/expressionDeprecations.ts b/packages/frontend/editor-ui/src/features/shared/editors/plugins/codemirror/expressionDeprecations.ts new file mode 100644 index 00000000000..1e2938a109a --- /dev/null +++ b/packages/frontend/editor-ui/src/features/shared/editors/plugins/codemirror/expressionDeprecations.ts @@ -0,0 +1,26 @@ +import { javascriptLanguage } from '@codemirror/lang-javascript'; + +// Expression functions deprecated in the frontend editor. Still resolve on the +// backend at execution time — we only surface them as errors in the editor. +export const DEPRECATED_EXPRESSION_FUNCTION = '$getPairedItem'; + +/** + * Whether an expression body references a deprecated function. Matches + * `VariableName` nodes so occurrences in string literals aren't flagged. + */ +export function usesDeprecatedExpressionFunction(expression: string): boolean { + let found = false; + + javascriptLanguage.parser.parse(expression).iterate({ + enter: (node) => { + if ( + node.name === 'VariableName' && + expression.slice(node.from, node.to) === DEPRECATED_EXPRESSION_FUNCTION + ) { + found = true; + } + }, + }); + + return found; +}