diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/completions.test.ts index 5b5362acd3b4b..7d8d89c5064cc 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/completions.test.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/completions.test.ts @@ -121,6 +121,15 @@ describe('Resolution-based completions', () => { ); }); + test('should properly handle string that contain dollar signs', () => { + // @ts-expect-error Spied function is mistyped + resolveParameterSpy.mockReturnValueOnce('"You \'owe\' me 200$"'); + + expect(completions('{{ "You \'owe\' me 200$".| }}')).toHaveLength( + natives('string').length + extensions('string').length, + ); + }); + test('should return completions for number literal: {{ (123).| }}', () => { // @ts-expect-error Spied function is mistyped resolveParameterSpy.mockReturnValueOnce(123); @@ -161,6 +170,61 @@ describe('Resolution-based completions', () => { }); }); + describe('complex expression completions', () => { + const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter'); + const { $input } = mockProxy; + + test('should return completions when $input is used as a function parameter', () => { + resolveParameterSpy.mockReturnValue($input.item.json.num); + const found = completions('{{ Math.abs($input.item.json.num1).| }}'); + if (!found) throw new Error('Expected to find completions'); + expect(found).toHaveLength(extensions('number').length + natives('number').length); + }); + + test('should return completions when node reference is used as a function parameter', () => { + const initialState = { workflows: { workflow: { nodes: mockNodes } } }; + + setActivePinia(createTestingPinia({ initialState })); + + expect(completions('{{ new Date($(|) }}')).toHaveLength(mockNodes.length); + }); + + test('should return completions for complex expression: {{ $now.diff($now.diff($now.|)) }}', () => { + expect(completions('{{ $now.diff($now.diff($now.|)) }}')).toHaveLength( + natives('date').length + extensions('object').length, + ); + }); + + test('should return completions for complex expression: {{ $execution.resumeUrl.includes($json.) }}', () => { + resolveParameterSpy.mockReturnValue($input.item.json); + const { $json } = mockProxy; + const found = completions('{{ $execution.resumeUrl.includes($json.|) }}'); + + if (!found) throw new Error('Expected to find completions'); + expect(found).toHaveLength(Object.keys($json).length + natives('object').length); + }); + + test('should return completions for operation expression: {{ $now.day + $json. }}', () => { + resolveParameterSpy.mockReturnValue($input.item.json); + const { $json } = mockProxy; + const found = completions('{{ $now.day + $json.| }}'); + + if (!found) throw new Error('Expected to find completions'); + + expect(found).toHaveLength(Object.keys($json).length + natives('object').length); + }); + + test('should return completions for operation expression: {{ Math.abs($now.day) >= 10 ? $now : Math.abs($json.). }}', () => { + resolveParameterSpy.mockReturnValue($input.item.json); + const { $json } = mockProxy; + const found = completions('{{ Math.abs($now.day) >= 10 ? $now : Math.abs($json.|) }}'); + + if (!found) throw new Error('Expected to find completions'); + + expect(found).toHaveLength(Object.keys($json).length + natives('object').length); + }); + }); + describe('bracket-aware completions', () => { const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter'); const { $input } = mockProxy; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts index beff5f4a6b552..446ce3ff333a8 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -5,11 +5,16 @@ import { resolveParameter } from '@/mixins/workflowHelpers'; import { useNDVStore } from '@/stores/ndv'; import type { Completion, CompletionContext } from '@codemirror/autocomplete'; +// String literal expression is everything enclosed in single, double or tick quotes following a dot +const stringLiteralRegex = /^"[^"]+"|^'[^']+'|^`[^`]+`\./; +// JavaScript operands +const operandsRegex = /[+\-*/><<==>**!=?]/; /** * Split user input into base (to resolve) and tail (to filter). */ export function splitBaseTail(userInput: string): [string, string] { - const parts = userInput.split('.'); + const processedInput = extractSubExpression(userInput); + const parts = processedInput.split('.'); const tail = parts.pop() ?? ''; return [parts.join('.'), tail]; @@ -31,6 +36,30 @@ export function longestCommonPrefix(...strings: string[]) { }); } +// Process user input if expressions are used as part of complex expression +// i.e. as a function parameter or an operation expression +// this function will extract expression that is currently typed so autocomplete +// suggestions can be matched based on it. +function extractSubExpression(userInput: string): string { + const dollarSignIndex = userInput.indexOf('$'); + // If it's not a dollar sign expression just strip parentheses + if (dollarSignIndex === -1) { + userInput = userInput.replace(/^.+(\(|\[|{)/, ''); + } else if (!stringLiteralRegex.test(userInput)) { + // If there is a dollar sign in the input and input is not a string literal, + // extract part of following the last $ + const expressionParts = userInput.split('$'); + userInput = `$${expressionParts[expressionParts.length - 1]}`; + // If input is part of a complex operation expression and extract last operand + const operationPart = userInput.split(operandsRegex).pop()?.trim() || ''; + const lastOperand = operationPart.split(' ').pop(); + if (lastOperand) { + userInput = lastOperand; + } + } + return userInput; +} + export const prefixMatch = (first: string, second: string) => first.startsWith(second) && first !== second;