From 22bd573cd5ad7f41acee9e9b8be8c72fff56c811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 11 Jan 2023 17:00:51 +0100 Subject: [PATCH 001/160] :fire: Remove test extensions --- .../src/Extensions/StringExtensions.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index e0ce8cb4d3371..4906980d24735 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -56,18 +56,6 @@ function encrypt(value: string, extraArgs?: unknown): string { // return createHash(format).update(value.toString()).digest('hex'); } -function getOnlyFirstCharacters(value: string, extraArgs: number[]): string { - const [end] = extraArgs; - - if (typeof end !== 'number') { - throw new ExpressionError.ExpressionExtensionError( - 'getOnlyFirstCharacters() requires a argument', - ); - } - - return value.slice(0, end); -} - function isBlank(value: string): boolean { return value === ''; } @@ -122,10 +110,6 @@ function removeMarkdown(value: string): string { return output; } -function sayHi(value: string) { - return `hi ${value}`; -} - function stripTags(value: string): string { return value.replace(/<[^>]*>?/gm, ''); } @@ -270,9 +254,7 @@ export const stringExtensions: ExtensionMap = { functions: { encrypt, hash: encrypt, - getOnlyFirstCharacters, removeMarkdown, - sayHi, stripTags, toBoolean: isTrue, toDate, From 53f2226241b6402d3cecbfb3bd1f0de3f2b6820d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 11 Jan 2023 17:01:08 +0100 Subject: [PATCH 002/160] :construction: Add test description --- packages/workflow/src/Extensions/NumberExtensions.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/workflow/src/Extensions/NumberExtensions.ts b/packages/workflow/src/Extensions/NumberExtensions.ts index afa20c0b652ca..6e80aaf654f9e 100644 --- a/packages/workflow/src/Extensions/NumberExtensions.ts +++ b/packages/workflow/src/Extensions/NumberExtensions.ts @@ -49,6 +49,8 @@ function ceil(value: number) { return Math.ceil(value); } +ceil.description = 'This is a description'; // @TODO: Add docs + function round(value: number, extraArgs: number[]) { const [decimalPlaces = 0] = extraArgs; return +value.toFixed(decimalPlaces); From 6a8d709417b2c4ae37be292b0d81c5db68ff56c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 11 Jan 2023 17:01:27 +0100 Subject: [PATCH 003/160] :blue_book: Expand types --- .../workflow/src/Extensions/Extensions.ts | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/workflow/src/Extensions/Extensions.ts b/packages/workflow/src/Extensions/Extensions.ts index 460567d0ebb01..08f2450ae4817 100644 --- a/packages/workflow/src/Extensions/Extensions.ts +++ b/packages/workflow/src/Extensions/Extensions.ts @@ -1,5 +1,26 @@ -export interface ExtensionMap { - typeName: string; - // eslint-disable-next-line @typescript-eslint/ban-types - functions: Record; -} +type TypeName = 'String' | 'Number' | 'Array' | 'Object' | 'Date'; + +export type ExtensionMap = + | NumberExtensions + | StringExtensions + | ObjectExtensions + | DateExtensions + | ArrayExtensions; + +type ExtensionFunctionMetadata = { + description?: string; +}; + +type MakeExtensions = { + typeName: N; + functions: { + // eslint-disable-next-line @typescript-eslint/ban-types + [key: string]: Function & ExtensionFunctionMetadata; + }; +}; + +type NumberExtensions = MakeExtensions<'Number'>; +type StringExtensions = MakeExtensions<'String'>; +type ObjectExtensions = MakeExtensions<'Object'>; +type DateExtensions = MakeExtensions<'Date'>; +type ArrayExtensions = MakeExtensions<'Array'>; From 0edac3b237154091b881ad5192f24ce9b052ae2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 11 Jan 2023 17:01:39 +0100 Subject: [PATCH 004/160] :zap: Export extensions --- packages/workflow/src/Extensions/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/workflow/src/Extensions/index.ts b/packages/workflow/src/Extensions/index.ts index 9bc07b1e8ba67..802c8ba34862a 100644 --- a/packages/workflow/src/Extensions/index.ts +++ b/packages/workflow/src/Extensions/index.ts @@ -5,3 +5,5 @@ export { hasNativeMethod, extendTransform, } from './ExpressionExtension'; + +export { EXTENSION_OBJECTS as ExpressionExtensions } from './ExpressionExtension'; From 8bb55267eba28bc4e7e01545817881b6a8095812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 11 Jan 2023 17:01:54 +0100 Subject: [PATCH 005/160] :zap: Export collection --- packages/workflow/src/Extensions/ExpressionExtension.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow/src/Extensions/ExpressionExtension.ts b/packages/workflow/src/Extensions/ExpressionExtension.ts index ecae3f00c2c74..4b9d862593999 100644 --- a/packages/workflow/src/Extensions/ExpressionExtension.ts +++ b/packages/workflow/src/Extensions/ExpressionExtension.ts @@ -19,7 +19,7 @@ function isPresent(value: unknown) { return !isBlank(value); } -const EXTENSION_OBJECTS = [ +export const EXTENSION_OBJECTS = [ arrayExtensions, dateExtensions, numberExtensions, From a925ecea06de913205d32f6548d2bf0cc1e2bc57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 11 Jan 2023 17:02:03 +0100 Subject: [PATCH 006/160] :zap: Mark all proxies --- packages/workflow/src/WorkflowDataProxy.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index 5f6547b7aae5c..53cfc5f3fa996 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -158,6 +158,7 @@ export class WorkflowDataProxy { }; }, get(target, name, receiver) { + if (name === 'isProxy') return true; // eslint-disable-next-line no-param-reassign name = name.toString(); const contextData = NodeHelpers.getContext(that.runExecutionData!, 'node', node); @@ -179,6 +180,7 @@ export class WorkflowDataProxy { }, // eslint-disable-next-line @typescript-eslint/no-unused-vars get(target, name, receiver) { + if (name === 'isProxy') return true; name = name.toString(); return that.selfData[name]; }, @@ -207,6 +209,7 @@ export class WorkflowDataProxy { }; }, get(target, name, receiver) { + if (name === 'isProxy') return true; name = name.toString(); let returnValue: NodeParameterValueType; @@ -385,6 +388,7 @@ export class WorkflowDataProxy { { binary: undefined, data: undefined, json: undefined }, { get(target, name, receiver) { + if (name === 'isProxy') return true; name = name.toString(); if (!node) { @@ -461,6 +465,8 @@ export class WorkflowDataProxy { {}, { get(target, name, receiver) { + if (name === 'isProxy') return true; + if ( typeof process === 'undefined' || // env vars are inaccessible to frontend process.env.N8N_BLOCK_ENV_ACCESS_IN_NODE === 'true' @@ -496,6 +502,8 @@ export class WorkflowDataProxy { }; }, get(target, name, receiver) { + if (name === 'isProxy') return true; + if (!that.executeData?.source) { // Means the previous node did not get executed yet return undefined; @@ -541,6 +549,8 @@ export class WorkflowDataProxy { }; }, get(target, name, receiver) { + if (name === 'isProxy') return true; + if (allowedValues.includes(name.toString())) { const value = that.workflow[name as keyof typeof target]; @@ -573,6 +583,8 @@ export class WorkflowDataProxy { {}, { get(target, name, receiver) { + if (name === 'isProxy') return true; + const nodeName = name.toString(); if (that.workflow.getNode(nodeName) === null) { @@ -944,6 +956,8 @@ export class WorkflowDataProxy { ]; }, get(target, property, receiver) { + if (property === 'isProxy') return true; + if (['pairedItem', 'itemMatching', 'item'].includes(property as string)) { const pairedItemMethod = (itemIndex?: number) => { if (itemIndex === undefined) { @@ -1064,6 +1078,8 @@ export class WorkflowDataProxy { }; }, get(target, property, receiver) { + if (property === 'isProxy') return true; + if (property === 'item') { return that.connectionInputData[that.itemIndex]; } @@ -1210,6 +1226,8 @@ export class WorkflowDataProxy { return new Proxy(base, { get(target, name, receiver) { + if (name === 'isProxy') return true; + if (['$data', '$json'].includes(name as string)) { return that.nodeDataGetter(that.activeNodeName, true)?.json; } From fe5acdc7ecbaa53061667e56fd11e301f95b8143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 11 Jan 2023 17:02:44 +0100 Subject: [PATCH 007/160] :pencil2: Rename for clarity --- .../plugins/codemirror/completions/proxy.completions.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts index c59befc0f9310..bf18f4e9f6a5d 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts @@ -24,11 +24,13 @@ export function proxyCompletions(context: CompletionContext): CompletionResult | let options: Completion[] = []; try { - const proxy = resolveParameter(`={{ ${toResolve} }}`); + const resolved = resolveParameter(`={{ ${toResolve} }}`); - if (!proxy || typeof proxy !== 'object' || Array.isArray(proxy)) return null; + if (!resolved || typeof resolved !== 'object' || Array.isArray(resolved)) return null; - options = generateOptions(toResolve, proxy, word); + // resolved to proxy + + options = generateOptions(toResolve, resolved, word); } catch (_) { return null; } From ce112cdad413e98e09a24db639152c901b747884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 11 Jan 2023 17:03:11 +0100 Subject: [PATCH 008/160] :zap: Export from barrel --- packages/workflow/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index 86a5de4d5543a..6a976ca3251f9 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -28,3 +28,5 @@ export { isINodePropertyCollectionList, isINodePropertyOptionsList, } from './type-guards'; + +export { ExpressionExtensions } from './Extensions'; From fccc9a384a80a8e480a49a37d0f3740a285043ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 11 Jan 2023 17:03:26 +0100 Subject: [PATCH 009/160] :sparkles: Create datatype completions --- .../completions/datatype.completions.ts | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts new file mode 100644 index 0000000000000..30bc860f06328 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -0,0 +1,110 @@ +import { ExpressionExtensions, IDataObject } from 'n8n-workflow'; +import { resolveParameter } from '@/mixins/workflowHelpers'; +import { longestCommonPrefix } from './utils'; +import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; + +/** + * Completions from datatypes to native JS methods (pending) and expression extensions. + */ +export function datatypeCompletions(context: CompletionContext): CompletionResult | null { + const numberRegex = /[\S]+\.(\w|\W)*/; + const stringRegex = /(".+"|('.+'))\.(\w|\W)*/; + const arrayRegex = /(\[.+\])\.(\w|\W)*/; + const objectRegex = /(\{.*\})\.(\w|\W)*/; + const dateRegex = /\(?new Date\(\(?.*?\)\)?\.(\w|\W)*/; + + const combinedRegex = new RegExp( + [ + numberRegex.source, + stringRegex.source, + arrayRegex.source, + objectRegex.source, + dateRegex.source, + ].join('|'), + ); + + const word = context.matchBefore(combinedRegex); + + if (!word) return null; + + if (word.from === word.to && !context.explicit) return null; + + // remove opening marker grabbed by `objectRegex`, @TODO: negative lookbehind instead + if (word.text.startsWith('{{')) { + word.text = word.text.replace(/^{{/, ''); + } + + const toResolve = word.text.endsWith('.') + ? word.text.slice(0, -1) + : word.text.split('.').slice(0, -1).join('.'); + + let options: Completion[] = []; + let resolved: IDataObject | null; + + try { + resolved = resolveParameter(`={{ ${toResolve} }}`); + } catch (_) { + return null; + } + + if (typeof resolved === 'number') { + options = extensionOptions('Number'); + } else if (typeof resolved === 'string') { + options = extensionOptions('String'); + } else if (Array.isArray(resolved)) { + options = extensionOptions('Array'); + } else if (resolved instanceof Date) { + options = extensionOptions('Date'); + } else if ( + typeof resolved === 'object' && + resolved !== null && + !resolved.isProxy && + !resolved.json && + !toResolve.endsWith('json') + ) { + options = extensionOptions('Object'); + } + + let userInputTail = ''; + + const delimiter = word.text.includes('json[') ? 'json[' : '.'; + + userInputTail = word.text.split(delimiter).pop() as string; + + if (userInputTail !== '') { + options = options.filter((o) => o.label.startsWith(userInputTail) && userInputTail !== o.label); + } + + return { + from: word.to - userInputTail.length, + options, + filter: false, + getMatch(completion: Completion) { + const lcp = longestCommonPrefix([userInputTail, completion.label]); + + return [0, lcp.length]; + }, + }; +} + +const extensionOptions = (typeName: 'String' | 'Number' | 'Date' | 'Object' | 'Array') => { + const extensions = ExpressionExtensions.find((ee) => ee.typeName === typeName); + + if (!extensions) return []; + + const options = Object.values(extensions.functions) + .filter((f) => f.length === 1) // @TEMP Filter out functions needing args until documented + .sort((a, b) => a.name.localeCompare(b.name)) + .map((f) => { + const option: Completion = { + label: `${f.name}()`, + type: 'function', + }; + + if (f.description) option.info = f.description; + + return option; + }); + + return options; +}; From 05cce74766cfb8fb69a261bda5e4e3be3514de4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 11 Jan 2023 17:03:36 +0100 Subject: [PATCH 010/160] :zap: Mount datatype completions --- packages/editor-ui/src/plugins/codemirror/n8nLang.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts index 439a2a384cf8d..d9416799cf192 100644 --- a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts +++ b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts @@ -8,6 +8,7 @@ import { proxyCompletions } from './completions/proxy.completions'; import { rootCompletions } from './completions/root.completions'; import { luxonCompletions } from './completions/luxon.completions'; import { alphaCompletions } from './completions/alpha.completions'; +import { datatypeCompletions } from './completions/datatype.completions'; const n8nParserWithNestedJsParser = n8nParser.configure({ wrap: parseMixed((node) => { @@ -22,9 +23,13 @@ const n8nParserWithNestedJsParser = n8nParser.configure({ const n8nLanguage = LRLanguage.define({ parser: n8nParserWithNestedJsParser }); export function n8nLang() { - const options = [alphaCompletions, rootCompletions, proxyCompletions, luxonCompletions].map( - (group) => n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], group) }), - ); + const options = [ + alphaCompletions, + datatypeCompletions, + rootCompletions, + proxyCompletions, + luxonCompletions, + ].map((group) => n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], group) })); return new LanguageSupport(n8nLanguage, [ n8nLanguage.data.of({ closeBrackets: { brackets: ['{'] } }), From 9d9f6108e577c24a62f808f4dba57ab32e8ce2d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 12 Jan 2023 13:55:30 +0100 Subject: [PATCH 011/160] :test_tube: Adjust tests --- .../ExpressionExtension.test.ts | 42 ++++++++++--------- .../NumberExtensions.test.ts | 4 +- .../StringExtensions.test.ts | 22 ---------- 3 files changed, 25 insertions(+), 43 deletions(-) diff --git a/packages/workflow/test/ExpressionExtensions/ExpressionExtension.test.ts b/packages/workflow/test/ExpressionExtensions/ExpressionExtension.test.ts index 72f1bd62c7131..c49d8aff16cbe 100644 --- a/packages/workflow/test/ExpressionExtensions/ExpressionExtension.test.ts +++ b/packages/workflow/test/ExpressionExtensions/ExpressionExtension.test.ts @@ -12,15 +12,15 @@ describe('Expression Extension Transforms', () => { expect(extendTransform('"".isBlank()')!.code).toEqual('extend("", "isBlank", [])'); }); - test('Chained transform with .sayHi.getOnlyFirstCharacters', () => { - expect(extendTransform('"".sayHi().getOnlyFirstCharacters(2)')!.code).toEqual( - 'extend(extend("", "sayHi", []), "getOnlyFirstCharacters", [2])', + test('Chained transform with .toSnakeCase.toSnakeCase', () => { + expect(extendTransform('"".toSnakeCase().toSnakeCase()')!.code).toEqual( + 'extend(extend("", "toSnakeCase", []), "toSnakeCase", [])', ); }); - test('Chained transform with native functions .sayHi.trim.getOnlyFirstCharacters', () => { - expect(extendTransform('"aaa ".sayHi().trim().getOnlyFirstCharacters(2)')!.code).toEqual( - 'extend(extend("aaa ", "sayHi", []).trim(), "getOnlyFirstCharacters", [2])', + test('Chained transform with native functions .toSnakeCase.trim.toSnakeCase', () => { + expect(extendTransform('"aaa ".toSnakeCase().trim().toSnakeCase()')!.code).toEqual( + 'extend(extend("aaa ", "toSnakeCase", []).trim(), "toSnakeCase", [])', ); }); }); @@ -36,19 +36,21 @@ describe('tmpl Expression Parser', () => { }); test('Multiple expression', () => { - expect(splitExpression('{{ "test".sayHi() }} you have ${{ (100).format() }}.')).toEqual([ - { type: 'text', text: '' }, - { type: 'code', text: ' "test".sayHi() ', hasClosingBrackets: true }, - { type: 'text', text: ' you have $' }, - { type: 'code', text: ' (100).format() ', hasClosingBrackets: true }, - { type: 'text', text: '.' }, - ]); + expect(splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format() }}.')).toEqual( + [ + { type: 'text', text: '' }, + { type: 'code', text: ' "test".toSnakeCase() ', hasClosingBrackets: true }, + { type: 'text', text: ' you have $' }, + { type: 'code', text: ' (100).format() ', hasClosingBrackets: true }, + { type: 'text', text: '.' }, + ], + ); }); test('Unclosed expression', () => { - expect(splitExpression('{{ "test".sayHi() }} you have ${{ (100).format()')).toEqual([ + expect(splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format()')).toEqual([ { type: 'text', text: '' }, - { type: 'code', text: ' "test".sayHi() ', hasClosingBrackets: true }, + { type: 'code', text: ' "test".toSnakeCase() ', hasClosingBrackets: true }, { type: 'text', text: ' you have $' }, { type: 'code', text: ' (100).format()', hasClosingBrackets: false }, ]); @@ -75,14 +77,16 @@ describe('tmpl Expression Parser', () => { test('Multiple expression', () => { expect( - joinExpression(splitExpression('{{ "test".sayHi() }} you have ${{ (100).format() }}.')), - ).toEqual('{{ "test".sayHi() }} you have ${{ (100).format() }}.'); + joinExpression( + splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format() }}.'), + ), + ).toEqual('{{ "test".toSnakeCase() }} you have ${{ (100).format() }}.'); }); test('Unclosed expression', () => { expect( - joinExpression(splitExpression('{{ "test".sayHi() }} you have ${{ (100).format()')), - ).toEqual('{{ "test".sayHi() }} you have ${{ (100).format()'); + joinExpression(splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format()')), + ).toEqual('{{ "test".toSnakeCase() }} you have ${{ (100).format()'); }); test('Escaped opening bracket', () => { diff --git a/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts index 9dbd6c78a9d43..75b4999ec1b60 100644 --- a/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts @@ -77,8 +77,8 @@ describe('Data Transformation Functions', () => { describe('Multiple expressions', () => { test('Basic multiple expressions', () => { - expect(evaluate('={{ "Test".sayHi() }} you have ${{ (100).format() }}.')).toEqual( - 'hi Test you have $100.', + expect(evaluate('={{ "abc def".toSnakeCase() }} you have ${{ (100).format() }}.')).toEqual( + 'abc_def you have $100.', ); }); }); diff --git a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts index bc80d89c1cca8..5a6143b2ec5be 100644 --- a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts @@ -16,28 +16,6 @@ describe('Data Transformation Functions', () => { expect(evaluate('={{"".isBlank()}}')).toEqual(true); }); - test('.getOnlyFirstCharacters() should work correctly on a string', () => { - expect(evaluate('={{"myNewField".getOnlyFirstCharacters(5)}}')).toEqual('myNew'); - - expect(evaluate('={{"myNewField".getOnlyFirstCharacters(10)}}')).toEqual('myNewField'); - - expect( - evaluate('={{"myNewField".getOnlyFirstCharacters(5).length >= "myNewField".length}}'), - ).toEqual(false); - - expect(evaluate('={{DateTime.now().toLocaleString().getOnlyFirstCharacters(2)}}')).toEqual( - stringExtensions.functions.getOnlyFirstCharacters( - // @ts-ignore - dateExtensions.functions.toLocaleString(new Date(), []), - [2], - ), - ); - }); - - test('.sayHi() should work correctly on a string', () => { - expect(evaluate('={{ "abc".sayHi() }}')).toEqual('hi abc'); - }); - test('.encrypt() should work correctly on a string', () => { expect(evaluate('={{ "12345".encrypt("sha256") }}')).toEqual( stringExtensions.functions.encrypt('12345', ['sha256']), From 11dd7e23f9fde032d202d30bf8a251a43ddfceff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 12 Jan 2023 17:32:53 +0100 Subject: [PATCH 012/160] :zap: Add `path` prop --- .../editor-ui/src/components/ExpressionParameterInput.vue | 4 ++++ packages/editor-ui/src/components/ParameterInput.vue | 1 + .../src/components/ResourceLocator/ResourceLocator.vue | 1 + 3 files changed, 6 insertions(+) diff --git a/packages/editor-ui/src/components/ExpressionParameterInput.vue b/packages/editor-ui/src/components/ExpressionParameterInput.vue index b4a1471c4f4b1..51e58eef9dc9d 100644 --- a/packages/editor-ui/src/components/ExpressionParameterInput.vue +++ b/packages/editor-ui/src/components/ExpressionParameterInput.vue @@ -15,6 +15,7 @@ :isReadOnly="isReadOnly" :targetItem="hoveringItem" :isSingleLine="isForRecordLocator" + :path="path" @focus="onFocus" @blur="onBlur" @change="onChange" @@ -93,6 +94,9 @@ export default Vue.extend({ }; }, props: { + path: { + type: String, + }, value: { type: String, }, diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue index 0f1825c12e12d..9bbd14f3ad1ee 100644 --- a/packages/editor-ui/src/components/ParameterInput.vue +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -38,6 +38,7 @@ :value="expressionDisplayValue" :title="displayTitle" :isReadOnly="isReadOnly" + :path="path" @valueChanged="expressionUpdated" @modalOpenerClick="openExpressionEditorModal" @focus="setFocus" diff --git a/packages/editor-ui/src/components/ResourceLocator/ResourceLocator.vue b/packages/editor-ui/src/components/ResourceLocator/ResourceLocator.vue index b25a0b4321848..108879343f65e 100644 --- a/packages/editor-ui/src/components/ResourceLocator/ResourceLocator.vue +++ b/packages/editor-ui/src/components/ResourceLocator/ResourceLocator.vue @@ -81,6 +81,7 @@ Date: Thu, 12 Jan 2023 17:33:25 +0100 Subject: [PATCH 013/160] :fire: Remove `()` from completion labels --- .../src/plugins/codemirror/completions/alpha.completions.ts | 2 +- .../src/plugins/codemirror/completions/luxon.completions.ts | 4 ++-- .../src/plugins/codemirror/completions/proxy.completions.ts | 2 +- .../src/plugins/codemirror/completions/root.completions.ts | 2 +- packages/editor-ui/src/plugins/i18n/index.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts index 1cae6151e58f1..9d77ff6f34deb 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts @@ -37,7 +37,7 @@ function generateOptions() { return emptyKeys.map((key) => { const option: Completion = { - label: key, + label: key.endsWith('()') ? key.slice(0, -2) : key, type: key.endsWith('()') ? 'function' : 'keyword', }; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts index 3f9e4bc8c8f93..629b63b9ae272 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts @@ -54,7 +54,7 @@ export const nowTodayOptions = () => { const isFunction = typeof descriptor.value === 'function'; const option: Completion = { - label: isFunction ? `${key}()` : key, + label: key, type: isFunction ? 'function' : 'keyword', }; @@ -74,7 +74,7 @@ export const dateTimeOptions = () => { .sort((a, b) => a.localeCompare(b)); return keys.map((key) => { - const option: Completion = { label: `${key}()`, type: 'function' }; + const option: Completion = { label: key, type: 'function' }; const info = i18n.luxonStatic[key]; if (info) option.info = info; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts index bf18f4e9f6a5d..afe1bc89c036c 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts @@ -85,7 +85,7 @@ function generateOptions(toResolve: string, proxy: IDataObject, word: Word): Com const isFunction = typeof proxy[key] === 'function'; const option: Completion = { - label: isFunction ? `${key}()` : key, + label: key, type: isFunction ? 'function' : 'keyword', }; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts index 8525041f8f1bd..41cc06109e876 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts @@ -38,7 +38,7 @@ function generateOptions() { const options: Completion[] = rootKeys.map((key) => { const option: Completion = { label: key, - type: key.endsWith('()') ? 'function' : 'keyword', + type: key === '$jmespath' ? 'function' : 'keyword', // @TODO: Extract $jmespath to constant set }; const info = i18n.rootVars[key]; diff --git a/packages/editor-ui/src/plugins/i18n/index.ts b/packages/editor-ui/src/plugins/i18n/index.ts index b855c4c44f498..f3bf5c14ad163 100644 --- a/packages/editor-ui/src/plugins/i18n/index.ts +++ b/packages/editor-ui/src/plugins/i18n/index.ts @@ -330,7 +330,7 @@ export class I18nClass { $binary: this.baseText('codeNodeEditor.completer.binary'), $execution: this.baseText('codeNodeEditor.completer.$execution'), $input: this.baseText('codeNodeEditor.completer.$input'), - '$jmespath()': this.baseText('codeNodeEditor.completer.$jmespath'), + $jmespath: this.baseText('codeNodeEditor.completer.$jmespath'), $json: this.baseText('codeNodeEditor.completer.json'), $itemIndex: this.baseText('codeNodeEditor.completer.$itemIndex'), $now: this.baseText('codeNodeEditor.completer.$now'), From 09229b574c6082d3f5833446ddf9a00620a9ba5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 12 Jan 2023 17:33:54 +0100 Subject: [PATCH 014/160] :zap: Filter out completions for pseudo-proxies --- .../completions/datatype.completions.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 30bc860f06328..f2e6082172538 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -30,14 +30,19 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul if (word.from === word.to && !context.explicit) return null; // remove opening marker grabbed by `objectRegex`, @TODO: negative lookbehind instead - if (word.text.startsWith('{{')) { - word.text = word.text.replace(/^{{/, ''); - } + if (word.text.startsWith('{{')) word.text = word.text.replace(/^{{/, ''); const toResolve = word.text.endsWith('.') ? word.text.slice(0, -1) : word.text.split('.').slice(0, -1).join('.'); + /** + * n8n vars that should not trigger datatype completions + */ + const SKIP_SET = new Set(['$execution', '$binary', '$itemIndex', '$now', '$today', '$runIndex']); + + if (SKIP_SET.has(toResolve)) return null; + let options: Completion[] = []; let resolved: IDataObject | null; @@ -47,6 +52,8 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul return null; } + if (resolved === null) return null; + if (typeof resolved === 'number') { options = extensionOptions('Number'); } else if (typeof resolved === 'string') { @@ -57,11 +64,11 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul options = extensionOptions('Date'); } else if ( typeof resolved === 'object' && - resolved !== null && !resolved.isProxy && !resolved.json && !toResolve.endsWith('json') ) { + // object extension completions apply only to native objects options = extensionOptions('Object'); } @@ -97,7 +104,7 @@ const extensionOptions = (typeName: 'String' | 'Number' | 'Date' | 'Object' | 'A .sort((a, b) => a.name.localeCompare(b.name)) .map((f) => { const option: Completion = { - label: `${f.name}()`, + label: f.name, type: 'function', }; From bc4070c7f5a55599b782572c14ab9b2793ce2b1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 12 Jan 2023 17:34:06 +0100 Subject: [PATCH 015/160] :bug: Fix method error --- packages/workflow/src/Extensions/ExpressionExtension.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/workflow/src/Extensions/ExpressionExtension.ts b/packages/workflow/src/Extensions/ExpressionExtension.ts index 4b9d862593999..6c4e070326303 100644 --- a/packages/workflow/src/Extensions/ExpressionExtension.ts +++ b/packages/workflow/src/Extensions/ExpressionExtension.ts @@ -227,7 +227,6 @@ export function extend(input: unknown, functionName: string, args: unknown[]) { if ( inputAny && functionName && - functionName in inputAny && // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access typeof inputAny[functionName] === 'function' ) { From 2d9280a287cfe27c5205874cd6e088da09fe0d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 12 Jan 2023 17:34:17 +0100 Subject: [PATCH 016/160] :zap: Add metrics --- .../InlineExpressionEditorInput.vue | 76 ++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue index 7358e7ea0da72..cee861e2afad6 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue +++ b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue @@ -5,9 +5,10 @@ From 269e601304745f6300d4ee30c096ac8357470157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 12 Jan 2023 18:30:20 +0100 Subject: [PATCH 017/160] :pencil2: Improve naming --- .../plugins/codemirror/completions/alpha.completions.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts index 9d77ff6f34deb..cf3b1183ab365 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts @@ -33,12 +33,12 @@ export function alphaCompletions(context: CompletionContext): CompletionResult | } function generateOptions() { - const emptyKeys = ['DateTime']; + const ALPHABETIC_KEYS = ['DateTime']; - return emptyKeys.map((key) => { + return ALPHABETIC_KEYS.map((key) => { const option: Completion = { - label: key.endsWith('()') ? key.slice(0, -2) : key, - type: key.endsWith('()') ? 'function' : 'keyword', + label: key, + type: 'keyword', }; const info = i18n.rootVars[key]; From 7f57c8cf90b4658d65ef49b3cd7ba25b691bb348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 12 Jan 2023 18:30:56 +0100 Subject: [PATCH 018/160] :sparkles: Start completion on empty resolvable --- .../inputHandlers/expression.inputHandler.ts | 9 +++++++- .../src/plugins/codemirror/n8nLang.ts | 22 ++++++++++++++----- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts b/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts index cb3f77b5ffdb6..7942772aedf8b 100644 --- a/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts +++ b/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts @@ -1,4 +1,9 @@ -import { closeBrackets, completionStatus, insertBracket } from '@codemirror/autocomplete'; +import { + closeBrackets, + completionStatus, + insertBracket, + startCompletion, +} from '@codemirror/autocomplete'; import { codePointAt, codePointSize, Extension } from '@codemirror/state'; import { EditorView } from '@codemirror/view'; @@ -47,6 +52,8 @@ const handler = EditorView.inputHandler.of((view, from, to, insert) => { selection: { anchor: cursor + 1 }, }); + startCompletion(view); + return true; } diff --git a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts index d9416799cf192..3f41020845c78 100644 --- a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts +++ b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts @@ -2,8 +2,9 @@ import { parserWithMetaData as n8nParser } from 'codemirror-lang-n8n-expression' import { LanguageSupport, LRLanguage } from '@codemirror/language'; import { parseMixed } from '@lezer/common'; import { javascriptLanguage } from '@codemirror/lang-javascript'; -import { ifIn } from '@codemirror/autocomplete'; +import { completeFromList, ifIn, snippetCompletion } from '@codemirror/autocomplete'; +import { i18n } from '@/plugins/i18n'; import { proxyCompletions } from './completions/proxy.completions'; import { rootCompletions } from './completions/root.completions'; import { luxonCompletions } from './completions/luxon.completions'; @@ -23,12 +24,21 @@ const n8nParserWithNestedJsParser = n8nParser.configure({ const n8nLanguage = LRLanguage.define({ parser: n8nParserWithNestedJsParser }); export function n8nLang() { + const autoshownSnippets = completeFromList( + [...Object.keys(i18n.rootVars), '$parameter', 'DateTime'] + .sort((a, b) => a.localeCompare(b)) + .map((key) => + snippetCompletion(key, { label: key, type: key === '$jmespath' ? 'function' : 'keyword' }), + ), // @TODO: Extract $jmespath check + ); + const options = [ - alphaCompletions, - datatypeCompletions, - rootCompletions, - proxyCompletions, - luxonCompletions, + autoshownSnippets, // displayed on creating empty resolvable {{ | }} + rootCompletions, // $entity. + proxyCompletions, // $input., $prevNode., etc. + datatypeCompletions, // 'abc'., $json.name., etc. + alphaCompletions, // D (for DateTime) + luxonCompletions, // DateTime., $now., $today. ].map((group) => n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], group) })); return new LanguageSupport(n8nLanguage, [ From 2ee6750d5d61dc67e1b0808cc7e78ccc1b1e209c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 13 Jan 2023 02:25:57 +0100 Subject: [PATCH 019/160] :sparkles: Implement completion previews --- .../components/ExpressionParameterInput.vue | 10 ++ .../InlineExpressionEditorInput.vue | 133 +++++++++++++++--- .../completion-evaluation-event-bus.ts | 3 + .../editor-ui/src/mixins/expressionManager.ts | 80 ++++++----- .../completions/blank.completions.ts | 35 +++++ .../completions/datatype.completions.ts | 22 ++- .../completions/proxy.completions.ts | 9 +- .../completions/root.completions.ts | 12 +- .../src/plugins/codemirror/n8nLang.ts | 16 +-- 9 files changed, 242 insertions(+), 78 deletions(-) create mode 100644 packages/editor-ui/src/event-bus/completion-evaluation-event-bus.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts diff --git a/packages/editor-ui/src/components/ExpressionParameterInput.vue b/packages/editor-ui/src/components/ExpressionParameterInput.vue index 51e58eef9dc9d..8d5dfbf4ffc65 100644 --- a/packages/editor-ui/src/components/ExpressionParameterInput.vue +++ b/packages/editor-ui/src/components/ExpressionParameterInput.vue @@ -75,6 +75,7 @@ import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/In import ExpressionFunctionIcon from '@/components/ExpressionFunctionIcon.vue'; import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils'; import { EXPRESSIONS_DOCS_URL } from '@/constants'; +import { completionEvaluationEventBus } from '@/event-bus/completion-evaluation-event-bus'; import type { Segment } from '@/types/expressions'; import type { TargetItem } from '@/Interface'; @@ -109,6 +110,12 @@ export default Vue.extend({ default: false, }, }, + mounted() { + completionEvaluationEventBus.$on('preview-in-output', this.previewSegments); + }, + destroyed() { + completionEvaluationEventBus.$off('preview-in-output', this.previewSegments); + }, computed: { ...mapStores(useNDVStore, useWorkflowsStore), hoveringItemNumber(): number { @@ -122,6 +129,9 @@ export default Vue.extend({ }, }, methods: { + previewSegments(previewSegments: Segment[]) { + this.segments = previewSegments; + }, focus() { const inlineInput = this.$refs.inlineInput as (Vue & HTMLElement) | undefined; diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue index cee861e2afad6..80afd5be393ff 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue +++ b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue @@ -5,10 +5,18 @@ diff --git a/packages/editor-ui/src/mixins/completionManager.ts b/packages/editor-ui/src/mixins/completionManager.ts new file mode 100644 index 0000000000000..504379e59305a --- /dev/null +++ b/packages/editor-ui/src/mixins/completionManager.ts @@ -0,0 +1,151 @@ +import mixins from 'vue-typed-mixins'; +import { ExpressionExtensions } from 'n8n-workflow'; +import { EditorView, keymap, ViewUpdate } from '@codemirror/view'; +import { + completionStatus, + currentCompletions, + selectedCompletionIndex, +} from '@codemirror/autocomplete'; + +import { completionEvaluationEventBus } from '@/event-bus/completion-evaluation-event-bus'; +import { expressionManager } from './expressionManager'; + +import type { Extension } from '@codemirror/state'; + +export const completionManager = mixins(expressionManager).extend({ + data() { + return { + editor: null as EditorView | null, + errorsInSuccession: 0, + }; + }, + + computed: { + expressionExtensionsCategories() { + return ExpressionExtensions.reduce>((acc, cur) => { + for (const funcName of Object.keys(cur.functions)) { + acc[funcName] = cur.typeName; + } + + return acc; + }, {}); + }, + previewKeymap(): Extension { + return keymap.of([ + { + key: 'Escape', + run: (view) => { + if (completionStatus(view.state) !== null) { + this.$emit('change', { + value: this.unresolvedExpression, + segments: this.displayableSegments, + }); + } + + return false; + }, + }, + { + key: 'ArrowUp', + run: (view) => { + const completion = this.getCompletion('previous'); + + if (completion === null) return false; + + const previewSegments = this.toPreviewSegments(completion, view.state); + + completionEvaluationEventBus.$emit('preview-in-output', previewSegments); + + return false; + }, + }, + { + key: 'ArrowDown', + run: (view) => { + const completion = this.getCompletion('next'); + + if (completion === null) return false; + + const previewSegments = this.toPreviewSegments(completion, view.state); + + completionEvaluationEventBus.$emit('preview-in-output', previewSegments); + + return false; + }, + }, + ]); + }, + }, + methods: { + getCompletion(which: 'previous' | 'next') { + if (!this.editor) return null; + + if (completionStatus(this.editor.state) !== 'active') return null; + + const currentIndex = selectedCompletionIndex(this.editor.state); + + if (currentIndex === null) return null; + + const requestedIndex = which === 'previous' ? currentIndex - 1 : currentIndex + 1; + + return currentCompletions(this.editor.state)[requestedIndex] ?? null; + }, + + trackCompletion(viewUpdate: ViewUpdate, parameterPath: string) { + if (!this.editor) return; + + const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete')); + + if (!completionTx) return; + + let completion = ''; + let completionBase = ''; + + viewUpdate.changes.iterChanges((_: number, __: number, fromB: number, toB: number) => { + if (!this.editor) return; + + completion = this.editor.state.doc.slice(fromB, toB).toString(); + + const completionBaseStartIndex = this.findCompletionStart(fromB); + + completionBase = this.editor.state.doc + .slice(completionBaseStartIndex, fromB - 1) + .toString() + .trim(); + }); + + const category = this.expressionExtensionsCategories[completion]; + + const payload = { + instance_id: this.rootStore.instanceId, + node_type: this.ndvStore.activeNode?.type, + field_name: parameterPath, + field_type: 'expression', + context: completionBase, + inserted_text: completion, + category: category ?? 'none', // only applicable for expression extension completion + }; + + this.$telemetry.track('User autocompleted code', payload); + }, + + findCompletionStart(fromIndex: number) { + if (!this.editor) return -1; + + const INDICATORS = [ + ' $', // proxy + '{ ', // primitive + ]; + + const doc = this.editor.state.doc.toString(); + + for (let index = fromIndex; index > 0; index--) { + if (INDICATORS.some((indicator) => indicator === doc[index] + doc[index + 1])) { + return index + 1; + } + } + + return -1; + }, + }, +}); diff --git a/packages/editor-ui/src/mixins/expressionManager.ts b/packages/editor-ui/src/mixins/expressionManager.ts index 3bd37823f9a76..7ae4dcb56cd90 100644 --- a/packages/editor-ui/src/mixins/expressionManager.ts +++ b/packages/editor-ui/src/mixins/expressionManager.ts @@ -1,8 +1,11 @@ import mixins from 'vue-typed-mixins'; import { mapStores } from 'pinia'; -import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'; +import { ensureSyntaxTree } from '@codemirror/language'; +import { EditorState } from '@codemirror/state'; +import { Completion } from '@codemirror/autocomplete'; import { workflowHelpers } from '@/mixins/workflowHelpers'; +import { n8nLang } from '@/plugins/codemirror/n8nLang'; import { useNDVStore } from '@/stores/ndv'; import { EXPRESSION_EDITOR_PARSER_TIMEOUT } from '@/constants'; @@ -10,7 +13,6 @@ import type { PropType } from 'vue'; import type { EditorView } from '@codemirror/view'; import type { TargetItem } from '@/Interface'; import type { Plaintext, RawSegment, Resolvable, Segment } from '@/types/expressions'; -import type { EditorState } from '@codemirror/state'; export const expressionManager = mixins(workflowHelpers).extend({ props: { @@ -21,7 +23,7 @@ export const expressionManager = mixins(workflowHelpers).extend({ data() { return { editor: null as EditorView | null, - errorsInSuccession: 0, + errorsInSuccession: 0, // @TODO: No longer used? }; }, watch: { @@ -63,6 +65,7 @@ export const expressionManager = mixins(workflowHelpers).extend({ return this.toSegments(this.editor.state); }, + // @TODO: No longer used? evaluationDelay() { const DEFAULT_EVALUATION_DELAY = 300; // ms @@ -139,10 +142,6 @@ export const expressionManager = mixins(workflowHelpers).extend({ }, }, methods: { - isEmptyExpression(resolvable: string) { - return /\{\{\s*\}\}/.test(resolvable); - }, - toSegments(state: EditorState) { const rawSegments: RawSegment[] = []; @@ -163,14 +162,11 @@ export const expressionManager = mixins(workflowHelpers).extend({ }); }); - // console.log('rawSegments', rawSegments); - return rawSegments.reduce((acc, segment) => { const { from, to, text, type } = segment; if (type === 'Resolvable') { const { resolved, error, fullError } = this.resolve(text, this.hoveringItem); - // console.log('text', text); acc.push({ kind: 'resolvable', from, to, resolvable: text, resolved, error, fullError }); @@ -183,6 +179,28 @@ export const expressionManager = mixins(workflowHelpers).extend({ }, []); }, + toPreviewSegments(completion: Completion, state: EditorState) { + if (!this.editor) return []; + + const cursorPosition = state.selection.ranges[0].from; + + const firstHalf = state.doc.slice(0, cursorPosition).toString(); + const secondHalf = state.doc.slice(cursorPosition, state.doc.length).toString(); + + const previewDoc = [ + firstHalf, + firstHalf.endsWith('$') ? completion.label.slice(1) : completion.label, + secondHalf, + ].join(''); + + const previewState = EditorState.create({ + doc: previewDoc, + extensions: [n8nLang()], + }); + + return this.toSegments(previewState); + }, + resolve(resolvable: string, targetItem?: TargetItem) { const result: { resolved: unknown; error: boolean; fullError: Error | null } = { resolved: undefined, @@ -207,7 +225,7 @@ export const expressionManager = mixins(workflowHelpers).extend({ result.resolved = this.$locale.baseText('expressionModalInput.empty'); } - if (result.resolved === undefined && this.isEmptyExpression(resolvable)) { + if (result.resolved === undefined && /\{\{\s*\}\}/.test(resolvable)) { result.resolved = this.$locale.baseText('expressionModalInput.empty'); } diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 833e59251eecb..a1e74c68a350d 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -31,16 +31,14 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul if (word.from === word.to && !context.explicit) return null; - // remove opening marker grabbed by `objectRegex`, @TODO: negative lookbehind instead + // remove opening marker grabbed by objectRegex if (word.text.startsWith('{{')) word.text = word.text.replace(/^{{/, ''); const toResolve = word.text.endsWith('.') ? word.text.slice(0, -1) : word.text.split('.').slice(0, -1).join('.'); - /** - * n8n vars that should not trigger datatype completions - */ + // n8n vars should not trigger datatype completions const SKIP_SET = new Set(['$execution', '$binary', '$itemIndex', '$now', '$today', '$runIndex']); if (SKIP_SET.has(toResolve)) return null; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts index 01f29012971c4..8fc04b965829a 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts @@ -58,7 +58,7 @@ export function proxyCompletions(context: CompletionContext): CompletionResult | } function generateOptions(toResolve: string, proxy: IDataObject, word: Word): Completion[] { - const SKIP_SET = new Set(['__ob__', 'pairedItem', 'context']); + const SKIP_SET = new Set(['__ob__', 'pairedItem']); const BOOSTED_KEYS = ['item']; if (word.text.includes('json[')) { From 15e54b653a78db524d802e5cb1d1be0662ceba43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 13 Jan 2023 20:52:29 +0100 Subject: [PATCH 021/160] :zap: Implement in expression editor modal --- .../src/components/ExpressionEdit.vue | 11 +++++ .../ExpressionEditorModalInput.vue | 47 ++++++++++++++----- .../completions/proxy.completions.ts | 6 +-- .../completions/root.completions.ts | 6 +-- packages/workflow/src/WorkflowDataProxy.ts | 2 +- 5 files changed, 52 insertions(+), 20 deletions(-) diff --git a/packages/editor-ui/src/components/ExpressionEdit.vue b/packages/editor-ui/src/components/ExpressionEdit.vue index 2baf3f9900d9c..ae1755f7c6dd0 100644 --- a/packages/editor-ui/src/components/ExpressionEdit.vue +++ b/packages/editor-ui/src/components/ExpressionEdit.vue @@ -46,6 +46,7 @@ import mixins from 'vue-typed-mixins'; import { EditorView } from '@codemirror/view'; -import { EditorState } from '@codemirror/state'; +import { EditorState, Prec } from '@codemirror/state'; import { history } from '@codemirror/commands'; import { workflowHelpers } from '@/mixins/workflowHelpers'; import { expressionManager } from '@/mixins/expressionManager'; +import { completionManager } from '@/mixins/completionManager'; import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler'; import { n8nLang } from '@/plugins/codemirror/n8nLang'; import { highlighter } from '@/plugins/codemirror/resolvableHighlighter'; import { inputTheme } from './theme'; - -import type { IVariableItemSelected } from '@/Interface'; import { forceParse } from '@/utils/forceParse'; -import { autocompletion } from '@codemirror/autocomplete'; +import { autocompletion, selectedCompletion } from '@codemirror/autocomplete'; +import type { IVariableItemSelected } from '@/Interface'; +import { completionEvaluationEventBus } from '@/event-bus/completion-evaluation-event-bus'; -export default mixins(expressionManager, workflowHelpers).extend({ +export default mixins(expressionManager, completionManager, workflowHelpers).extend({ name: 'ExpressionEditorModalInput', props: { value: { type: String, }, + path: { + type: String, + }, isReadOnly: { type: Boolean, }, @@ -37,7 +41,10 @@ export default mixins(expressionManager, workflowHelpers).extend({ mounted() { const extensions = [ inputTheme(), - autocompletion(), + Prec.highest(this.previewKeymap), + autocompletion({ + aboveCursor: true, + }), n8nLang(), history(), expressionInputHandler(), @@ -45,19 +52,33 @@ export default mixins(expressionManager, workflowHelpers).extend({ EditorState.readOnly.of(this.isReadOnly), EditorView.domEventHandlers({ scroll: forceParse }), EditorView.updateListener.of((viewUpdate) => { - if (!this.editor || !viewUpdate.docChanged) return; + if (!this.editor) return; + + const completion = selectedCompletion(this.editor.state); + + if (completion) { + const previewSegments = this.toPreviewSegments(completion, this.editor.state); + + completionEvaluationEventBus.$emit('preview-in-output', previewSegments); + + return; + } + + if (!viewUpdate.docChanged) return; highlighter.removeColor(this.editor, this.plaintextSegments); highlighter.addColor(this.editor, this.resolvableSegments); + try { + this.trackCompletion(viewUpdate, this.path); + } catch (_) {} + setTimeout(() => this.editor?.focus()); // prevent blur on paste - setTimeout(() => { - this.$emit('change', { - value: this.unresolvedExpression, - segments: this.displayableSegments, - }); - }, this.evaluationDelay); + this.$emit('change', { + value: this.unresolvedExpression, + segments: this.displayableSegments, + }); }), ]; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts index 8fc04b965829a..29e9551a1d898 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts @@ -59,7 +59,7 @@ export function proxyCompletions(context: CompletionContext): CompletionResult | function generateOptions(toResolve: string, proxy: IDataObject, word: Word): Completion[] { const SKIP_SET = new Set(['__ob__', 'pairedItem']); - const BOOSTED_KEYS = ['item']; + const BOOST_SET = new Set(['item', 'all', 'first', 'last']); if (word.text.includes('json[')) { return Object.keys(proxy.json as object) @@ -81,8 +81,8 @@ function generateOptions(toResolve: string, proxy: IDataObject, word: Word): Com return !SKIP_SET.has(key); }) .sort((a, b) => { - if (BOOSTED_KEYS.includes(a)) return -1; - if (BOOSTED_KEYS.includes(b)) return 1; + if (BOOST_SET.has(a)) return -1; + if (BOOST_SET.has(b)) return 1; return a.localeCompare(b); }) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts index 19f85667952af..d7d7fee4daadc 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts @@ -33,12 +33,12 @@ export function rootCompletions(context: CompletionContext): CompletionResult | } export function generateOptions() { - const BOOSTED_KEYS = ['$input', '$json']; + const BOOST_SET = new Set(['$input', '$json']); // @TODO: Add $parameter to i18n and remove here const rootKeys = [...Object.keys(i18n.rootVars), '$parameter'].sort((a, b) => { - if (BOOSTED_KEYS.includes(a)) return -1; - if (BOOSTED_KEYS.includes(b)) return 1; + if (BOOST_SET.has(a)) return -1; + if (BOOST_SET.has(b)) return 1; return a.localeCompare(b); }); diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index 53cfc5f3fa996..9510f65540608 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -1069,7 +1069,7 @@ export class WorkflowDataProxy { {}, { ownKeys(target) { - return ['all', 'context', 'first', 'item', 'last', 'params']; + return ['item', 'all', 'first', 'last', 'params', 'context']; }, getOwnPropertyDescriptor(k) { return { From 9a230bd40ec5585591d78beab004bd74820f6d51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 13 Jan 2023 21:29:07 +0100 Subject: [PATCH 022/160] :pencil2: Improve naming --- .../src/components/ExpressionEdit.vue | 6 +++--- .../ExpressionEditorModalInput.vue | 4 ++-- .../components/ExpressionParameterInput.vue | 6 +++--- .../InlineExpressionEditorInput.vue | 18 +++++------------- .../completion-evaluation-event-bus.ts | 3 --- .../event-bus/completion-preview-event-bus.ts | 3 +++ .../editor-ui/src/mixins/completionManager.ts | 6 +++--- packages/workflow/src/Expression.ts | 4 +++- 8 files changed, 22 insertions(+), 28 deletions(-) delete mode 100644 packages/editor-ui/src/event-bus/completion-evaluation-event-bus.ts create mode 100644 packages/editor-ui/src/event-bus/completion-preview-event-bus.ts diff --git a/packages/editor-ui/src/components/ExpressionEdit.vue b/packages/editor-ui/src/components/ExpressionEdit.vue index ae1755f7c6dd0..9c5eac294d326 100644 --- a/packages/editor-ui/src/components/ExpressionEdit.vue +++ b/packages/editor-ui/src/components/ExpressionEdit.vue @@ -91,7 +91,7 @@ import { mapStores } from 'pinia'; import { useWorkflowsStore } from '@/stores/workflows'; import { useNDVStore } from '@/stores/ndv'; import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils'; -import { completionEvaluationEventBus } from '@/event-bus/completion-evaluation-event-bus'; +import { completionPreviewEventBus } from '@/event-bus/completion-preview-event-bus'; import type { Segment } from '@/types/expressions'; @@ -112,10 +112,10 @@ export default mixins(externalHooks, genericHelpers, debounceHelper).extend({ }; }, mounted() { - completionEvaluationEventBus.$on('preview-in-output', this.previewSegments); + completionPreviewEventBus.$on('preview-completion', this.previewSegments); }, destroyed() { - completionEvaluationEventBus.$off('preview-in-output', this.previewSegments); + completionPreviewEventBus.$off('preview-completion', this.previewSegments); }, computed: { ...mapStores(useNDVStore, useWorkflowsStore), diff --git a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue index 1ad1754d39ff7..96315398cfb83 100644 --- a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue +++ b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue @@ -17,8 +17,8 @@ import { highlighter } from '@/plugins/codemirror/resolvableHighlighter'; import { inputTheme } from './theme'; import { forceParse } from '@/utils/forceParse'; import { autocompletion, selectedCompletion } from '@codemirror/autocomplete'; +import { completionPreviewEventBus } from '@/event-bus/completion-preview-event-bus'; import type { IVariableItemSelected } from '@/Interface'; -import { completionEvaluationEventBus } from '@/event-bus/completion-evaluation-event-bus'; export default mixins(expressionManager, completionManager, workflowHelpers).extend({ name: 'ExpressionEditorModalInput', @@ -59,7 +59,7 @@ export default mixins(expressionManager, completionManager, workflowHelpers).ext if (completion) { const previewSegments = this.toPreviewSegments(completion, this.editor.state); - completionEvaluationEventBus.$emit('preview-in-output', previewSegments); + completionPreviewEventBus.$emit('preview-completion', previewSegments); return; } diff --git a/packages/editor-ui/src/components/ExpressionParameterInput.vue b/packages/editor-ui/src/components/ExpressionParameterInput.vue index 8d5dfbf4ffc65..08646a00d073a 100644 --- a/packages/editor-ui/src/components/ExpressionParameterInput.vue +++ b/packages/editor-ui/src/components/ExpressionParameterInput.vue @@ -75,7 +75,7 @@ import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/In import ExpressionFunctionIcon from '@/components/ExpressionFunctionIcon.vue'; import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils'; import { EXPRESSIONS_DOCS_URL } from '@/constants'; -import { completionEvaluationEventBus } from '@/event-bus/completion-evaluation-event-bus'; +import { completionPreviewEventBus } from '@/event-bus/completion-preview-event-bus'; import type { Segment } from '@/types/expressions'; import type { TargetItem } from '@/Interface'; @@ -111,10 +111,10 @@ export default Vue.extend({ }, }, mounted() { - completionEvaluationEventBus.$on('preview-in-output', this.previewSegments); + completionPreviewEventBus.$on('preview-completion', this.previewSegments); }, destroyed() { - completionEvaluationEventBus.$off('preview-in-output', this.previewSegments); + completionPreviewEventBus.$off('preview-completion', this.previewSegments); }, computed: { ...mapStores(useNDVStore, useWorkflowsStore), diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue index 9101e541c5607..f567208838e6c 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue +++ b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue @@ -5,18 +5,10 @@ diff --git a/packages/editor-ui/src/mixins/completionManager.ts b/packages/editor-ui/src/mixins/completionManager.ts index 8713eec0dd235..a5cff4ea1002d 100644 --- a/packages/editor-ui/src/mixins/completionManager.ts +++ b/packages/editor-ui/src/mixins/completionManager.ts @@ -32,6 +32,16 @@ export const completionManager = mixins(expressionManager).extend({ }, previewKeymap(): Extension { return keymap.of([ + { + any(view: EditorView, event: KeyboardEvent) { + if (event.key === 'Escape' && completionStatus(view.state) !== null) { + // prevent completions dismissal from also closing modal + event.stopPropagation(); + } + + return false; + }, + }, { key: 'Escape', run: (view) => { From 892d62e21ea8de94cbf95de9a8ea6f81bbb2f3a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Sat, 14 Jan 2023 18:09:49 +0100 Subject: [PATCH 032/160] :zap: Parse Unicode --- .../editor-ui/src/mixins/expressionManager.ts | 6 +- .../src/plugins/codemirror/n8nLang.ts | 2 +- .../codemirror/parser-with-unicode/index.cjs | 57 +++++++++++++++++++ .../parser-with-unicode/index.d.cts | 5 ++ .../codemirror/parser-with-unicode/index.d.ts | 5 ++ .../codemirror/parser-with-unicode/index.js | 51 +++++++++++++++++ packages/editor-ui/src/types/expressions.ts | 2 +- 7 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.cjs create mode 100644 packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.d.cts create mode 100644 packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.d.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.js diff --git a/packages/editor-ui/src/mixins/expressionManager.ts b/packages/editor-ui/src/mixins/expressionManager.ts index e3085058c09d5..eb4ec9326c192 100644 --- a/packages/editor-ui/src/mixins/expressionManager.ts +++ b/packages/editor-ui/src/mixins/expressionManager.ts @@ -159,14 +159,14 @@ export const expressionManager = mixins(workflowHelpers).extend({ from: node.from, to: node.to, text: state.sliceDoc(node.from, node.to), - type: node.type.name, + token: node.type.name, }); }); return rawSegments.reduce((acc, segment) => { - const { from, to, text, type } = segment; + const { from, to, text, token } = segment; - if (type === 'plaintext') { + if (token === 'Plaintext') { return acc.push({ kind: 'plaintext', from, to, plaintext: text }), acc; } diff --git a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts index 916286a3a3abd..f171e449ac9c3 100644 --- a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts +++ b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts @@ -1,4 +1,4 @@ -import { parserWithMetaData as n8nParser } from 'codemirror-lang-n8n-expression'; +import { parserWithMetaData as n8nParser } from './parser-with-unicode'; // @TODO: Update lib import { LanguageSupport, LRLanguage } from '@codemirror/language'; import { parseMixed } from '@lezer/common'; import { javascriptLanguage } from '@codemirror/lang-javascript'; diff --git a/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.cjs b/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.cjs new file mode 100644 index 0000000000000..136fa8aac9165 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.cjs @@ -0,0 +1,57 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +var autocomplete = require('@codemirror/autocomplete'); +var lr = require('@lezer/lr'); +var language = require('@codemirror/language'); +var highlight = require('@lezer/highlight'); + +// This file was generated by lezer-generator. You probably shouldn't edit it. +const parser = lr.LRParser.deserialize({ + version: 14, + states: "nQQOPOOOOOO'#Cc'#CcOOOO'#Ca'#CaQQOPOOOOOO-E6_-E6_", + stateData: "]~OQPORPOSPO~O", + goto: "cWPPPPPXP_QRORSRTQOR", + nodeNames: "⚠ Program Plaintext Resolvable BrokenResolvable", + maxTerm: 7, + skippedNodes: [0], + repeatNodeCount: 1, + tokenData: "&U~RRO#o[#o#p{#p~[~aRQ~O#o[#o#pj#p~[~mRO#o[#p~[~~v~{OQ~~!OSO#o[#o#p![#p~[~~v~!aUS~O#O![#O#P!s#P#q![#q#r%v#rG|![G|~%c~!xUS~O#O![#O#P!s#P#q![#q#r#[#rG|![G|~%c~#aRS~O#q#j#q#r$l#r~#j~#mTO#O#j#O#P#|#P#q#j#q#r%Q#rG|#j~$PTO#O#j#O#P#|#P#q#j#q#r$`#rG|#j~$cRO#q#j#q#r$l#r~#j~$qTR~O#O#j#O#P#|#P#q#j#q#r%Q#rG|#j~%TRO#q#j#q#r%^#r~#j~%cOR~~%hRS~O#q%c#q#r%q#r~%c~%vOS~~%{RS~O#q#j#q#r%^#r~#j", + tokenizers: [0], + topRules: {"Program":[0,1]}, + tokenPrec: 0 +}); + +const parserWithMetaData = parser.configure({ + props: [ + language.foldNodeProp.add({ + Application: language.foldInside, + }), + highlight.styleTags({ + OpenMarker: highlight.tags.brace, + CloseMarker: highlight.tags.brace, + Plaintext: highlight.tags.content, + Resolvable: highlight.tags.string, + BrokenResolvable: highlight.tags.className, + }), + ], +}); +const n8nLanguage = language.LRLanguage.define({ + parser: parserWithMetaData, + languageData: { + commentTokens: { line: ";" }, + }, +}); +const completions = n8nLanguage.data.of({ + autocomplete: autocomplete.completeFromList([ + // { label: "test", type: "keyword" }, // to add in future + ]), +}); +function n8nExpression() { + return new language.LanguageSupport(n8nLanguage, [completions]); +} + +exports.n8nExpression = n8nExpression; +exports.n8nLanguage = n8nLanguage; +exports.parserWithMetaData = parserWithMetaData; diff --git a/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.d.cts b/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.d.cts new file mode 100644 index 0000000000000..961b8c8fca186 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.d.cts @@ -0,0 +1,5 @@ +import { LRLanguage, LanguageSupport } from "@codemirror/language"; +declare const parserWithMetaData: import("@lezer/lr").LRParser; +declare const n8nLanguage: LRLanguage; +declare function n8nExpression(): LanguageSupport; +export { parserWithMetaData, n8nLanguage, n8nExpression }; diff --git a/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.d.ts b/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.d.ts new file mode 100644 index 0000000000000..961b8c8fca186 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.d.ts @@ -0,0 +1,5 @@ +import { LRLanguage, LanguageSupport } from "@codemirror/language"; +declare const parserWithMetaData: import("@lezer/lr").LRParser; +declare const n8nLanguage: LRLanguage; +declare function n8nExpression(): LanguageSupport; +export { parserWithMetaData, n8nLanguage, n8nExpression }; diff --git a/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.js b/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.js new file mode 100644 index 0000000000000..f62765e8cce65 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.js @@ -0,0 +1,51 @@ +import { completeFromList } from '@codemirror/autocomplete'; +import { LRParser } from '@lezer/lr'; +import { foldNodeProp, foldInside, LRLanguage, LanguageSupport } from '@codemirror/language'; +import { styleTags, tags } from '@lezer/highlight'; + +// This file was generated by lezer-generator. You probably shouldn't edit it. +const parser = LRParser.deserialize({ + version: 14, + states: "nQQOPOOOOOO'#Cc'#CcOOOO'#Ca'#CaQQOPOOOOOO-E6_-E6_", + stateData: "]~OQPORPOSPO~O", + goto: "cWPPPPPXP_QRORSRTQOR", + nodeNames: "⚠ Program Plaintext Resolvable BrokenResolvable", + maxTerm: 7, + skippedNodes: [0], + repeatNodeCount: 1, + tokenData: "&U~RRO#o[#o#p{#p~[~aRQ~O#o[#o#pj#p~[~mRO#o[#p~[~~v~{OQ~~!OSO#o[#o#p![#p~[~~v~!aUS~O#O![#O#P!s#P#q![#q#r%v#rG|![G|~%c~!xUS~O#O![#O#P!s#P#q![#q#r#[#rG|![G|~%c~#aRS~O#q#j#q#r$l#r~#j~#mTO#O#j#O#P#|#P#q#j#q#r%Q#rG|#j~$PTO#O#j#O#P#|#P#q#j#q#r$`#rG|#j~$cRO#q#j#q#r$l#r~#j~$qTR~O#O#j#O#P#|#P#q#j#q#r%Q#rG|#j~%TRO#q#j#q#r%^#r~#j~%cOR~~%hRS~O#q%c#q#r%q#r~%c~%vOS~~%{RS~O#q#j#q#r%^#r~#j", + tokenizers: [0], + topRules: {"Program":[0,1]}, + tokenPrec: 0 +}); + +const parserWithMetaData = parser.configure({ + props: [ + foldNodeProp.add({ + Application: foldInside, + }), + styleTags({ + OpenMarker: tags.brace, + CloseMarker: tags.brace, + Plaintext: tags.content, + Resolvable: tags.string, + BrokenResolvable: tags.className, + }), + ], +}); +const n8nLanguage = LRLanguage.define({ + parser: parserWithMetaData, + languageData: { + commentTokens: { line: ";" }, + }, +}); +const completions = n8nLanguage.data.of({ + autocomplete: completeFromList([ + // { label: "test", type: "keyword" }, // to add in future + ]), +}); +function n8nExpression() { + return new LanguageSupport(n8nLanguage, [completions]); +} + +export { n8nExpression, n8nLanguage, parserWithMetaData }; diff --git a/packages/editor-ui/src/types/expressions.ts b/packages/editor-ui/src/types/expressions.ts index 00efc61e34b4e..6676fcf81ff61 100644 --- a/packages/editor-ui/src/types/expressions.ts +++ b/packages/editor-ui/src/types/expressions.ts @@ -1,6 +1,6 @@ type Range = { from: number; to: number }; -export type RawSegment = { text: string; type: string } & Range; +export type RawSegment = { text: string; token: string } & Range; export type Segment = Plaintext | Resolvable; From 33f968c6217c28307b91326aae1d7fcc139eac8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Sat, 14 Jan 2023 19:57:43 +0100 Subject: [PATCH 033/160] :zap: Throw on invalid `DateTime` --- packages/workflow/src/Expression.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/workflow/src/Expression.ts b/packages/workflow/src/Expression.ts index c991373ea27c9..6902a11a08c8c 100644 --- a/packages/workflow/src/Expression.ts +++ b/packages/workflow/src/Expression.ts @@ -56,6 +56,11 @@ export class Expression { */ convertObjectValueToString(value: object): string { const typeName = Array.isArray(value) ? 'Array' : 'Object'; + + if (value instanceof DateTime && value.invalidReason !== undefined) { + throw new Error('invalid DateTime'); + } + const result = JSON.stringify(value) .replace(/,"/g, ', "') // spacing for .replace(/":/g, '": '); // readability From 53331f5fd8830e2096d68e0eef9203f39af1cee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Sat, 14 Jan 2023 19:58:45 +0100 Subject: [PATCH 034/160] :zap: Fix second root completion detection --- .../src/plugins/codemirror/completions/root.completions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts index 8626811ee4dd2..2c42d7c83a509 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts @@ -6,7 +6,7 @@ import type { Completion, CompletionContext, CompletionResult } from '@codemirro * Completions from `$` to proxies. */ export function rootCompletions(context: CompletionContext): CompletionResult | null { - const word = context.matchBefore(/\$\w*[^.]*/); + const word = context.matchBefore(/\$\w*[^.}]*/); if (!word) return null; From 97abc86c0f02b4baa9ced6d5e6c65912c6098b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 17 Jan 2023 17:47:44 +0100 Subject: [PATCH 035/160] :zap: Switch message at completable prefix position --- .../editor-ui/src/mixins/expressionManager.ts | 37 +++++++++++++++++++ packages/editor-ui/src/plugins/i18n/index.ts | 2 + .../src/plugins/i18n/locales/en.json | 2 + 3 files changed, 41 insertions(+) diff --git a/packages/editor-ui/src/mixins/expressionManager.ts b/packages/editor-ui/src/mixins/expressionManager.ts index eb4ec9326c192..0cea052ed7c24 100644 --- a/packages/editor-ui/src/mixins/expressionManager.ts +++ b/packages/editor-ui/src/mixins/expressionManager.ts @@ -5,10 +5,12 @@ import { ensureSyntaxTree } from '@codemirror/language'; import { EditorState } from '@codemirror/state'; import { Completion } from '@codemirror/autocomplete'; +import { i18n } from '@/plugins/i18n'; import { workflowHelpers } from '@/mixins/workflowHelpers'; import { n8nLang } from '@/plugins/codemirror/n8nLang'; import { useNDVStore } from '@/stores/ndv'; import { EXPRESSION_EDITOR_PARSER_TIMEOUT } from '@/constants'; +import { completionPreviewEventBus } from '@/event-bus/completion-preview-event-bus'; import type { PropType } from 'vue'; import type { EditorView } from '@codemirror/view'; @@ -28,6 +30,10 @@ export const expressionManager = mixins(workflowHelpers).extend({ }; }, watch: { + isCursorAtCompletablePrefix() { + // @TODO: Overrides output but is not a preview, so improve naming + completionPreviewEventBus.$emit('preview-completion', this.segments); + }, targetItem() { setTimeout(() => { this.$emit('change', { @@ -66,6 +72,25 @@ export const expressionManager = mixins(workflowHelpers).extend({ return this.toSegments(this.editor.state); }, + cursorPosition(): number { + if (!this.editor) return -1; + + return this.editor.state.selection.ranges[0].from; + }, + + /** + * Whether cursor position is at `{{ $| }}`. + */ + isCursorAtCompletablePrefix(): boolean { + if (!this.editor) return false; + + return ( + this.editor.state.doc + .slice(this.cursorPosition - '{{ $'.length, this.cursorPosition + ' }}'.length) + .toString() === '{{ $ }}' + ); + }, + // @TODO: No longer used? evaluationDelay() { const DEFAULT_EVALUATION_DELAY = 300; // ms @@ -190,9 +215,21 @@ export const expressionManager = mixins(workflowHelpers).extend({ resolved = [hint, resultWithCall.resolved].join(' '); error = false; fullError = null; + } else { + fullError = new Error(i18n.expressionEditor.previewUnavailable); + resolved = fullError.message; } } + if ( + this.isCursorAtCompletablePrefix && + hasErrorCode(fullError) && + fullError.cause.code === ERROR_CODES.N8N_PREFIX + ) { + fullError = new Error(i18n.expressionEditor.completablePrefix); + resolved = fullError.message; + } + acc.push({ kind: 'resolvable', from, to, resolvable: text, resolved, error, fullError }); return acc; diff --git a/packages/editor-ui/src/plugins/i18n/index.ts b/packages/editor-ui/src/plugins/i18n/index.ts index 86e7746709e7c..b4e27c8572d51 100644 --- a/packages/editor-ui/src/plugins/i18n/index.ts +++ b/packages/editor-ui/src/plugins/i18n/index.ts @@ -327,7 +327,9 @@ export class I18nClass { } expressionEditor: Record = { + completablePrefix: this.baseText('expressionEditor.completablePrefix'), previewHint: this.baseText('expressionEditor.previewHint'), + previewUnavailable: this.baseText('expressionEditor.previewUnavailable'), }; rootVars: Record = { diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 074fe04e5946f..0d6c08f08e5c2 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -496,7 +496,9 @@ "expressionEdit.expression": "Expression", "expressionEdit.resultOfItem1": "Result of item 1", "expressionEdit.variableSelector": "Variable Selector", + "expressionEditor.completablePrefix": "[This is an n8n prefix, please press ctrl+space]", "expressionEditor.previewHint": "[if called:]", + "expressionEditor.previewUnavailable": "[requires arg, preview unavailable]", "expressionModalInput.empty": "[empty]", "expressionModalInput.undefined": "[undefined]", "expressionModalInput.null": "null", From fbbd0bc582e905bd1867bcf165ced9fb935050fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 18 Jan 2023 11:34:17 +0100 Subject: [PATCH 036/160] :bug: Fix function names for non-dev build --- .../codemirror/completions/datatype.completions.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index a1e74c68a350d..3de468a4907a6 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -27,6 +27,8 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul const word = context.matchBefore(combinedRegex); + console.log('word', word); + if (!word) return null; if (word.from === word.to && !context.explicit) return null; @@ -105,12 +107,12 @@ const extensionOptions = (typeName: 'String' | 'Number' | 'Date' | 'Object' | 'A if (!extensions) return []; - const options = Object.values(extensions.functions) - .filter((f) => f.length === 1) // @TEMP Filter out functions needing args until documented - .sort((a, b) => a.name.localeCompare(b.name)) - .map((f) => { + const options = Object.entries(extensions.functions) + .filter(([name, f]) => f.length === 1) // @TEMP Filter out functions needing args until documented + .sort((a, b) => a[0].localeCompare(a[0])) + .map(([name, f]) => { const option: Completion = { - label: f.name, + label: name, type: 'function', }; From 0f8f72653fa0110f4f85214959f9566c914de443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 18 Jan 2023 12:45:37 +0100 Subject: [PATCH 037/160] :bug: Fix `json` handling --- .../plugins/codemirror/completions/datatype.completions.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 3de468a4907a6..b42d73b8a21a0 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -27,8 +27,6 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul const word = context.matchBefore(combinedRegex); - console.log('word', word); - if (!word) return null; if (word.from === word.to && !context.explicit) return null; @@ -82,7 +80,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul let userInputTail = ''; - const delimiter = word.text.includes('json[') ? 'json[' : '.'; + const delimiter = word.text.includes('.json[') ? 'json[' : '.'; userInputTail = word.text.split(delimiter).pop() as string; From cba7f56668876459cc299b268d21db6118b35bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 18 Jan 2023 16:56:23 +0100 Subject: [PATCH 038/160] :fire: Comment out previews --- .../ExpressionEditorModalInput.vue | 14 ++--- .../InlineExpressionEditorInput.vue | 12 ++--- .../editor-ui/src/mixins/completionManager.ts | 42 +++++++-------- .../editor-ui/src/mixins/expressionManager.ts | 39 +++++++------- .../codemirror/resolvableHighlighter.ts | 52 +++++++++---------- packages/editor-ui/src/plugins/i18n/index.ts | 2 +- .../src/plugins/i18n/locales/en.json | 1 - 7 files changed, 81 insertions(+), 81 deletions(-) diff --git a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue index 96315398cfb83..18c6201361631 100644 --- a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue +++ b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue @@ -17,7 +17,7 @@ import { highlighter } from '@/plugins/codemirror/resolvableHighlighter'; import { inputTheme } from './theme'; import { forceParse } from '@/utils/forceParse'; import { autocompletion, selectedCompletion } from '@codemirror/autocomplete'; -import { completionPreviewEventBus } from '@/event-bus/completion-preview-event-bus'; +// import { completionPreviewEventBus } from '@/event-bus/completion-preview-event-bus'; import type { IVariableItemSelected } from '@/Interface'; export default mixins(expressionManager, completionManager, workflowHelpers).extend({ @@ -54,15 +54,15 @@ export default mixins(expressionManager, completionManager, workflowHelpers).ext EditorView.updateListener.of((viewUpdate) => { if (!this.editor) return; - const completion = selectedCompletion(this.editor.state); + // const completion = selectedCompletion(this.editor.state); - if (completion) { - const previewSegments = this.toPreviewSegments(completion, this.editor.state); + // if (completion) { + // const previewSegments = this.toPreviewSegments(completion, this.editor.state); - completionPreviewEventBus.$emit('preview-completion', previewSegments); + // completionPreviewEventBus.$emit('preview-completion', previewSegments); - return; - } + // return; + // } if (!viewUpdate.docChanged) return; diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue index e5a9a8e800199..350a9e98e4cee 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue +++ b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue @@ -22,7 +22,7 @@ import { highlighter } from '@/plugins/codemirror/resolvableHighlighter'; import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler'; import { inputTheme } from './theme'; import { n8nLang } from '@/plugins/codemirror/n8nLang'; -import { completionPreviewEventBus } from '@/event-bus/completion-preview-event-bus'; +// import { completionPreviewEventBus } from '@/event-bus/completion-preview-event-bus'; import { completionManager } from '@/mixins/completionManager'; export default mixins(completionManager, expressionManager, workflowHelpers).extend({ @@ -94,13 +94,13 @@ export default mixins(completionManager, expressionManager, workflowHelpers).ext const completion = selectedCompletion(this.editor.state); - if (completion) { - const previewSegments = this.toPreviewSegments(completion, this.editor.state); + // if (completion) { + // const previewSegments = this.toPreviewSegments(completion, this.editor.state); - completionPreviewEventBus.$emit('preview-completion', previewSegments); + // completionPreviewEventBus.$emit('preview-completion', previewSegments); - return; - } + // return; + // } if (!viewUpdate.docChanged) return; diff --git a/packages/editor-ui/src/mixins/completionManager.ts b/packages/editor-ui/src/mixins/completionManager.ts index a5cff4ea1002d..9db0d7a8d7fc4 100644 --- a/packages/editor-ui/src/mixins/completionManager.ts +++ b/packages/editor-ui/src/mixins/completionManager.ts @@ -7,7 +7,7 @@ import { selectedCompletionIndex, } from '@codemirror/autocomplete'; -import { completionPreviewEventBus } from '@/event-bus/completion-preview-event-bus'; +// import { completionPreviewEventBus } from '@/event-bus/completion-preview-event-bus'; import { expressionManager } from './expressionManager'; import type { Extension } from '@codemirror/state'; @@ -55,34 +55,34 @@ export const completionManager = mixins(expressionManager).extend({ return false; }, }, - { - key: 'ArrowUp', - run: (view) => { - const completion = this.getCompletion('previous'); + // { + // key: 'ArrowUp', + // run: (view) => { + // const completion = this.getCompletion('previous'); - if (completion === null) return false; + // if (completion === null) return false; - const previewSegments = this.toPreviewSegments(completion, view.state); + // const previewSegments = this.toPreviewSegments(completion, view.state); - completionPreviewEventBus.$emit('preview-completion', previewSegments); + // completionPreviewEventBus.$emit('preview-completion', previewSegments); - return false; - }, - }, - { - key: 'ArrowDown', - run: (view) => { - const completion = this.getCompletion('next'); + // return false; + // }, + // }, + // { + // key: 'ArrowDown', + // run: (view) => { + // const completion = this.getCompletion('next'); - if (completion === null) return false; + // if (completion === null) return false; - const previewSegments = this.toPreviewSegments(completion, view.state); + // const previewSegments = this.toPreviewSegments(completion, view.state); - completionPreviewEventBus.$emit('preview-completion', previewSegments); + // completionPreviewEventBus.$emit('preview-completion', previewSegments); - return false; - }, - }, + // return false; + // }, + // }, ]); }, }, diff --git a/packages/editor-ui/src/mixins/expressionManager.ts b/packages/editor-ui/src/mixins/expressionManager.ts index 0cea052ed7c24..79431fb71dd29 100644 --- a/packages/editor-ui/src/mixins/expressionManager.ts +++ b/packages/editor-ui/src/mixins/expressionManager.ts @@ -195,31 +195,32 @@ export const expressionManager = mixins(workflowHelpers).extend({ return acc.push({ kind: 'plaintext', from, to, plaintext: text }), acc; } + // eslint-disable-next-line prefer-const let { resolved, error, fullError } = this.resolve(text, this.hoveringItem); /** * If this is a preview of an uncalled function, call it and display it * with a hint `[if called:] [result]` if the call succeeds */ - if ( - isPreview && - hasErrorCode(fullError) && - fullError.cause.code === ERROR_CODES.UNCALLED_FUNCTION - ) { - const textWithCall = text.replace(/\s{1}}}$/, '() }}'); // @TODO: Improve this replacement - const resultWithCall = this.resolve(textWithCall, this.hoveringItem); - - const hint = this.$locale.baseText('expressionEditor.previewHint'); - - if (!resultWithCall.error) { - resolved = [hint, resultWithCall.resolved].join(' '); - error = false; - fullError = null; - } else { - fullError = new Error(i18n.expressionEditor.previewUnavailable); - resolved = fullError.message; - } - } + // if ( + // isPreview && + // hasErrorCode(fullError) && + // fullError.cause.code === ERROR_CODES.UNCALLED_FUNCTION + // ) { + // const textWithCall = text.replace(/\s{1}}}$/, '() }}'); // @TODO: Improve this replacement + // const resultWithCall = this.resolve(textWithCall, this.hoveringItem); + + // const hint = this.$locale.baseText('expressionEditor.previewHint'); + + // if (!resultWithCall.error) { + // resolved = [hint, resultWithCall.resolved].join(' '); + // error = false; + // fullError = null; + // } else { + // fullError = new Error(i18n.expressionEditor.previewUnavailable); + // resolved = fullError.message; + // } + // } if ( this.isCursorAtCompletablePrefix && diff --git a/packages/editor-ui/src/plugins/codemirror/resolvableHighlighter.ts b/packages/editor-ui/src/plugins/codemirror/resolvableHighlighter.ts index d4cf9041d5c4d..0328e331abe16 100644 --- a/packages/editor-ui/src/plugins/codemirror/resolvableHighlighter.ts +++ b/packages/editor-ui/src/plugins/codemirror/resolvableHighlighter.ts @@ -12,7 +12,7 @@ const cssClasses = { invalidResolvable: 'cm-invalid-resolvable', brokenResolvable: 'cm-broken-resolvable', plaintext: 'cm-plaintext', - previewHint: 'cm-preview-hint', + // previewHint: 'cm-preview-hint', }; const resolvablesTheme = EditorView.theme({ @@ -24,15 +24,15 @@ const resolvablesTheme = EditorView.theme({ color: 'var(--color-invalid-resolvable-foreground)', backgroundColor: 'var(--color-invalid-resolvable-background)', }, - ['.' + cssClasses.previewHint]: { - fontWeight: 'bold', - }, + // ['.' + cssClasses.previewHint]: { + // fontWeight: 'bold', + // }, }); const marks = { valid: Decoration.mark({ class: cssClasses.validResolvable }), invalid: Decoration.mark({ class: cssClasses.invalidResolvable }), - previewHint: Decoration.mark({ class: cssClasses.previewHint }), + // previewHint: Decoration.mark({ class: cssClasses.previewHint }), }; const coloringStateEffects = { @@ -76,7 +76,7 @@ const coloringStateField = StateField.define({ const payload = [decoration.range(txEffect.value.from, txEffect.value.to)]; - stylePreviewHint(transaction, txEffect, payload); + // stylePreviewHint(transaction, txEffect, payload); if (txEffect.value.from === 0 && txEffect.value.to === 0) continue; @@ -88,26 +88,26 @@ const coloringStateField = StateField.define({ }, }); -function stylePreviewHint( - transaction: Transaction, - txEffect: StateEffect, - payload: Array>, -) { - if (txEffect.value.error) return; - - const validResolvableText = transaction.state.doc - .slice(txEffect.value.from, txEffect.value.to) - .toString(); - - if (validResolvableText.startsWith(i18n.expressionEditor.previewHint)) { - payload.push( - marks.previewHint.range( - txEffect.value.from, - txEffect.value.from + i18n.expressionEditor.previewHint.length, - ), - ); - } -} +// function stylePreviewHint( +// transaction: Transaction, +// txEffect: StateEffect, +// payload: Array>, +// ) { +// if (txEffect.value.error) return; + +// const validResolvableText = transaction.state.doc +// .slice(txEffect.value.from, txEffect.value.to) +// .toString(); + +// if (validResolvableText.startsWith(i18n.expressionEditor.previewHint)) { +// payload.push( +// marks.previewHint.range( +// txEffect.value.from, +// txEffect.value.from + i18n.expressionEditor.previewHint.length, +// ), +// ); +// } +// } function addColor(view: EditorView, segments: Array) { const effects: Array> = segments.map(({ from, to, kind, error }) => diff --git a/packages/editor-ui/src/plugins/i18n/index.ts b/packages/editor-ui/src/plugins/i18n/index.ts index b4e27c8572d51..fd8aca5d60bfc 100644 --- a/packages/editor-ui/src/plugins/i18n/index.ts +++ b/packages/editor-ui/src/plugins/i18n/index.ts @@ -328,7 +328,7 @@ export class I18nClass { expressionEditor: Record = { completablePrefix: this.baseText('expressionEditor.completablePrefix'), - previewHint: this.baseText('expressionEditor.previewHint'), + // previewHint: this.baseText('expressionEditor.previewHint'), previewUnavailable: this.baseText('expressionEditor.previewUnavailable'), }; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 0d6c08f08e5c2..11d87ae98a8df 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -497,7 +497,6 @@ "expressionEdit.resultOfItem1": "Result of item 1", "expressionEdit.variableSelector": "Variable Selector", "expressionEditor.completablePrefix": "[This is an n8n prefix, please press ctrl+space]", - "expressionEditor.previewHint": "[if called:]", "expressionEditor.previewUnavailable": "[requires arg, preview unavailable]", "expressionModalInput.empty": "[empty]", "expressionModalInput.undefined": "[undefined]", From 1abc05fe3997f4efd9fb4ff74f93552c57a6191d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 18 Jan 2023 20:33:59 +0100 Subject: [PATCH 039/160] :recycle: Apply feedback --- .../InlineExpressionEditorInput.vue | 4 +- .../editor-ui/src/mixins/expressionManager.ts | 30 +++++++++- .../completions/datatype.completions.ts | 60 +++++++++++++------ .../completions/luxon.completions.ts | 4 +- .../completions/proxy.completions.ts | 31 ++++++---- .../src/plugins/i18n/locales/en.json | 4 +- packages/workflow/src/ErrorCodes.ts | 2 +- packages/workflow/src/Expression.ts | 8 +-- 8 files changed, 101 insertions(+), 42 deletions(-) diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue index 350a9e98e4cee..57d18df98953a 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue +++ b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue @@ -76,9 +76,7 @@ export default mixins(completionManager, expressionManager, workflowHelpers).ext const extensions = [ inputTheme({ isSingleLine: this.isSingleLine }), Prec.highest(this.previewKeymap), - autocompletion({ - aboveCursor: true, - }), + autocompletion(), n8nLang(), history(), expressionInputHandler(), diff --git a/packages/editor-ui/src/mixins/expressionManager.ts b/packages/editor-ui/src/mixins/expressionManager.ts index 79431fb71dd29..9ea25d3cceee0 100644 --- a/packages/editor-ui/src/mixins/expressionManager.ts +++ b/packages/editor-ui/src/mixins/expressionManager.ts @@ -1,5 +1,8 @@ import mixins from 'vue-typed-mixins'; -import { EXPRESSION_RESOLUTION_ERROR_CODES as ERROR_CODES } from 'n8n-workflow'; +import { + ExpressionExtensions, + EXPRESSION_RESOLUTION_ERROR_CODES as ERROR_CODES, +} from 'n8n-workflow'; import { mapStores } from 'pinia'; import { ensureSyntaxTree } from '@codemirror/language'; import { EditorState } from '@codemirror/state'; @@ -116,6 +119,14 @@ export const expressionManager = mixins(workflowHelpers).extend({ return delay; }, + expressionExtensions() { + return new Set( + ExpressionExtensions.reduce((acc, cur) => { + return [...acc, ...Object.keys(cur.functions)]; + }, []), + ); + }, + /** * Some segments are conditionally displayed, i.e. not displayed when they are * _part_ of the result, but displayed when they are the _entire_ result. @@ -225,7 +236,7 @@ export const expressionManager = mixins(workflowHelpers).extend({ if ( this.isCursorAtCompletablePrefix && hasErrorCode(fullError) && - fullError.cause.code === ERROR_CODES.N8N_PREFIX + fullError.cause.code === ERROR_CODES.STANDALONE_PREFIX ) { fullError = new Error(i18n.expressionEditor.completablePrefix); resolved = fullError.message; @@ -259,6 +270,16 @@ export const expressionManager = mixins(workflowHelpers).extend({ return this.toSegments(previewState, { isPreview: true }); }, + isUncalledExpressionExtension(resolvable: string) { + const end = resolvable + .replace(/^{{|}}$/g, '') + .trim() + .split('.') + .pop(); + + return end && this.expressionExtensions.has(end); + }, + resolve(resolvable: string, targetItem?: TargetItem) { const result: { resolved: unknown; error: boolean; fullError: Error | null } = { resolved: undefined, @@ -288,7 +309,10 @@ export const expressionManager = mixins(workflowHelpers).extend({ } if (result.resolved === undefined) { - result.resolved = this.$locale.baseText('expressionModalInput.undefined'); + result.resolved = this.isUncalledExpressionExtension(resolvable) + ? this.$locale.baseText('expressionEditor.uncalledFunction') + : this.$locale.baseText('expressionModalInput.undefined'); + result.error = true; } diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index b42d73b8a21a0..392745492f7da 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -7,6 +7,10 @@ import type { Completion, CompletionContext, CompletionResult } from '@codemirro * Completions from datatypes to native JS methods (pending) and expression extensions. */ export function datatypeCompletions(context: CompletionContext): CompletionResult | null { + // ---------------------------------- + // match before cursor + // ---------------------------------- + const referenceRegex = /\$[\S]+\.(\w|\W)*/; // $input.item.json.name. const numberRegex = /(\d+)\.?(\d*)\.(\w|\W)*/; // 123. or 123.4. const stringRegex = /(".+"|('.+'))\.(\w|\W)*/; // 'abc'. or "abc". @@ -31,18 +35,30 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul if (word.from === word.to && !context.explicit) return null; - // remove opening marker grabbed by objectRegex + // cleanup - remove opener grabbed by objectRegex if (word.text.startsWith('{{')) word.text = word.text.replace(/^{{/, ''); - const toResolve = word.text.endsWith('.') - ? word.text.slice(0, -1) - : word.text.split('.').slice(0, -1).join('.'); + // ---------------------------------- + // find string to resolve + // ---------------------------------- + + const toResolve = + word.text.endsWith('.') || word.text.endsWith('json[') + ? word.text.slice(0, -1) + : word.text.split('.').slice(0, -1).join('.'); + + // ---------------------------------- + // skip exceptions + // ---------------------------------- - // n8n vars should not trigger datatype completions const SKIP_SET = new Set(['$execution', '$binary', '$itemIndex', '$now', '$today', '$runIndex']); if (SKIP_SET.has(toResolve)) return null; + // ---------------------------------- + // resolve and get options + // ---------------------------------- + let options: Completion[] = []; let resolved: IDataObject | null; @@ -59,30 +75,40 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul } else if (typeof resolved === 'string') { options = extensionOptions('String'); } else if (Array.isArray(resolved)) { + if (toResolve.endsWith('all()')) { + // exclude array proxy from array expression extensions + return null; + } options = extensionOptions('Array'); } else if (resolved instanceof Date) { options = extensionOptions('Date'); } else if ( typeof resolved === 'object' && + // exclude object proxies from object expression extensions !resolved.isProxy && !resolved.json && !toResolve.endsWith('json') && !toResolve.startsWith('{') && !toResolve.endsWith('}') ) { - /** - * Object expression extensions are _only_ completed for - * - bracketed object literals: `({}).` - * - referenced objects: `$input.item.json.myObj.` - */ - options = extensionOptions('Object'); + options = [ + ...Object.keys(resolved).map((key) => ({ label: key, type: 'keyword' })), + ...extensionOptions('Object'), + ]; + } else if (word.text.endsWith('json[')) { + options = Object.keys(resolved).map((key) => { + return { + label: `'${key}']`, + type: 'keyword', + }; + }); } - let userInputTail = ''; - - const delimiter = word.text.includes('.json[') ? 'json[' : '.'; + // ---------------------------------- + // filter by user input + // ---------------------------------- - userInputTail = word.text.split(delimiter).pop() as string; + const userInputTail = word.text.includes('json[') ? '' : word.text.split('.').pop() ?? ''; if (userInputTail !== '') { options = options.filter((o) => o.label.startsWith(userInputTail) && userInputTail !== o.label); @@ -106,8 +132,8 @@ const extensionOptions = (typeName: 'String' | 'Number' | 'Date' | 'Object' | 'A if (!extensions) return []; const options = Object.entries(extensions.functions) - .filter(([name, f]) => f.length === 1) // @TEMP Filter out functions needing args until documented - .sort((a, b) => a[0].localeCompare(a[0])) + .filter(([_, f]) => f.length === 1) // complete only argless functions until further notice + .sort((a, b) => a[0].localeCompare(b[0])) .map(([name, f]) => { const option: Completion = { label: name, diff --git a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts index 629b63b9ae272..90cd9d5ca7807 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts @@ -44,7 +44,7 @@ function generateOptions(toResolve: string): Completion[] { } export const nowTodayOptions = () => { - const SKIP_SET = new Set(['constructor', 'get']); + const SKIP_SET = new Set(['constructor', 'get', 'invalidExplanation', 'invalidReason']); const entries = Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype)) .filter(([key]) => !SKIP_SET.has(key)) @@ -67,7 +67,7 @@ export const nowTodayOptions = () => { }; export const dateTimeOptions = () => { - const SKIP_SET = new Set(['prototype', 'name', 'length']); + const SKIP_SET = new Set(['prototype', 'name', 'length', 'invalid']); const keys = Object.keys(Object.getOwnPropertyDescriptors(DateTime)) .filter((key) => !SKIP_SET.has(key) && !key.includes('_')) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts index dbdb56b7e7457..a72cb1dd95290 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts @@ -14,6 +14,10 @@ import type { Word } from '@/types/completions'; * Completions from proxies to their content. */ export function proxyCompletions(context: CompletionContext): CompletionResult | null { + // ---------------------------------- + // match before cursor + // ---------------------------------- + const word = context.matchBefore( /\$(input|\(.+\)|prevNode|parameter|json|execution|workflow)*(\.|\[)(\w|\W)*/, ); @@ -22,9 +26,18 @@ export function proxyCompletions(context: CompletionContext): CompletionResult | if (word.from === word.to && !context.explicit) return null; - const toResolve = word.text.endsWith('.') - ? word.text.slice(0, -1) - : word.text.split('.').slice(0, -1).join('.'); + // ---------------------------------- + // find string to resolve + // ---------------------------------- + + const toResolve = + word.text.endsWith('.') || word.text.endsWith('json[') + ? word.text.slice(0, -1) // $input. -> $input, or $input.item.json[ -> $input.item.json + : word.text.split('.').slice(0, -1).join('.'); // $input.item.json.ab -> $input.item.json + + // ---------------------------------- + // resolve and get options + // ---------------------------------- let options: Completion[] = []; @@ -33,18 +46,16 @@ export function proxyCompletions(context: CompletionContext): CompletionResult | if (!resolved || typeof resolved !== 'object' || Array.isArray(resolved)) return null; - // resolved to proxy - options = generateOptions(toResolve, resolved, word); } catch (_) { return null; } - let userInputTail = ''; - - const delimiter = word.text.includes('json[') ? 'json[' : '.'; + // ---------------------------------- + // filter by user input + // ---------------------------------- - userInputTail = word.text.split(delimiter).pop() as string; + const userInputTail = word.text.includes('json[') ? '' : word.text.split('.').pop() ?? ''; if (userInputTail !== '') { options = options.filter((o) => o.label.startsWith(userInputTail) && userInputTail !== o.label); @@ -68,7 +79,7 @@ function generateOptions(toResolve: string, proxy: IDataObject, word: Word): Com const BOOST_SET = new Set(['item', 'all', 'first', 'last']); if (word.text.includes('json[')) { - return Object.keys(proxy.json as object) + return Object.keys(word.text === '$json[' ? proxy : (proxy.json as object)) .filter((key) => !SKIP_SET.has(key)) .map((key) => { return { diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 11d87ae98a8df..8484aca42ba2b 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -496,8 +496,8 @@ "expressionEdit.expression": "Expression", "expressionEdit.resultOfItem1": "Result of item 1", "expressionEdit.variableSelector": "Variable Selector", - "expressionEditor.completablePrefix": "[This is an n8n prefix, please press ctrl+space]", - "expressionEditor.previewUnavailable": "[requires arg, preview unavailable]", + "expressionEditor.completablePrefix": "['$' is a prefix, press ctrl+space for autocomplete options]", + "expressionEditor.uncalledFunction": "[this is a function, please add ()]", "expressionModalInput.empty": "[empty]", "expressionModalInput.undefined": "[undefined]", "expressionModalInput.null": "null", diff --git a/packages/workflow/src/ErrorCodes.ts b/packages/workflow/src/ErrorCodes.ts index 1fb5a35187e26..2134889152d00 100644 --- a/packages/workflow/src/ErrorCodes.ts +++ b/packages/workflow/src/ErrorCodes.ts @@ -1,4 +1,4 @@ export const EXPRESSION_RESOLUTION_ERROR_CODES = { - N8N_PREFIX: 0, // $ + STANDALONE_PREFIX: 0, // $ UNCALLED_FUNCTION: 1, // $input.first } as const; diff --git a/packages/workflow/src/Expression.ts b/packages/workflow/src/Expression.ts index 6902a11a08c8c..a44f4f9c0591c 100644 --- a/packages/workflow/src/Expression.ts +++ b/packages/workflow/src/Expression.ts @@ -282,12 +282,12 @@ export class Expression { const returnValue = this.renderExpression(extendedExpression, data); if (typeof returnValue === 'function') { if (returnValue.name === '$') { - throw new Error('This is an n8n prefix, please open completions', { - cause: { code: EXPRESSION_RESOLUTION_ERROR_CODES.N8N_PREFIX }, + throw new Error('invalid syntax', { + cause: { code: EXPRESSION_RESOLUTION_ERROR_CODES.STANDALONE_PREFIX }, }); } - throw new Error('This is a function. Please add ()', { + throw new Error('this is a function, please add ()', { cause: { code: EXPRESSION_RESOLUTION_ERROR_CODES.UNCALLED_FUNCTION }, }); } else if (typeof returnValue === 'string') { @@ -342,7 +342,7 @@ export class Expression { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (!output?.code) { - throw new ExpressionExtensionError('Failed to extend syntax'); + throw new ExpressionExtensionError('invalid syntax'); } let text = output.code; From d9075d89ef7e2938256e8f38c47c7cb5b2fed608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 19 Jan 2023 11:41:01 +0100 Subject: [PATCH 040/160] :fire: Remove extensions --- .../src/Extensions/ArrayExtensions.ts | 27 +-------- .../workflow/src/Extensions/DateExtensions.ts | 60 ------------------- .../src/Extensions/NumberExtensions.ts | 17 ------ .../src/Extensions/StringExtensions.ts | 15 ----- .../ArrayExtensions.test.ts | 23 ------- .../DateExtensions.test.ts | 5 -- .../NumberExtensions.test.ts | 20 ------- .../StringExtensions.test.ts | 42 ------------- 8 files changed, 2 insertions(+), 207 deletions(-) diff --git a/packages/workflow/src/Extensions/ArrayExtensions.ts b/packages/workflow/src/Extensions/ArrayExtensions.ts index 6ff73d516c80c..2cb4fca7686a1 100644 --- a/packages/workflow/src/Extensions/ArrayExtensions.ts +++ b/packages/workflow/src/Extensions/ArrayExtensions.ts @@ -121,7 +121,7 @@ function pluck(value: unknown[], extraArgs: unknown[]): unknown[] { }) as unknown[]; } -function random(value: unknown[]): unknown { +function randomItem(value: unknown[]): unknown { const len = value === undefined ? 0 : value.length; return len ? value[Math.floor(Math.random() * len)] : undefined; } @@ -241,26 +241,6 @@ function chunk(value: unknown[], extraArgs: number[]) { return chunks; } -function filter(value: unknown[], extraArgs: unknown[]): unknown[] { - const [field, term] = extraArgs as [string | (() => void), unknown | string]; - if (typeof field !== 'string' && typeof field !== 'function') { - throw new ExpressionExtensionError( - 'filter requires 1 or 2 arguments: (field and term), (term and [optional keepOrRemove "keep" or "remove" default "keep"] (for string arrays)), or function. e.g. .filter("type", "home") or .filter((i) => i.type === "home") or .filter("home", [optional keepOrRemove]) (for string arrays)', - ); - } - if (value.every((i) => typeof i === 'string') && typeof field === 'string') { - return (value as string[]).filter((i) => - term === 'remove' ? !i.includes(field) : i.includes(field), - ); - } else if (typeof field === 'string') { - return value.filter( - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any - (v) => typeof v === 'object' && v !== null && field in v && (v as any)[field] === term, - ); - } - return value.filter(field); -} - function renameKeys(value: unknown[], extraArgs: string[]): unknown[] { if (extraArgs.length === 0 || extraArgs.length % 2 !== 0) { throw new ExpressionExtensionError( @@ -369,15 +349,12 @@ export const arrayExtensions: ExtensionMap = { functions: { count: length, duplicates: unique, - filter, first, last, length, pluck, unique, - random, - randomItem: random, - remove: unique, + randomItem, size: length, sum, min, diff --git a/packages/workflow/src/Extensions/DateExtensions.ts b/packages/workflow/src/Extensions/DateExtensions.ts index 6c6d0c2b78eaa..5a58ec023ac41 100644 --- a/packages/workflow/src/Extensions/DateExtensions.ts +++ b/packages/workflow/src/Extensions/DateExtensions.ts @@ -4,7 +4,6 @@ import { DateTime, DateTimeFormatOptions, DateTimeUnit, - Duration, DurationLike, DurationObjectUnits, LocaleOptions, @@ -200,62 +199,6 @@ function toLocaleString(date: Date | DateTime, extraArgs: unknown[]): string { return DateTime.fromJSDate(date).toLocaleString(dateFormat, { locale }); } -function toTimeFromNow(date: Date): string { - let diffObj: Duration; - if (isDateTime(date)) { - diffObj = date.diffNow(); - } else { - diffObj = DateTime.fromJSDate(date).diffNow(); - } - - const as = (unit: DurationUnit) => { - return Math.round(Math.abs(diffObj.as(unit))); - }; - - if (as('years')) { - return `${as('years')} years ago`; - } - if (as('months')) { - return `${as('months')} months ago`; - } - if (as('weeks')) { - return `${as('weeks')} weeks ago`; - } - if (as('days')) { - return `${as('days')} days ago`; - } - if (as('hours')) { - return `${as('hours')} hours ago`; - } - if (as('minutes')) { - return `${as('minutes')} minutes ago`; - } - if (as('seconds') && as('seconds') > 10) { - return `${as('seconds')} seconds ago`; - } - return 'just now'; -} - -function timeTo(date: Date | DateTime, extraArgs: unknown[]): Duration { - const [diff = new Date().toISOString(), unit = 'seconds'] = extraArgs as [string, DurationUnit]; - const diffDate = new Date(diff); - if (isDateTime(date)) { - return date.diff(DateTime.fromJSDate(diffDate), DURATION_MAP[unit] || unit); - } - return DateTime.fromJSDate(date).diff(DateTime.fromJSDate(diffDate), DURATION_MAP[unit] || unit); -} - -function toDate(date: Date | DateTime) { - if (isDateTime(date)) { - return date.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).toJSDate(); - } - let datetime = DateTime.fromJSDate(date); - if (date.getTimezoneOffset() === 0) { - datetime = datetime.setZone('UTC'); - } - return datetime.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).toJSDate(); -} - export const dateExtensions: ExtensionMap = { typeName: 'Date', functions: { @@ -268,10 +211,7 @@ export const dateExtensions: ExtensionMap = { isWeekend, minus, plus, - toTimeFromNow, - timeTo, format, toLocaleString, - toDate, }, }; diff --git a/packages/workflow/src/Extensions/NumberExtensions.ts b/packages/workflow/src/Extensions/NumberExtensions.ts index afa20c0b652ca..d3dccbd949212 100644 --- a/packages/workflow/src/Extensions/NumberExtensions.ts +++ b/packages/workflow/src/Extensions/NumberExtensions.ts @@ -21,18 +21,6 @@ function isPresent(value: number): boolean { return !isBlank(value); } -function random(value: number): number { - return Math.floor(Math.random() * value); -} - -function isTrue(value: number) { - return value === 1; -} - -function isFalse(value: number) { - return value === 0; -} - function isEven(value: number) { return value % 2 === 0; } @@ -60,13 +48,8 @@ export const numberExtensions: ExtensionMap = { ceil, floor, format, - random, round, - isBlank, isPresent, - isTrue, - isNotTrue: isFalse, - isFalse, isEven, isOdd, }, diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index e0ce8cb4d3371..963ffee3cebc5 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -34,9 +34,6 @@ const URL_REGEXP = const CHAR_TEST_REGEXP = /\p{L}/u; const PUNC_TEST_REGEXP = /[!?.]/; -const TRUE_VALUES = ['true', '1', 't', 'yes', 'y']; -const FALSE_VALUES = ['false', '0', 'f', 'no', 'n']; - function encrypt(value: string, extraArgs?: unknown): string { const [format = 'MD5'] = extraArgs as string[]; if (format.toLowerCase() === 'base64') { @@ -166,14 +163,6 @@ function quote(value: string, extraArgs: string[]) { .replace(new RegExp(`\\${quoteChar}`, 'g'), `\\${quoteChar}`)}${quoteChar}`; } -function isTrue(value: string) { - return TRUE_VALUES.includes(value.toLowerCase()); -} - -function isFalse(value: string) { - return FALSE_VALUES.includes(value.toLowerCase()); -} - function isNumeric(value: string) { return !isNaN(value as unknown as number) && !isNaN(parseFloat(value)); } @@ -274,7 +263,6 @@ export const stringExtensions: ExtensionMap = { removeMarkdown, sayHi, stripTags, - toBoolean: isTrue, toDate, toDecimalNumber: toFloat, toFloat, @@ -290,9 +278,6 @@ export const stringExtensions: ExtensionMap = { length, isDomain, isEmail, - isTrue, - isFalse, - isNotTrue: isFalse, isNumeric, isUrl, isURL: isUrl, diff --git a/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts index bd696b3f90d4c..ac5328d3875fd 100644 --- a/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts @@ -6,10 +6,6 @@ import { evaluate } from './Helpers'; describe('Data Transformation Functions', () => { describe('Array Data Transformation Functions', () => { - test('.random() should work correctly on an array', () => { - expect(evaluate('={{ [1,2,3].random() }}')).not.toBeUndefined(); - }); - test('.randomItem() alias should work correctly on an array', () => { expect(evaluate('={{ [1,2,3].randomItem() }}')).not.toBeUndefined(); }); @@ -74,12 +70,6 @@ describe('Data Transformation Functions', () => { expect(evaluate('={{ ["repeat","repeat","a","b","c"].first() }}')).toEqual('repeat'); }); - test('.filter() should work correctly on an array', () => { - expect(evaluate('={{ ["repeat","repeat","a","b","c"].filter("repeat") }}')).toEqual( - expect.arrayContaining(['repeat', 'repeat']), - ); - }); - test('.merge() should work correctly on an array', () => { expect( evaluate( @@ -181,18 +171,5 @@ describe('Data Transformation Functions', () => { [16, 17, 18, 19, 20], ]); }); - - test('.filter() should work on a list of strings', () => { - expect( - evaluate( - '={{ ["i am a test string", "i should be kept", "i should be removed test"].filter("test", "remove") }}', - ), - ).toEqual(['i should be kept']); - expect( - evaluate( - '={{ ["i am a test string", "i should be kept test", "i should be removed"].filter("test") }}', - ), - ).toEqual(['i am a test string', 'i should be kept test']); - }); }); }); diff --git a/packages/workflow/test/ExpressionExtensions/DateExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/DateExtensions.test.ts index bd51a5cdff6f9..fbc9e9799235a 100644 --- a/packages/workflow/test/ExpressionExtensions/DateExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/DateExtensions.test.ts @@ -14,11 +14,6 @@ describe('Data Transformation Functions', () => { ); }); - test('.toTimeFromNow() should work correctly on a date', () => { - const JUST_NOW_STRING_RESULT = 'just now'; - expect(evaluate('={{DateTime.now().toTimeFromNow()}}')).toEqual(JUST_NOW_STRING_RESULT); - }); - test('.beginningOf("week") should work correctly on a date', () => { expect(evaluate('={{(new Date).beginningOf("week")}}')).toEqual( dateExtensions.functions.beginningOf(new Date(), ['week']), diff --git a/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts index 9dbd6c78a9d43..abe59a18ef4cb 100644 --- a/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts @@ -7,14 +7,6 @@ import { evaluate } from './Helpers'; describe('Data Transformation Functions', () => { describe('Number Data Transformation Functions', () => { - test('.random() should work correctly on a number', () => { - expect(evaluate('={{ Number(100).random() }}')).not.toBeUndefined(); - }); - - test('.isBlank() should work correctly on a number', () => { - expect(evaluate('={{ Number(100).isBlank() }}')).toEqual(false); - }); - test('.isPresent() should work correctly on a number', () => { expect(evaluate('={{ Number(100).isPresent() }}')).toEqual( numberExtensions.functions.isPresent(100), @@ -48,18 +40,6 @@ describe('Data Transformation Functions', () => { expect(evaluate('={{ (NaN).round(3) }}')).toBeNaN(); }); - test('.isTrue() should work on a number', () => { - expect(evaluate('={{ (1).isTrue() }}')).toEqual(true); - expect(evaluate('={{ (0).isTrue() }}')).toEqual(false); - expect(evaluate('={{ (NaN).isTrue() }}')).toEqual(false); - }); - - test('.isFalse() should work on a number', () => { - expect(evaluate('={{ (1).isFalse() }}')).toEqual(false); - expect(evaluate('={{ (0).isFalse() }}')).toEqual(true); - expect(evaluate('={{ (NaN).isFalse() }}')).toEqual(false); - }); - test('.isOdd() should work on a number', () => { expect(evaluate('={{ (9).isOdd() }}')).toEqual(true); expect(evaluate('={{ (8).isOdd() }}')).toEqual(false); diff --git a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts index bc80d89c1cca8..f08e09b8db37a 100644 --- a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts @@ -92,48 +92,6 @@ describe('Data Transformation Functions', () => { ); }); - test('.toBoolean should work correctly on a string', () => { - const validTrue = ['y', 'yes', 't', 'true', '1', 'YES']; - for (const v of validTrue) { - expect(evaluate(`={{ "${v}".toBoolean() }}`)).toEqual(true); - } - - const validFalse = ['n', 'no', 'f', 'false', '0', 'NO']; - for (const v of validFalse) { - expect(evaluate(`={{ "${v}".toBoolean() }}`)).toEqual(false); - } - - expect(evaluate('={{ "maybe".toBoolean() }}')).toEqual(false); - }); - - test('.isTrue should work correctly on a string', () => { - const validTrue = ['y', 'yes', 't', 'true', '1', 'YES']; - for (const v of validTrue) { - expect(evaluate(`={{ "${v}".isTrue() }}`)).toEqual(true); - } - - const validFalse = ['n', 'no', 'f', 'false', '0', 'NO']; - for (const v of validFalse) { - expect(evaluate(`={{ "${v}".isTrue() }}`)).toEqual(false); - } - - expect(evaluate('={{ "maybe".isTrue() }}')).toEqual(false); - }); - - test('.isFalse should work correctly on a string', () => { - const validTrue = ['y', 'yes', 't', 'true', '1', 'YES']; - for (const v of validTrue) { - expect(evaluate(`={{ "${v}".isFalse() }}`)).toEqual(false); - } - - const validFalse = ['n', 'no', 'f', 'false', '0', 'NO']; - for (const v of validFalse) { - expect(evaluate(`={{ "${v}".isFalse() }}`)).toEqual(true); - } - - expect(evaluate('={{ "maybe".isFalse() }}')).toEqual(false); - }); - test('.toFloat should work correctly on a string', () => { expect(evaluate('={{ "1.1".toFloat() }}')).toEqual(1.1); expect(evaluate('={{ "1.1".toDecimalNumber() }}')).toEqual(1.1); From 6e2f54d644c463d8630b295821966ad02a0c1219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 19 Jan 2023 12:03:38 +0100 Subject: [PATCH 041/160] :truck: Rename extensions --- .../workflow/src/Extensions/ArrayExtensions.ts | 10 +++++----- .../workflow/src/Extensions/NumberExtensions.ts | 9 --------- .../workflow/src/Extensions/StringExtensions.ts | 14 +++++++------- .../ExpressionExtensions/ArrayExtensions.test.ts | 12 ++++++------ .../ExpressionExtension.test.ts | 4 ++-- .../ExpressionExtensions/NumberExtensions.test.ts | 6 ------ .../ExpressionExtensions/StringExtensions.test.ts | 8 ++++---- 7 files changed, 24 insertions(+), 39 deletions(-) diff --git a/packages/workflow/src/Extensions/ArrayExtensions.ts b/packages/workflow/src/Extensions/ArrayExtensions.ts index 2cb4fca7686a1..34004097c0851 100644 --- a/packages/workflow/src/Extensions/ArrayExtensions.ts +++ b/packages/workflow/src/Extensions/ArrayExtensions.ts @@ -87,11 +87,11 @@ function first(value: unknown[]): unknown { return value[0]; } -function isBlank(value: unknown[]): boolean { +function isEmpty(value: unknown[]): boolean { return value.length === 0; } -function isPresent(value: unknown[]): boolean { +function isNotEmpty(value: unknown[]): boolean { return value.length > 0; } @@ -348,7 +348,7 @@ export const arrayExtensions: ExtensionMap = { typeName: 'Array', functions: { count: length, - duplicates: unique, + removeDuplicates: unique, first, last, length, @@ -360,8 +360,8 @@ export const arrayExtensions: ExtensionMap = { min, max, average, - isPresent, - isBlank, + isNotEmpty, + isEmpty, compact, smartJoin, chunk, diff --git a/packages/workflow/src/Extensions/NumberExtensions.ts b/packages/workflow/src/Extensions/NumberExtensions.ts index d3dccbd949212..11693e3632006 100644 --- a/packages/workflow/src/Extensions/NumberExtensions.ts +++ b/packages/workflow/src/Extensions/NumberExtensions.ts @@ -13,14 +13,6 @@ function format(value: number, extraArgs: unknown[]): string { return new Intl.NumberFormat(locales, config).format(value); } -function isBlank(value: number): boolean { - return typeof value !== 'number'; -} - -function isPresent(value: number): boolean { - return !isBlank(value); -} - function isEven(value: number) { return value % 2 === 0; } @@ -49,7 +41,6 @@ export const numberExtensions: ExtensionMap = { floor, format, round, - isPresent, isEven, isOdd, }, diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 963ffee3cebc5..422efe2c75d2d 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -65,12 +65,12 @@ function getOnlyFirstCharacters(value: string, extraArgs: number[]): string { return value.slice(0, end); } -function isBlank(value: string): boolean { +function isEmpty(value: string): boolean { return value === ''; } -function isPresent(value: string): boolean { - return !isBlank(value); +function isNotEmpty(value: string): boolean { + return !isEmpty(value); } function length(value: string): number { @@ -185,7 +185,7 @@ function isEmail(value: string) { return EMAIL_REGEXP.test(value); } -function stripSpecialChars(value: string) { +function replaceSpecialChars(value: string) { return transliterate(value, { unknown: '?' }); } @@ -274,15 +274,15 @@ export const stringExtensions: ExtensionMap = { urlDecode, urlEncode, quote, - stripSpecialChars, + replaceSpecialChars, length, isDomain, isEmail, isNumeric, isUrl, isURL: isUrl, - isBlank, - isPresent, + isEmpty, + isNotEmpty, extractEmail, extractDomain, extractUrl, diff --git a/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts index ac5328d3875fd..c0ecc8b5fe796 100644 --- a/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts @@ -10,8 +10,8 @@ describe('Data Transformation Functions', () => { expect(evaluate('={{ [1,2,3].randomItem() }}')).not.toBeUndefined(); }); - test('.isPresent() should work correctly on an array', () => { - expect(evaluate('={{ [1,2,3, "imhere"].isPresent() }}')).toEqual(true); + test('.isNotEmpty() should work correctly on an array', () => { + expect(evaluate('={{ [1,2,3, "imhere"].isNotEmpty() }}')).toEqual(true); }); test('.pluck() should work correctly on an array', () => { @@ -42,12 +42,12 @@ describe('Data Transformation Functions', () => { ); }); - test('.isBlank() should work correctly on an array', () => { - expect(evaluate('={{ [].isBlank() }}')).toEqual(true); + test('.isEmpty() should work correctly on an array', () => { + expect(evaluate('={{ [].isEmpty() }}')).toEqual(true); }); - test('.isBlank() should work correctly on an array', () => { - expect(evaluate('={{ [1].isBlank() }}')).toEqual(false); + test('.isEmpty() should work correctly on an array', () => { + expect(evaluate('={{ [1].isEmpty() }}')).toEqual(false); }); test('.length() should work correctly on an array', () => { diff --git a/packages/workflow/test/ExpressionExtensions/ExpressionExtension.test.ts b/packages/workflow/test/ExpressionExtensions/ExpressionExtension.test.ts index 72f1bd62c7131..951b0aa9f4dba 100644 --- a/packages/workflow/test/ExpressionExtensions/ExpressionExtension.test.ts +++ b/packages/workflow/test/ExpressionExtensions/ExpressionExtension.test.ts @@ -8,8 +8,8 @@ import { evaluate } from './Helpers'; describe('Expression Extension Transforms', () => { describe('extend() transform', () => { - test('Basic transform with .isBlank', () => { - expect(extendTransform('"".isBlank()')!.code).toEqual('extend("", "isBlank", [])'); + test('Basic transform with .isEmpty', () => { + expect(extendTransform('"".isEmpty()')!.code).toEqual('extend("", "isEmpty", [])'); }); test('Chained transform with .sayHi.getOnlyFirstCharacters', () => { diff --git a/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts index abe59a18ef4cb..75c912ae631bc 100644 --- a/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts @@ -7,12 +7,6 @@ import { evaluate } from './Helpers'; describe('Data Transformation Functions', () => { describe('Number Data Transformation Functions', () => { - test('.isPresent() should work correctly on a number', () => { - expect(evaluate('={{ Number(100).isPresent() }}')).toEqual( - numberExtensions.functions.isPresent(100), - ); - }); - test('.format() should work correctly on a number', () => { expect(evaluate('={{ Number(100).format() }}')).toEqual( numberExtensions.functions.format(100, []), diff --git a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts index f08e09b8db37a..2b87a9d004a51 100644 --- a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts @@ -8,12 +8,12 @@ import { evaluate } from './Helpers'; describe('Data Transformation Functions', () => { describe('String Data Transformation Functions', () => { - test('.isBlank() should work correctly on a string that is not empty', () => { - expect(evaluate('={{"NotBlank".isBlank()}}')).toEqual(false); + test('.isEmpty() should work correctly on a string that is not empty', () => { + expect(evaluate('={{"NotBlank".isEmpty()}}')).toEqual(false); }); - test('.isBlank() should work correctly on a string that is empty', () => { - expect(evaluate('={{"".isBlank()}}')).toEqual(true); + test('.isEmpty() should work correctly on a string that is empty', () => { + expect(evaluate('={{"".isEmpty()}}')).toEqual(true); }); test('.getOnlyFirstCharacters() should work correctly on a string', () => { From b9b966c8a1eda72447992872b9fa445ea5ace76c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 19 Jan 2023 12:20:43 +0100 Subject: [PATCH 042/160] :zap: Adjust some implementations --- packages/workflow/package.json | 1 + packages/workflow/src/Extensions/ArrayExtensions.ts | 3 ++- packages/workflow/src/Extensions/ObjectExtensions.ts | 2 +- packages/workflow/src/Extensions/StringExtensions.ts | 7 ++----- .../test/ExpressionExtensions/StringExtensions.test.ts | 10 ---------- pnpm-lock.yaml | 3 ++- 6 files changed, 8 insertions(+), 18 deletions(-) diff --git a/packages/workflow/package.json b/packages/workflow/package.json index c6c4ea6a10c31..6c9bfd800181a 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -61,6 +61,7 @@ "lodash.set": "^4.3.2", "luxon": "~2.3.0", "recast": "^0.21.5", + "title-case": "^3.0.3", "transliteration": "^2.3.5", "xml2js": "^0.4.23" } diff --git a/packages/workflow/src/Extensions/ArrayExtensions.ts b/packages/workflow/src/Extensions/ArrayExtensions.ts index 34004097c0851..a48f58c2fcc4f 100644 --- a/packages/workflow/src/Extensions/ArrayExtensions.ts +++ b/packages/workflow/src/Extensions/ArrayExtensions.ts @@ -199,8 +199,9 @@ export function average(value: unknown[]) { } function compact(value: unknown[]): unknown[] { + console.log('value[4]', value[4]); return value - .filter((v) => v !== null && v !== undefined) + .filter((v) => v !== null && v !== undefined && v !== 'nil' && v !== '') .map((v) => { if (typeof v === 'object' && v !== null) { return oCompact(v); diff --git a/packages/workflow/src/Extensions/ObjectExtensions.ts b/packages/workflow/src/Extensions/ObjectExtensions.ts index b3ef5dde57b1f..cf589ab4ac2ef 100644 --- a/packages/workflow/src/Extensions/ObjectExtensions.ts +++ b/packages/workflow/src/Extensions/ObjectExtensions.ts @@ -71,7 +71,7 @@ export function compact(value: object): object { // eslint-disable-next-line @typescript-eslint/no-explicit-any const newObj: any = {}; for (const [key, val] of Object.entries(value)) { - if (val !== null && val !== undefined) { + if (val !== null && val !== undefined && val !== 'nil' && val !== '') { if (typeof val === 'object') { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument newObj[key] = compact(val); diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 422efe2c75d2d..780499a2b648b 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/unbound-method */ // import { createHash } from 'crypto'; +import { titleCase } from 'title-case'; import * as ExpressionError from '../ExpressionError'; import type { ExtensionMap } from './Extensions'; import CryptoJS from 'crypto-js'; @@ -189,10 +190,6 @@ function replaceSpecialChars(value: string) { return transliterate(value, { unknown: '?' }); } -function toTitleCase(value: string) { - return value.replace(/\w\S*/g, (v) => v.charAt(0).toLocaleUpperCase() + v.slice(1)); -} - function toSentenceCase(value: string) { let current = value.slice(); let buffer = ''; @@ -270,7 +267,7 @@ export const stringExtensions: ExtensionMap = { toWholeNumber: toInt, toSentenceCase, toSnakeCase, - toTitleCase, + toTitleCase: titleCase, urlDecode, urlEncode, quote, diff --git a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts index 2b87a9d004a51..b0fe0197bfad4 100644 --- a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts @@ -143,16 +143,6 @@ describe('Data Transformation Functions', () => { expect(evaluate('={{ "i am a test".toSentenceCase() }}')).toEqual('I am a test'); }); - test('.toTitleCase should work on a string', () => { - expect( - evaluate( - '={{ "i am a test! i have multiple types of Punctuation. or do i?".toTitleCase() }}', - ), - ).toEqual('I Am A Test! I Have Multiple Types Of Punctuation. Or Do I?'); - expect(evaluate('={{ "i am a test!".toTitleCase() }}')).toEqual('I Am A Test!'); - expect(evaluate('={{ "i am a test".toTitleCase() }}')).toEqual('I Am A Test'); - }); - test('.extractUrl should work on a string', () => { expect( evaluate( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d148b432531e..46bc6cf4deef2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -905,6 +905,7 @@ importers: lodash.set: ^4.3.2 luxon: ~2.3.0 recast: ^0.21.5 + title-case: ^3.0.3 transliteration: ^2.3.5 xml2js: ^0.4.23 dependencies: @@ -918,6 +919,7 @@ importers: lodash.set: 4.3.2 luxon: 2.3.2 recast: 0.21.5 + title-case: 3.0.3 transliteration: 2.3.5 xml2js: 0.4.23 devDependencies: @@ -20433,7 +20435,6 @@ packages: resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==} dependencies: tslib: 2.4.0 - dev: true /tlds/1.231.0: resolution: {integrity: sha512-L7UQwueHSkGxZHQBXHVmXW64oi+uqNtzFt2x6Ssk7NVnpIbw16CRs4eb/jmKOZ9t2JnqZ/b3Cfvo97lnXqKrhw==} From e4d17f58172935fc4d6297d0ae7b84c3f709bef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 19 Jan 2023 12:35:15 +0100 Subject: [PATCH 043/160] :fire: Remove dummy extensions --- .../src/Extensions/ArrayExtensions.ts | 1 - .../src/Extensions/StringExtensions.ts | 18 -------- .../ExpressionExtension.test.ts | 42 ++++++++++--------- .../NumberExtensions.test.ts | 4 +- .../StringExtensions.test.ts | 22 ---------- 5 files changed, 25 insertions(+), 62 deletions(-) diff --git a/packages/workflow/src/Extensions/ArrayExtensions.ts b/packages/workflow/src/Extensions/ArrayExtensions.ts index a48f58c2fcc4f..cdc54902de630 100644 --- a/packages/workflow/src/Extensions/ArrayExtensions.ts +++ b/packages/workflow/src/Extensions/ArrayExtensions.ts @@ -199,7 +199,6 @@ export function average(value: unknown[]) { } function compact(value: unknown[]): unknown[] { - console.log('value[4]', value[4]); return value .filter((v) => v !== null && v !== undefined && v !== 'nil' && v !== '') .map((v) => { diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 780499a2b648b..62709db7a48ae 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -54,18 +54,6 @@ function encrypt(value: string, extraArgs?: unknown): string { // return createHash(format).update(value.toString()).digest('hex'); } -function getOnlyFirstCharacters(value: string, extraArgs: number[]): string { - const [end] = extraArgs; - - if (typeof end !== 'number') { - throw new ExpressionError.ExpressionExtensionError( - 'getOnlyFirstCharacters() requires a argument', - ); - } - - return value.slice(0, end); -} - function isEmpty(value: string): boolean { return value === ''; } @@ -120,10 +108,6 @@ function removeMarkdown(value: string): string { return output; } -function sayHi(value: string) { - return `hi ${value}`; -} - function stripTags(value: string): string { return value.replace(/<[^>]*>?/gm, ''); } @@ -256,9 +240,7 @@ export const stringExtensions: ExtensionMap = { functions: { encrypt, hash: encrypt, - getOnlyFirstCharacters, removeMarkdown, - sayHi, stripTags, toDate, toDecimalNumber: toFloat, diff --git a/packages/workflow/test/ExpressionExtensions/ExpressionExtension.test.ts b/packages/workflow/test/ExpressionExtensions/ExpressionExtension.test.ts index 951b0aa9f4dba..e12df5928e4e2 100644 --- a/packages/workflow/test/ExpressionExtensions/ExpressionExtension.test.ts +++ b/packages/workflow/test/ExpressionExtensions/ExpressionExtension.test.ts @@ -12,15 +12,15 @@ describe('Expression Extension Transforms', () => { expect(extendTransform('"".isEmpty()')!.code).toEqual('extend("", "isEmpty", [])'); }); - test('Chained transform with .sayHi.getOnlyFirstCharacters', () => { - expect(extendTransform('"".sayHi().getOnlyFirstCharacters(2)')!.code).toEqual( - 'extend(extend("", "sayHi", []), "getOnlyFirstCharacters", [2])', + test('Chained transform with .toSnakeCase.toSentenceCase', () => { + expect(extendTransform('"".toSnakeCase().toSentenceCase(2)')!.code).toEqual( + 'extend(extend("", "toSnakeCase", []), "toSentenceCase", [2])', ); }); - test('Chained transform with native functions .sayHi.trim.getOnlyFirstCharacters', () => { - expect(extendTransform('"aaa ".sayHi().trim().getOnlyFirstCharacters(2)')!.code).toEqual( - 'extend(extend("aaa ", "sayHi", []).trim(), "getOnlyFirstCharacters", [2])', + test('Chained transform with native functions .toSnakeCase.trim.toSentenceCase', () => { + expect(extendTransform('"aaa ".toSnakeCase().trim().toSentenceCase(2)')!.code).toEqual( + 'extend(extend("aaa ", "toSnakeCase", []).trim(), "toSentenceCase", [2])', ); }); }); @@ -36,19 +36,21 @@ describe('tmpl Expression Parser', () => { }); test('Multiple expression', () => { - expect(splitExpression('{{ "test".sayHi() }} you have ${{ (100).format() }}.')).toEqual([ - { type: 'text', text: '' }, - { type: 'code', text: ' "test".sayHi() ', hasClosingBrackets: true }, - { type: 'text', text: ' you have $' }, - { type: 'code', text: ' (100).format() ', hasClosingBrackets: true }, - { type: 'text', text: '.' }, - ]); + expect(splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format() }}.')).toEqual( + [ + { type: 'text', text: '' }, + { type: 'code', text: ' "test".toSnakeCase() ', hasClosingBrackets: true }, + { type: 'text', text: ' you have $' }, + { type: 'code', text: ' (100).format() ', hasClosingBrackets: true }, + { type: 'text', text: '.' }, + ], + ); }); test('Unclosed expression', () => { - expect(splitExpression('{{ "test".sayHi() }} you have ${{ (100).format()')).toEqual([ + expect(splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format()')).toEqual([ { type: 'text', text: '' }, - { type: 'code', text: ' "test".sayHi() ', hasClosingBrackets: true }, + { type: 'code', text: ' "test".toSnakeCase() ', hasClosingBrackets: true }, { type: 'text', text: ' you have $' }, { type: 'code', text: ' (100).format()', hasClosingBrackets: false }, ]); @@ -75,14 +77,16 @@ describe('tmpl Expression Parser', () => { test('Multiple expression', () => { expect( - joinExpression(splitExpression('{{ "test".sayHi() }} you have ${{ (100).format() }}.')), - ).toEqual('{{ "test".sayHi() }} you have ${{ (100).format() }}.'); + joinExpression( + splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format() }}.'), + ), + ).toEqual('{{ "test".toSnakeCase() }} you have ${{ (100).format() }}.'); }); test('Unclosed expression', () => { expect( - joinExpression(splitExpression('{{ "test".sayHi() }} you have ${{ (100).format()')), - ).toEqual('{{ "test".sayHi() }} you have ${{ (100).format()'); + joinExpression(splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format()')), + ).toEqual('{{ "test".toSnakeCase() }} you have ${{ (100).format()'); }); test('Escaped opening bracket', () => { diff --git a/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts index 75c912ae631bc..8452efa2df11f 100644 --- a/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts @@ -51,8 +51,8 @@ describe('Data Transformation Functions', () => { describe('Multiple expressions', () => { test('Basic multiple expressions', () => { - expect(evaluate('={{ "Test".sayHi() }} you have ${{ (100).format() }}.')).toEqual( - 'hi Test you have $100.', + expect(evaluate('={{ "test abc".toSnakeCase() }} you have ${{ (100).format() }}.')).toEqual( + 'test_abc you have $100.', ); }); }); diff --git a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts index b0fe0197bfad4..2bd6ae7d304de 100644 --- a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts @@ -16,28 +16,6 @@ describe('Data Transformation Functions', () => { expect(evaluate('={{"".isEmpty()}}')).toEqual(true); }); - test('.getOnlyFirstCharacters() should work correctly on a string', () => { - expect(evaluate('={{"myNewField".getOnlyFirstCharacters(5)}}')).toEqual('myNew'); - - expect(evaluate('={{"myNewField".getOnlyFirstCharacters(10)}}')).toEqual('myNewField'); - - expect( - evaluate('={{"myNewField".getOnlyFirstCharacters(5).length >= "myNewField".length}}'), - ).toEqual(false); - - expect(evaluate('={{DateTime.now().toLocaleString().getOnlyFirstCharacters(2)}}')).toEqual( - stringExtensions.functions.getOnlyFirstCharacters( - // @ts-ignore - dateExtensions.functions.toLocaleString(new Date(), []), - [2], - ), - ); - }); - - test('.sayHi() should work correctly on a string', () => { - expect(evaluate('={{ "abc".sayHi() }}')).toEqual('hi abc'); - }); - test('.encrypt() should work correctly on a string', () => { expect(evaluate('={{ "12345".encrypt("sha256") }}')).toEqual( stringExtensions.functions.encrypt('12345', ['sha256']), From bfa34e644a9065feb01fdaeae6f1ec3b7c3bf028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 19 Jan 2023 16:28:30 +0100 Subject: [PATCH 044/160] :bug: Fix object regex --- .../src/plugins/codemirror/completions/datatype.completions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 392745492f7da..ae734e7e8bf6d 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -15,7 +15,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul const numberRegex = /(\d+)\.?(\d*)\.(\w|\W)*/; // 123. or 123.4. const stringRegex = /(".+"|('.+'))\.(\w|\W)*/; // 'abc'. or "abc". const arrayRegex = /(\[.+\])\.(\w|\W)*/; // [1, 2, 3]. - const objectRegex = /(\{.*\})\.(\w|\W)*/; // ({}). + const objectRegex = /\(\{.*\}\)\.(\w|\W)*/; // ({}). const dateRegex = /\(?new Date\(\(?.*?\)\)?\.(\w|\W)*/; // new Date(). or (new Date()). const combinedRegex = new RegExp( From ad30a1d56c77c7c721d4264dc504d4f09ce0ad19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 19 Jan 2023 18:52:44 +0100 Subject: [PATCH 045/160] :recycle: Apply feedback --- .../__tests__/alpha.completions.test.ts | 6 +- .../completions/datatype.completions.ts | 110 +++++++++--------- ...a.completions.ts => global.completions.ts} | 4 +- .../completions/jsonBracket.completions.ts | 57 +++++++++ .../completions/luxon.completions.ts | 6 +- .../completions/proxy.completions.ts | 31 ++--- .../completions/root.completions.ts | 6 +- .../src/plugins/codemirror/n8nLang.ts | 8 +- 8 files changed, 136 insertions(+), 92 deletions(-) rename packages/editor-ui/src/plugins/codemirror/completions/{alpha.completions.ts => global.completions.ts} (87%) create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/jsonBracket.completions.ts diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts index 234bc5246a0ea..d11fa7b14ee5a 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts @@ -1,4 +1,4 @@ -import { alphaCompletions } from '../alpha.completions'; +import { globalCompletions } from '../global.completions'; import { CompletionContext } from '@codemirror/autocomplete'; import { EditorState } from '@codemirror/state'; @@ -9,7 +9,7 @@ test('should return alphabetic char completion options: D', () => { const position = doc.indexOf('D') + 1; const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); - const result = alphaCompletions(context); + const result = globalCompletions(context); if (!result) throw new Error('Expected D completion options'); @@ -24,7 +24,7 @@ test('should not return alphabetic char completion options: $input.D', () => { const position = doc.indexOf('D') + 1; const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); - const result = alphaCompletions(context); + const result = globalCompletions(context); expect(result).toBeNull(); }); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index ae734e7e8bf6d..3b8781c35c33e 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -1,22 +1,22 @@ import { ExpressionExtensions, IDataObject } from 'n8n-workflow'; import { resolveParameter } from '@/mixins/workflowHelpers'; -import { longestCommonPrefix } from './utils'; +import { isAllowedInDotNotation, longestCommonPrefix } from './utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; /** - * Completions from datatypes to native JS methods (pending) and expression extensions. + * Completions from datatypes to expression extensions. */ export function datatypeCompletions(context: CompletionContext): CompletionResult | null { // ---------------------------------- // match before cursor // ---------------------------------- - const referenceRegex = /\$[\S]+\.(\w|\W)*/; // $input.item.json.name. - const numberRegex = /(\d+)\.?(\d*)\.(\w|\W)*/; // 123. or 123.4. - const stringRegex = /(".+"|('.+'))\.(\w|\W)*/; // 'abc'. or "abc". - const arrayRegex = /(\[.+\])\.(\w|\W)*/; // [1, 2, 3]. - const objectRegex = /\(\{.*\}\)\.(\w|\W)*/; // ({}). - const dateRegex = /\(?new Date\(\(?.*?\)\)?\.(\w|\W)*/; // new Date(). or (new Date()). + const referenceRegex = /\$[\S]+\.([^{\s])*/; // $input. + const numberRegex = /(\d+)\.?(\d*)\.([^{\s])*/; // 123. or 123.4. + const stringRegex = /(".+"|('.+'))\.([^{\s])*/; // 'abc'. or "abc". + const arrayRegex = /(\[.+\])\.([^{\s])*/; // [1, 2, 3]. + const objectRegex = /\(\{.*\}\)\.([^{\s])*/; // ({}). + const dateRegex = /\(?new Date\(\(?.*?\)\)?\.([^{\s])*/; // new Date(). or (new Date()). const combinedRegex = new RegExp( [ @@ -35,21 +35,13 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul if (word.from === word.to && !context.explicit) return null; - // cleanup - remove opener grabbed by objectRegex - if (word.text.startsWith('{{')) word.text = word.text.replace(/^{{/, ''); - // ---------------------------------- // find string to resolve // ---------------------------------- - const toResolve = - word.text.endsWith('.') || word.text.endsWith('json[') - ? word.text.slice(0, -1) - : word.text.split('.').slice(0, -1).join('.'); - - // ---------------------------------- - // skip exceptions - // ---------------------------------- + const toResolve = word.text.endsWith('.') + ? word.text.slice(0, -1) + : word.text.split('.').slice(0, -1).join('.'); const SKIP_SET = new Set(['$execution', '$binary', '$itemIndex', '$now', '$today', '$runIndex']); @@ -59,7 +51,6 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul // resolve and get options // ---------------------------------- - let options: Completion[] = []; let resolved: IDataObject | null; try { @@ -70,45 +61,15 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul if (resolved === null) return null; - if (typeof resolved === 'number') { - options = extensionOptions('Number'); - } else if (typeof resolved === 'string') { - options = extensionOptions('String'); - } else if (Array.isArray(resolved)) { - if (toResolve.endsWith('all()')) { - // exclude array proxy from array expression extensions - return null; - } - options = extensionOptions('Array'); - } else if (resolved instanceof Date) { - options = extensionOptions('Date'); - } else if ( - typeof resolved === 'object' && - // exclude object proxies from object expression extensions - !resolved.isProxy && - !resolved.json && - !toResolve.endsWith('json') && - !toResolve.startsWith('{') && - !toResolve.endsWith('}') - ) { - options = [ - ...Object.keys(resolved).map((key) => ({ label: key, type: 'keyword' })), - ...extensionOptions('Object'), - ]; - } else if (word.text.endsWith('json[')) { - options = Object.keys(resolved).map((key) => { - return { - label: `'${key}']`, - type: 'keyword', - }; - }); - } + let options = getDatatypeOptions(resolved, toResolve); + + if (options.length === 0) return null; // ---------------------------------- // filter by user input // ---------------------------------- - const userInputTail = word.text.includes('json[') ? '' : word.text.split('.').pop() ?? ''; + const userInputTail = word.text.split('.').pop() ?? ''; if (userInputTail !== '') { options = options.filter((o) => o.label.startsWith(userInputTail) && userInputTail !== o.label); @@ -126,17 +87,54 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul }; } +function getDatatypeOptions(resolved: IDataObject, toResolve: string) { + if (typeof resolved === 'number') return extensionOptions('Number'); + + if (typeof resolved === 'string') return extensionOptions('String'); + + if (resolved instanceof Date) return extensionOptions('Date'); + + if (Array.isArray(resolved)) { + const isProxy = toResolve.endsWith('all()'); + + if (isProxy) return []; + + return extensionOptions('Array'); + } + + if (typeof resolved === 'object') { + const isProxy = + resolved.isProxy || + resolved.json || + toResolve.endsWith('json') || + toResolve.startsWith('{') || + toResolve.endsWith('}'); + + if (isProxy) return []; + + // @TODO: completions for bracket-notation chain e.g. $json['obj']['my Key'] + + const keys = Object.keys(resolved) + .filter((key) => isAllowedInDotNotation(key)) + .map((key) => ({ label: key, type: 'keyword' })); + + return [...keys, ...extensionOptions('Object')]; + } + + return []; +} + const extensionOptions = (typeName: 'String' | 'Number' | 'Date' | 'Object' | 'Array') => { const extensions = ExpressionExtensions.find((ee) => ee.typeName === typeName); if (!extensions) return []; const options = Object.entries(extensions.functions) - .filter(([_, f]) => f.length === 1) // complete only argless functions until further notice + .filter(([_, fn]) => fn.length === 1) // complete only argless functions for now .sort((a, b) => a[0].localeCompare(b[0])) .map(([name, f]) => { const option: Completion = { - label: name, + label: name + '()', type: 'function', }; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/global.completions.ts similarity index 87% rename from packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts rename to packages/editor-ui/src/plugins/codemirror/completions/global.completions.ts index cf3b1183ab365..8bef0ff1ac6d9 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/global.completions.ts @@ -3,9 +3,9 @@ import { longestCommonPrefix } from './utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; /** - * Completions from alphabetic char, e.g. `D` -> `DateTime`. + * Completions for global vars, e.g. `D` -> `DateTime`. */ -export function alphaCompletions(context: CompletionContext): CompletionResult | null { +export function globalCompletions(context: CompletionContext): CompletionResult | null { const word = context.matchBefore(/(\s+)D[ateTim]*/); if (!word) return null; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/jsonBracket.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/jsonBracket.completions.ts new file mode 100644 index 0000000000000..ae7757dbdd921 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/jsonBracket.completions.ts @@ -0,0 +1,57 @@ +import { resolveParameter } from '@/mixins/workflowHelpers'; +import { longestCommonPrefix } from './utils'; +import type { IDataObject } from 'n8n-workflow'; +import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; + +/** + * Completions from `$json[` and `.json[` to their keys. + */ +export function jsonBracketCompletions(context: CompletionContext): CompletionResult | null { + const word = context.matchBefore(/\$[\S]*json\[.*/); + + if (!word) return null; + + if (word.from === word.to && !context.explicit) return null; + + const toResolve = word.text.split('[').shift(); + + let resolved: IDataObject | null; + + try { + resolved = resolveParameter(`={{ ${toResolve} }}`); + } catch (_) { + return null; + } + + if (resolved === null) return null; + + let options = getJsonBracketOptions(resolved); + + const delimiter = word.text.startsWith('$json') ? '$json[' : '.json['; + + const userInputTail = word.text.split(delimiter).pop() ?? ''; + + if (userInputTail !== '') { + options = options.filter((o) => o.label.startsWith(userInputTail) && userInputTail !== o.label); + } + + return { + from: word.to - userInputTail.length, + options, + filter: false, + getMatch(completion: Completion) { + const lcp = longestCommonPrefix([userInputTail, completion.label]); + + return [0, lcp.length]; + }, + }; +} + +function getJsonBracketOptions(resolved: IDataObject) { + return Object.keys(resolved).map((key) => { + return { + label: `'${key}']`, + type: 'keyword', + }; + }); +} diff --git a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts index 90cd9d5ca7807..8cefca4dc01e5 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts @@ -14,7 +14,7 @@ export function luxonCompletions(context: CompletionContext): CompletionResult | ? word.text.slice(0, -1) : word.text.split('.').slice(0, -1).join('.'); - let options = generateOptions(toResolve); + let options = getLuxonOptions(toResolve); const userInputTail = word.text.split('.').pop(); @@ -36,7 +36,7 @@ export function luxonCompletions(context: CompletionContext): CompletionResult | }; } -function generateOptions(toResolve: string): Completion[] { +function getLuxonOptions(toResolve: string): Completion[] { if (toResolve === '$now' || toResolve === '$today') return nowTodayOptions(); if (toResolve === 'DateTime') return dateTimeOptions(); @@ -74,7 +74,7 @@ export const dateTimeOptions = () => { .sort((a, b) => a.localeCompare(b)); return keys.map((key) => { - const option: Completion = { label: key, type: 'function' }; + const option: Completion = { label: key + '()', type: 'function' }; const info = i18n.luxonStatic[key]; if (info) option.info = info; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts index a72cb1dd95290..0d552ffd660b5 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts @@ -19,7 +19,7 @@ export function proxyCompletions(context: CompletionContext): CompletionResult | // ---------------------------------- const word = context.matchBefore( - /\$(input|\(.+\)|prevNode|parameter|json|execution|workflow)*(\.|\[)(\w|\W)*/, + /\$(input|\(.+\)|prevNode|parameter|json|execution|workflow)*\.([^{\s])*/, ); if (!word) return null; @@ -30,10 +30,9 @@ export function proxyCompletions(context: CompletionContext): CompletionResult | // find string to resolve // ---------------------------------- - const toResolve = - word.text.endsWith('.') || word.text.endsWith('json[') - ? word.text.slice(0, -1) // $input. -> $input, or $input.item.json[ -> $input.item.json - : word.text.split('.').slice(0, -1).join('.'); // $input.item.json.ab -> $input.item.json + const toResolve = word.text.endsWith('.') + ? word.text.slice(0, -1) + : word.text.split('.').slice(0, -1).join('.'); // ---------------------------------- // resolve and get options @@ -55,7 +54,7 @@ export function proxyCompletions(context: CompletionContext): CompletionResult | // filter by user input // ---------------------------------- - const userInputTail = word.text.includes('json[') ? '' : word.text.split('.').pop() ?? ''; + const userInputTail = word.text.split('.').pop() ?? ''; if (userInputTail !== '') { options = options.filter((o) => o.label.startsWith(userInputTail) && userInputTail !== o.label); @@ -74,20 +73,8 @@ export function proxyCompletions(context: CompletionContext): CompletionResult | } function generateOptions(toResolve: string, proxy: IDataObject, word: Word): Completion[] { - const SKIP_SET = new Set(['__ob__', 'pairedItem']); - const BOOST_SET = new Set(['item', 'all', 'first', 'last']); - - if (word.text.includes('json[')) { - return Object.keys(word.text === '$json[' ? proxy : (proxy.json as object)) - .filter((key) => !SKIP_SET.has(key)) - .map((key) => { - return { - label: `'${key}']`, - type: 'keyword', - }; - }); - } + const SKIP_SET = new Set(['__ob__', 'pairedItem']); if (isSplitInBatchesAbsent()) SKIP_SET.add('context'); @@ -99,9 +86,7 @@ function generateOptions(toResolve: string, proxy: IDataObject, word: Word): Com return (Reflect.ownKeys(proxy) as string[]) .filter((key) => { - if (word.text.endsWith('json.')) return !SKIP_SET.has(key) && isAllowedInDotNotation(key); - - return !SKIP_SET.has(key); + return !SKIP_SET.has(key) && isAllowedInDotNotation(key); }) .sort((a, b) => { if (BOOST_SET.has(a)) return -1; @@ -115,7 +100,7 @@ function generateOptions(toResolve: string, proxy: IDataObject, word: Word): Com const isFunction = typeof proxy[key] === 'function'; const option: Completion = { - label: key, + label: isFunction ? key + '()' : key, type: isFunction ? 'function' : 'keyword', }; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts index 2c42d7c83a509..d96937df8d44e 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts @@ -49,9 +49,11 @@ export function generateOptions() { const options: Completion[] = rootKeys .filter((key) => !SKIP_SET.has(key)) .map((key) => { + const isFunction = ['$jmespath'].includes(key); + const option: Completion = { - label: key, - type: key === '$jmespath' ? 'function' : 'keyword', // @TODO: Extract $jmespath to constant set + label: isFunction ? key + '()' : key, + type: isFunction ? 'function' : 'keyword', }; const info = i18n.rootVars[key]; diff --git a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts index f171e449ac9c3..ae67ca2a0a75e 100644 --- a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts +++ b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts @@ -7,9 +7,10 @@ import { ifIn } from '@codemirror/autocomplete'; import { proxyCompletions } from './completions/proxy.completions'; import { rootCompletions } from './completions/root.completions'; import { luxonCompletions } from './completions/luxon.completions'; -import { alphaCompletions } from './completions/alpha.completions'; +import { globalCompletions } from './completions/global.completions'; import { datatypeCompletions } from './completions/datatype.completions'; import { blankCompletions } from './completions/blank.completions'; +import { jsonBracketCompletions } from './completions/jsonBracket.completions'; const n8nParserWithNestedJsParser = n8nParser.configure({ wrap: parseMixed((node) => { @@ -27,10 +28,11 @@ export function n8nLang() { const options = [ blankCompletions, // from `{{ | }}` rootCompletions, // from `$` - proxyCompletions, // from `$var.` + proxyCompletions, // from `$input.`, `$(...)`, etc. datatypeCompletions, // from primitives `'abc'.` and from references `$json.name.` - alphaCompletions, // from alphabetic chars: `D` + globalCompletions, // for global var: `D` -> `DateTime` luxonCompletions, // from luxon vars: `DateTime.`, `$now.`, `$today.` + jsonBracketCompletions, // from `json[` ].map((group) => n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], group) })); return new LanguageSupport(n8nLanguage, [ From f9a382e5d4ce5f73fe44725e5d591afc21813edd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 19 Jan 2023 18:56:07 +0100 Subject: [PATCH 046/160] :pencil2: Fix typos --- packages/workflow/src/Extensions/ArrayExtensions.ts | 2 +- packages/workflow/src/Extensions/ObjectExtensions.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/workflow/src/Extensions/ArrayExtensions.ts b/packages/workflow/src/Extensions/ArrayExtensions.ts index cdc54902de630..7c3c7c86e940c 100644 --- a/packages/workflow/src/Extensions/ArrayExtensions.ts +++ b/packages/workflow/src/Extensions/ArrayExtensions.ts @@ -327,7 +327,7 @@ function intersection(value: unknown[], extraArgs: unknown[][]): unknown[] { const [others] = extraArgs; if (!Array.isArray(others)) { throw new ExpressionExtensionError( - 'difference requires 1 argument that is an array. e.g. .difference([1, 2, 3, 4])', + 'intersection requires 1 argument that is an array. e.g. .intersection([1, 2, 3, 4])', ); } const newArr: unknown[] = []; diff --git a/packages/workflow/src/Extensions/ObjectExtensions.ts b/packages/workflow/src/Extensions/ObjectExtensions.ts index cf589ab4ac2ef..8dc1ac28a6861 100644 --- a/packages/workflow/src/Extensions/ObjectExtensions.ts +++ b/packages/workflow/src/Extensions/ObjectExtensions.ts @@ -40,7 +40,7 @@ function removeField(value: object, extraArgs: string[]): object { function removeFieldsContaining(value: object, extraArgs: string[]): object { const [match] = extraArgs; if (typeof match !== 'string') { - throw new ExpressionExtensionError('argument of removeFieldsContaining must be an string'); + throw new ExpressionExtensionError('argument of removeFieldsContaining must be a string'); } const newObject = { ...value }; for (const [key, val] of Object.entries(value)) { @@ -55,7 +55,7 @@ function removeFieldsContaining(value: object, extraArgs: string[]): object { function keepFieldsContaining(value: object, extraArgs: string[]): object { const [match] = extraArgs; if (typeof match !== 'string') { - throw new ExpressionExtensionError('argument of keepFieldsContaining must be an string'); + throw new ExpressionExtensionError('argument of keepFieldsContaining must be a string'); } const newObject = { ...value }; for (const [key, val] of Object.entries(value)) { From 19d3887252009b0bdbfdabd531d51e1c7ec173d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 19 Jan 2023 19:11:43 +0100 Subject: [PATCH 047/160] :pencil2: Add `fn is not a function` message --- packages/workflow/src/Expression.ts | 13 +++++++++++++ .../src/Extensions/ExpressionExtension.ts | 18 ------------------ .../GenericExtensions.test.ts | 9 --------- 3 files changed, 13 insertions(+), 27 deletions(-) delete mode 100644 packages/workflow/test/ExpressionExtensions/GenericExtensions.test.ts diff --git a/packages/workflow/src/Expression.ts b/packages/workflow/src/Expression.ts index 8817bb7da92ae..fc7134f186e51 100644 --- a/packages/workflow/src/Expression.ts +++ b/packages/workflow/src/Expression.ts @@ -309,6 +309,19 @@ export class Expression { ) { throw new Error('invalid syntax'); } + + if ( + typeof process === 'undefined' && + error instanceof Error && + error.name === 'TypeError' && + error.message.endsWith('is not a function') + ) { + const match = error.message.match(/(?[^.]+is not a function)/); + + if (!match?.groups?.msg) return null; + + throw new Error(match.groups.msg); + } } return null; } diff --git a/packages/workflow/src/Extensions/ExpressionExtension.ts b/packages/workflow/src/Extensions/ExpressionExtension.ts index 59ab1cc9d2b49..84dd4258c3389 100644 --- a/packages/workflow/src/Extensions/ExpressionExtension.ts +++ b/packages/workflow/src/Extensions/ExpressionExtension.ts @@ -11,14 +11,6 @@ import { objectExtensions } from './ObjectExtensions'; const EXPRESSION_EXTENDER = 'extend'; -function isBlank(value: unknown) { - return value === null || value === undefined || !value; -} - -function isPresent(value: unknown) { - return !isBlank(value); -} - const EXTENSION_OBJECTS = [ arrayExtensions, dateExtensions, @@ -27,12 +19,6 @@ const EXTENSION_OBJECTS = [ stringExtensions, ]; -// eslint-disable-next-line @typescript-eslint/ban-types -const genericExtensions: Record = { - isBlank, - isPresent, -}; - const EXPRESSION_EXTENSION_METHODS = Array.from( new Set([ ...Object.keys(stringExtensions.functions), @@ -40,7 +26,6 @@ const EXPRESSION_EXTENSION_METHODS = Array.from( ...Object.keys(dateExtensions.functions), ...Object.keys(arrayExtensions.functions), ...Object.keys(objectExtensions.functions), - ...Object.keys(genericExtensions), '$if', ]), ); @@ -235,9 +220,6 @@ export function extend(input: unknown, functionName: string, args: unknown[]) { // eslint-disable-next-line return inputAny[functionName](...args); } - - // Use a generic version if available - foundFunction = genericExtensions[functionName]; } // No type specific or generic function found. Check to see if diff --git a/packages/workflow/test/ExpressionExtensions/GenericExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/GenericExtensions.test.ts deleted file mode 100644 index 0ebebce6e0585..0000000000000 --- a/packages/workflow/test/ExpressionExtensions/GenericExtensions.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { evaluate } from './Helpers'; - -describe('Data Transformation Functions', () => { - describe('Genric Data Transformation Functions', () => { - test('.isBlank() should work correctly on undefined', () => { - expect(evaluate('={{(undefined).isBlank()}}')).toEqual(true); - }); - }); -}); From 4ddf260afd5acf49b550ea67242c89e12161a3c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 20 Jan 2023 09:25:40 +0100 Subject: [PATCH 048/160] :fire: Remove check --- packages/workflow/src/Expression.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/packages/workflow/src/Expression.ts b/packages/workflow/src/Expression.ts index fc7134f186e51..8817bb7da92ae 100644 --- a/packages/workflow/src/Expression.ts +++ b/packages/workflow/src/Expression.ts @@ -309,19 +309,6 @@ export class Expression { ) { throw new Error('invalid syntax'); } - - if ( - typeof process === 'undefined' && - error instanceof Error && - error.name === 'TypeError' && - error.message.endsWith('is not a function') - ) { - const match = error.message.match(/(?[^.]+is not a function)/); - - if (!match?.groups?.msg) return null; - - throw new Error(match.groups.msg); - } } return null; } From 8264fcb79582cb55dc4257d8a0e375bdab761c1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 20 Jan 2023 09:36:25 +0100 Subject: [PATCH 049/160] :sparkles: Add `isNotEmpty` for objects --- packages/workflow/src/Extensions/ObjectExtensions.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/workflow/src/Extensions/ObjectExtensions.ts b/packages/workflow/src/Extensions/ObjectExtensions.ts index 8dc1ac28a6861..ea7a8538cb012 100644 --- a/packages/workflow/src/Extensions/ObjectExtensions.ts +++ b/packages/workflow/src/Extensions/ObjectExtensions.ts @@ -21,6 +21,10 @@ function isEmpty(value: object): boolean { return Object.keys(value).length === 0; } +function isNotEmpty(value: object): boolean { + return !isEmpty(value); +} + function hasField(value: object, extraArgs: string[]): boolean { const [name] = extraArgs; return name in value; @@ -93,6 +97,7 @@ export const objectExtensions: ExtensionMap = { typeName: 'Object', functions: { isEmpty, + isNotEmpty, merge, hasField, removeField, From ebe2ef8ac61b5d50c25fae377521ed9a8e0f7918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 20 Jan 2023 10:21:57 +0100 Subject: [PATCH 050/160] :truck: Rename `global` to `alpha` --- .../completions/__tests__/alpha.completions.test.ts | 6 +++--- .../{global.completions.ts => alpha.completions.ts} | 2 +- packages/editor-ui/src/plugins/codemirror/n8nLang.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) rename packages/editor-ui/src/plugins/codemirror/completions/{global.completions.ts => alpha.completions.ts} (92%) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts index d11fa7b14ee5a..234bc5246a0ea 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts @@ -1,4 +1,4 @@ -import { globalCompletions } from '../global.completions'; +import { alphaCompletions } from '../alpha.completions'; import { CompletionContext } from '@codemirror/autocomplete'; import { EditorState } from '@codemirror/state'; @@ -9,7 +9,7 @@ test('should return alphabetic char completion options: D', () => { const position = doc.indexOf('D') + 1; const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); - const result = globalCompletions(context); + const result = alphaCompletions(context); if (!result) throw new Error('Expected D completion options'); @@ -24,7 +24,7 @@ test('should not return alphabetic char completion options: $input.D', () => { const position = doc.indexOf('D') + 1; const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); - const result = globalCompletions(context); + const result = alphaCompletions(context); expect(result).toBeNull(); }); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/global.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts similarity index 92% rename from packages/editor-ui/src/plugins/codemirror/completions/global.completions.ts rename to packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts index 8bef0ff1ac6d9..de4ce81d05e33 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/global.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts @@ -5,7 +5,7 @@ import type { Completion, CompletionContext, CompletionResult } from '@codemirro /** * Completions for global vars, e.g. `D` -> `DateTime`. */ -export function globalCompletions(context: CompletionContext): CompletionResult | null { +export function alphaCompletions(context: CompletionContext): CompletionResult | null { const word = context.matchBefore(/(\s+)D[ateTim]*/); if (!word) return null; diff --git a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts index ae67ca2a0a75e..26e58ecb2eede 100644 --- a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts +++ b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts @@ -7,7 +7,7 @@ import { ifIn } from '@codemirror/autocomplete'; import { proxyCompletions } from './completions/proxy.completions'; import { rootCompletions } from './completions/root.completions'; import { luxonCompletions } from './completions/luxon.completions'; -import { globalCompletions } from './completions/global.completions'; +import { alphaCompletions } from './completions/alpha.completions'; import { datatypeCompletions } from './completions/datatype.completions'; import { blankCompletions } from './completions/blank.completions'; import { jsonBracketCompletions } from './completions/jsonBracket.completions'; @@ -30,7 +30,7 @@ export function n8nLang() { rootCompletions, // from `$` proxyCompletions, // from `$input.`, `$(...)`, etc. datatypeCompletions, // from primitives `'abc'.` and from references `$json.name.` - globalCompletions, // for global var: `D` -> `DateTime` + alphaCompletions, // for global var: `D` -> `DateTime` luxonCompletions, // from luxon vars: `DateTime.`, `$now.`, `$today.` jsonBracketCompletions, // from `json[` ].map((group) => n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], group) })); From e4652e164f599e819a5a097f175b512579f5cb87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 20 Jan 2023 15:05:27 +0100 Subject: [PATCH 051/160] :fire: Remove `encrypt` --- .../workflow/src/Extensions/StringExtensions.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 62709db7a48ae..67caa0a8c49cd 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -35,17 +35,17 @@ const URL_REGEXP = const CHAR_TEST_REGEXP = /\p{L}/u; const PUNC_TEST_REGEXP = /[!?.]/; -function encrypt(value: string, extraArgs?: unknown): string { - const [format = 'MD5'] = extraArgs as string[]; - if (format.toLowerCase() === 'base64') { +function hash(value: string, extraArgs?: unknown): string { + const [algorithm = 'MD5'] = extraArgs as string[]; + if (algorithm.toLowerCase() === 'base64') { // We're using a library instead of btoa because btoa only // works on ASCII return encode(value); } - const hashFunction = hashFunctions[format.toLowerCase()]; + const hashFunction = hashFunctions[algorithm.toLowerCase()]; if (!hashFunction) { throw new ExpressionError.ExpressionExtensionError( - `Unknown encrypt type ${format}. Available types are: ${Object.keys(hashFunctions) + `Unknown algorithm ${algorithm}. Available algorithms are: ${Object.keys(hashFunctions) .map((s) => s.toUpperCase()) .join(', ')}, and Base64.`, ); @@ -238,8 +238,7 @@ function extractUrl(value: string) { export const stringExtensions: ExtensionMap = { typeName: 'String', functions: { - encrypt, - hash: encrypt, + hash, removeMarkdown, stripTags, toDate, From ad9f22857d99cac513ad00dfd85337306ec151fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 20 Jan 2023 15:05:41 +0100 Subject: [PATCH 052/160] :zap: Restore `is not a function` error --- packages/workflow/src/Expression.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/workflow/src/Expression.ts b/packages/workflow/src/Expression.ts index 8817bb7da92ae..fc7134f186e51 100644 --- a/packages/workflow/src/Expression.ts +++ b/packages/workflow/src/Expression.ts @@ -309,6 +309,19 @@ export class Expression { ) { throw new Error('invalid syntax'); } + + if ( + typeof process === 'undefined' && + error instanceof Error && + error.name === 'TypeError' && + error.message.endsWith('is not a function') + ) { + const match = error.message.match(/(?[^.]+is not a function)/); + + if (!match?.groups?.msg) return null; + + throw new Error(match.groups.msg); + } } return null; } From 54db360a7f5bd1b906c7b1bdb8e6b5c5984680c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 20 Jan 2023 15:12:46 +0100 Subject: [PATCH 053/160] :zap: Support `week` on `extract()` --- packages/workflow/src/Extensions/DateExtensions.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/workflow/src/Extensions/DateExtensions.ts b/packages/workflow/src/Extensions/DateExtensions.ts index 5a58ec023ac41..b64fe5bfcfd84 100644 --- a/packages/workflow/src/Extensions/DateExtensions.ts +++ b/packages/workflow/src/Extensions/DateExtensions.ts @@ -22,6 +22,7 @@ type DurationUnit = | 'years'; type DatePart = | 'day' + | 'week' | 'month' | 'year' | 'hour' @@ -102,7 +103,7 @@ function endOfMonth(date: Date | DateTime): Date { } function extract(inputDate: Date | DateTime, extraArgs: DatePart[]): number | Date { - const [part] = extraArgs; + let [part] = extraArgs; let date = inputDate; if (isDateTime(date)) { date = date.toJSDate(); @@ -116,6 +117,10 @@ function extract(inputDate: Date | DateTime, extraArgs: DatePart[]): number | Da return Math.floor(diff / (1000 * 60 * 60 * 24)); } + if (part === 'week') { + part = 'weekNumber'; + } + return DateTime.fromJSDate(date).get((DATETIMEUNIT_MAP[part] as keyof DateTime) || part); } From b0b5f1882e24d8ea1b566a1f61868d70e9af509e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 20 Jan 2023 15:14:50 +0100 Subject: [PATCH 054/160] :test_tube: Fix tests --- .../ExpressionExtensions/StringExtensions.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts index 2bd6ae7d304de..4af2333767c53 100644 --- a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts @@ -16,17 +16,17 @@ describe('Data Transformation Functions', () => { expect(evaluate('={{"".isEmpty()}}')).toEqual(true); }); - test('.encrypt() should work correctly on a string', () => { - expect(evaluate('={{ "12345".encrypt("sha256") }}')).toEqual( - stringExtensions.functions.encrypt('12345', ['sha256']), + test('.hash() should work correctly on a string', () => { + expect(evaluate('={{ "12345".hash("sha256") }}')).toEqual( + stringExtensions.functions.hash('12345', ['sha256']), ); - expect(evaluate('={{ "12345".encrypt("sha256") }}')).not.toEqual( - stringExtensions.functions.encrypt('12345', ['MD5']), + expect(evaluate('={{ "12345".hash("sha256") }}')).not.toEqual( + stringExtensions.functions.hash('12345', ['MD5']), ); - expect(evaluate('={{ "12345".encrypt("MD5") }}')).toEqual( - stringExtensions.functions.encrypt('12345', ['MD5']), + expect(evaluate('={{ "12345".hash("MD5") }}')).toEqual( + stringExtensions.functions.hash('12345', ['MD5']), ); expect(evaluate('={{ "12345".hash("sha256") }}')).toEqual( From 4f2823ae0ea32530b0f2e275e7d3575a6357b0e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 20 Jan 2023 15:29:36 +0100 Subject: [PATCH 055/160] :zap: Add validation to some string extensions --- .../src/Extensions/StringExtensions.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 67caa0a8c49cd..5058ddce5d4a6 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -113,7 +113,13 @@ function stripTags(value: string): string { } function toDate(value: string): Date { - return new Date(value.toString()); + const date = new Date(value.toString()); + + if (date.toString() === 'Invalid Date') { + throw new ExpressionError.ExpressionExtensionError('cannot convert to date'); + } + + return date; } function urlDecode(value: string, extraArgs: boolean[]): string { @@ -134,11 +140,23 @@ function urlEncode(value: string, extraArgs: boolean[]): string { function toInt(value: string, extraArgs: Array) { const [radix] = extraArgs; - return parseInt(value.replace(CURRENCY_REGEXP, ''), radix); + const int = parseInt(value.replace(CURRENCY_REGEXP, ''), radix); + + if (isNaN(int)) { + throw new ExpressionError.ExpressionExtensionError('cannot convert to int'); + } + + return int; } function toFloat(value: string) { - return parseFloat(value.replace(CURRENCY_REGEXP, '')); + const float = parseFloat(value.replace(CURRENCY_REGEXP, '')); + + if (isNaN(float)) { + throw new ExpressionError.ExpressionExtensionError('cannot convert to float'); + } + + return float; } function quote(value: string, extraArgs: string[]) { From 2c9a58f9fb71e5d1c9f019c5b7646b65a29d5039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 20 Jan 2023 15:35:50 +0100 Subject: [PATCH 056/160] :zap: Validate number arrays in some extensions --- .../workflow/src/Extensions/ArrayExtensions.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/workflow/src/Extensions/ArrayExtensions.ts b/packages/workflow/src/Extensions/ArrayExtensions.ts index 7c3c7c86e940c..228e967e0712a 100644 --- a/packages/workflow/src/Extensions/ArrayExtensions.ts +++ b/packages/workflow/src/Extensions/ArrayExtensions.ts @@ -149,7 +149,15 @@ function unique(value: unknown[], extraArgs: string[]): unknown[] { }, []); } +const ensureNumberArray = (arr: unknown[]) => { + if (arr.some((i) => typeof i !== 'number')) { + throw new ExpressionExtensionError('all array elements must be of type number'); + } +}; + function sum(value: unknown[]): number { + ensureNumberArray(value); + return value.reduce((p: number, c: unknown) => { if (typeof c === 'string') { return p + parseFloat(c); @@ -162,6 +170,8 @@ function sum(value: unknown[]): number { } function min(value: unknown[]): number { + ensureNumberArray(value); + return Math.min( ...value.map((v) => { if (typeof v === 'string') { @@ -176,6 +186,8 @@ function min(value: unknown[]): number { } function max(value: unknown[]): number { + ensureNumberArray(value); + return Math.max( ...value.map((v) => { if (typeof v === 'string') { @@ -190,6 +202,8 @@ function max(value: unknown[]): number { } export function average(value: unknown[]) { + ensureNumberArray(value); + // This would usually be NaN but I don't think users // will expect that if (value.length === 0) { From 489977596e02943d63eedbfb5afa3ae7640744a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 20 Jan 2023 15:41:03 +0100 Subject: [PATCH 057/160] :test_tube: Fix tests --- .../ExpressionExtensions/ArrayExtensions.test.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts index c0ecc8b5fe796..0839956a75e1a 100644 --- a/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts @@ -105,26 +105,22 @@ describe('Data Transformation Functions', () => { test('.sum() should work on an array of numbers', () => { expect(evaluate('={{ [1, 2, 3, 4, 5, 6].sum() }}')).toEqual(21); - expect(evaluate('={{ ["1", 2, 3, 4, 5, 6].sum() }}')).toEqual(21); - expect(evaluate('={{ ["1", 2, 3, 4, 5, "bad"].sum() }}')).toBeNaN(); + expect(() => evaluate('={{ ["1", 2, 3, 4, 5, "bad"].sum() }}')).toThrow(); }); test('.average() should work on an array of numbers', () => { expect(evaluate('={{ [1, 2, 3, 4, 5, 6].average() }}')).toEqual(3.5); - expect(evaluate('={{ ["1", 2, 3, 4, 5, 6].average() }}')).toEqual(3.5); - expect(evaluate('={{ ["1", 2, 3, 4, 5, "bad"].average() }}')).toBeNaN(); + expect(() => evaluate('={{ ["1", 2, 3, 4, 5, "bad"].average() }}')).toThrow(); }); test('.min() should work on an array of numbers', () => { expect(evaluate('={{ [1, 2, 3, 4, 5, 6].min() }}')).toEqual(1); - expect(evaluate('={{ ["1", 2, 3, 4, 5, 6].min() }}')).toEqual(1); - expect(evaluate('={{ ["1", 2, 3, 4, 5, "bad"].min() }}')).toBeNaN(); + expect(() => evaluate('={{ ["1", 2, 3, 4, 5, "bad"].min() }}')).toThrow(); }); test('.max() should work on an array of numbers', () => { expect(evaluate('={{ [1, 2, 3, 4, 5, 6].max() }}')).toEqual(6); - expect(evaluate('={{ ["1", 2, 3, 4, 5, 6].max() }}')).toEqual(6); - expect(evaluate('={{ ["1", 2, 3, 4, 5, "bad"].max() }}')).toBeNaN(); + expect(() => evaluate('={{ ["1", 2, 3, 4, 5, "bad"].max() }}')).toThrow(); }); test('.union() should work on an array of objects', () => { From f1519b34be61ef10be7fd783cbbbc7f05b7b992d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 20 Jan 2023 15:50:23 +0100 Subject: [PATCH 058/160] :pencil2: Improve error message --- packages/workflow/src/Extensions/StringExtensions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 5058ddce5d4a6..59032638eef61 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -143,7 +143,7 @@ function toInt(value: string, extraArgs: Array) { const int = parseInt(value.replace(CURRENCY_REGEXP, ''), radix); if (isNaN(int)) { - throw new ExpressionError.ExpressionExtensionError('cannot convert to int'); + throw new ExpressionError.ExpressionExtensionError('cannot convert to integer'); } return int; From 18b185c8ea119ec939e7b70c792eee9c96f5700d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 20 Jan 2023 21:03:18 +0100 Subject: [PATCH 059/160] :rewind: Revert extensions framework changes --- .../workflow/src/Extensions/Extensions.ts | 33 +++------------ .../src/Extensions/NumberExtensions.ts | 2 - .../src/Extensions/StringExtensions.ts | 18 ++++++++ .../ExpressionExtension.test.ts | 42 +++++++++---------- .../NumberExtensions.test.ts | 4 +- .../StringExtensions.test.ts | 22 ++++++++++ 6 files changed, 66 insertions(+), 55 deletions(-) diff --git a/packages/workflow/src/Extensions/Extensions.ts b/packages/workflow/src/Extensions/Extensions.ts index dfea1845864f2..460567d0ebb01 100644 --- a/packages/workflow/src/Extensions/Extensions.ts +++ b/packages/workflow/src/Extensions/Extensions.ts @@ -1,28 +1,5 @@ -// @TODO: Improve typings, rename file - -type TypeName = 'String' | 'Number' | 'Array' | 'Object' | 'Date'; - -export type ExtensionMap = - | NumberExtensions - | StringExtensions - | ObjectExtensions - | DateExtensions - | ArrayExtensions; - -type ExtensionFunctionMetadata = { - description?: string; -}; - -type MakeExtensions = { - typeName: N; - functions: { - // eslint-disable-next-line @typescript-eslint/ban-types - [key: string]: Function & ExtensionFunctionMetadata; - }; -}; - -type NumberExtensions = MakeExtensions<'Number'>; -type StringExtensions = MakeExtensions<'String'>; -type ObjectExtensions = MakeExtensions<'Object'>; -type DateExtensions = MakeExtensions<'Date'>; -type ArrayExtensions = MakeExtensions<'Array'>; +export interface ExtensionMap { + typeName: string; + // eslint-disable-next-line @typescript-eslint/ban-types + functions: Record; +} diff --git a/packages/workflow/src/Extensions/NumberExtensions.ts b/packages/workflow/src/Extensions/NumberExtensions.ts index 6e80aaf654f9e..afa20c0b652ca 100644 --- a/packages/workflow/src/Extensions/NumberExtensions.ts +++ b/packages/workflow/src/Extensions/NumberExtensions.ts @@ -49,8 +49,6 @@ function ceil(value: number) { return Math.ceil(value); } -ceil.description = 'This is a description'; // @TODO: Add docs - function round(value: number, extraArgs: number[]) { const [decimalPlaces = 0] = extraArgs; return +value.toFixed(decimalPlaces); diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 4906980d24735..e0ce8cb4d3371 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -56,6 +56,18 @@ function encrypt(value: string, extraArgs?: unknown): string { // return createHash(format).update(value.toString()).digest('hex'); } +function getOnlyFirstCharacters(value: string, extraArgs: number[]): string { + const [end] = extraArgs; + + if (typeof end !== 'number') { + throw new ExpressionError.ExpressionExtensionError( + 'getOnlyFirstCharacters() requires a argument', + ); + } + + return value.slice(0, end); +} + function isBlank(value: string): boolean { return value === ''; } @@ -110,6 +122,10 @@ function removeMarkdown(value: string): string { return output; } +function sayHi(value: string) { + return `hi ${value}`; +} + function stripTags(value: string): string { return value.replace(/<[^>]*>?/gm, ''); } @@ -254,7 +270,9 @@ export const stringExtensions: ExtensionMap = { functions: { encrypt, hash: encrypt, + getOnlyFirstCharacters, removeMarkdown, + sayHi, stripTags, toBoolean: isTrue, toDate, diff --git a/packages/workflow/test/ExpressionExtensions/ExpressionExtension.test.ts b/packages/workflow/test/ExpressionExtensions/ExpressionExtension.test.ts index c49d8aff16cbe..72f1bd62c7131 100644 --- a/packages/workflow/test/ExpressionExtensions/ExpressionExtension.test.ts +++ b/packages/workflow/test/ExpressionExtensions/ExpressionExtension.test.ts @@ -12,15 +12,15 @@ describe('Expression Extension Transforms', () => { expect(extendTransform('"".isBlank()')!.code).toEqual('extend("", "isBlank", [])'); }); - test('Chained transform with .toSnakeCase.toSnakeCase', () => { - expect(extendTransform('"".toSnakeCase().toSnakeCase()')!.code).toEqual( - 'extend(extend("", "toSnakeCase", []), "toSnakeCase", [])', + test('Chained transform with .sayHi.getOnlyFirstCharacters', () => { + expect(extendTransform('"".sayHi().getOnlyFirstCharacters(2)')!.code).toEqual( + 'extend(extend("", "sayHi", []), "getOnlyFirstCharacters", [2])', ); }); - test('Chained transform with native functions .toSnakeCase.trim.toSnakeCase', () => { - expect(extendTransform('"aaa ".toSnakeCase().trim().toSnakeCase()')!.code).toEqual( - 'extend(extend("aaa ", "toSnakeCase", []).trim(), "toSnakeCase", [])', + test('Chained transform with native functions .sayHi.trim.getOnlyFirstCharacters', () => { + expect(extendTransform('"aaa ".sayHi().trim().getOnlyFirstCharacters(2)')!.code).toEqual( + 'extend(extend("aaa ", "sayHi", []).trim(), "getOnlyFirstCharacters", [2])', ); }); }); @@ -36,21 +36,19 @@ describe('tmpl Expression Parser', () => { }); test('Multiple expression', () => { - expect(splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format() }}.')).toEqual( - [ - { type: 'text', text: '' }, - { type: 'code', text: ' "test".toSnakeCase() ', hasClosingBrackets: true }, - { type: 'text', text: ' you have $' }, - { type: 'code', text: ' (100).format() ', hasClosingBrackets: true }, - { type: 'text', text: '.' }, - ], - ); + expect(splitExpression('{{ "test".sayHi() }} you have ${{ (100).format() }}.')).toEqual([ + { type: 'text', text: '' }, + { type: 'code', text: ' "test".sayHi() ', hasClosingBrackets: true }, + { type: 'text', text: ' you have $' }, + { type: 'code', text: ' (100).format() ', hasClosingBrackets: true }, + { type: 'text', text: '.' }, + ]); }); test('Unclosed expression', () => { - expect(splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format()')).toEqual([ + expect(splitExpression('{{ "test".sayHi() }} you have ${{ (100).format()')).toEqual([ { type: 'text', text: '' }, - { type: 'code', text: ' "test".toSnakeCase() ', hasClosingBrackets: true }, + { type: 'code', text: ' "test".sayHi() ', hasClosingBrackets: true }, { type: 'text', text: ' you have $' }, { type: 'code', text: ' (100).format()', hasClosingBrackets: false }, ]); @@ -77,16 +75,14 @@ describe('tmpl Expression Parser', () => { test('Multiple expression', () => { expect( - joinExpression( - splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format() }}.'), - ), - ).toEqual('{{ "test".toSnakeCase() }} you have ${{ (100).format() }}.'); + joinExpression(splitExpression('{{ "test".sayHi() }} you have ${{ (100).format() }}.')), + ).toEqual('{{ "test".sayHi() }} you have ${{ (100).format() }}.'); }); test('Unclosed expression', () => { expect( - joinExpression(splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format()')), - ).toEqual('{{ "test".toSnakeCase() }} you have ${{ (100).format()'); + joinExpression(splitExpression('{{ "test".sayHi() }} you have ${{ (100).format()')), + ).toEqual('{{ "test".sayHi() }} you have ${{ (100).format()'); }); test('Escaped opening bracket', () => { diff --git a/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts index 75b4999ec1b60..9dbd6c78a9d43 100644 --- a/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts @@ -77,8 +77,8 @@ describe('Data Transformation Functions', () => { describe('Multiple expressions', () => { test('Basic multiple expressions', () => { - expect(evaluate('={{ "abc def".toSnakeCase() }} you have ${{ (100).format() }}.')).toEqual( - 'abc_def you have $100.', + expect(evaluate('={{ "Test".sayHi() }} you have ${{ (100).format() }}.')).toEqual( + 'hi Test you have $100.', ); }); }); diff --git a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts index 5a6143b2ec5be..bc80d89c1cca8 100644 --- a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts @@ -16,6 +16,28 @@ describe('Data Transformation Functions', () => { expect(evaluate('={{"".isBlank()}}')).toEqual(true); }); + test('.getOnlyFirstCharacters() should work correctly on a string', () => { + expect(evaluate('={{"myNewField".getOnlyFirstCharacters(5)}}')).toEqual('myNew'); + + expect(evaluate('={{"myNewField".getOnlyFirstCharacters(10)}}')).toEqual('myNewField'); + + expect( + evaluate('={{"myNewField".getOnlyFirstCharacters(5).length >= "myNewField".length}}'), + ).toEqual(false); + + expect(evaluate('={{DateTime.now().toLocaleString().getOnlyFirstCharacters(2)}}')).toEqual( + stringExtensions.functions.getOnlyFirstCharacters( + // @ts-ignore + dateExtensions.functions.toLocaleString(new Date(), []), + [2], + ), + ); + }); + + test('.sayHi() should work correctly on a string', () => { + expect(evaluate('={{ "abc".sayHi() }}')).toEqual('hi abc'); + }); + test('.encrypt() should work correctly on a string', () => { expect(evaluate('={{ "12345".encrypt("sha256") }}')).toEqual( stringExtensions.functions.encrypt('12345', ['sha256']), From 6fb6e351534e8ddd6e2966d5f33e6ee06ce69b48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 20 Jan 2023 21:52:37 +0100 Subject: [PATCH 060/160] :broom: Previews cleanup --- .../src/components/ExpressionEdit.vue | 10 - .../ExpressionEditorModalInput.vue | 24 +- .../components/ExpressionParameterInput.vue | 10 - .../InlineExpressionEditorInput.vue | 34 +-- .../event-bus/completion-preview-event-bus.ts | 3 - .../editor-ui/src/mixins/completionManager.ts | 90 +------ .../editor-ui/src/mixins/expressionManager.ts | 240 +++++------------- .../completions/datatype.completions.ts | 3 +- .../completions/root.completions.ts | 6 +- .../plugins/codemirror/completions/utils.ts | 2 +- .../codemirror/resolvableHighlighter.ts | 38 +-- packages/editor-ui/src/plugins/i18n/index.ts | 6 - .../src/plugins/i18n/locales/en.json | 1 - packages/workflow/src/ErrorCodes.ts | 4 - packages/workflow/src/Expression.ts | 12 +- packages/workflow/src/index.ts | 1 - 16 files changed, 106 insertions(+), 378 deletions(-) delete mode 100644 packages/editor-ui/src/event-bus/completion-preview-event-bus.ts delete mode 100644 packages/workflow/src/ErrorCodes.ts diff --git a/packages/editor-ui/src/components/ExpressionEdit.vue b/packages/editor-ui/src/components/ExpressionEdit.vue index 9c5eac294d326..32daae6fda8bb 100644 --- a/packages/editor-ui/src/components/ExpressionEdit.vue +++ b/packages/editor-ui/src/components/ExpressionEdit.vue @@ -91,7 +91,6 @@ import { mapStores } from 'pinia'; import { useWorkflowsStore } from '@/stores/workflows'; import { useNDVStore } from '@/stores/ndv'; import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils'; -import { completionPreviewEventBus } from '@/event-bus/completion-preview-event-bus'; import type { Segment } from '@/types/expressions'; @@ -111,19 +110,10 @@ export default mixins(externalHooks, genericHelpers, debounceHelper).extend({ expressionsDocsUrl: EXPRESSIONS_DOCS_URL, }; }, - mounted() { - completionPreviewEventBus.$on('preview-completion', this.previewSegments); - }, - destroyed() { - completionPreviewEventBus.$off('preview-completion', this.previewSegments); - }, computed: { ...mapStores(useNDVStore, useWorkflowsStore), }, methods: { - previewSegments(previewSegments: Segment[]) { - this.segments = previewSegments; - }, valueChanged({ value, segments }: { value: string; segments: Segment[] }, forceUpdate = false) { this.latestValue = value; this.segments = segments; diff --git a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue index 18c6201361631..e319486578e84 100644 --- a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue +++ b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue @@ -16,8 +16,8 @@ import { n8nLang } from '@/plugins/codemirror/n8nLang'; import { highlighter } from '@/plugins/codemirror/resolvableHighlighter'; import { inputTheme } from './theme'; import { forceParse } from '@/utils/forceParse'; -import { autocompletion, selectedCompletion } from '@codemirror/autocomplete'; -// import { completionPreviewEventBus } from '@/event-bus/completion-preview-event-bus'; +import { autocompletion } from '@codemirror/autocomplete'; + import type { IVariableItemSelected } from '@/Interface'; export default mixins(expressionManager, completionManager, workflowHelpers).extend({ @@ -41,10 +41,8 @@ export default mixins(expressionManager, completionManager, workflowHelpers).ext mounted() { const extensions = [ inputTheme(), + autocompletion(), Prec.highest(this.previewKeymap), - autocompletion({ - aboveCursor: true, - }), n8nLang(), history(), expressionInputHandler(), @@ -52,26 +50,14 @@ export default mixins(expressionManager, completionManager, workflowHelpers).ext EditorState.readOnly.of(this.isReadOnly), EditorView.domEventHandlers({ scroll: forceParse }), EditorView.updateListener.of((viewUpdate) => { - if (!this.editor) return; - - // const completion = selectedCompletion(this.editor.state); - - // if (completion) { - // const previewSegments = this.toPreviewSegments(completion, this.editor.state); - - // completionPreviewEventBus.$emit('preview-completion', previewSegments); - - // return; - // } - - if (!viewUpdate.docChanged) return; + if (!this.editor || !viewUpdate.docChanged) return; highlighter.removeColor(this.editor, this.plaintextSegments); highlighter.addColor(this.editor, this.resolvableSegments); try { this.trackCompletion(viewUpdate, this.path); - } catch (_) {} + } catch {} setTimeout(() => this.editor?.focus()); // prevent blur on paste diff --git a/packages/editor-ui/src/components/ExpressionParameterInput.vue b/packages/editor-ui/src/components/ExpressionParameterInput.vue index 08646a00d073a..51e58eef9dc9d 100644 --- a/packages/editor-ui/src/components/ExpressionParameterInput.vue +++ b/packages/editor-ui/src/components/ExpressionParameterInput.vue @@ -75,7 +75,6 @@ import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/In import ExpressionFunctionIcon from '@/components/ExpressionFunctionIcon.vue'; import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils'; import { EXPRESSIONS_DOCS_URL } from '@/constants'; -import { completionPreviewEventBus } from '@/event-bus/completion-preview-event-bus'; import type { Segment } from '@/types/expressions'; import type { TargetItem } from '@/Interface'; @@ -110,12 +109,6 @@ export default Vue.extend({ default: false, }, }, - mounted() { - completionPreviewEventBus.$on('preview-completion', this.previewSegments); - }, - destroyed() { - completionPreviewEventBus.$off('preview-completion', this.previewSegments); - }, computed: { ...mapStores(useNDVStore, useWorkflowsStore), hoveringItemNumber(): number { @@ -129,9 +122,6 @@ export default Vue.extend({ }, }, methods: { - previewSegments(previewSegments: Segment[]) { - this.segments = previewSegments; - }, focus() { const inlineInput = this.$refs.inlineInput as (Vue & HTMLElement) | undefined; diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue index 57d18df98953a..a2649d26c7f79 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue +++ b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue @@ -13,7 +13,7 @@ import { mapStores } from 'pinia'; import { EditorView } from '@codemirror/view'; import { EditorState, Prec } from '@codemirror/state'; import { history } from '@codemirror/commands'; -import { autocompletion, selectedCompletion } from '@codemirror/autocomplete'; +import { autocompletion } from '@codemirror/autocomplete'; import { useNDVStore } from '@/stores/ndv'; import { workflowHelpers } from '@/mixins/workflowHelpers'; @@ -22,7 +22,6 @@ import { highlighter } from '@/plugins/codemirror/resolvableHighlighter'; import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler'; import { inputTheme } from './theme'; import { n8nLang } from '@/plugins/codemirror/n8nLang'; -// import { completionPreviewEventBus } from '@/event-bus/completion-preview-event-bus'; import { completionManager } from '@/mixins/completionManager'; export default mixins(completionManager, expressionManager, workflowHelpers).extend({ @@ -43,22 +42,29 @@ export default mixins(completionManager, expressionManager, workflowHelpers).ext type: String, }, }, - watch: { value(newValue) { const isInternalChange = newValue === this.editor?.state.doc.toString(); if (isInternalChange) return; - // on external change (e.g. from expression modal or mapping drop), dispatch to update + // manual update on external change, e.g. from expression modal or mapping drop this.editor?.dispatch({ - changes: { from: 0, to: this.editor?.state.doc.length, insert: newValue }, + changes: { + from: 0, + to: this.editor?.state.doc.length, + insert: newValue, + }, }); }, ndvInputData() { this.editor?.dispatch({ - changes: { from: 0, to: this.editor.state.doc.length, insert: this.value }, + changes: { + from: 0, + to: this.editor.state.doc.length, + insert: this.value, + }, }); setTimeout(() => { @@ -88,26 +94,14 @@ export default mixins(completionManager, expressionManager, workflowHelpers).ext }, }), EditorView.updateListener.of((viewUpdate) => { - if (!this.editor) return; - - const completion = selectedCompletion(this.editor.state); - - // if (completion) { - // const previewSegments = this.toPreviewSegments(completion, this.editor.state); - - // completionPreviewEventBus.$emit('preview-completion', previewSegments); - - // return; - // } - - if (!viewUpdate.docChanged) return; + if (!this.editor || !viewUpdate.docChanged) return; highlighter.removeColor(this.editor, this.plaintextSegments); highlighter.addColor(this.editor, this.resolvableSegments); try { this.trackCompletion(viewUpdate, this.path); - } catch (_) {} + } catch {} this.$emit('change', { value: this.unresolvedExpression, diff --git a/packages/editor-ui/src/event-bus/completion-preview-event-bus.ts b/packages/editor-ui/src/event-bus/completion-preview-event-bus.ts deleted file mode 100644 index a61abebd36918..0000000000000 --- a/packages/editor-ui/src/event-bus/completion-preview-event-bus.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Vue from 'vue'; - -export const completionPreviewEventBus = new Vue(); diff --git a/packages/editor-ui/src/mixins/completionManager.ts b/packages/editor-ui/src/mixins/completionManager.ts index 9db0d7a8d7fc4..b49b422141e90 100644 --- a/packages/editor-ui/src/mixins/completionManager.ts +++ b/packages/editor-ui/src/mixins/completionManager.ts @@ -1,13 +1,8 @@ import mixins from 'vue-typed-mixins'; import { ExpressionExtensions } from 'n8n-workflow'; import { EditorView, keymap, ViewUpdate } from '@codemirror/view'; -import { - completionStatus, - currentCompletions, - selectedCompletionIndex, -} from '@codemirror/autocomplete'; +import { completionStatus } from '@codemirror/autocomplete'; -// import { completionPreviewEventBus } from '@/event-bus/completion-preview-event-bus'; import { expressionManager } from './expressionManager'; import type { Extension } from '@codemirror/state'; @@ -15,14 +10,15 @@ import type { Extension } from '@codemirror/state'; export const completionManager = mixins(expressionManager).extend({ data() { return { - editor: null as EditorView | null, - errorsInSuccession: 0, + editor: {} as EditorView, }; }, - computed: { + /** + * Map of expression extensions to categories, only for telemetry. + */ expressionExtensionsCategories() { - return ExpressionExtensions.reduce>((acc, cur) => { + return ExpressionExtensions.reduce>((acc, cur) => { for (const funcName of Object.keys(cur.functions)) { acc[funcName] = cur.typeName; } @@ -30,80 +26,26 @@ export const completionManager = mixins(expressionManager).extend({ return acc; }, {}); }, + + /** + * Prevent completions dismissal from also closing container if modal. + */ previewKeymap(): Extension { return keymap.of([ { any(view: EditorView, event: KeyboardEvent) { if (event.key === 'Escape' && completionStatus(view.state) !== null) { - // prevent completions dismissal from also closing modal event.stopPropagation(); } return false; }, }, - { - key: 'Escape', - run: (view) => { - if (completionStatus(view.state) !== null) { - this.$emit('change', { - value: this.unresolvedExpression, - segments: this.displayableSegments, - }); - } - - return false; - }, - }, - // { - // key: 'ArrowUp', - // run: (view) => { - // const completion = this.getCompletion('previous'); - - // if (completion === null) return false; - - // const previewSegments = this.toPreviewSegments(completion, view.state); - - // completionPreviewEventBus.$emit('preview-completion', previewSegments); - - // return false; - // }, - // }, - // { - // key: 'ArrowDown', - // run: (view) => { - // const completion = this.getCompletion('next'); - - // if (completion === null) return false; - - // const previewSegments = this.toPreviewSegments(completion, view.state); - - // completionPreviewEventBus.$emit('preview-completion', previewSegments); - - // return false; - // }, - // }, ]); }, }, methods: { - getCompletion(which: 'previous' | 'next') { - if (!this.editor) return null; - - if (completionStatus(this.editor.state) !== 'active') return null; - - const currentIndex = selectedCompletionIndex(this.editor.state); - - if (currentIndex === null) return null; - - const requestedIndex = which === 'previous' ? currentIndex - 1 : currentIndex + 1; - - return currentCompletions(this.editor.state)[requestedIndex] ?? null; - }, - trackCompletion(viewUpdate: ViewUpdate, parameterPath: string) { - if (!this.editor) return; - const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete')); if (!completionTx) return; @@ -112,14 +54,12 @@ export const completionManager = mixins(expressionManager).extend({ let completionBase = ''; viewUpdate.changes.iterChanges((_: number, __: number, fromB: number, toB: number) => { - if (!this.editor) return; - completion = this.editor.state.doc.slice(fromB, toB).toString(); - const completionBaseStartIndex = this.findCompletionStart(fromB); + const index = this.findCompletionBaseStartIndex(fromB); completionBase = this.editor.state.doc - .slice(completionBaseStartIndex, fromB - 1) + .slice(index, fromB - 1) .toString() .trim(); }); @@ -133,15 +73,13 @@ export const completionManager = mixins(expressionManager).extend({ field_type: 'expression', context: completionBase, inserted_text: completion, - category: category ?? 'none', // only applicable for expression extension completion + category: category ?? 'n/a', // only applicable if expression extension completion }; this.$telemetry.track('User autocompleted code', payload); }, - findCompletionStart(fromIndex: number) { - if (!this.editor) return -1; - + findCompletionBaseStartIndex(fromIndex: number) { const INDICATORS = [ ' $', // proxy '{ ', // primitive diff --git a/packages/editor-ui/src/mixins/expressionManager.ts b/packages/editor-ui/src/mixins/expressionManager.ts index 9ea25d3cceee0..024d3eb5a35bf 100644 --- a/packages/editor-ui/src/mixins/expressionManager.ts +++ b/packages/editor-ui/src/mixins/expressionManager.ts @@ -1,19 +1,11 @@ import mixins from 'vue-typed-mixins'; -import { - ExpressionExtensions, - EXPRESSION_RESOLUTION_ERROR_CODES as ERROR_CODES, -} from 'n8n-workflow'; +import { ExpressionExtensions } from 'n8n-workflow'; import { mapStores } from 'pinia'; import { ensureSyntaxTree } from '@codemirror/language'; -import { EditorState } from '@codemirror/state'; -import { Completion } from '@codemirror/autocomplete'; -import { i18n } from '@/plugins/i18n'; import { workflowHelpers } from '@/mixins/workflowHelpers'; -import { n8nLang } from '@/plugins/codemirror/n8nLang'; import { useNDVStore } from '@/stores/ndv'; import { EXPRESSION_EDITOR_PARSER_TIMEOUT } from '@/constants'; -import { completionPreviewEventBus } from '@/event-bus/completion-preview-event-bus'; import type { PropType } from 'vue'; import type { EditorView } from '@codemirror/view'; @@ -28,15 +20,10 @@ export const expressionManager = mixins(workflowHelpers).extend({ }, data() { return { - editor: null as EditorView | null, - errorsInSuccession: 0, // @TODO: No longer used? + editor: {} as EditorView, }; }, watch: { - isCursorAtCompletablePrefix() { - // @TODO: Overrides output but is not a preview, so improve naming - completionPreviewEventBus.$emit('preview-completion', this.segments); - }, targetItem() { setTimeout(() => { this.$emit('change', { @@ -69,83 +56,74 @@ export const expressionManager = mixins(workflowHelpers).extend({ return this.segments.filter((s): s is Plaintext => s.kind === 'plaintext'); }, - segments(): Segment[] { - if (!this.editor) return []; - - return this.toSegments(this.editor.state); + expressionExtensionNames(): Set { + return new Set( + ExpressionExtensions.reduce((acc, cur) => { + return [...acc, ...Object.keys(cur.functions)]; + }, []), + ); }, + // @TODO: Used? cursorPosition(): number { - if (!this.editor) return -1; - return this.editor.state.selection.ranges[0].from; }, - /** - * Whether cursor position is at `{{ $| }}`. - */ - isCursorAtCompletablePrefix(): boolean { - if (!this.editor) return false; + segments(): Segment[] { + const rawSegments: RawSegment[] = []; - return ( - this.editor.state.doc - .slice(this.cursorPosition - '{{ $'.length, this.cursorPosition + ' }}'.length) - .toString() === '{{ $ }}' + const fullTree = ensureSyntaxTree( + this.editor.state, + this.editor.state.doc.length, + EXPRESSION_EDITOR_PARSER_TIMEOUT, ); - }, - // @TODO: No longer used? - evaluationDelay() { - const DEFAULT_EVALUATION_DELAY = 300; // ms + if (fullTree === null) { + throw new Error(`Failed to parse expression: ${this.editor.state.doc.toString()}`); + } - const prevErrorsInSuccession = this.errorsInSuccession; + fullTree.cursor().iterate((node) => { + if (node.type.name === 'Program') return; - if (this.resolvableSegments.filter((s) => s.error).length > 0) { - this.errorsInSuccession += 1; - } else { - this.errorsInSuccession = 0; - } + rawSegments.push({ + from: node.from, + to: node.to, + text: this.editor.state.sliceDoc(node.from, node.to), + token: node.type.name, + }); + }); - const addsNewError = this.errorsInSuccession > prevErrorsInSuccession; + return rawSegments.reduce((acc, segment) => { + const { from, to, text, token } = segment; - let delay = DEFAULT_EVALUATION_DELAY; + if (token === 'Plaintext') { + return acc.push({ kind: 'plaintext', from, to, plaintext: text }), acc; + } - if (addsNewError && this.errorsInSuccession > 1 && this.errorsInSuccession < 5) { - delay = DEFAULT_EVALUATION_DELAY * this.errorsInSuccession; - } else if (addsNewError && this.errorsInSuccession >= 5) { - delay = 0; - } + const { resolved, error, fullError } = this.resolve(text, this.hoveringItem); - return delay; - }, + acc.push({ kind: 'resolvable', from, to, resolvable: text, resolved, error, fullError }); - expressionExtensions() { - return new Set( - ExpressionExtensions.reduce((acc, cur) => { - return [...acc, ...Object.keys(cur.functions)]; - }, []), - ); + return acc; + }, []); }, /** - * Some segments are conditionally displayed, i.e. not displayed when they are - * _part_ of the result, but displayed when they are the _entire_ result. + * Segments to display in the output of an expression editor. * - * Example: - * - Expression `This is a {{ [] }} test` is displayed as `This is a test`. - * - Expression `{{ [] }}` is displayed as `[Array: []]`. + * Some segments are not displayed when they are _part_ of the result, + * but displayed when they are the _entire_ result: * - * Conditionally displayed segments: - * - `[Array: []]` - * - `[empty]` (from `''`, not from `undefined`) + * - `This is a {{ [] }} test` displays as `This is a test`. + * - `{{ [] }}` displays as `[Array: []]`. * - * Exceptionally, for two segments, display differs based on context: - * - Date is displayed as - * - `Mon Nov 14 2022 17:26:13 GMT+0100 (CST)` when part of the result - * - `[Object: "2022-11-14T17:26:13.130Z"]` when the entire result - * - Non-empty array is displayed as - * - `1,2,3` when part of the result - * - `[Array: [1, 2, 3]]` when the entire result + * Some segments display differently based on context: + * + * Date displays as + * - `Mon Nov 14 2022 17:26:13 GMT+0100 (CST)` when part of the result + * - `[Object: "2022-11-14T17:26:13.130Z"]` when the entire result + * + * Only needed in order to mimic behavior of `ParameterInputHint`. */ displayableSegments(): Segment[] { return this.segments @@ -179,105 +157,8 @@ export const expressionManager = mixins(workflowHelpers).extend({ }, }, methods: { - toSegments(state: EditorState, { isPreview } = { isPreview: false }) { - const rawSegments: RawSegment[] = []; - - const fullTree = ensureSyntaxTree(state, state.doc.length, EXPRESSION_EDITOR_PARSER_TIMEOUT); - - if (fullTree === null) { - throw new Error(`Failed to parse expression: ${state.doc.toString()}`); - } - - fullTree.cursor().iterate((node) => { - if (!this.editor || node.type.name === 'Program') return; - - rawSegments.push({ - from: node.from, - to: node.to, - text: state.sliceDoc(node.from, node.to), - token: node.type.name, - }); - }); - - return rawSegments.reduce((acc, segment) => { - const { from, to, text, token } = segment; - - if (token === 'Plaintext') { - return acc.push({ kind: 'plaintext', from, to, plaintext: text }), acc; - } - - // eslint-disable-next-line prefer-const - let { resolved, error, fullError } = this.resolve(text, this.hoveringItem); - - /** - * If this is a preview of an uncalled function, call it and display it - * with a hint `[if called:] [result]` if the call succeeds - */ - // if ( - // isPreview && - // hasErrorCode(fullError) && - // fullError.cause.code === ERROR_CODES.UNCALLED_FUNCTION - // ) { - // const textWithCall = text.replace(/\s{1}}}$/, '() }}'); // @TODO: Improve this replacement - // const resultWithCall = this.resolve(textWithCall, this.hoveringItem); - - // const hint = this.$locale.baseText('expressionEditor.previewHint'); - - // if (!resultWithCall.error) { - // resolved = [hint, resultWithCall.resolved].join(' '); - // error = false; - // fullError = null; - // } else { - // fullError = new Error(i18n.expressionEditor.previewUnavailable); - // resolved = fullError.message; - // } - // } - - if ( - this.isCursorAtCompletablePrefix && - hasErrorCode(fullError) && - fullError.cause.code === ERROR_CODES.STANDALONE_PREFIX - ) { - fullError = new Error(i18n.expressionEditor.completablePrefix); - resolved = fullError.message; - } - - acc.push({ kind: 'resolvable', from, to, resolvable: text, resolved, error, fullError }); - - return acc; - }, []); - }, - - toPreviewSegments(completion: Completion, state: EditorState) { - if (!this.editor) return []; - - const cursorPosition = state.selection.ranges[0].from; - - const firstHalf = state.doc.slice(0, cursorPosition).toString(); - const secondHalf = state.doc.slice(cursorPosition, state.doc.length).toString(); - - const previewDoc = [ - firstHalf, - firstHalf.endsWith('$') ? completion.label.slice(1) : completion.label, - secondHalf, - ].join(''); - - const previewState = EditorState.create({ - doc: previewDoc, - extensions: [n8nLang()], - }); - - return this.toSegments(previewState, { isPreview: true }); - }, - - isUncalledExpressionExtension(resolvable: string) { - const end = resolvable - .replace(/^{{|}}$/g, '') - .trim() - .split('.') - .pop(); - - return end && this.expressionExtensions.has(end); + isEmptyExpression(resolvable: string) { + return /\{\{\s*\}\}/.test(resolvable); }, resolve(resolvable: string, targetItem?: TargetItem) { @@ -304,7 +185,7 @@ export const expressionManager = mixins(workflowHelpers).extend({ result.resolved = this.$locale.baseText('expressionModalInput.empty'); } - if (result.resolved === undefined && /\{\{\s*\}\}/.test(resolvable)) { + if (result.resolved === undefined && this.isEmptyExpression(resolvable)) { result.resolved = this.$locale.baseText('expressionModalInput.empty'); } @@ -322,14 +203,15 @@ export const expressionManager = mixins(workflowHelpers).extend({ return result; }, + + isUncalledExpressionExtension(resolvable: string) { + const end = resolvable + .replace(/^{{|}}$/g, '') + .trim() + .split('.') + .pop(); + + return end !== undefined && this.expressionExtensionNames.has(end); + }, }, }); - -function hasErrorCode(error: Error | null): error is Error & { cause: { code: number } } { - return ( - error instanceof Error && - typeof error.cause === 'object' && - error.cause !== null && - 'code' in error.cause - ); -} diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 3b8781c35c33e..3294b3a370ee7 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -138,7 +138,8 @@ const extensionOptions = (typeName: 'String' | 'Number' | 'Date' | 'Object' | 'A type: 'function', }; - if (f.description) option.info = f.description; + // @TODO + // if (f.description) option.info = f.description; return option; }); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts index d96937df8d44e..cb1fe7b814a95 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts @@ -1,5 +1,5 @@ import { i18n } from '@/plugins/i18n'; -import { autocompletableNodeNames, inputHasNoBinaryData, longestCommonPrefix } from './utils'; +import { autocompletableNodeNames, receivesNoBinaryData, longestCommonPrefix } from './utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; /** @@ -33,10 +33,10 @@ export function rootCompletions(context: CompletionContext): CompletionResult | } export function generateOptions() { - const BOOST_SET = new Set(['$input', '$json']); + const BOOST_SET = new Set(['$input', '$json']); // @TODO: Refactor boosting const SKIP_SET = new Set(); - if (inputHasNoBinaryData()) SKIP_SET.add('$binary'); + if (receivesNoBinaryData()) SKIP_SET.add('$binary'); // @TODO: Add $parameter to i18n and remove here const rootKeys = [...Object.keys(i18n.rootVars), '$parameter'].sort((a, b) => { diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts index cd065b278f92c..40476cf7d1bdd 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -35,7 +35,7 @@ export const isAllowedInDotNotation = (str: string) => { export const isSplitInBatchesAbsent = () => !useWorkflowsStore().workflow.nodes.some((node) => node.type === SPLIT_IN_BATCHES_NODE_TYPE); -export const inputHasNoBinaryData = () => resolveParameter('={{ $binary }}')?.data === undefined; +export const receivesNoBinaryData = () => resolveParameter('={{ $binary }}')?.data === undefined; export function hasNoParams(toResolve: string) { const PSEUDO_PARAMS = ['notice']; // not proper params, no user input allowed diff --git a/packages/editor-ui/src/plugins/codemirror/resolvableHighlighter.ts b/packages/editor-ui/src/plugins/codemirror/resolvableHighlighter.ts index 0328e331abe16..489d637453a2f 100644 --- a/packages/editor-ui/src/plugins/codemirror/resolvableHighlighter.ts +++ b/packages/editor-ui/src/plugins/codemirror/resolvableHighlighter.ts @@ -1,10 +1,8 @@ import { EditorView, Decoration, DecorationSet } from '@codemirror/view'; -import { StateField, StateEffect, Range, Transaction } from '@codemirror/state'; +import { StateField, StateEffect } from '@codemirror/state'; import { tags } from '@lezer/highlight'; import { syntaxHighlighting, HighlightStyle } from '@codemirror/language'; -import { i18n } from '@/plugins/i18n'; - import type { ColoringStateEffect, Plaintext, Resolvable, Resolved } from '@/types/expressions'; const cssClasses = { @@ -12,7 +10,6 @@ const cssClasses = { invalidResolvable: 'cm-invalid-resolvable', brokenResolvable: 'cm-broken-resolvable', plaintext: 'cm-plaintext', - // previewHint: 'cm-preview-hint', }; const resolvablesTheme = EditorView.theme({ @@ -24,15 +21,11 @@ const resolvablesTheme = EditorView.theme({ color: 'var(--color-invalid-resolvable-foreground)', backgroundColor: 'var(--color-invalid-resolvable-background)', }, - // ['.' + cssClasses.previewHint]: { - // fontWeight: 'bold', - // }, }); const marks = { valid: Decoration.mark({ class: cssClasses.validResolvable }), invalid: Decoration.mark({ class: cssClasses.invalidResolvable }), - // previewHint: Decoration.mark({ class: cssClasses.previewHint }), }; const coloringStateEffects = { @@ -74,13 +67,11 @@ const coloringStateField = StateField.define({ const decoration = txEffect.value.error ? marks.invalid : marks.valid; - const payload = [decoration.range(txEffect.value.from, txEffect.value.to)]; - - // stylePreviewHint(transaction, txEffect, payload); - if (txEffect.value.from === 0 && txEffect.value.to === 0) continue; - colorings = colorings.update({ add: payload }); + colorings = colorings.update({ + add: [decoration.range(txEffect.value.from, txEffect.value.to)], + }); } } @@ -88,27 +79,6 @@ const coloringStateField = StateField.define({ }, }); -// function stylePreviewHint( -// transaction: Transaction, -// txEffect: StateEffect, -// payload: Array>, -// ) { -// if (txEffect.value.error) return; - -// const validResolvableText = transaction.state.doc -// .slice(txEffect.value.from, txEffect.value.to) -// .toString(); - -// if (validResolvableText.startsWith(i18n.expressionEditor.previewHint)) { -// payload.push( -// marks.previewHint.range( -// txEffect.value.from, -// txEffect.value.from + i18n.expressionEditor.previewHint.length, -// ), -// ); -// } -// } - function addColor(view: EditorView, segments: Array) { const effects: Array> = segments.map(({ from, to, kind, error }) => coloringStateEffects.addColorEffect.of({ from, to, kind, error }), diff --git a/packages/editor-ui/src/plugins/i18n/index.ts b/packages/editor-ui/src/plugins/i18n/index.ts index fd8aca5d60bfc..f3bf5c14ad163 100644 --- a/packages/editor-ui/src/plugins/i18n/index.ts +++ b/packages/editor-ui/src/plugins/i18n/index.ts @@ -326,12 +326,6 @@ export class I18nClass { }; } - expressionEditor: Record = { - completablePrefix: this.baseText('expressionEditor.completablePrefix'), - // previewHint: this.baseText('expressionEditor.previewHint'), - previewUnavailable: this.baseText('expressionEditor.previewUnavailable'), - }; - rootVars: Record = { $binary: this.baseText('codeNodeEditor.completer.binary'), $execution: this.baseText('codeNodeEditor.completer.$execution'), diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 25377ce977082..c6535f46a6d64 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -497,7 +497,6 @@ "expressionEdit.expression": "Expression", "expressionEdit.resultOfItem1": "Result of item 1", "expressionEdit.variableSelector": "Variable Selector", - "expressionEditor.completablePrefix": "['$' is a prefix, press ctrl+space for autocomplete options]", "expressionEditor.uncalledFunction": "[this is a function, please add ()]", "expressionModalInput.empty": "[empty]", "expressionModalInput.undefined": "[undefined]", diff --git a/packages/workflow/src/ErrorCodes.ts b/packages/workflow/src/ErrorCodes.ts deleted file mode 100644 index 2134889152d00..0000000000000 --- a/packages/workflow/src/ErrorCodes.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const EXPRESSION_RESOLUTION_ERROR_CODES = { - STANDALONE_PREFIX: 0, // $ - UNCALLED_FUNCTION: 1, // $input.first -} as const; diff --git a/packages/workflow/src/Expression.ts b/packages/workflow/src/Expression.ts index a44f4f9c0591c..bcbd6db3756c5 100644 --- a/packages/workflow/src/Expression.ts +++ b/packages/workflow/src/Expression.ts @@ -28,7 +28,6 @@ import { } from './Extensions/ExpressionParser'; import { extendTransform } from './Extensions/ExpressionExtension'; import { extendedFunctions } from './Extensions/ExtendedFunctions'; -import { EXPRESSION_RESOLUTION_ERROR_CODES } from './ErrorCodes'; // Set it to use double curly brackets instead of single ones tmpl.brackets.set('{{ }}'); @@ -281,15 +280,8 @@ export class Expression { const extendedExpression = this.extendSyntax(parameterValue); const returnValue = this.renderExpression(extendedExpression, data); if (typeof returnValue === 'function') { - if (returnValue.name === '$') { - throw new Error('invalid syntax', { - cause: { code: EXPRESSION_RESOLUTION_ERROR_CODES.STANDALONE_PREFIX }, - }); - } - - throw new Error('this is a function, please add ()', { - cause: { code: EXPRESSION_RESOLUTION_ERROR_CODES.UNCALLED_FUNCTION }, - }); + if (returnValue.name === '$') throw new Error('invalid syntax'); + throw new Error('This is a function. Please add ()'); } else if (typeof returnValue === 'string') { return returnValue; } else if (returnValue !== null && typeof returnValue === 'object') { diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index 0298cbe32d597..6a976ca3251f9 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -30,4 +30,3 @@ export { } from './type-guards'; export { ExpressionExtensions } from './Extensions'; -export { EXPRESSION_RESOLUTION_ERROR_CODES } from './ErrorCodes'; From 67f57a80bc518d4d0269a7d4f451f2eba38b82a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 20 Jan 2023 22:27:22 +0100 Subject: [PATCH 061/160] :zap: Condense blank completions --- .../__tests__/root.completions.test.ts | 8 +++--- .../completions/blank.completions.ts | 26 +++++-------------- ...t.completions.ts => dollar.completions.ts} | 8 +++--- .../src/plugins/codemirror/n8nLang.ts | 6 ++--- 4 files changed, 17 insertions(+), 31 deletions(-) rename packages/editor-ui/src/plugins/codemirror/completions/{root.completions.ts => dollar.completions.ts} (88%) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/root.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/root.completions.test.ts index 87c38da079627..6a2f4db70d3c5 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/root.completions.test.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/root.completions.test.ts @@ -1,4 +1,4 @@ -import { rootCompletions } from '../root.completions'; +import { dollarCompletions } from '../dollar.completions'; import { CompletionContext } from '@codemirror/autocomplete'; import { EditorState } from '@codemirror/state'; import { setActivePinia } from 'pinia'; @@ -15,7 +15,7 @@ test('should return completion options: $', () => { const position = doc.indexOf('$') + 1; const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); - const result = rootCompletions(context); + const result = dollarCompletions(context); if (!result) throw new Error('Expected dollar-sign completion options'); @@ -55,7 +55,7 @@ test('should return completion options: $(', () => { const position = doc.indexOf('(') + 1; const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); - const result = rootCompletions(context); + const result = dollarCompletions(context); if (!result) throw new Error('Expected dollar-sign-selector completion options'); @@ -74,7 +74,7 @@ test('should not return completion options for regular strings', () => { const position = doc.indexOf('o') + 1; const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); - const result = rootCompletions(context); + const result = dollarCompletions(context); expect(result).toBeNull(); }); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts index 82e686dbebdc9..837deaf169819 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts @@ -1,35 +1,21 @@ -import { longestCommonPrefix } from './utils'; -import { generateOptions as generateRootOptions } from './root.completions'; -import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import { generateDollarOptions } from './dollar.completions'; +import type { CompletionContext, CompletionResult } from '@codemirror/autocomplete'; /** - * Completions from blank position: {{ | }} + * Completions offered at the blank position: `{{ | }}` */ export function blankCompletions(context: CompletionContext): CompletionResult | null { const word = context.matchBefore(/\{\{\s/); - const afterCursor = context.state.doc.slice(context.pos, context.pos + ' }}'.length).toString(); + const afterCursor = context.state.sliceDoc(context.pos, context.pos + ' }}'.length).toString(); if (!word || afterCursor !== ' }}') return null; if (word.from === word.to && !context.explicit) return null; - let options = generateRootOptions(); - - const userInput = word.text.replace(/^{{/, '').trim(); - - if (userInput.length > 0) { - options = options.filter((o) => o.label.startsWith(userInput) && userInput !== o.label); - } - return { - from: word.to - userInput.length, - options, + from: word.to, + options: generateDollarOptions(), filter: false, - getMatch(completion: Completion) { - const lcp = longestCommonPrefix([userInput, completion.label]); - - return [0, lcp.length]; - }, }; } diff --git a/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts similarity index 88% rename from packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts rename to packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts index cb1fe7b814a95..71f98513c37ab 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts @@ -3,16 +3,16 @@ import { autocompletableNodeNames, receivesNoBinaryData, longestCommonPrefix } f import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; /** - * Completions from `$` to proxies. + * Completions offered at the dollar position: `$` */ -export function rootCompletions(context: CompletionContext): CompletionResult | null { +export function dollarCompletions(context: CompletionContext): CompletionResult | null { const word = context.matchBefore(/\$\w*[^.}]*/); if (!word) return null; if (word.from === word.to && !context.explicit) return null; - let options = generateOptions(); + let options = generateDollarOptions(); const { text: userInput } = word; @@ -32,7 +32,7 @@ export function rootCompletions(context: CompletionContext): CompletionResult | }; } -export function generateOptions() { +export function generateDollarOptions() { const BOOST_SET = new Set(['$input', '$json']); // @TODO: Refactor boosting const SKIP_SET = new Set(); diff --git a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts index 26e58ecb2eede..0cc3576e84415 100644 --- a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts +++ b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts @@ -5,7 +5,7 @@ import { javascriptLanguage } from '@codemirror/lang-javascript'; import { ifIn } from '@codemirror/autocomplete'; import { proxyCompletions } from './completions/proxy.completions'; -import { rootCompletions } from './completions/root.completions'; +import { dollarCompletions } from './completions/dollar.completions'; import { luxonCompletions } from './completions/luxon.completions'; import { alphaCompletions } from './completions/alpha.completions'; import { datatypeCompletions } from './completions/datatype.completions'; @@ -26,8 +26,8 @@ const n8nLanguage = LRLanguage.define({ parser: n8nParserWithNestedJsParser }); export function n8nLang() { const options = [ - blankCompletions, // from `{{ | }}` - rootCompletions, // from `$` + blankCompletions, + dollarCompletions, proxyCompletions, // from `$input.`, `$(...)`, etc. datatypeCompletions, // from primitives `'abc'.` and from references `$json.name.` alphaCompletions, // for global var: `D` -> `DateTime` From 45b647811f9a4b3ca86745c20cc754407006223a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 20 Jan 2023 23:07:12 +0100 Subject: [PATCH 062/160] :zap: Refactor dollar completions --- .../completions/dollar.completions.ts | 42 ++++++++++++------- .../plugins/codemirror/completions/utils.ts | 16 +++++++ packages/editor-ui/src/plugins/i18n/index.ts | 1 + .../src/plugins/i18n/locales/en.json | 1 + packages/workflow/src/Expression.ts | 2 +- 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts index 71f98513c37ab..8be47ad4d0cc2 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts @@ -1,9 +1,16 @@ import { i18n } from '@/plugins/i18n'; -import { autocompletableNodeNames, receivesNoBinaryData, longestCommonPrefix } from './utils'; +import { + autocompletableNodeNames, + receivesNoBinaryData, + longestCommonPrefix, + bringForward, +} from './utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; /** * Completions offered at the dollar position: `$` + * + * Negative charset `[^.}]` ensures match stays within resolvable boundaries. */ export function dollarCompletions(context: CompletionContext): CompletionResult | null { const word = context.matchBefore(/\$\w*[^.}]*/); @@ -14,9 +21,13 @@ export function dollarCompletions(context: CompletionContext): CompletionResult let options = generateDollarOptions(); - const { text: userInput } = word; + const userInput = word.text; - if (userInput !== '' && userInput !== '$') { + /** + * If user typed anything after `$`, narrow down options based on + * left-to-right match of options to user input. + */ + if (userInput !== '$') { options = options.filter((o) => o.label.startsWith(userInput) && userInput !== o.label); } @@ -24,6 +35,10 @@ export function dollarCompletions(context: CompletionContext): CompletionResult from: word.to - userInput.length, options, filter: false, + + /** + * Compute underline range based on left-to-right match. + */ getMatch(completion: Completion) { const lcp = longestCommonPrefix([userInput, completion.label]); @@ -33,23 +48,18 @@ export function dollarCompletions(context: CompletionContext): CompletionResult } export function generateDollarOptions() { - const BOOST_SET = new Set(['$input', '$json']); // @TODO: Refactor boosting - const SKIP_SET = new Set(); - - if (receivesNoBinaryData()) SKIP_SET.add('$binary'); + const BOOST = ['$input', '$json']; + const SKIP = new Set(); + const FUNCTIONS = ['$jmespath']; - // @TODO: Add $parameter to i18n and remove here - const rootKeys = [...Object.keys(i18n.rootVars), '$parameter'].sort((a, b) => { - if (BOOST_SET.has(a)) return -1; - if (BOOST_SET.has(b)) return 1; + if (receivesNoBinaryData()) SKIP.add('$binary'); - return a.localeCompare(b); - }); + const keys = Object.keys(i18n.rootVars).sort((a, b) => a.localeCompare(b)); - const options: Completion[] = rootKeys - .filter((key) => !SKIP_SET.has(key)) + const options = bringForward(keys, BOOST) + .filter((key) => !SKIP.has(key)) .map((key) => { - const isFunction = ['$jmespath'].includes(key); + const isFunction = FUNCTIONS.includes(key); const option: Completion = { label: isFunction ? key + '()' : key, diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts index 40476cf7d1bdd..cd7056baf68c9 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -23,6 +23,22 @@ export const longestCommonPrefix = (strings: string[]) => { }); }; +/** + * Move selected elements to the start of an array, in order. + * Selected elements are assumed to be in the array. + */ +export function bringForward(array: string[], selected: string[]) { + const copy = [...array]; + + [...selected].reverse().forEach((s) => { + const index = copy.indexOf(s); + + if (index !== -1) copy.unshift(copy.splice(index, 1)[0]); + }); + + return copy; +} + /** * Whether a string may be used as a key in object dot notation access. */ diff --git a/packages/editor-ui/src/plugins/i18n/index.ts b/packages/editor-ui/src/plugins/i18n/index.ts index f3bf5c14ad163..1655f6d331213 100644 --- a/packages/editor-ui/src/plugins/i18n/index.ts +++ b/packages/editor-ui/src/plugins/i18n/index.ts @@ -334,6 +334,7 @@ export class I18nClass { $json: this.baseText('codeNodeEditor.completer.json'), $itemIndex: this.baseText('codeNodeEditor.completer.$itemIndex'), $now: this.baseText('codeNodeEditor.completer.$now'), + $parameter: this.baseText('codeNodeEditor.completer.$parameter'), $prevNode: this.baseText('codeNodeEditor.completer.$prevNode'), $runIndex: this.baseText('codeNodeEditor.completer.$runIndex'), $today: this.baseText('codeNodeEditor.completer.$today'), diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index c6535f46a6d64..cee2688b41a0f 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -123,6 +123,7 @@ "codeNodeEditor.completer.$itemIndex": "The position of the current item in the list of items", "codeNodeEditor.completer.$jmespath": "Evaluate a JMESPath expression", "codeNodeEditor.completer.$now": "The current timestamp (as a Luxon object)", + "codeNodeEditor.completer.$parameter": "The parameters of the current node", "codeNodeEditor.completer.$prevNode": "The node providing the input data for this run", "codeNodeEditor.completer.$prevNode.name": "The name of the node providing the input data for this run", "codeNodeEditor.completer.$prevNode.outputIndex": "The output connector of the node providing input data for this run", diff --git a/packages/workflow/src/Expression.ts b/packages/workflow/src/Expression.ts index bcbd6db3756c5..0c8cda59329f3 100644 --- a/packages/workflow/src/Expression.ts +++ b/packages/workflow/src/Expression.ts @@ -281,7 +281,7 @@ export class Expression { const returnValue = this.renderExpression(extendedExpression, data); if (typeof returnValue === 'function') { if (returnValue.name === '$') throw new Error('invalid syntax'); - throw new Error('This is a function. Please add ()'); + throw new Error('this is a function, please add ()'); } else if (typeof returnValue === 'string') { return returnValue; } else if (returnValue !== null && typeof returnValue === 'object') { From 8292b5f892a97b3a9044f902c3c7567582beb77a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 20 Jan 2023 23:26:50 +0100 Subject: [PATCH 063/160] :zap: Refactor non-dollar completions --- .../__tests__/alpha.completions.test.ts | 6 +-- .../completions/alpha.completions.ts | 50 ------------------- .../completions/nonDollar.completions.ts | 28 +++++++++++ .../plugins/codemirror/completions/utils.ts | 1 + .../src/plugins/codemirror/n8nLang.ts | 4 +- packages/workflow/src/Expression.ts | 4 ++ 6 files changed, 38 insertions(+), 55 deletions(-) delete mode 100644 packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts index 234bc5246a0ea..d94fe8bfead04 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts @@ -1,4 +1,4 @@ -import { alphaCompletions } from '../alpha.completions'; +import { nonDollarCompletions } from '../nonDollar.completions'; import { CompletionContext } from '@codemirror/autocomplete'; import { EditorState } from '@codemirror/state'; @@ -9,7 +9,7 @@ test('should return alphabetic char completion options: D', () => { const position = doc.indexOf('D') + 1; const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); - const result = alphaCompletions(context); + const result = nonDollarCompletions(context); if (!result) throw new Error('Expected D completion options'); @@ -24,7 +24,7 @@ test('should not return alphabetic char completion options: $input.D', () => { const position = doc.indexOf('D') + 1; const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); - const result = alphaCompletions(context); + const result = nonDollarCompletions(context); expect(result).toBeNull(); }); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts deleted file mode 100644 index de4ce81d05e33..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { i18n } from '@/plugins/i18n'; -import { longestCommonPrefix } from './utils'; -import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; - -/** - * Completions for global vars, e.g. `D` -> `DateTime`. - */ -export function alphaCompletions(context: CompletionContext): CompletionResult | null { - const word = context.matchBefore(/(\s+)D[ateTim]*/); - - if (!word) return null; - - if (word.from === word.to && !context.explicit) return null; - - let options = generateOptions(); - - const userInput = word.text.trim(); - - if (userInput !== '' && userInput !== '$') { - options = options.filter((o) => o.label.startsWith(userInput) && userInput !== o.label); - } - - return { - from: word.to - userInput.length, - options, - filter: false, - getMatch(completion: Completion) { - const lcp = longestCommonPrefix([userInput, completion.label]); - - return [0, lcp.length]; - }, - }; -} - -function generateOptions() { - const ALPHABETIC_KEYS = ['DateTime']; - - return ALPHABETIC_KEYS.map((key) => { - const option: Completion = { - label: key, - type: 'keyword', - }; - - const info = i18n.rootVars[key]; - - if (info) option.info = info; - - return option; - }); -} diff --git a/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts new file mode 100644 index 0000000000000..1b91a6a473634 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts @@ -0,0 +1,28 @@ +import { i18n } from '@/plugins/i18n'; +import type { CompletionContext, CompletionResult } from '@codemirror/autocomplete'; + +/** + * Completions offered at the base position for any char other than `$`. + * + * Currently only `D` for `DateTime`. + */ +export function nonDollarCompletions(context: CompletionContext): CompletionResult | null { + const word = context.matchBefore(/(\s+)D[ateTim]*/); // loose charset but covered by CodeMirror's filter + + if (!word) return null; + + if (word.from === word.to && !context.explicit) return null; + + const userInput = word.text.trim(); + + return { + from: word.to - userInput.length, + options: [ + { + label: 'DateTime', + type: 'keyword', + info: i18n.rootVars.DateTime, + }, + ], + }; +} diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts index cd7056baf68c9..643036fc61ae2 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -9,6 +9,7 @@ export function autocompletableNodeNames() { .map((node) => node.name); } +// @TODO: Refactor to take two args export const longestCommonPrefix = (strings: string[]) => { if (strings.length === 0) return ''; diff --git a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts index 0cc3576e84415..749ba9b8438bd 100644 --- a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts +++ b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts @@ -7,7 +7,7 @@ import { ifIn } from '@codemirror/autocomplete'; import { proxyCompletions } from './completions/proxy.completions'; import { dollarCompletions } from './completions/dollar.completions'; import { luxonCompletions } from './completions/luxon.completions'; -import { alphaCompletions } from './completions/alpha.completions'; +import { nonDollarCompletions } from './completions/nonDollar.completions'; import { datatypeCompletions } from './completions/datatype.completions'; import { blankCompletions } from './completions/blank.completions'; import { jsonBracketCompletions } from './completions/jsonBracket.completions'; @@ -28,9 +28,9 @@ export function n8nLang() { const options = [ blankCompletions, dollarCompletions, + nonDollarCompletions, proxyCompletions, // from `$input.`, `$(...)`, etc. datatypeCompletions, // from primitives `'abc'.` and from references `$json.name.` - alphaCompletions, // for global var: `D` -> `DateTime` luxonCompletions, // from luxon vars: `DateTime.`, `$now.`, `$today.` jsonBracketCompletions, // from `json[` ].map((group) => n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], group) })); diff --git a/packages/workflow/src/Expression.ts b/packages/workflow/src/Expression.ts index 0c8cda59329f3..6017bc142155f 100644 --- a/packages/workflow/src/Expression.ts +++ b/packages/workflow/src/Expression.ts @@ -281,6 +281,10 @@ export class Expression { const returnValue = this.renderExpression(extendedExpression, data); if (typeof returnValue === 'function') { if (returnValue.name === '$') throw new Error('invalid syntax'); + + if (returnValue.name === 'DateTime') + throw new Error('this is a DateTime, please access its methods'); + throw new Error('this is a function, please add ()'); } else if (typeof returnValue === 'string') { return returnValue; From 213735d0d5cf5af55325950fb56ff3fadd10018d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Sat, 21 Jan 2023 00:12:16 +0100 Subject: [PATCH 064/160] :zap: Refactor Luxon completions --- .../completions/blank.completions.ts | 4 +- .../completions/dollar.completions.ts | 8 +-- .../completions/jsonBracket.completions.ts | 6 +- .../completions/luxon.completions.ts | 72 ++++++++++++------- .../completions/nonDollar.completions.ts | 17 +++-- 5 files changed, 64 insertions(+), 43 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts index 837deaf169819..7c04c64c9c94f 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts @@ -1,4 +1,4 @@ -import { generateDollarOptions } from './dollar.completions'; +import { dollarOptions } from './dollar.completions'; import type { CompletionContext, CompletionResult } from '@codemirror/autocomplete'; /** @@ -15,7 +15,7 @@ export function blankCompletions(context: CompletionContext): CompletionResult | return { from: word.to, - options: generateDollarOptions(), + options: dollarOptions(), filter: false, }; } diff --git a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts index 8be47ad4d0cc2..97d3de0035f9a 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts @@ -10,16 +10,16 @@ import type { Completion, CompletionContext, CompletionResult } from '@codemirro /** * Completions offered at the dollar position: `$` * - * Negative charset `[^.}]` ensures match stays within resolvable boundaries. + * Negative charset `[^}]` ensures match stays within resolvable boundaries. */ export function dollarCompletions(context: CompletionContext): CompletionResult | null { - const word = context.matchBefore(/\$\w*[^.}]*/); + const word = context.matchBefore(/\$\w*[^}]*/); if (!word) return null; if (word.from === word.to && !context.explicit) return null; - let options = generateDollarOptions(); + let options = dollarOptions(); const userInput = word.text; @@ -47,7 +47,7 @@ export function dollarCompletions(context: CompletionContext): CompletionResult }; } -export function generateDollarOptions() { +export function dollarOptions() { const BOOST = ['$input', '$json']; const SKIP = new Set(); const FUNCTIONS = ['$jmespath']; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/jsonBracket.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/jsonBracket.completions.ts index ae7757dbdd921..1592a68d7e098 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/jsonBracket.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/jsonBracket.completions.ts @@ -4,7 +4,7 @@ import type { IDataObject } from 'n8n-workflow'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; /** - * Completions from `$json[` and `.json[` to their keys. + * Completions offered at these positions: `$json[` and `.json[` */ export function jsonBracketCompletions(context: CompletionContext): CompletionResult | null { const word = context.matchBefore(/\$[\S]*json\[.*/); @@ -25,7 +25,7 @@ export function jsonBracketCompletions(context: CompletionContext): CompletionRe if (resolved === null) return null; - let options = getJsonBracketOptions(resolved); + let options = jsonBracketOptions(resolved); const delimiter = word.text.startsWith('$json') ? '$json[' : '.json['; @@ -47,7 +47,7 @@ export function jsonBracketCompletions(context: CompletionContext): CompletionRe }; } -function getJsonBracketOptions(resolved: IDataObject) { +function jsonBracketOptions(resolved: IDataObject) { return Object.keys(resolved).map((key) => { return { label: `'${key}']`, diff --git a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts index 8cefca4dc01e5..af44987455e35 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts @@ -3,58 +3,58 @@ import { longestCommonPrefix } from './utils'; import { DateTime } from 'luxon'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +/** + * Completions offered at the end position of a Luxon entity. + * + * - `DateTime.` + * - `$now.` + * - `$today.` + */ export function luxonCompletions(context: CompletionContext): CompletionResult | null { - const word = context.matchBefore(/(DateTime|\$(now|today)*)\.(\w|\.|\(|\))*/); // + const word = context.matchBefore(/(DateTime|\$now|\$today)+\.[^}]*/); if (!word) return null; if (word.from === word.to && !context.explicit) return null; - const toResolve = word.text.endsWith('.') - ? word.text.slice(0, -1) - : word.text.split('.').slice(0, -1).join('.'); + const [base, tail] = splitBaseTail(word.text); - let options = getLuxonOptions(toResolve); + let options = base === 'DateTime' ? dateTimeOptions() : nowTodayOptions(); - const userInputTail = word.text.split('.').pop(); - - if (userInputTail === undefined) return null; - - if (userInputTail !== '') { - options = options.filter((o) => o.label.startsWith(userInputTail) && userInputTail !== o.label); + /** + * If user typed anything after `.`, narrow down options based on + * left-to-right match of options to user input. + */ + if (tail !== '') { + options = options.filter((o) => o.label.startsWith(tail) && tail !== o.label); } return { - from: word.to - userInputTail.length, + from: word.to - tail.length, options, filter: false, getMatch(completion: Completion) { - const lcp = longestCommonPrefix([userInputTail, completion.label]); + const lcp = longestCommonPrefix([tail, completion.label]); return [0, lcp.length]; }, }; } -function getLuxonOptions(toResolve: string): Completion[] { - if (toResolve === '$now' || toResolve === '$today') return nowTodayOptions(); - if (toResolve === 'DateTime') return dateTimeOptions(); - - return []; -} - export const nowTodayOptions = () => { - const SKIP_SET = new Set(['constructor', 'get', 'invalidExplanation', 'invalidReason']); + const SKIP = new Set(['constructor', 'get', 'invalidExplanation', 'invalidReason']); + + const map = Object.getOwnPropertyDescriptors(DateTime.prototype); - const entries = Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype)) - .filter(([key]) => !SKIP_SET.has(key)) + const entries = Object.entries(map) + .filter(([key]) => !SKIP.has(key)) .sort(([a], [b]) => a.localeCompare(b)); return entries.map(([key, descriptor]) => { const isFunction = typeof descriptor.value === 'function'; const option: Completion = { - label: key, + label: isFunction ? key + '()' : key, type: isFunction ? 'function' : 'keyword', }; @@ -67,10 +67,12 @@ export const nowTodayOptions = () => { }; export const dateTimeOptions = () => { - const SKIP_SET = new Set(['prototype', 'name', 'length', 'invalid']); + const SKIP = new Set(['prototype', 'name', 'length', 'invalid']); - const keys = Object.keys(Object.getOwnPropertyDescriptors(DateTime)) - .filter((key) => !SKIP_SET.has(key) && !key.includes('_')) + const map = Object.getOwnPropertyDescriptors(DateTime); + + const keys = Object.keys(map) + .filter((key) => !SKIP.has(key) && !key.includes('_')) .sort((a, b) => a.localeCompare(b)); return keys.map((key) => { @@ -82,3 +84,19 @@ export const dateTimeOptions = () => { return option; }); }; + +/** + * Split user input into base (Luxon entity) and tail (trailing section for filtering). + * + * ``` + * DateTime. -> ['DateTime', ''] + * DateTime.fr -> ['DateTime', 'fr'] + * ``` + */ +function splitBaseTail(userInput: string): [string, string] { + const parts = userInput.split('.'); + + const [tail, ...base] = parts.reverse(); + + return [base.join('.'), tail]; +} diff --git a/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts index 1b91a6a473634..0d3324ab1fa85 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts @@ -15,14 +15,17 @@ export function nonDollarCompletions(context: CompletionContext): CompletionResu const userInput = word.text.trim(); + const options = [ + { + label: 'DateTime', + type: 'keyword', + info: i18n.rootVars.DateTime, + }, + ].filter((o) => o.label.startsWith(userInput) && userInput !== o.label); + return { from: word.to - userInput.length, - options: [ - { - label: 'DateTime', - type: 'keyword', - info: i18n.rootVars.DateTime, - }, - ], + filter: false, + options, }; } From 1100f74303fc4d3ea50aae1f8ca6c80b2eb091c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Sat, 21 Jan 2023 14:39:03 +0100 Subject: [PATCH 065/160] :zap: Refactor datatype completions --- ...ons.test.ts => dollar.completions.test.ts} | 0 ....test.ts => nonDollar.completions.test.ts} | 0 .../__tests__/proxy.completions.test.ts | 146 --------------- .../completions/blank.completions.ts | 8 +- .../completions/bracketAccess.completions.ts | 72 ++++++++ .../completions/datatype.completions.ts | 169 ++++++++++-------- .../completions/dollar.completions.ts | 41 ++--- .../completions/jsonBracket.completions.ts | 57 ------ .../completions/luxon.completions.ts | 74 +++----- .../completions/nonDollar.completions.ts | 5 +- .../completions/proxy.completions.ts | 123 ------------- .../plugins/codemirror/completions/utils.ts | 53 ++++-- .../src/plugins/codemirror/n8nLang.ts | 14 +- packages/workflow/src/WorkflowDataProxy.ts | 2 +- 14 files changed, 266 insertions(+), 498 deletions(-) rename packages/editor-ui/src/plugins/codemirror/completions/__tests__/{root.completions.test.ts => dollar.completions.test.ts} (100%) rename packages/editor-ui/src/plugins/codemirror/completions/__tests__/{alpha.completions.test.ts => nonDollar.completions.test.ts} (100%) delete mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/proxy.completions.test.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts delete mode 100644 packages/editor-ui/src/plugins/codemirror/completions/jsonBracket.completions.ts delete mode 100644 packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/root.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/dollar.completions.test.ts similarity index 100% rename from packages/editor-ui/src/plugins/codemirror/completions/__tests__/root.completions.test.ts rename to packages/editor-ui/src/plugins/codemirror/completions/__tests__/dollar.completions.test.ts diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/nonDollar.completions.test.ts similarity index 100% rename from packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts rename to packages/editor-ui/src/plugins/codemirror/completions/__tests__/nonDollar.completions.test.ts diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/proxy.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/proxy.completions.test.ts deleted file mode 100644 index ec5ad40b586f8..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/proxy.completions.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { proxyCompletions } from '../proxy.completions'; -import { CompletionContext } from '@codemirror/autocomplete'; -import { EditorState } from '@codemirror/state'; -import { setActivePinia } from 'pinia'; -import { createTestingPinia } from '@pinia/testing'; -import { vi } from 'vitest'; -import { v4 as uuidv4 } from 'uuid'; -import * as workflowHelpers from '@/mixins/workflowHelpers'; -import { - executionProxy, - inputProxy, - itemProxy, - nodeSelectorProxy, - prevNodeProxy, - workflowProxy, -} from './proxyMocks'; -import { IDataObject } from 'n8n-workflow'; - -const EXPLICIT = false; - -beforeEach(() => { - setActivePinia(createTestingPinia()); -}); - -function testCompletionOptions(proxy: IDataObject, toResolve: string) { - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(proxy); - - const doc = `{{ ${toResolve}. }}`; - const position = doc.indexOf('.') + 1; - const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); - - const result = proxyCompletions(context); - - if (!result) throw new Error(`Expected ${toResolve} completion options`); - - const { options: actual, from } = result; - - expect(actual.map((o) => o.label)).toEqual(Reflect.ownKeys(proxy)); - expect(from).toEqual(position); -} - -// input proxy - -test('should return proxy completion options: $input', () => { - testCompletionOptions(inputProxy, '$input'); -}); - -// item proxy - -test('should return proxy completion options: $input.first()', () => { - testCompletionOptions(itemProxy, '$input.first()'); -}); - -test('should return proxy completion options: $input.last()', () => { - testCompletionOptions(itemProxy, '$input.last()'); -}); - -test('should return proxy completion options: $input.item', () => { - testCompletionOptions(itemProxy, '$input.item'); -}); - -test('should return proxy completion options: $input.all()[0]', () => { - testCompletionOptions(itemProxy, '$input.all()[0]'); -}); - -// json proxy - -test('should return proxy completion options: $json', () => { - testCompletionOptions(workflowProxy, '$json'); -}); - -// prevNode proxy - -test('should return proxy completion options: $prevNode', () => { - testCompletionOptions(prevNodeProxy, '$prevNode'); -}); - -// execution proxy - -test('should return proxy completion options: $execution', () => { - testCompletionOptions(executionProxy, '$execution'); -}); - -// workflow proxy - -test('should return proxy completion options: $workflow', () => { - testCompletionOptions(workflowProxy, '$workflow'); -}); - -// node selector proxy - -test('should return proxy completion options: $()', () => { - const firstNodeName = 'Manual'; - const secondNodeName = 'Set'; - - const nodes = [ - { - id: uuidv4(), - name: firstNodeName, - position: [0, 0], - type: 'n8n-nodes-base.manualTrigger', - typeVersion: 1, - }, - { - id: uuidv4(), - name: secondNodeName, - position: [0, 0], - type: 'n8n-nodes-base.set', - typeVersion: 1, - }, - ]; - - const connections = { - Manual: { - main: [ - [ - { - node: 'Set', - type: 'main', - index: 0, - }, - ], - ], - }, - }; - - const initialState = { workflows: { workflow: { nodes, connections } } }; - - setActivePinia(createTestingPinia({ initialState })); - - testCompletionOptions(nodeSelectorProxy, "$('Set')"); -}); - -// no proxy - -test('should not return completion options for non-existing proxies', () => { - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(null); - - const doc = '{{ $hello. }}'; - const position = doc.indexOf('.') + 1; - const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); - - const result = proxyCompletions(context); - - expect(result).toBeNull(); -}); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts index 7c04c64c9c94f..dee2b327a1b2f 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts @@ -7,12 +7,14 @@ import type { CompletionContext, CompletionResult } from '@codemirror/autocomple export function blankCompletions(context: CompletionContext): CompletionResult | null { const word = context.matchBefore(/\{\{\s/); - const afterCursor = context.state.sliceDoc(context.pos, context.pos + ' }}'.length).toString(); - - if (!word || afterCursor !== ' }}') return null; + if (!word) return null; if (word.from === word.to && !context.explicit) return null; + const afterCursor = context.state.sliceDoc(context.pos, context.pos + ' }}'.length); + + if (afterCursor !== ' }}') return null; + return { from: word.to, options: dollarOptions(), diff --git a/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts new file mode 100644 index 0000000000000..67b998194aff5 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts @@ -0,0 +1,72 @@ +import { resolveParameter } from '@/mixins/workflowHelpers'; +import { prefixMatch, longestCommonPrefix } from './utils'; +import type { IDataObject } from 'n8n-workflow'; +import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; + +/** + * Completions offered at the start of bracket access notation, + * including keys for objects and indices for arrays and strings. + * + * - `$json[` + * - `$input.item.json[` + * - `$json['field'][` + * - `$json.myObj[` + * - `$('Test').last().json.myArr[` + * - `$input.first().json.myStr[` + */ +export function bracketAccessCompletions(context: CompletionContext): CompletionResult | null { + const word = context.matchBefore(/\$[\S]*\[.*/); + + if (!word) return null; + + if (word.from === word.to && !context.explicit) return null; + + const skipBracketAccessCompletions = ['$input[', '$now[', '$today[']; + + if (skipBracketAccessCompletions.includes(word.text)) return null; + + const base = word.text.substring(0, word.text.lastIndexOf('[')); + const tail = word.text.split('[').pop() ?? ''; + + let resolved: IDataObject | null; + + try { + resolved = resolveParameter(`={{ ${base} }}`); + } catch (_) { + return null; + } + + if (resolved === null || resolved === undefined) return null; + + let options = objectBracketOptions(resolved); + + if (tail !== '') { + options = options.filter((o) => prefixMatch(o.label, tail)); + } + + return { + from: word.to - tail.length, + options, + filter: false, + getMatch(completion: Completion) { + const lcp = longestCommonPrefix(tail, completion.label); + + return [0, lcp.length]; + }, + }; +} + +function objectBracketOptions(resolved: IDataObject) { + const SKIP = new Set(['__ob__', 'pairedItem']); + + return Object.keys(resolved) + .filter((key) => !SKIP.has(key)) + .map((key) => { + const isNumber = !isNaN(parseInt(key)); // array or string index + + return { + label: isNumber ? `${key}]` : `'${key}']`, + type: 'keyword', + }; + }); +} diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 3294b3a370ee7..e0b210afe250b 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -1,31 +1,37 @@ import { ExpressionExtensions, IDataObject } from 'n8n-workflow'; +import { i18n } from '@/plugins/i18n'; import { resolveParameter } from '@/mixins/workflowHelpers'; -import { isAllowedInDotNotation, longestCommonPrefix } from './utils'; +import { + bringToStart, + hasNoParams, + prefixMatch, + isAllowedInDotNotation, + isSplitInBatchesAbsent, + longestCommonPrefix, + splitBaseTail, + isPseudoParam, +} from './utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; /** - * Completions from datatypes to expression extensions. + * Completions offered for values based on their datatype. */ export function datatypeCompletions(context: CompletionContext): CompletionResult | null { - // ---------------------------------- - // match before cursor - // ---------------------------------- - - const referenceRegex = /\$[\S]+\.([^{\s])*/; // $input. - const numberRegex = /(\d+)\.?(\d*)\.([^{\s])*/; // 123. or 123.4. - const stringRegex = /(".+"|('.+'))\.([^{\s])*/; // 'abc'. or "abc". - const arrayRegex = /(\[.+\])\.([^{\s])*/; // [1, 2, 3]. - const objectRegex = /\(\{.*\}\)\.([^{\s])*/; // ({}). - const dateRegex = /\(?new Date\(\(?.*?\)\)?\.([^{\s])*/; // new Date(). or (new Date()). + const reference = /\$[\S]+\.([^{\s])*/; // $input. + const numberLiteral = /\((\d+)\.?(\d*)\)\.([^{\s])*/; // (123). or (123.4). + const stringLiteral = /(".+"|('.+'))\.([^{\s])*/; // 'abc'. or "abc". + const dateLiteral = /\(?new Date\(\(?.*?\)\)?\.([^{\s])*/; // new Date(). or (new Date()). + const arrayLiteral = /(\[.+\])\.([^{\s])*/; // [1, 2, 3]. + const objectLiteral = /\(\{.*\}\)\.([^{\s])*/; // ({}). const combinedRegex = new RegExp( [ - referenceRegex.source, - numberRegex.source, - stringRegex.source, - arrayRegex.source, - objectRegex.source, - dateRegex.source, + reference.source, + numberLiteral.source, + stringLiteral.source, + dateLiteral.source, + arrayLiteral.source, + objectLiteral.source, ].join('|'), ); @@ -35,114 +41,135 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul if (word.from === word.to && !context.explicit) return null; - // ---------------------------------- - // find string to resolve - // ---------------------------------- - - const toResolve = word.text.endsWith('.') - ? word.text.slice(0, -1) - : word.text.split('.').slice(0, -1).join('.'); + const skipDatatypeCompletions = ['$now.', '$today.']; - const SKIP_SET = new Set(['$execution', '$binary', '$itemIndex', '$now', '$today', '$runIndex']); + if (skipDatatypeCompletions.includes(word.text)) return null; - if (SKIP_SET.has(toResolve)) return null; - - // ---------------------------------- - // resolve and get options - // ---------------------------------- + const [base, tail] = splitBaseTail(word.text); let resolved: IDataObject | null; try { - resolved = resolveParameter(`={{ ${toResolve} }}`); + resolved = resolveParameter(`={{ ${base} }}`); } catch (_) { return null; } if (resolved === null) return null; - let options = getDatatypeOptions(resolved, toResolve); - - if (options.length === 0) return null; + let options: Completion[] = []; - // ---------------------------------- - // filter by user input - // ---------------------------------- + try { + options = datatypeOptions(resolved, base); + } catch (_) { + return null; + } - const userInputTail = word.text.split('.').pop() ?? ''; + if (options.length === 0) return null; - if (userInputTail !== '') { - options = options.filter((o) => o.label.startsWith(userInputTail) && userInputTail !== o.label); + if (tail !== '') { + options = options.filter((o) => prefixMatch(o.label, tail)); } return { - from: word.to - userInputTail.length, + from: word.to - tail.length, options, filter: false, getMatch(completion: Completion) { - const lcp = longestCommonPrefix([userInputTail, completion.label]); + const lcp = longestCommonPrefix(tail, completion.label); return [0, lcp.length]; }, }; } -function getDatatypeOptions(resolved: IDataObject, toResolve: string) { - if (typeof resolved === 'number') return extensionOptions('Number'); +function datatypeOptions(resolved: IDataObject, toResolve: string) { + if (typeof resolved === 'number') return extensions('number'); - if (typeof resolved === 'string') return extensionOptions('String'); + if (typeof resolved === 'string') return extensions('string'); - if (resolved instanceof Date) return extensionOptions('Date'); + if (resolved instanceof Date) return extensions('date'); if (Array.isArray(resolved)) { - const isProxy = toResolve.endsWith('all()'); + if (toResolve.endsWith('all()')) return []; - if (isProxy) return []; - - return extensionOptions('Array'); + return extensions('array'); } if (typeof resolved === 'object') { - const isProxy = - resolved.isProxy || - resolved.json || - toResolve.endsWith('json') || - toResolve.startsWith('{') || - toResolve.endsWith('}'); + const BOOST = ['item', 'all', 'first', 'last']; + const SKIP = new Set(['__ob__', 'pairedItem']); + + if (isSplitInBatchesAbsent()) SKIP.add('context'); + + const name = toResolve.startsWith('$(') ? '$()' : toResolve; - if (isProxy) return []; + if (['$input', '$()'].includes(name) && hasNoParams(toResolve)) SKIP.add('params'); - // @TODO: completions for bracket-notation chain e.g. $json['obj']['my Key'] + const rawKeys = + name === '$()' ? (Reflect.ownKeys(resolved) as string[]) : Object.keys(resolved); - const keys = Object.keys(resolved) - .filter((key) => isAllowedInDotNotation(key)) - .map((key) => ({ label: key, type: 'keyword' })); + const keys = bringToStart(rawKeys, BOOST) + .filter((key) => !SKIP.has(key) && isAllowedInDotNotation(key) && !isPseudoParam(key)) + .map((key) => { + ensureKeyCanBeResolved(resolved, key); - return [...keys, ...extensionOptions('Object')]; + const isFunction = typeof resolved[key] === 'function'; + + const option: Completion = { + label: isFunction ? key + '()' : key, + type: isFunction ? 'function' : 'keyword', + }; + + const infoKey = [name, key].join('.'); + const info = i18n.proxyVars[infoKey]; + + if (info) option.info = info; + + return option; + }); + + const skipObjectExtensions = + resolved.isProxy || + resolved.json || + /json('])?$/.test(toResolve) || + toResolve === '$execution' || + toResolve.endsWith('params'); + + if (skipObjectExtensions) return keys; + + return [...keys, ...extensions('object')]; } return []; } -const extensionOptions = (typeName: 'String' | 'Number' | 'Date' | 'Object' | 'Array') => { - const extensions = ExpressionExtensions.find((ee) => ee.typeName === typeName); +const extensions = (typeName: 'number' | 'string' | 'date' | 'array' | 'object') => { + const extensions = ExpressionExtensions.find((ee) => ee.typeName.toLowerCase() === typeName); if (!extensions) return []; - const options = Object.entries(extensions.functions) - .filter(([_, fn]) => fn.length === 1) // complete only argless functions for now + return Object.entries(extensions.functions) + .filter(([_, fn]) => fn.length === 1) // @TODO: Remove in next phase .sort((a, b) => a[0].localeCompare(b[0])) - .map(([name, f]) => { + .map(([name, fn]) => { const option: Completion = { label: name + '()', type: 'function', }; // @TODO - // if (f.description) option.info = f.description; + // if (fn.description) option.info = f.description; return option; }); - - return options; }; + +function ensureKeyCanBeResolved(obj: IDataObject, key: string) { + try { + obj[key]; + } catch (error) { + // e.g. attempting to access non-parent node with `$()` + throw new Error('Cannot generate options', { cause: error }); + } +} diff --git a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts index 97d3de0035f9a..8fff96888dee7 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts @@ -3,14 +3,15 @@ import { autocompletableNodeNames, receivesNoBinaryData, longestCommonPrefix, - bringForward, + bringToStart, + prefixMatch, } from './utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; /** * Completions offered at the dollar position: `$` * - * Negative charset `[^}]` ensures match stays within resolvable boundaries. + * Negative charset `[^}]` ensures completion match stays within resolvable boundaries. */ export function dollarCompletions(context: CompletionContext): CompletionResult | null { const word = context.matchBefore(/\$\w*[^}]*/); @@ -24,23 +25,18 @@ export function dollarCompletions(context: CompletionContext): CompletionResult const userInput = word.text; /** - * If user typed anything after `$`, narrow down options based on - * left-to-right match of options to user input. + * If user typed anything after `$`, whittle down options based on user input. */ if (userInput !== '$') { - options = options.filter((o) => o.label.startsWith(userInput) && userInput !== o.label); + options = options.filter((o) => prefixMatch(o.label, userInput)); } return { from: word.to - userInput.length, options, filter: false, - - /** - * Compute underline range based on left-to-right match. - */ getMatch(completion: Completion) { - const lcp = longestCommonPrefix([userInput, completion.label]); + const lcp = longestCommonPrefix(userInput, completion.label); return [0, lcp.length]; }, @@ -50,16 +46,16 @@ export function dollarCompletions(context: CompletionContext): CompletionResult export function dollarOptions() { const BOOST = ['$input', '$json']; const SKIP = new Set(); - const FUNCTIONS = ['$jmespath']; + const DOLLAR_FUNCTIONS = ['$jmespath']; if (receivesNoBinaryData()) SKIP.add('$binary'); const keys = Object.keys(i18n.rootVars).sort((a, b) => a.localeCompare(b)); - const options = bringForward(keys, BOOST) + return bringToStart(keys, BOOST) .filter((key) => !SKIP.has(key)) .map((key) => { - const isFunction = FUNCTIONS.includes(key); + const isFunction = DOLLAR_FUNCTIONS.includes(key); const option: Completion = { label: isFunction ? key + '()' : key, @@ -71,15 +67,12 @@ export function dollarOptions() { if (info) option.info = info; return option; - }); - - options.push( - ...autocompletableNodeNames().map((nodeName) => ({ - label: `$('${nodeName}')`, - type: 'keyword', - info: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }), - })), - ); - - return options; + }) + .concat( + ...autocompletableNodeNames().map((nodeName) => ({ + label: `$('${nodeName}')`, + type: 'keyword', + info: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }), + })), + ); } diff --git a/packages/editor-ui/src/plugins/codemirror/completions/jsonBracket.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/jsonBracket.completions.ts deleted file mode 100644 index 1592a68d7e098..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/completions/jsonBracket.completions.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { resolveParameter } from '@/mixins/workflowHelpers'; -import { longestCommonPrefix } from './utils'; -import type { IDataObject } from 'n8n-workflow'; -import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; - -/** - * Completions offered at these positions: `$json[` and `.json[` - */ -export function jsonBracketCompletions(context: CompletionContext): CompletionResult | null { - const word = context.matchBefore(/\$[\S]*json\[.*/); - - if (!word) return null; - - if (word.from === word.to && !context.explicit) return null; - - const toResolve = word.text.split('[').shift(); - - let resolved: IDataObject | null; - - try { - resolved = resolveParameter(`={{ ${toResolve} }}`); - } catch (_) { - return null; - } - - if (resolved === null) return null; - - let options = jsonBracketOptions(resolved); - - const delimiter = word.text.startsWith('$json') ? '$json[' : '.json['; - - const userInputTail = word.text.split(delimiter).pop() ?? ''; - - if (userInputTail !== '') { - options = options.filter((o) => o.label.startsWith(userInputTail) && userInputTail !== o.label); - } - - return { - from: word.to - userInputTail.length, - options, - filter: false, - getMatch(completion: Completion) { - const lcp = longestCommonPrefix([userInputTail, completion.label]); - - return [0, lcp.length]; - }, - }; -} - -function jsonBracketOptions(resolved: IDataObject) { - return Object.keys(resolved).map((key) => { - return { - label: `'${key}']`, - type: 'keyword', - }; - }); -} diff --git a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts index af44987455e35..7ff332f98204d 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts @@ -1,5 +1,5 @@ import { i18n } from '@/plugins/i18n'; -import { longestCommonPrefix } from './utils'; +import { prefixMatch, longestCommonPrefix, splitBaseTail } from './utils'; import { DateTime } from 'luxon'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; @@ -11,7 +11,7 @@ import type { Completion, CompletionContext, CompletionResult } from '@codemirro * - `$today.` */ export function luxonCompletions(context: CompletionContext): CompletionResult | null { - const word = context.matchBefore(/(DateTime|\$now|\$today)+\.[^}]*/); + const word = context.matchBefore(/(DateTime|\$now|\$today)+\.[^.}]*/); if (!word) return null; @@ -21,12 +21,8 @@ export function luxonCompletions(context: CompletionContext): CompletionResult | let options = base === 'DateTime' ? dateTimeOptions() : nowTodayOptions(); - /** - * If user typed anything after `.`, narrow down options based on - * left-to-right match of options to user input. - */ if (tail !== '') { - options = options.filter((o) => o.label.startsWith(tail) && tail !== o.label); + options = options.filter((o) => prefixMatch(o.label, tail)); } return { @@ -34,7 +30,7 @@ export function luxonCompletions(context: CompletionContext): CompletionResult | options, filter: false, getMatch(completion: Completion) { - const lcp = longestCommonPrefix([tail, completion.label]); + const lcp = longestCommonPrefix(tail, completion.label); return [0, lcp.length]; }, @@ -44,59 +40,37 @@ export function luxonCompletions(context: CompletionContext): CompletionResult | export const nowTodayOptions = () => { const SKIP = new Set(['constructor', 'get', 'invalidExplanation', 'invalidReason']); - const map = Object.getOwnPropertyDescriptors(DateTime.prototype); - - const entries = Object.entries(map) + return Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype)) .filter(([key]) => !SKIP.has(key)) - .sort(([a], [b]) => a.localeCompare(b)); - - return entries.map(([key, descriptor]) => { - const isFunction = typeof descriptor.value === 'function'; + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, descriptor]) => { + const isFunction = typeof descriptor.value === 'function'; - const option: Completion = { - label: isFunction ? key + '()' : key, - type: isFunction ? 'function' : 'keyword', - }; + const option: Completion = { + label: isFunction ? key + '()' : key, + type: isFunction ? 'function' : 'keyword', + }; - const info = i18n.luxonInstance[key]; + const info = i18n.luxonInstance[key]; - if (info) option.info = info; + if (info) option.info = info; - return option; - }); + return option; + }); }; export const dateTimeOptions = () => { const SKIP = new Set(['prototype', 'name', 'length', 'invalid']); - const map = Object.getOwnPropertyDescriptors(DateTime); - - const keys = Object.keys(map) + return Object.keys(Object.getOwnPropertyDescriptors(DateTime)) .filter((key) => !SKIP.has(key) && !key.includes('_')) - .sort((a, b) => a.localeCompare(b)); + .sort((a, b) => a.localeCompare(b)) + .map((key) => { + const option: Completion = { label: key + '()', type: 'function' }; + const info = i18n.luxonStatic[key]; - return keys.map((key) => { - const option: Completion = { label: key + '()', type: 'function' }; - const info = i18n.luxonStatic[key]; + if (info) option.info = info; - if (info) option.info = info; - - return option; - }); + return option; + }); }; - -/** - * Split user input into base (Luxon entity) and tail (trailing section for filtering). - * - * ``` - * DateTime. -> ['DateTime', ''] - * DateTime.fr -> ['DateTime', 'fr'] - * ``` - */ -function splitBaseTail(userInput: string): [string, string] { - const parts = userInput.split('.'); - - const [tail, ...base] = parts.reverse(); - - return [base.join('.'), tail]; -} diff --git a/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts index 0d3324ab1fa85..1e2c3bbce1f6b 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts @@ -1,5 +1,6 @@ import { i18n } from '@/plugins/i18n'; import type { CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import { prefixMatch } from './utils'; /** * Completions offered at the base position for any char other than `$`. @@ -7,7 +8,7 @@ import type { CompletionContext, CompletionResult } from '@codemirror/autocomple * Currently only `D` for `DateTime`. */ export function nonDollarCompletions(context: CompletionContext): CompletionResult | null { - const word = context.matchBefore(/(\s+)D[ateTim]*/); // loose charset but covered by CodeMirror's filter + const word = context.matchBefore(/(\s+)D[ateTim]*/); // loose charset but covered by filter if (!word) return null; @@ -21,7 +22,7 @@ export function nonDollarCompletions(context: CompletionContext): CompletionResu type: 'keyword', info: i18n.rootVars.DateTime, }, - ].filter((o) => o.label.startsWith(userInput) && userInput !== o.label); + ].filter((o) => prefixMatch(o.label, userInput)); return { from: word.to - userInput.length, diff --git a/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts deleted file mode 100644 index 0d552ffd660b5..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { i18n } from '@/plugins/i18n'; -import { resolveParameter } from '@/mixins/workflowHelpers'; -import { - isSplitInBatchesAbsent, - isAllowedInDotNotation, - longestCommonPrefix, - hasNoParams, -} from './utils'; -import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; -import type { IDataObject } from 'n8n-workflow'; -import type { Word } from '@/types/completions'; - -/** - * Completions from proxies to their content. - */ -export function proxyCompletions(context: CompletionContext): CompletionResult | null { - // ---------------------------------- - // match before cursor - // ---------------------------------- - - const word = context.matchBefore( - /\$(input|\(.+\)|prevNode|parameter|json|execution|workflow)*\.([^{\s])*/, - ); - - if (!word) return null; - - if (word.from === word.to && !context.explicit) return null; - - // ---------------------------------- - // find string to resolve - // ---------------------------------- - - const toResolve = word.text.endsWith('.') - ? word.text.slice(0, -1) - : word.text.split('.').slice(0, -1).join('.'); - - // ---------------------------------- - // resolve and get options - // ---------------------------------- - - let options: Completion[] = []; - - try { - const resolved = resolveParameter(`={{ ${toResolve} }}`); - - if (!resolved || typeof resolved !== 'object' || Array.isArray(resolved)) return null; - - options = generateOptions(toResolve, resolved, word); - } catch (_) { - return null; - } - - // ---------------------------------- - // filter by user input - // ---------------------------------- - - const userInputTail = word.text.split('.').pop() ?? ''; - - if (userInputTail !== '') { - options = options.filter((o) => o.label.startsWith(userInputTail) && userInputTail !== o.label); - } - - return { - from: word.to - userInputTail.length, - options, - filter: false, - getMatch(completion: Completion) { - const lcp = longestCommonPrefix([userInputTail, completion.label]); - - return [0, lcp.length]; - }, - }; -} - -function generateOptions(toResolve: string, proxy: IDataObject, word: Word): Completion[] { - const BOOST_SET = new Set(['item', 'all', 'first', 'last']); - const SKIP_SET = new Set(['__ob__', 'pairedItem']); - - if (isSplitInBatchesAbsent()) SKIP_SET.add('context'); - - if ((toResolve === '$input' || toResolve.startsWith('$(')) && hasNoParams(toResolve)) { - SKIP_SET.add('params'); - } - - const proxyName = toResolve.startsWith('$(') ? '$()' : toResolve; - - return (Reflect.ownKeys(proxy) as string[]) - .filter((key) => { - return !SKIP_SET.has(key) && isAllowedInDotNotation(key); - }) - .sort((a, b) => { - if (BOOST_SET.has(a)) return -1; - if (BOOST_SET.has(b)) return 1; - - return a.localeCompare(b); - }) - .map((key) => { - ensureKeyCanBeResolved(proxy, key); - - const isFunction = typeof proxy[key] === 'function'; - - const option: Completion = { - label: isFunction ? key + '()' : key, - type: isFunction ? 'function' : 'keyword', - }; - - const infoKey = [proxyName, key].join('.'); - const info = i18n.proxyVars[infoKey]; - - if (info) option.info = info; - - return option; - }); -} - -function ensureKeyCanBeResolved(proxy: IDataObject, key: string) { - try { - proxy[key]; - } catch (error) { - // e.g. attempting to access non-parent node with `$()` - throw new Error('Cannot generate options', { cause: error }); - } -} diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts index 643036fc61ae2..04da84daf2994 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -3,15 +3,25 @@ import { SPLIT_IN_BATCHES_NODE_TYPE } from '@/constants'; import { useWorkflowsStore } from '@/stores/workflows'; import { resolveParameter } from '@/mixins/workflowHelpers'; -export function autocompletableNodeNames() { - return useWorkflowsStore() - .allNodes.filter((node) => !NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION.includes(node.type)) - .map((node) => node.name); +/** + * Split user input into base (to resolve) and tail (to filter). + * + * ``` + * DateTime. -> ['DateTime', ''] + * DateTime.fr -> ['DateTime', 'fr'] + * ``` + */ +export function splitBaseTail(userInput: string): [string, string] { + const parts = userInput.split('.'); + const tail = parts.pop() ?? ''; + + return [parts.join('.'), tail]; } -// @TODO: Refactor to take two args -export const longestCommonPrefix = (strings: string[]) => { - if (strings.length === 0) return ''; +export function longestCommonPrefix(...strings: string[]) { + if (strings.length < 2) { + throw new Error('Expected at least two strings'); + } return strings.reduce((acc, next) => { let i = 0; @@ -22,13 +32,16 @@ export const longestCommonPrefix = (strings: string[]) => { return acc.slice(0, i); }); -}; +} + +export const prefixMatch = (first: string, second: string) => + first.startsWith(second) && first !== second; /** * Move selected elements to the start of an array, in order. * Selected elements are assumed to be in the array. */ -export function bringForward(array: string[], selected: string[]) { +export function bringToStart(array: string[], selected: string[]) { const copy = [...array]; [...selected].reverse().forEach((s) => { @@ -40,8 +53,14 @@ export function bringForward(array: string[], selected: string[]) { return copy; } +export const isPseudoParam = (candidate: string) => { + const PSEUDO_PARAMS = ['notice']; // not real params, user input disallowed + + return PSEUDO_PARAMS.includes(candidate); +}; + /** - * Whether a string may be used as a key in object dot notation access. + * Whether a string may be used as a key in object dot access notation. */ export const isAllowedInDotNotation = (str: string) => { const DOT_NOTATION_BANNED_CHARS = /^(\d)|[\\ `!@#$%^&*()_+\-=[\]{};':"\\|,.<>?~]/g; @@ -49,19 +68,27 @@ export const isAllowedInDotNotation = (str: string) => { return !DOT_NOTATION_BANNED_CHARS.test(str); }; +// ---------------------------------- +// state-based utils +// ---------------------------------- + export const isSplitInBatchesAbsent = () => !useWorkflowsStore().workflow.nodes.some((node) => node.type === SPLIT_IN_BATCHES_NODE_TYPE); export const receivesNoBinaryData = () => resolveParameter('={{ $binary }}')?.data === undefined; export function hasNoParams(toResolve: string) { - const PSEUDO_PARAMS = ['notice']; // not proper params, no user input allowed - const params = resolveParameter(`={{ ${toResolve}.params }}`); if (!params) return true; const paramKeys = Object.keys(params); - return paramKeys.length === 1 && PSEUDO_PARAMS.includes(paramKeys[0]); + return paramKeys.length === 1 && isPseudoParam(paramKeys[0]); +} + +export function autocompletableNodeNames() { + return useWorkflowsStore() + .allNodes.filter((node) => !NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION.includes(node.type)) + .map((node) => node.name); } diff --git a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts index 749ba9b8438bd..6bd39535a8d48 100644 --- a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts +++ b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts @@ -4,13 +4,12 @@ import { parseMixed } from '@lezer/common'; import { javascriptLanguage } from '@codemirror/lang-javascript'; import { ifIn } from '@codemirror/autocomplete'; -import { proxyCompletions } from './completions/proxy.completions'; +import { blankCompletions } from './completions/blank.completions'; +import { bracketAccessCompletions } from './completions/bracketAccess.completions'; +import { datatypeCompletions } from './completions/datatype.completions'; import { dollarCompletions } from './completions/dollar.completions'; import { luxonCompletions } from './completions/luxon.completions'; import { nonDollarCompletions } from './completions/nonDollar.completions'; -import { datatypeCompletions } from './completions/datatype.completions'; -import { blankCompletions } from './completions/blank.completions'; -import { jsonBracketCompletions } from './completions/jsonBracket.completions'; const n8nParserWithNestedJsParser = n8nParser.configure({ wrap: parseMixed((node) => { @@ -27,12 +26,11 @@ const n8nLanguage = LRLanguage.define({ parser: n8nParserWithNestedJsParser }); export function n8nLang() { const options = [ blankCompletions, + bracketAccessCompletions, + datatypeCompletions, dollarCompletions, + luxonCompletions, nonDollarCompletions, - proxyCompletions, // from `$input.`, `$(...)`, etc. - datatypeCompletions, // from primitives `'abc'.` and from references `$json.name.` - luxonCompletions, // from luxon vars: `DateTime.`, `$now.`, `$today.` - jsonBracketCompletions, // from `json[` ].map((group) => n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], group) })); return new LanguageSupport(n8nLanguage, [ diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index 6d7822dbefb2a..3824fad2b10d6 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -1070,7 +1070,7 @@ export class WorkflowDataProxy { {}, { ownKeys(target) { - return ['item', 'all', 'first', 'last', 'params', 'context']; + return ['all', 'context', 'first', 'item', 'last', 'params']; }, getOwnPropertyDescriptor(k) { return { From 472a77df5cd789905d162f3c3db02ac767b89b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 23 Jan 2023 12:04:43 +0100 Subject: [PATCH 066/160] :zap: Use `DATETIMEUNIT_MAP` --- packages/workflow/src/Extensions/DateExtensions.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/workflow/src/Extensions/DateExtensions.ts b/packages/workflow/src/Extensions/DateExtensions.ts index b64fe5bfcfd84..a9dd6bdb570bc 100644 --- a/packages/workflow/src/Extensions/DateExtensions.ts +++ b/packages/workflow/src/Extensions/DateExtensions.ts @@ -53,6 +53,7 @@ const DURATION_MAP: Record = { const DATETIMEUNIT_MAP: Record = { days: 'day', + week: 'weekNumber', months: 'month', years: 'year', hours: 'hour', @@ -117,10 +118,6 @@ function extract(inputDate: Date | DateTime, extraArgs: DatePart[]): number | Da return Math.floor(diff / (1000 * 60 * 60 * 24)); } - if (part === 'week') { - part = 'weekNumber'; - } - return DateTime.fromJSDate(date).get((DATETIMEUNIT_MAP[part] as keyof DateTime) || part); } From a3693d1fcc8f1cd950f17cd0fa067c163fc7c9ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 23 Jan 2023 12:05:37 +0100 Subject: [PATCH 067/160] :pencil2: Update test description --- .../workflow/test/ExpressionExtensions/ArrayExtensions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts index 0839956a75e1a..7a8764773bd6f 100644 --- a/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts @@ -6,7 +6,7 @@ import { evaluate } from './Helpers'; describe('Data Transformation Functions', () => { describe('Array Data Transformation Functions', () => { - test('.randomItem() alias should work correctly on an array', () => { + test('.randomItem() should work correctly on an array', () => { expect(evaluate('={{ [1,2,3].randomItem() }}')).not.toBeUndefined(); }); From 623961c3702773c7b762b573061f6d3a0f6ae0b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 23 Jan 2023 12:14:22 +0100 Subject: [PATCH 068/160] :rewind: Revert "Use `DATETIMEUNIT_MAP`" This reverts commit 472a77df5cd789905d162f3c3db02ac767b89b4e. --- packages/workflow/src/Extensions/DateExtensions.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/workflow/src/Extensions/DateExtensions.ts b/packages/workflow/src/Extensions/DateExtensions.ts index a9dd6bdb570bc..b64fe5bfcfd84 100644 --- a/packages/workflow/src/Extensions/DateExtensions.ts +++ b/packages/workflow/src/Extensions/DateExtensions.ts @@ -53,7 +53,6 @@ const DURATION_MAP: Record = { const DATETIMEUNIT_MAP: Record = { days: 'day', - week: 'weekNumber', months: 'month', years: 'year', hours: 'hour', @@ -118,6 +117,10 @@ function extract(inputDate: Date | DateTime, extraArgs: DatePart[]): number | Da return Math.floor(diff / (1000 * 60 * 60 * 24)); } + if (part === 'week') { + part = 'weekNumber'; + } + return DateTime.fromJSDate(date).get((DATETIMEUNIT_MAP[part] as keyof DateTime) || part); } From ff670c5c3497da54aeb42e63743be38d36d4cfc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 23 Jan 2023 14:16:23 +0100 Subject: [PATCH 069/160] :test_tube: Add tests --- packages/editor-ui/package.json | 2 +- .../editor-ui/src/mixins/workflowHelpers.ts | 2 +- .../completions/__tests__/completions.test.ts | 137 ++++++++++++++++++ .../__tests__/dollar.completions.test.ts | 80 ---------- .../__tests__/luxon.completions.test.ts | 37 ----- .../__tests__/nonDollar.completions.test.ts | 30 ---- .../completions/__tests__/proxyMocks.ts | 97 ------------- .../completions/bracketAccess.completions.ts | 5 +- .../completions/datatype.completions.ts | 4 +- .../completions/dollar.completions.ts | 2 + .../completions/luxon.completions.ts | 2 + .../plugins/codemirror/completions/utils.ts | 4 +- 12 files changed, 151 insertions(+), 251 deletions(-) create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/completions.test.ts delete mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/dollar.completions.test.ts delete mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/luxon.completions.test.ts delete mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/nonDollar.completions.test.ts delete mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/proxyMocks.ts diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index e63664d9df8a0..091fb4ca53b30 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -22,7 +22,7 @@ "lintfix": "eslint --ext .js,.ts,.vue src --fix", "format": "prettier --write . --ignore-path ../../.prettierignore", "serve": "cross-env VUE_APP_URL_BASE_API=http://localhost:5678/ vite --host 0.0.0.0 --port 8080 dev", - "test": "vitest run", + "test": "vitest completions", "test:ci": "vitest run --coverage", "test:dev": "vitest" }, diff --git a/packages/editor-ui/src/mixins/workflowHelpers.ts b/packages/editor-ui/src/mixins/workflowHelpers.ts index bead0d1cf47ab..e97bc3c99e6e1 100644 --- a/packages/editor-ui/src/mixins/workflowHelpers.ts +++ b/packages/editor-ui/src/mixins/workflowHelpers.ts @@ -77,7 +77,7 @@ export function resolveParameter( inputRunIndex?: number; inputBranchIndex?: number; } = {}, -): IDataObject | null { +): IDataObject | string | number | unknown[] | null { let itemIndex = opts?.targetItem?.itemIndex || 0; const inputName = 'main'; 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 new file mode 100644 index 0000000000000..2864c70db8c6c --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/completions.test.ts @@ -0,0 +1,137 @@ +import { CompletionContext, CompletionResult, CompletionSource } from '@codemirror/autocomplete'; +import { EditorState } from '@codemirror/state'; +import { createTestingPinia } from '@pinia/testing'; +import { setActivePinia } from 'pinia'; +import { v4 as uuidv4 } from 'uuid'; + +import { n8nLang } from '@/plugins/codemirror/n8nLang'; +import { dollarOptions } from '@/plugins/codemirror/completions/dollar.completions'; +import { + dateTimeOptions, + nowTodayOptions, +} from '@/plugins/codemirror/completions/luxon.completions'; +import * as utils from '@/plugins/codemirror/completions/utils'; +import * as workflowHelpers from '@/mixins/workflowHelpers'; +import { extensions } from '../datatype.completions'; + +beforeEach(() => { + setActivePinia(createTestingPinia()); + vi.spyOn(utils, 'receivesNoBinaryData').mockReturnValue(true); +}); + +describe('Top-level completions', () => { + test('should return blank completions for: {{ | }}', async () => { + expect(completions('{{ | }}')).toHaveLength(dollarOptions().length); + }); + + test('should return non-dollar completions for: {{ D| }}', async () => { + expect(completions('{{ D| }}')).toHaveLength(1); + }); + + test('should return dollar completions for: {{ $| }}', async () => { + expect(completions('{{ $| }}')).toHaveLength(dollarOptions().length); + }); + + test('should return node selector completions for: {{ $(| }}', async () => { + const nodes = [ + { + id: uuidv4(), + name: 'Manual', + position: [0, 0], + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + }, + { + id: uuidv4(), + name: 'Set', + position: [0, 0], + type: 'n8n-nodes-base.set', + typeVersion: 1, + }, + ]; + + const initialState = { workflows: { workflow: { nodes } } }; + + setActivePinia(createTestingPinia({ initialState })); + + expect(completions('{{ $(| }}')).toHaveLength(nodes.length); + }); +}); + +describe('Luxon completions', () => { + test('should return Luxon completions for: {{ DateTime.| }}', async () => { + expect(completions('{{ DateTime.| }}')).toHaveLength(dateTimeOptions().length); + }); + + test('should return Luxon completions for: {{ $now.| }}', async () => { + expect(completions('{{ $now.| }}')).toHaveLength(nowTodayOptions().length); + }); + + test('should return Luxon completions for: {{ $today.| }}', async () => { + expect(completions('{{ $today.| }}')).toHaveLength(nowTodayOptions().length); + }); +}); + +describe('Resolution-based completions', () => { + test('should return string completions', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('abc'); + + expect(completions('{{ "abc".| }}')).toHaveLength(extensions('string').length); + }); + + test('should return number completions', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(123); + + expect(completions('{{ (123).| }}')).toHaveLength(extensions('number').length); + }); + + test('should return array completions', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce([1, 2, 3]); + + expect(completions('{{ [1, 2, 3].| }}')).toHaveLength(extensions('array').length); + }); + + test('should return object completions', () => { + const object = { a: 1 }; + + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(object); + + expect(completions('{{ ({ a: 1 }).| }}')).toHaveLength( + Object.keys(object).length + extensions('object').length, + ); + }); +}); + +// @TODO Test datatype completions for references +// @TODO Test bracketAccess completions + +function completions(docWithCursor: string) { + const cursorPosition = docWithCursor.indexOf('|'); + + const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1); + + const state = EditorState.create({ + doc, + selection: { anchor: cursorPosition }, + extensions: [n8nLang()], + }); + + const context = new CompletionContext(state, cursorPosition, false); + + for (const completionSource of state.languageDataAt( + 'autocomplete', + cursorPosition, + )) { + const result = completionSource(context); + + if (isCompletionResult(result)) return result.options; + } + + return null; +} + +function isCompletionResult( + candidate: ReturnType, +): candidate is CompletionResult { + return candidate !== null && 'from' in candidate && 'options' in candidate; +} diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/dollar.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/dollar.completions.test.ts deleted file mode 100644 index 6a2f4db70d3c5..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/dollar.completions.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { dollarCompletions } from '../dollar.completions'; -import { CompletionContext } from '@codemirror/autocomplete'; -import { EditorState } from '@codemirror/state'; -import { setActivePinia } from 'pinia'; -import { createTestingPinia } from '@pinia/testing'; -import { v4 as uuidv4 } from 'uuid'; -import { i18n } from '@/plugins/i18n'; - -const EXPLICIT = false; - -test('should return completion options: $', () => { - setActivePinia(createTestingPinia()); - - const doc = '{{ $ }}'; - const position = doc.indexOf('$') + 1; - const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); - - const result = dollarCompletions(context); - - if (!result) throw new Error('Expected dollar-sign completion options'); - - const { options, from } = result; - - const rootKeys = [...Object.keys(i18n.rootVars), '$parameter'].sort((a, b) => a.localeCompare(b)); - expect(options.map((o) => o.label)).toEqual(rootKeys); - expect(from).toEqual(position - 1); -}); - -test('should return completion options: $(', () => { - const firstNodeName = 'Manual Trigger'; - const secondNodeName = 'Set'; - - const nodes = [ - { - id: uuidv4(), - name: firstNodeName, - position: [0, 0], - type: 'n8n-nodes-base.manualTrigger', - typeVersion: 1, - }, - { - id: uuidv4(), - name: secondNodeName, - position: [0, 0], - type: 'n8n-nodes-base.set', - typeVersion: 1, - }, - ]; - - const initialState = { workflows: { workflow: { nodes } } }; - - setActivePinia(createTestingPinia({ initialState })); - - const doc = '{{ $( }}'; - const position = doc.indexOf('(') + 1; - const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); - - const result = dollarCompletions(context); - - if (!result) throw new Error('Expected dollar-sign-selector completion options'); - - const { options, from } = result; - - expect(options).toHaveLength(nodes.length); - expect(options[0].label).toEqual(`$('${firstNodeName}')`); - expect(options[1].label).toEqual(`$('${secondNodeName}')`); - expect(from).toEqual(position - 2); -}); - -test('should not return completion options for regular strings', () => { - setActivePinia(createTestingPinia()); - - const doc = '{{ hello }}'; - const position = doc.indexOf('o') + 1; - const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); - - const result = dollarCompletions(context); - - expect(result).toBeNull(); -}); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/luxon.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/luxon.completions.test.ts deleted file mode 100644 index ffd13032e2609..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/luxon.completions.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { CompletionContext } from '@codemirror/autocomplete'; -import { EditorState } from '@codemirror/state'; -import { dateTimeOptions, nowTodayOptions, luxonCompletions } from '../luxon.completions'; - -const EXPLICIT = false; - -test('should return luxon completion options: $now, $today', () => { - ['$now', '$today'].forEach((luxonVar) => { - const doc = `{{ ${luxonVar}. }}`; - const position = doc.indexOf('.') + 1; - const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); - - const result = luxonCompletions(context); - - if (!result) throw new Error(`Expected luxon ${luxonVar} completion options`); - - const { options, from } = result; - - expect(options.map((o) => o.label)).toEqual(nowTodayOptions().map((o) => o.label)); - expect(from).toEqual(position); - }); -}); - -test('should return luxon completion options: DateTime', () => { - const doc = '{{ DateTime. }}'; - const position = doc.indexOf('.') + 1; - const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); - - const result = luxonCompletions(context); - - if (!result) throw new Error('Expected luxon completion options'); - - const { options, from } = result; - - expect(options.map((o) => o.label)).toEqual(dateTimeOptions().map((o) => o.label)); - expect(from).toEqual(position); -}); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/nonDollar.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/nonDollar.completions.test.ts deleted file mode 100644 index d94fe8bfead04..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/nonDollar.completions.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { nonDollarCompletions } from '../nonDollar.completions'; -import { CompletionContext } from '@codemirror/autocomplete'; -import { EditorState } from '@codemirror/state'; - -const EXPLICIT = false; - -test('should return alphabetic char completion options: D', () => { - const doc = '{{ D }}'; - const position = doc.indexOf('D') + 1; - const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); - - const result = nonDollarCompletions(context); - - if (!result) throw new Error('Expected D completion options'); - - const { options, from } = result; - - expect(options.map((o) => o.label)).toEqual(['DateTime']); - expect(from).toEqual(position - 1); -}); - -test('should not return alphabetic char completion options: $input.D', () => { - const doc = '{{ $input.D }}'; - const position = doc.indexOf('D') + 1; - const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); - - const result = nonDollarCompletions(context); - - expect(result).toBeNull(); -}); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/proxyMocks.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/proxyMocks.ts deleted file mode 100644 index 8748203a85b2a..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/proxyMocks.ts +++ /dev/null @@ -1,97 +0,0 @@ -export const inputProxy = new Proxy( - {}, - { - ownKeys() { - return ['all', 'context', 'first', 'item', 'last', 'params']; - }, - get(_, property) { - if (property === 'all') return []; - if (property === 'context') return {}; - if (property === 'first') return {}; - if (property === 'item') return {}; - if (property === 'last') return {}; - if (property === 'params') return {}; - - return undefined; - }, - }, -); - -export const nodeSelectorProxy = new Proxy( - {}, - { - ownKeys() { - return ['all', 'context', 'first', 'item', 'last', 'params', 'itemMatching']; - }, - get(_, property) { - if (property === 'all') return []; - if (property === 'context') return {}; - if (property === 'first') return {}; - if (property === 'item') return {}; - if (property === 'last') return {}; - if (property === 'params') return {}; - if (property === 'itemMatching') return {}; - - return undefined; - }, - }, -); - -export const itemProxy = new Proxy( - { json: {} }, - { - get(_, property) { - if (property === 'json') return {}; - - return undefined; - }, - }, -); - -export const prevNodeProxy = new Proxy( - {}, - { - ownKeys() { - return ['name', 'outputIndex', 'runIndex']; - }, - get(_, property) { - if (property === 'name') return ''; - if (property === 'outputIndex') return 0; - if (property === 'runIndex') return 0; - - return undefined; - }, - }, -); - -export const executionProxy = new Proxy( - {}, - { - ownKeys() { - return ['id', 'mode', 'resumeUrl']; - }, - get(_, property) { - if (property === 'id') return ''; - if (property === 'mode') return ''; - if (property === 'resumeUrl') return ''; - - return undefined; - }, - }, -); - -export const workflowProxy = new Proxy( - {}, - { - ownKeys() { - return ['active', 'id', 'name']; - }, - get(_, property) { - if (property === 'active') return false; - if (property === 'id') return ''; - if (property === 'name') return ''; - - return undefined; - }, - }, -); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts index 67b998194aff5..6a2cfb5879742 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts @@ -4,8 +4,7 @@ import type { IDataObject } from 'n8n-workflow'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; /** - * Completions offered at the start of bracket access notation, - * including keys for objects and indices for arrays and strings. + * Resolution-based completions offered at the start of bracket access notation. * * - `$json[` * - `$input.item.json[` @@ -44,6 +43,8 @@ export function bracketAccessCompletions(context: CompletionContext): Completion options = options.filter((o) => prefixMatch(o.label, tail)); } + if (options.length === 0) return null; + return { from: word.to - tail.length, options, diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index e0b210afe250b..06b4ed3f17f3b 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -14,7 +14,7 @@ import { import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; /** - * Completions offered for values based on their datatype. + * Resolution-based completions offered according to datatype. */ export function datatypeCompletions(context: CompletionContext): CompletionResult | null { const reference = /\$[\S]+\.([^{\s])*/; // $input. @@ -144,7 +144,7 @@ function datatypeOptions(resolved: IDataObject, toResolve: string) { return []; } -const extensions = (typeName: 'number' | 'string' | 'date' | 'array' | 'object') => { +export const extensions = (typeName: 'number' | 'string' | 'date' | 'array' | 'object') => { const extensions = ExpressionExtensions.find((ee) => ee.typeName.toLowerCase() === typeName); if (!extensions) return []; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts index 8fff96888dee7..0afd0355c047e 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts @@ -31,6 +31,8 @@ export function dollarCompletions(context: CompletionContext): CompletionResult options = options.filter((o) => prefixMatch(o.label, userInput)); } + if (options.length === 0) return null; + return { from: word.to - userInput.length, options, diff --git a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts index 7ff332f98204d..16e6858f93a21 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts @@ -25,6 +25,8 @@ export function luxonCompletions(context: CompletionContext): CompletionResult | options = options.filter((o) => prefixMatch(o.label, tail)); } + if (options.length === 0) return null; + return { from: word.to - tail.length, options, diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts index 04da84daf2994..56bec9fd439e4 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -75,7 +75,9 @@ export const isAllowedInDotNotation = (str: string) => { export const isSplitInBatchesAbsent = () => !useWorkflowsStore().workflow.nodes.some((node) => node.type === SPLIT_IN_BATCHES_NODE_TYPE); -export const receivesNoBinaryData = () => resolveParameter('={{ $binary }}')?.data === undefined; +export function receivesNoBinaryData() { + return resolveParameter('={{ $binary }}')?.data === undefined; +} export function hasNoParams(toResolve: string) { const params = resolveParameter(`={{ ${toResolve}.params }}`); From 1a6d3bcec003c18259ab78e2a3bb2aaeff9698b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 24 Jan 2023 09:37:29 +0100 Subject: [PATCH 070/160] :recycle: Restore generic extensions --- .../src/Extensions/ExpressionExtension.ts | 21 +++++++++++++++++++ .../GenericExtensions.test.ts | 9 ++++++++ 2 files changed, 30 insertions(+) create mode 100644 packages/workflow/test/ExpressionExtensions/GenericExtensions.test.ts diff --git a/packages/workflow/src/Extensions/ExpressionExtension.ts b/packages/workflow/src/Extensions/ExpressionExtension.ts index 8349e8af274f6..3093d5cfff4cb 100644 --- a/packages/workflow/src/Extensions/ExpressionExtension.ts +++ b/packages/workflow/src/Extensions/ExpressionExtension.ts @@ -11,6 +11,14 @@ import { objectExtensions } from './ObjectExtensions'; const EXPRESSION_EXTENDER = 'extend'; +function isEmpty(value: unknown) { + return value === null || value === undefined || !value; +} + +function isNotEmpty(value: unknown) { + return !isEmpty(value); +} + const EXTENSION_OBJECTS = [ arrayExtensions, dateExtensions, @@ -19,6 +27,12 @@ const EXTENSION_OBJECTS = [ stringExtensions, ]; +// eslint-disable-next-line @typescript-eslint/ban-types +const genericExtensions: Record = { + isEmpty, + isNotEmpty, +}; + const EXPRESSION_EXTENSION_METHODS = Array.from( new Set([ ...Object.keys(stringExtensions.functions), @@ -26,6 +40,7 @@ const EXPRESSION_EXTENSION_METHODS = Array.from( ...Object.keys(dateExtensions.functions), ...Object.keys(arrayExtensions.functions), ...Object.keys(objectExtensions.functions), + ...Object.keys(genericExtensions), ]), ); @@ -223,6 +238,12 @@ export function extend(input: unknown, functionName: string, args: unknown[]) { // eslint-disable-next-line return inputAny[functionName](...args); } + + console.log('genericExtensions', genericExtensions); + console.log('functionName', functionName); + + // Use a generic version if available + foundFunction = genericExtensions[functionName]; } // No type specific or generic function found. Check to see if diff --git a/packages/workflow/test/ExpressionExtensions/GenericExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/GenericExtensions.test.ts new file mode 100644 index 0000000000000..2f0838d663bf3 --- /dev/null +++ b/packages/workflow/test/ExpressionExtensions/GenericExtensions.test.ts @@ -0,0 +1,9 @@ +import { evaluate } from './Helpers'; + +describe('Data Transformation Functions', () => { + describe('Genric Data Transformation Functions', () => { + test('.isEmpty() should work correctly on undefined', () => { + expect(evaluate('={{(undefined).isEmpty()}}')).toEqual(true); + }); + }); +}); From fcc5758c7df60188f88d7f78e72478b7ce337b9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 24 Jan 2023 09:42:44 +0100 Subject: [PATCH 071/160] :fire: Remove logs --- packages/workflow/src/Extensions/ExpressionExtension.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/workflow/src/Extensions/ExpressionExtension.ts b/packages/workflow/src/Extensions/ExpressionExtension.ts index 3093d5cfff4cb..923577978be8c 100644 --- a/packages/workflow/src/Extensions/ExpressionExtension.ts +++ b/packages/workflow/src/Extensions/ExpressionExtension.ts @@ -239,9 +239,6 @@ export function extend(input: unknown, functionName: string, args: unknown[]) { return inputAny[functionName](...args); } - console.log('genericExtensions', genericExtensions); - console.log('functionName', functionName); - // Use a generic version if available foundFunction = genericExtensions[functionName]; } From 01834384b97e7a300a38c466141ffa2b2c3dd98f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 24 Jan 2023 10:33:51 +0100 Subject: [PATCH 072/160] :test_tube: Expand tests --- packages/editor-ui/package.json | 2 +- .../completions/__tests__/completions.test.ts | 158 ++++++++++++++---- .../codemirror/completions/__tests__/mock.ts | 61 +++++++ .../completions/datatype.completions.ts | 16 +- .../plugins/codemirror/completions/types.ts | 3 + 5 files changed, 206 insertions(+), 34 deletions(-) create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/mock.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/types.ts diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 091fb4ca53b30..032b3adfb2eef 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -22,7 +22,7 @@ "lintfix": "eslint --ext .js,.ts,.vue src --fix", "format": "prettier --write . --ignore-path ../../.prettierignore", "serve": "cross-env VUE_APP_URL_BASE_API=http://localhost:5678/ vite --host 0.0.0.0 --port 8080 dev", - "test": "vitest completions", + "test": "vitest", "test:ci": "vitest run --coverage", "test:dev": "vitest" }, 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 2864c70db8c6c..3fdea7cc8ea07 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 @@ -13,26 +13,28 @@ import { import * as utils from '@/plugins/codemirror/completions/utils'; import * as workflowHelpers from '@/mixins/workflowHelpers'; import { extensions } from '../datatype.completions'; +import { mock } from './mock'; beforeEach(() => { setActivePinia(createTestingPinia()); - vi.spyOn(utils, 'receivesNoBinaryData').mockReturnValue(true); + vi.spyOn(utils, 'receivesNoBinaryData').mockReturnValue(true); // hide $binary + vi.spyOn(utils, 'isSplitInBatchesAbsent').mockReturnValue(false); // show context }); describe('Top-level completions', () => { - test('should return blank completions for: {{ | }}', async () => { + test('should return blank completions for: {{ | }}', () => { expect(completions('{{ | }}')).toHaveLength(dollarOptions().length); }); - test('should return non-dollar completions for: {{ D| }}', async () => { - expect(completions('{{ D| }}')).toHaveLength(1); + test('should return non-dollar completions for: {{ D| }}', () => { + expect(completions('{{ D| }}')).toHaveLength(1); // DateTime }); - test('should return dollar completions for: {{ $| }}', async () => { + test('should return dollar completions for: {{ $| }}', () => { expect(completions('{{ $| }}')).toHaveLength(dollarOptions().length); }); - test('should return node selector completions for: {{ $(| }}', async () => { + test('should return node selector completions for: {{ $(| }}', () => { const nodes = [ { id: uuidv4(), @@ -58,52 +60,150 @@ describe('Top-level completions', () => { }); }); -describe('Luxon completions', () => { - test('should return Luxon completions for: {{ DateTime.| }}', async () => { +describe('Luxon method completions', () => { + test('should return static method completions for: {{ DateTime.| }}', () => { expect(completions('{{ DateTime.| }}')).toHaveLength(dateTimeOptions().length); }); - test('should return Luxon completions for: {{ $now.| }}', async () => { + test('should return instance method completions for: {{ $now.| }}', () => { expect(completions('{{ $now.| }}')).toHaveLength(nowTodayOptions().length); }); - test('should return Luxon completions for: {{ $today.| }}', async () => { + test('should return instance method completions for: {{ $today.| }}', () => { expect(completions('{{ $today.| }}')).toHaveLength(nowTodayOptions().length); }); }); describe('Resolution-based completions', () => { - test('should return string completions', () => { - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('abc'); + describe('literals', () => { + test('should return completions for string literal', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('abc'); - expect(completions('{{ "abc".| }}')).toHaveLength(extensions('string').length); - }); + expect(completions('{{ "abc".| }}')).toHaveLength(extensions('string').length); + }); - test('should return number completions', () => { - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(123); + test('should return completions for number literal', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(123); - expect(completions('{{ (123).| }}')).toHaveLength(extensions('number').length); - }); + expect(completions('{{ (123).| }}')).toHaveLength(extensions('number').length); + }); + + test('should return completions for array literal', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce([1, 2, 3]); + + expect(completions('{{ [1, 2, 3].| }}')).toHaveLength(extensions('array').length); + }); - test('should return array completions', () => { - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce([1, 2, 3]); + test('should return completions for object literal', () => { + const object = { a: 1 }; - expect(completions('{{ [1, 2, 3].| }}')).toHaveLength(extensions('array').length); + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(object); + + expect(completions('{{ ({ a: 1 }).| }}')).toHaveLength( + Object.keys(object).length + extensions('object').length, + ); + }); }); - test('should return object completions', () => { - const object = { a: 1 }; + describe('references', () => { + test('should return completions for: {{ $input.| }}', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.inputProxy); + + expect(completions('{{ $input.| }}')).toHaveLength(Reflect.ownKeys(mock.inputProxy).length); + }); + + test("should return completions for: {{ $('nodeName').| }}", () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.nodeSelectorProxy); + + expect(completions('{{ $nodeName.| }}')).toHaveLength( + Reflect.ownKeys(mock.nodeSelectorProxy).length, + ); + }); - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(object); + ['{{ $input.item.| }}', '{{ $input.first().| }}', '{{ $input.last().| }}'].forEach( + (expression) => { + test(`should return completions for: ${expression}`, () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.item); - expect(completions('{{ ({ a: 1 }).| }}')).toHaveLength( - Object.keys(object).length + extensions('object').length, + expect(completions(expression)).toHaveLength(1); // json + }); + }, ); + + test('should return no completions for: {{ $input.all().| }}', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue([mock.item]); + + expect(completions('{{ $input.all().| }}')).toBeNull(); + }); + + [ + '{{ $input.item.| }}', + '{{ $input.first().| }}', + '{{ $input.last().| }}', + '{{ $input.all()[0].| }}', + ].forEach((expression) => { + test(`should return completions for: ${expression}`, () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.item.json); + + expect(completions(expression)).toHaveLength(Object.keys(mock.item.json).length); + }); + }); + + test('should return completions for: {{ $input.item.json.str.| }}', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.item.json.str); + + expect(completions('{{ $input.item.json.str.| }}')).toHaveLength(extensions('string').length); + }); + + test('should return completions for: {{ $input.item.json.num.| }}', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.item.json.num); + + expect(completions('{{ $input.item.json.num.| }}')).toHaveLength(extensions('number').length); + }); + + test('should return completions for: {{ $input.item.json.arr.| }}', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.item.json.arr); + + expect(completions('{{ $input.item.json.arr.| }}')).toHaveLength(extensions('array').length); + }); + + test('should return completions for: {{ $input.item.json.obj.| }}', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.item.json.obj); + + expect(completions('{{ $input.item.json.obj.| }}')).toHaveLength( + Object.keys(mock.item.json.obj).length + extensions('object').length, + ); + }); }); -}); -// @TODO Test datatype completions for references -// @TODO Test bracketAccess completions + describe('bracket access', () => { + ['{{ $input.item.json[| }}', '{{ $json[| }}'].forEach((expression) => { + test(`should return completions for: ${expression}`, () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.item.json); + + const found = completions(expression); + + if (!found) throw new Error('Expected bracket access completions'); + + expect(found).toHaveLength(Object.keys(mock.item.json).length); + expect(found.map((c) => c.label).every((l) => l.endsWith(']'))); + }); + }); + + ["{{ $input.item.json['obj'][| }}", "{{ $json['obj'][| }}"].forEach((expression) => { + test(`should return completions for: ${expression}`, () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.item.json.obj); + + const found = completions(expression); + + if (!found) throw new Error('Expected bracket access completions'); + + expect(found).toHaveLength(Object.keys(mock.item.json.obj).length); + expect(found.map((c) => c.label).every((l) => l.endsWith(']'))); + }); + }); + }); +}); function completions(docWithCursor: string) { const cursorPosition = docWithCursor.indexOf('|'); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/mock.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/mock.ts new file mode 100644 index 0000000000000..9b6b2d89cb678 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/mock.ts @@ -0,0 +1,61 @@ +const inputProxy = new Proxy( + {}, + { + ownKeys() { + return ['all', 'context', 'first', 'item', 'last', 'params']; + }, + get(_, property) { + if (property === 'isMockProxy') return true; + + if (property === 'all') return []; + if (property === 'context') return {}; + if (property === 'first') return {}; + if (property === 'item') return {}; + if (property === 'last') return {}; + if (property === 'params') return {}; + + return undefined; + }, + }, +); + +const nodeSelectorProxy = new Proxy( + {}, + { + ownKeys() { + return ['all', 'context', 'first', 'item', 'last', 'params', 'itemMatching']; + }, + get(_, property) { + if (property === 'isMockProxy') return true; + + if (property === 'all') return []; + if (property === 'context') return {}; + if (property === 'first') return {}; + if (property === 'item') return {}; + if (property === 'last') return {}; + if (property === 'params') return {}; + if (property === 'itemMatching') return {}; + + return undefined; + }, + }, +); + +const item = { + json: { __isMockObject: true, str: 'abc', num: 123, arr: [1, 2, 3], obj: { a: 123 } }, + pairedItem: { item: 0, input: 0 }, +}; + +Object.defineProperty(item, '__isMockObject', { + enumerable: false, +}); + +Object.defineProperty(item.json, '__isMockObject', { + enumerable: false, +}); + +export const mock = { + inputProxy, + nodeSelectorProxy, + item, +}; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 06b4ed3f17f3b..76ddedd0e49f7 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -12,6 +12,7 @@ import { isPseudoParam, } from './utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import type { Resolved } from './types'; /** * Resolution-based completions offered according to datatype. @@ -47,7 +48,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul const [base, tail] = splitBaseTail(word.text); - let resolved: IDataObject | null; + let resolved: Resolved; try { resolved = resolveParameter(`={{ ${base} }}`); @@ -62,6 +63,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul try { options = datatypeOptions(resolved, base); } catch (_) { + console.log('_', _); return null; } @@ -83,7 +85,9 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul }; } -function datatypeOptions(resolved: IDataObject, toResolve: string) { +function datatypeOptions(resolved: Resolved, toResolve: string) { + if (resolved === null) return []; + if (typeof resolved === 'number') return extensions('number'); if (typeof resolved === 'string') return extensions('string'); @@ -107,7 +111,9 @@ function datatypeOptions(resolved: IDataObject, toResolve: string) { if (['$input', '$()'].includes(name) && hasNoParams(toResolve)) SKIP.add('params'); const rawKeys = - name === '$()' ? (Reflect.ownKeys(resolved) as string[]) : Object.keys(resolved); + name === '$()' || resolved.isMockProxy + ? (Reflect.ownKeys(resolved) as string[]) + : Object.keys(resolved); const keys = bringToStart(rawKeys, BOOST) .filter((key) => !SKIP.has(key) && isAllowedInDotNotation(key) && !isPseudoParam(key)) @@ -134,7 +140,9 @@ function datatypeOptions(resolved: IDataObject, toResolve: string) { resolved.json || /json('])?$/.test(toResolve) || toResolve === '$execution' || - toResolve.endsWith('params'); + toResolve.endsWith('params') || + resolved.isMockProxy || + resolved.__isMockObject; if (skipObjectExtensions) return keys; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/types.ts b/packages/editor-ui/src/plugins/codemirror/completions/types.ts new file mode 100644 index 0000000000000..f77aafc0fe961 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/types.ts @@ -0,0 +1,3 @@ +import { resolveParameter } from '@/mixins/workflowHelpers'; + +export type Resolved = ReturnType; From 206f15a4aececd1fc84a5767e2575c1534964fa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 24 Jan 2023 11:30:57 +0100 Subject: [PATCH 073/160] :sparkles: Add `Math` completions --- .../completions/datatype.completions.ts | 16 ++++++++++----- .../completions/nonDollar.completions.ts | 20 +++++++++++++++---- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 76ddedd0e49f7..370b7dfe9bc79 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -24,6 +24,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul const dateLiteral = /\(?new Date\(\(?.*?\)\)?\.([^{\s])*/; // new Date(). or (new Date()). const arrayLiteral = /(\[.+\])\.([^{\s])*/; // [1, 2, 3]. const objectLiteral = /\(\{.*\}\)\.([^{\s])*/; // ({}). + const mathGlobal = /Math\.([^{\s])*/; // Math. const combinedRegex = new RegExp( [ @@ -33,6 +34,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul dateLiteral.source, arrayLiteral.source, objectLiteral.source, + mathGlobal.source, ].join('|'), ); @@ -63,7 +65,6 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul try { options = datatypeOptions(resolved, base); } catch (_) { - console.log('_', _); return null; } @@ -110,10 +111,15 @@ function datatypeOptions(resolved: Resolved, toResolve: string) { if (['$input', '$()'].includes(name) && hasNoParams(toResolve)) SKIP.add('params'); - const rawKeys = - name === '$()' || resolved.isMockProxy - ? (Reflect.ownKeys(resolved) as string[]) - : Object.keys(resolved); + let rawKeys = Object.keys(resolved); + + if (name === '$()' || resolved.isMockProxy) { + rawKeys = Reflect.ownKeys(resolved) as string[]; + } + + if (toResolve === 'Math') { + rawKeys = Object.keys(Object.getOwnPropertyDescriptors(Math)); + } const keys = bringToStart(rawKeys, BOOST) .filter((key) => !SKIP.has(key) && isAllowedInDotNotation(key) && !isPseudoParam(key)) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts index 1e2c3bbce1f6b..fb2f93e0f4a87 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts @@ -5,10 +5,15 @@ import { prefixMatch } from './utils'; /** * Completions offered at the base position for any char other than `$`. * - * Currently only `D` for `DateTime`. + * Currently only `D...` for `DateTime` and `M...` for `Math` */ export function nonDollarCompletions(context: CompletionContext): CompletionResult | null { - const word = context.matchBefore(/(\s+)D[ateTim]*/); // loose charset but covered by filter + const dateTime = /(\s+)D[ateTim]*/; + const math = /(\s+)M[ath]*/; + + const combinedRegex = new RegExp([dateTime.source, math.source].join('|')); + + const word = context.matchBefore(combinedRegex); if (!word) return null; @@ -16,13 +21,20 @@ export function nonDollarCompletions(context: CompletionContext): CompletionResu const userInput = word.text.trim(); - const options = [ + const nonDollarOptions = [ { label: 'DateTime', type: 'keyword', info: i18n.rootVars.DateTime, }, - ].filter((o) => prefixMatch(o.label, userInput)); + { + label: 'Math', + type: 'keyword', + info: i18n.rootVars.DateTime, + }, + ]; + + const options = nonDollarOptions.filter((o) => prefixMatch(o.label, userInput)); return { from: word.to - userInput.length, From 34bc2785e341f5d7bc66a9a79b1dff3d55f42c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 24 Jan 2023 11:37:15 +0100 Subject: [PATCH 074/160] :pencil2: List breaking change --- packages/cli/BREAKING-CHANGES.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/cli/BREAKING-CHANGES.md b/packages/cli/BREAKING-CHANGES.md index d762f824da579..2f1d15329e6a9 100644 --- a/packages/cli/BREAKING-CHANGES.md +++ b/packages/cli/BREAKING-CHANGES.md @@ -2,6 +2,16 @@ This list shows all the versions which include breaking changes and how to upgrade. +## 0.213.0 + +### What changed? + +In expressions, `DateTime.fromHTTP()`, `DateTime.fromISO()` and `DateTime.fromJSDate()` require an argument. Before, they returned `null` when called without an argument; now, they throw an error. + +### When is action necessary? + +If you were relying on the above behavior, review your workflow to ensure the argument being passed in cannot be `undefined`. + ## 0.202.0 ### What changed? From 999e13292bf38a35bd91e448a089d8346b48cb5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 24 Jan 2023 16:07:10 +0100 Subject: [PATCH 075/160] :zap: Add doc tooltips --- .../completions/datatype.completions.ts | 35 ++++- .../src/Extensions/ArrayExtensions.ts | 116 ++++++++++++++ .../workflow/src/Extensions/DateExtensions.ts | 60 +++++++ .../workflow/src/Extensions/Extensions.ts | 10 +- .../src/Extensions/NumberExtensions.ts | 36 +++++ .../src/Extensions/ObjectExtensions.ts | 51 ++++++ .../src/Extensions/StringExtensions.ts | 147 +++++++++++++++++- 7 files changed, 451 insertions(+), 4 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 370b7dfe9bc79..80b9af87c9aa3 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -172,8 +172,39 @@ export const extensions = (typeName: 'number' | 'string' | 'date' | 'array' | 'o type: 'function', }; - // @TODO - // if (fn.description) option.info = f.description; + option.info = () => { + // @TODO: Tooltip will be refactored completely in next phase + const tooltipContainer = document.createElement('div'); + + if (!fn.doc?.description) return null; + + tooltipContainer.style.display = 'flex'; + tooltipContainer.style.flexDirection = 'column'; + tooltipContainer.style.paddingTop = 'var(--spacing-4xs)'; + tooltipContainer.style.paddingBottom = 'var(--spacing-4xs)'; + + const header = document.createElement('div'); + header.style.marginBottom = 'var(--spacing-2xs)'; + + const typeNameSpan = document.createElement('span'); + typeNameSpan.innerHTML = typeName.slice(0, 1).toUpperCase() + typeName.slice(1) + '.'; + + const functionNameSpan = document.createElement('span'); + functionNameSpan.innerHTML = fn.doc.name + '()'; + functionNameSpan.style.fontWeight = 'var(--font-weight-bold)'; + + const returnTypeSpan = document.createElement('span'); + returnTypeSpan.innerHTML = ': ' + fn.doc.returnType; + + header.appendChild(typeNameSpan); + header.appendChild(functionNameSpan); + header.appendChild(returnTypeSpan); + + tooltipContainer.appendChild(header); + tooltipContainer.appendChild(document.createTextNode(fn.doc.description)); + + return tooltipContainer; + }; return option; }); diff --git a/packages/workflow/src/Extensions/ArrayExtensions.ts b/packages/workflow/src/Extensions/ArrayExtensions.ts index 228e967e0712a..0cf60b034e58c 100644 --- a/packages/workflow/src/Extensions/ArrayExtensions.ts +++ b/packages/workflow/src/Extensions/ArrayExtensions.ts @@ -358,6 +358,122 @@ function intersection(value: unknown[], extraArgs: unknown[][]): unknown[] { return unique(newArr, []); } +average.doc = { + name: 'average', + description: 'Returns the mean average of all values in the array', + returnType: 'number', +}; + +compact.doc = { + name: 'compact', + description: 'Removes all empty values from the array', + returnType: 'array', +}; + +length.doc = { + name: 'length', + description: 'Returns the number of elements in the array', + returnType: 'array', + aliases: ['count', 'size'], +}; + +isEmpty.doc = { + name: 'isEmpty', + description: 'Checks if the array doesn’t have any elements', + returnType: 'boolean', +}; + +isNotEmpty.doc = { + name: 'isNotEmpty', + description: 'Checks if the array has elements', + returnType: 'boolean', +}; + +first.doc = { + name: 'first', + description: 'Returns the first element of the array', + returnType: 'array item', +}; + +last.doc = { + name: 'last', + description: 'Returns the last element of the array', + returnType: 'array item', +}; + +max.doc = { + name: 'max', + description: 'Gets the maximum value from a number-only array', + returnType: 'number', +}; + +min.doc = { + name: 'min', + description: 'Gets the minimum value from a number-only array', + returnType: 'number', +}; + +randomItem.doc = { + name: 'randomItem', + description: 'Returns a random element from an array', + returnType: 'number', +}; + +unique.doc = { + name: 'unique', + description: 'Returns a random element from an array', + returnType: 'number', + aliases: ['removeDuplicates'], +}; + +sum.doc = { + name: 'sum', + description: 'Returns the total sum all the values in an array of parsable numbers', + returnType: 'number', +}; + +// @TODO: Extensions below will be documented in next phase + +chunk.doc = { + name: 'chunk', + returnType: 'array', +}; + +difference.doc = { + name: 'difference', + returnType: 'array', +}; + +intersection.doc = { + name: 'intersection', + returnType: 'array', +}; + +merge.doc = { + name: 'merge', + returnType: 'array', +}; + +pluck.doc = { + name: 'pluck', + returnType: 'array', +}; + +renameKeys.doc = { + name: 'renameKeys', + returnType: 'array', +}; + +smartJoin.doc = { + name: 'smartJoin', + returnType: 'array', +}; + +union.doc = { + name: 'union', + returnType: 'array', +}; + export const arrayExtensions: ExtensionMap = { typeName: 'Array', functions: { diff --git a/packages/workflow/src/Extensions/DateExtensions.ts b/packages/workflow/src/Extensions/DateExtensions.ts index 36c4e5f7185c2..90b55a4a2d87f 100644 --- a/packages/workflow/src/Extensions/DateExtensions.ts +++ b/packages/workflow/src/Extensions/DateExtensions.ts @@ -210,6 +210,66 @@ function toLocaleString(date: Date | DateTime, extraArgs: unknown[]): string { return DateTime.fromJSDate(date).toLocaleString(dateFormat, { locale }); } +endOfMonth.doc = { + name: 'endOfMonth', + returnType: 'Date', + description: 'Transforms a date to the last possible moment that lies within the month', +}; + +isDst.doc = { + name: 'isDst', + returnType: 'boolean', + description: 'Checks if a Date is within Daylight Savings Time', +}; + +isWeekend.doc = { + name: 'isWeekend', + returnType: 'boolean', + description: 'Checks if the Date falls on a Saturday or Sunday', +}; + +// @TODO: Extensions below will be documented in next phase + +beginningOf.doc = { + name: 'beginningOf', + returnType: 'Date', +}; + +extract.doc = { + name: 'extract', + returnType: 'number', +}; + +format.doc = { + name: 'format', + returnType: '(?)', +}; + +isBetween.doc = { + name: 'isBetween', + returnType: 'boolean', +}; + +isInLast.doc = { + name: 'isInLast', + returnType: 'boolean', +}; + +minus.doc = { + name: 'minus', + returnType: 'Date', +}; + +plus.doc = { + name: 'plus', + returnType: 'Date', +}; + +toLocaleString.doc = { + name: 'toLocaleString', + returnType: 'string', +}; + export const dateExtensions: ExtensionMap = { typeName: 'Date', functions: { diff --git a/packages/workflow/src/Extensions/Extensions.ts b/packages/workflow/src/Extensions/Extensions.ts index 460567d0ebb01..3f2506d2a2441 100644 --- a/packages/workflow/src/Extensions/Extensions.ts +++ b/packages/workflow/src/Extensions/Extensions.ts @@ -1,5 +1,13 @@ export interface ExtensionMap { typeName: string; // eslint-disable-next-line @typescript-eslint/ban-types - functions: Record; + functions: Record; } + +type DocMetadata = { + name: string; + returnType: string; + description?: string; + aliases?: string[]; + args?: unknown[]; +}; diff --git a/packages/workflow/src/Extensions/NumberExtensions.ts b/packages/workflow/src/Extensions/NumberExtensions.ts index 11693e3632006..088a07c0a100e 100644 --- a/packages/workflow/src/Extensions/NumberExtensions.ts +++ b/packages/workflow/src/Extensions/NumberExtensions.ts @@ -34,6 +34,42 @@ function round(value: number, extraArgs: number[]) { return +value.toFixed(decimalPlaces); } +ceil.doc = { + name: 'ceil', + description: 'Rounds up a number to a whole number', + returnType: 'number', +}; + +floor.doc = { + name: 'floor', + description: 'Rounds down a number to a whole number', + returnType: 'number', +}; + +isEven.doc = { + name: 'isEven', + description: 'Returns true if the number is even. Only works on whole numbers.', + returnType: 'boolean', +}; + +isOdd.doc = { + name: 'isOdd', + description: 'Returns true if the number is odd. Only works on whole numbers.', + returnType: 'boolean', +}; + +// @TODO: Extensions below will be documented in next phase + +format.doc = { + name: 'format', + returnType: 'string', +}; + +round.doc = { + name: 'round', + returnType: 'number', +}; + export const numberExtensions: ExtensionMap = { typeName: 'Number', functions: { diff --git a/packages/workflow/src/Extensions/ObjectExtensions.ts b/packages/workflow/src/Extensions/ObjectExtensions.ts index ea7a8538cb012..3e0e05b96041b 100644 --- a/packages/workflow/src/Extensions/ObjectExtensions.ts +++ b/packages/workflow/src/Extensions/ObjectExtensions.ts @@ -93,6 +93,57 @@ export function urlEncode(value: object) { return new URLSearchParams(value as Record).toString(); } +isEmpty.doc = { + name: 'isEmpty', + description: 'Checks if the Object has no key-value pairs', + returnType: 'boolean', +}; + +isNotEmpty.doc = { + name: 'isNotEmpty', + description: 'Checks if the Object has key-value pairs', + returnType: 'boolean', +}; + +compact.doc = { + name: 'compact', + description: 'Removes empty values from an Object', + returnType: 'boolean', +}; + +urlEncode.doc = { + name: 'urlEncode', + description: 'Transforms an Object into a URL parameter list. Only top-level keys are supported.', + returnType: 'string', +}; + +// @TODO: Extensions below will be documented in next phase + +merge.doc = { + name: 'merge', + returnType: 'object', +}; + +hasField.doc = { + name: 'hasField', + returnType: 'boolean', +}; + +removeField.doc = { + name: 'removeField', + returnType: 'object', +}; + +removeFieldsContaining.doc = { + name: 'removeFieldsContaining', + returnType: 'object', +}; + +keepFieldsContaining.doc = { + name: 'keepFieldsContaining', + returnType: 'object', +}; + export const objectExtensions: ExtensionMap = { typeName: 'Object', functions: { diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 59032638eef61..fc3217eb3d7b5 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -253,6 +253,151 @@ function extractUrl(value: string) { return matched[0]; } +function toTitleCase(value: string) { + return titleCase(value); +} + +removeMarkdown.doc = { + name: 'removeMarkdown', + description: 'Removes Markdown formatting from a string', + returnType: 'string', +}; + +stripTags.doc = { + name: 'stripTags', + description: 'Removes tags, such as HTML or XML from a string', + returnType: 'string', +}; + +toDate.doc = { + name: 'toDate', + description: 'Converts a string to a date', + returnType: 'Date', +}; + +toFloat.doc = { + name: 'toFloat', + description: 'Converts a string to a decimal number', + returnType: 'number', + aliases: ['toDecimalNumber'], +}; + +toInt.doc = { + name: 'toInt', + description: 'Converts a string to an integer', + returnType: 'number', + aliases: ['toWholeNumber'], +}; + +toSentenceCase.doc = { + name: 'toSentenceCase', + description: 'Formats a string to sentence case. Example: "This is a sentence"', + returnType: 'string', +}; + +toSnakeCase.doc = { + name: 'toSnakeCase', + description: 'Formats a string to snake case. Example: "this_is_snake_case"', + returnType: 'string', +}; + +toTitleCase.doc = { + name: 'toTitleCase', + description: 'Formats a string to title case. Example: “This Is a Title”', + returnType: 'string', +}; + +urlDecode.doc = { + name: 'urlDecode', + description: + 'Decodes a URL-encoded string. It decodes any percent-encoded characters in the input string, and replaces them with their original characters.', + returnType: 'string', +}; + +replaceSpecialChars.doc = { + name: 'replaceSpecialChars', + description: 'Replaces non-ASCII characters in a string with an ASCII representation', + returnType: 'string', +}; + +length.doc = { + name: 'length', + description: 'Returns the character count of a string', + returnType: 'number', +}; + +isDomain.doc = { + name: 'isDomain', + description: 'Checks if a string is a domain', + returnType: 'boolean', +}; + +isEmail.doc = { + name: 'isEmail', + description: 'Checks if a string is an email', + returnType: 'boolean', +}; + +isNumeric.doc = { + name: 'isEmail', + description: 'Checks if a string only contains digits', + returnType: 'boolean', +}; + +isUrl.doc = { + name: 'isUrl', + description: 'Checks if a string is a valid URL', + returnType: 'boolean', + aliases: ['isURL'], +}; + +isEmpty.doc = { + name: 'isEmpty', + description: 'Checks if a string is empty', + returnType: 'boolean', +}; + +isNotEmpty.doc = { + name: 'isNotEmpty', + description: 'Checks if a string has content', + returnType: 'boolean', +}; + +extractEmail.doc = { + name: 'extractEmail', + description: 'Extracts an email from a string', + returnType: 'string', +}; + +extractDomain.doc = { + name: 'extractDomain', + description: 'Extracts a domain from a string', + returnType: 'string', +}; + +extractUrl.doc = { + name: 'extractUrl', + description: 'Extracts a URL from a string', + returnType: 'string', +}; + +// @TODO: Extensions below will be documented in next phase + +hash.doc = { + name: 'hash', + returnType: 'string', +}; + +urlEncode.doc = { + name: 'urlEncode', + returnType: 'string', +}; + +quote.doc = { + name: 'quote', + returnType: 'string', +}; + export const stringExtensions: ExtensionMap = { typeName: 'String', functions: { @@ -266,7 +411,7 @@ export const stringExtensions: ExtensionMap = { toWholeNumber: toInt, toSentenceCase, toSnakeCase, - toTitleCase: titleCase, + toTitleCase, urlDecode, urlEncode, quote, From d301f5884503cf6b8b87cb6fe9cec2966a9d9b10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 24 Jan 2023 17:07:24 +0100 Subject: [PATCH 076/160] :bug: Fix node selector regex --- .../plugins/codemirror/completions/datatype.completions.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 80b9af87c9aa3..33dcbdd271b69 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -18,7 +18,8 @@ import type { Resolved } from './types'; * Resolution-based completions offered according to datatype. */ export function datatypeCompletions(context: CompletionContext): CompletionResult | null { - const reference = /\$[\S]+\.([^{\s])*/; // $input. + const generalReference = /\$[\S]+\.([^{\s])*/; // $input. + const nodeSelectorReference = /\$\(['"][\S\s]+['"]\)\..*/; // $('nodeName') const numberLiteral = /\((\d+)\.?(\d*)\)\.([^{\s])*/; // (123). or (123.4). const stringLiteral = /(".+"|('.+'))\.([^{\s])*/; // 'abc'. or "abc". const dateLiteral = /\(?new Date\(\(?.*?\)\)?\.([^{\s])*/; // new Date(). or (new Date()). @@ -28,7 +29,8 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul const combinedRegex = new RegExp( [ - reference.source, + generalReference.source, + nodeSelectorReference.source, numberLiteral.source, stringLiteral.source, dateLiteral.source, From 3c79a507d8ca499438b64afa50e17f8b1c826c7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 24 Jan 2023 18:14:09 +0100 Subject: [PATCH 077/160] :bug: Fix `context` resolution --- packages/workflow/src/WorkflowDataProxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index 2a961e247b5f5..9177a664ba9e7 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -126,7 +126,7 @@ export class WorkflowDataProxy { const that = this; const node = this.workflow.nodes[nodeName]; - if (!that.runExecutionData?.executionData && that.connectionInputData.length > 1) { + if (!that.runExecutionData?.executionData && that.connectionInputData.length > 0) { return {}; // incoming connection has pinned data, so stub context object } From f42e391088c7f3790727c46fccb8bf8d54696964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 24 Jan 2023 18:31:01 +0100 Subject: [PATCH 078/160] :bug: Allow dollar completions in args --- .../plugins/codemirror/completions/datatype.completions.ts | 2 +- .../src/plugins/codemirror/completions/dollar.completions.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 33dcbdd271b69..ad90ac341588c 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -18,7 +18,7 @@ import type { Resolved } from './types'; * Resolution-based completions offered according to datatype. */ export function datatypeCompletions(context: CompletionContext): CompletionResult | null { - const generalReference = /\$[\S]+\.([^{\s])*/; // $input. + const generalReference = /\$[^$]+\.([^{\s])*/; // $input. const nodeSelectorReference = /\$\(['"][\S\s]+['"]\)\..*/; // $('nodeName') const numberLiteral = /\((\d+)\.?(\d*)\)\.([^{\s])*/; // (123). or (123.4). const stringLiteral = /(".+"|('.+'))\.([^{\s])*/; // 'abc'. or "abc". diff --git a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts index 0afd0355c047e..a13d7d842c41c 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts @@ -10,11 +10,9 @@ import type { Completion, CompletionContext, CompletionResult } from '@codemirro /** * Completions offered at the dollar position: `$` - * - * Negative charset `[^}]` ensures completion match stays within resolvable boundaries. */ export function dollarCompletions(context: CompletionContext): CompletionResult | null { - const word = context.matchBefore(/\$\w*[^}]*/); + const word = context.matchBefore(/\$[^$]*/); if (!word) return null; From 891e371c1b8f2300c91c89eef0cbf5b770e501cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 24 Jan 2023 18:45:40 +0100 Subject: [PATCH 079/160] :zap: Make numeric array methods context-dependent --- .../codemirror/completions/datatype.completions.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index ad90ac341588c..520894a7c6b4e 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -100,7 +100,15 @@ function datatypeOptions(resolved: Resolved, toResolve: string) { if (Array.isArray(resolved)) { if (toResolve.endsWith('all()')) return []; - return extensions('array'); + const arrayExtensions = extensions('array'); + + if (resolved.length > 0 && resolved.some((i) => typeof i !== 'number')) { + const NUMBER_ONLY_ARRAY_EXTENSIONS = new Set(['max()', 'min()', 'sum()', 'average()']); + + return arrayExtensions.filter((e) => !NUMBER_ONLY_ARRAY_EXTENSIONS.has(e.label)); + } + + return arrayExtensions; } if (typeof resolved === 'object') { From 152f0d76a41d58bc93315f97948401ba076cf1e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 24 Jan 2023 18:48:37 +0100 Subject: [PATCH 080/160] :pencil: Adjust docs --- packages/workflow/src/Extensions/ArrayExtensions.ts | 4 ++-- packages/workflow/src/Extensions/StringExtensions.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/workflow/src/Extensions/ArrayExtensions.ts b/packages/workflow/src/Extensions/ArrayExtensions.ts index 0cf60b034e58c..e33cdf393622b 100644 --- a/packages/workflow/src/Extensions/ArrayExtensions.ts +++ b/packages/workflow/src/Extensions/ArrayExtensions.ts @@ -373,7 +373,7 @@ compact.doc = { length.doc = { name: 'length', description: 'Returns the number of elements in the array', - returnType: 'array', + returnType: 'number', aliases: ['count', 'size'], }; @@ -422,7 +422,7 @@ randomItem.doc = { unique.doc = { name: 'unique', description: 'Returns a random element from an array', - returnType: 'number', + returnType: 'array item', aliases: ['removeDuplicates'], }; diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index fc3217eb3d7b5..37a3ceb503ef2 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -265,7 +265,7 @@ removeMarkdown.doc = { stripTags.doc = { name: 'stripTags', - description: 'Removes tags, such as HTML or XML from a string', + description: 'Removes tags, such as HTML or XML, from a string', returnType: 'string', }; @@ -303,7 +303,7 @@ toSnakeCase.doc = { toTitleCase.doc = { name: 'toTitleCase', - description: 'Formats a string to title case. Example: “This Is a Title”', + description: 'Formats a string to title case. Example: "This Is a Title"', returnType: 'string', }; From b1d356905adde4fa6c285cca1ead23a151f7662b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 25 Jan 2023 10:36:39 +0100 Subject: [PATCH 081/160] :bug: Fix selector ref --- packages/editor-ui/src/mixins/workflowHelpers.ts | 2 +- .../codemirror/completions/bracketAccess.completions.ts | 5 +++-- .../editor-ui/src/plugins/codemirror/completions/utils.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/editor-ui/src/mixins/workflowHelpers.ts b/packages/editor-ui/src/mixins/workflowHelpers.ts index e97bc3c99e6e1..bead0d1cf47ab 100644 --- a/packages/editor-ui/src/mixins/workflowHelpers.ts +++ b/packages/editor-ui/src/mixins/workflowHelpers.ts @@ -77,7 +77,7 @@ export function resolveParameter( inputRunIndex?: number; inputBranchIndex?: number; } = {}, -): IDataObject | string | number | unknown[] | null { +): IDataObject | null { let itemIndex = opts?.targetItem?.itemIndex || 0; const inputName = 'main'; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts index 6a2cfb5879742..a479aa9341086 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts @@ -2,6 +2,7 @@ import { resolveParameter } from '@/mixins/workflowHelpers'; import { prefixMatch, longestCommonPrefix } from './utils'; import type { IDataObject } from 'n8n-workflow'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import type { Resolved } from './types'; /** * Resolution-based completions offered at the start of bracket access notation. @@ -14,7 +15,7 @@ import type { Completion, CompletionContext, CompletionResult } from '@codemirro * - `$input.first().json.myStr[` */ export function bracketAccessCompletions(context: CompletionContext): CompletionResult | null { - const word = context.matchBefore(/\$[\S]*\[.*/); + const word = context.matchBefore(/\$[\S\s]*\[.*/); if (!word) return null; @@ -27,7 +28,7 @@ export function bracketAccessCompletions(context: CompletionContext): Completion const base = word.text.substring(0, word.text.lastIndexOf('[')); const tail = word.text.split('[').pop() ?? ''; - let resolved: IDataObject | null; + let resolved: Resolved; try { resolved = resolveParameter(`={{ ${base} }}`); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts index 56bec9fd439e4..0a9946e0fc117 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -63,7 +63,7 @@ export const isPseudoParam = (candidate: string) => { * Whether a string may be used as a key in object dot access notation. */ export const isAllowedInDotNotation = (str: string) => { - const DOT_NOTATION_BANNED_CHARS = /^(\d)|[\\ `!@#$%^&*()_+\-=[\]{};':"\\|,.<>?~]/g; + const DOT_NOTATION_BANNED_CHARS = /^(\d)|[\\ `!@#$%^&*()+\-=[\]{};':"\\|,.<>?~]/g; return !DOT_NOTATION_BANNED_CHARS.test(str); }; From 0e67e5d87ba9162f42d2ba7fa7ad051223d798ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 25 Jan 2023 12:04:55 +0100 Subject: [PATCH 082/160] :zap: Surface error for valid URL --- packages/workflow/src/Extensions/StringExtensions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 37a3ceb503ef2..e2a95f388ddf3 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -175,7 +175,7 @@ function isUrl(value: string) { try { url = new URL(value); } catch (_error) { - return false; + throw new ExpressionError.ExpressionExtensionError(`${value} is not a valid URL`); } return url.protocol === 'http:' || url.protocol === 'https:'; } From b3742d1fa6c37b33c57d0d379e2767c3a2595260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 25 Jan 2023 12:11:31 +0100 Subject: [PATCH 083/160] :bug: Disallow whitespace in `isEmail` check --- packages/workflow/src/Extensions/StringExtensions.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index e2a95f388ddf3..65230f87b6e1d 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -185,7 +185,13 @@ function isDomain(value: string) { } function isEmail(value: string) { - return EMAIL_REGEXP.test(value); + const result = EMAIL_REGEXP.test(value); + + if (result && value.includes(' ')) { + return false; + } + + return result; } function replaceSpecialChars(value: string) { From a5c2a9b43c2473b2ea7940b2633a1d637678931e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 25 Jan 2023 12:12:59 +0100 Subject: [PATCH 084/160] :test_tube: Fix test for `isUrl` --- .../workflow/test/ExpressionExtensions/StringExtensions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts index 4af2333767c53..671d91eb53352 100644 --- a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts @@ -97,7 +97,7 @@ describe('Data Transformation Functions', () => { test('.isUrl should work on a string', () => { expect(evaluate('={{ "https://example.com/".isUrl() }}')).toEqual(true); - expect(evaluate('={{ "example.com".isUrl() }}')).toEqual(false); + expect(() => evaluate('={{ "example.com".isUrl() }}')).toThrow(); }); test('.isDomain should work on a string', () => { From 2113474e1338feb098078e114ca502116425bafc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 25 Jan 2023 12:18:43 +0100 Subject: [PATCH 085/160] :zap: Add comma validator in `toFloat` --- packages/workflow/src/Extensions/StringExtensions.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 65230f87b6e1d..a75aece5ae905 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -150,6 +150,12 @@ function toInt(value: string, extraArgs: Array) { } function toFloat(value: string) { + if (value.includes(',')) { + throw new ExpressionError.ExpressionExtensionError( + 'cannot convert to float, expected . as decimal separator', + ); + } + const float = parseFloat(value.replace(CURRENCY_REGEXP, '')); if (isNaN(float)) { From b23fe61c4302eba67516955a8aec9cfd7dbce6a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 25 Jan 2023 12:36:57 +0100 Subject: [PATCH 086/160] :zap: Add validation to `$jmespath()` --- packages/cli/BREAKING-CHANGES.md | 2 ++ packages/workflow/src/Expression.ts | 9 +++++++++ packages/workflow/src/ExpressionError.ts | 7 +++++++ packages/workflow/src/WorkflowDataProxy.ts | 16 ++++++++++++++++ 4 files changed, 34 insertions(+) diff --git a/packages/cli/BREAKING-CHANGES.md b/packages/cli/BREAKING-CHANGES.md index 2f1d15329e6a9..115afc1d75089 100644 --- a/packages/cli/BREAKING-CHANGES.md +++ b/packages/cli/BREAKING-CHANGES.md @@ -6,6 +6,8 @@ This list shows all the versions which include breaking changes and how to upgra ### What changed? +@TODO: Check if more breaking changes were introduced + In expressions, `DateTime.fromHTTP()`, `DateTime.fromISO()` and `DateTime.fromJSDate()` require an argument. Before, they returned `null` when called without an argument; now, they throw an error. ### When is action necessary? diff --git a/packages/workflow/src/Expression.ts b/packages/workflow/src/Expression.ts index fa029b1f48c0a..57fea4c8fde81 100644 --- a/packages/workflow/src/Expression.ts +++ b/packages/workflow/src/Expression.ts @@ -32,12 +32,17 @@ import { extendedFunctions } from './Extensions/ExtendedFunctions'; // Set it to use double curly brackets instead of single ones tmpl.brackets.set('{{ }}'); +// @TODO: Duplicated below, remove? // Make sure that error get forwarded tmpl.tmpl.errorHandler = (error: Error) => { if (error instanceof ExpressionError) { if (error.context.failExecution) { throw error; } + + if (typeof process === 'undefined' && error.clientOnly) { + throw error; + } } }; @@ -310,6 +315,10 @@ export class Expression { if (error.context.failExecution) { throw error; } + + if (typeof process === 'undefined' && error.clientOnly) { + throw error; + } } // Syntax errors resolve to `Error` on the frontend and `null` on the backend. diff --git a/packages/workflow/src/ExpressionError.ts b/packages/workflow/src/ExpressionError.ts index f0972aa4f3105..c7540ccd6fc92 100644 --- a/packages/workflow/src/ExpressionError.ts +++ b/packages/workflow/src/ExpressionError.ts @@ -5,6 +5,8 @@ import { ExecutionBaseError } from './NodeErrors'; * Class for instantiating an expression error */ export class ExpressionError extends ExecutionBaseError { + clientOnly = false; + constructor( message: string, options?: { @@ -13,6 +15,7 @@ export class ExpressionError extends ExecutionBaseError { description?: string; descriptionTemplate?: string; failExecution?: boolean; + clientOnly?: boolean; // whether to throw error only on frontend functionality?: 'pairedItem'; itemIndex?: number; messageTemplate?: string; @@ -28,6 +31,10 @@ export class ExpressionError extends ExecutionBaseError { this.description = options.description; } + if (options?.clientOnly) { + this.clientOnly = options.clientOnly; + } + this.context.failExecution = !!options?.failExecution; const allowedKeys = [ diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index 9177a664ba9e7..63147ada1aacc 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -619,6 +619,22 @@ export class WorkflowDataProxy { // replacing proxies with the actual data. const jmespathWrapper = (data: IDataObject | IDataObject[], query: string) => { + if (typeof data !== 'object') { + throw new ExpressionError('expected object as first argument', { + runIndex: that.runIndex, + itemIndex: that.itemIndex, + clientOnly: true, + }); + } + + if (typeof query !== 'string') { + throw new ExpressionError('expected string as second argument', { + runIndex: that.runIndex, + itemIndex: that.itemIndex, + clientOnly: true, + }); + } + if (!Array.isArray(data) && typeof data === 'object') { return jmespath.search({ ...data }, query); } From 36adc8c77567857b740fa9ff131f6ca3419a309f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 25 Jan 2023 12:47:40 +0100 Subject: [PATCH 087/160] :rewind: Revert valid URL error --- packages/workflow/src/Extensions/StringExtensions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index a75aece5ae905..597ad634cf89f 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -181,7 +181,7 @@ function isUrl(value: string) { try { url = new URL(value); } catch (_error) { - throw new ExpressionError.ExpressionExtensionError(`${value} is not a valid URL`); + return false; } return url.protocol === 'http:' || url.protocol === 'https:'; } From 525391dd0afaf44315c7f227ff62c136450b37ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 25 Jan 2023 13:34:28 +0100 Subject: [PATCH 088/160] :zap: Adjust `$jmespath()` validation --- packages/workflow/src/WorkflowDataProxy.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index 63147ada1aacc..a23ec76bb13c6 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -619,16 +619,8 @@ export class WorkflowDataProxy { // replacing proxies with the actual data. const jmespathWrapper = (data: IDataObject | IDataObject[], query: string) => { - if (typeof data !== 'object') { - throw new ExpressionError('expected object as first argument', { - runIndex: that.runIndex, - itemIndex: that.itemIndex, - clientOnly: true, - }); - } - - if (typeof query !== 'string') { - throw new ExpressionError('expected string as second argument', { + if (typeof data !== 'object' || typeof query !== 'string') { + throw new ExpressionError('expected two arguments (Object, string) for this function', { runIndex: that.runIndex, itemIndex: that.itemIndex, clientOnly: true, From 62bf09611f6ceedc2fcb4802a8b00ce435f94602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 25 Jan 2023 13:50:36 +0100 Subject: [PATCH 089/160] :test_tube: Adjust `isUrl` test --- .../workflow/test/ExpressionExtensions/StringExtensions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts index 671d91eb53352..4af2333767c53 100644 --- a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts @@ -97,7 +97,7 @@ describe('Data Transformation Functions', () => { test('.isUrl should work on a string', () => { expect(evaluate('={{ "https://example.com/".isUrl() }}')).toEqual(true); - expect(() => evaluate('={{ "example.com".isUrl() }}')).toThrow(); + expect(evaluate('={{ "example.com".isUrl() }}')).toEqual(false); }); test('.isDomain should work on a string', () => { From b4147bbd0377d8c391a25f122a6aab9da75e40de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 25 Jan 2023 13:59:21 +0100 Subject: [PATCH 090/160] :zap: Remove `{}` and `[]` from compact --- packages/workflow/src/Extensions/ArrayExtensions.ts | 6 +++++- packages/workflow/src/Extensions/ObjectExtensions.ts | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/workflow/src/Extensions/ArrayExtensions.ts b/packages/workflow/src/Extensions/ArrayExtensions.ts index e33cdf393622b..649f2e468de53 100644 --- a/packages/workflow/src/Extensions/ArrayExtensions.ts +++ b/packages/workflow/src/Extensions/ArrayExtensions.ts @@ -214,7 +214,11 @@ export function average(value: unknown[]) { function compact(value: unknown[]): unknown[] { return value - .filter((v) => v !== null && v !== undefined && v !== 'nil' && v !== '') + .filter((v) => { + if (v && typeof v === 'object' && Object.keys(v).length === 0) return false; + + return v !== null && v !== undefined && v !== 'nil' && v !== ''; + }) .map((v) => { if (typeof v === 'object' && v !== null) { return oCompact(v); diff --git a/packages/workflow/src/Extensions/ObjectExtensions.ts b/packages/workflow/src/Extensions/ObjectExtensions.ts index 3e0e05b96041b..bbc4d91867b05 100644 --- a/packages/workflow/src/Extensions/ObjectExtensions.ts +++ b/packages/workflow/src/Extensions/ObjectExtensions.ts @@ -77,6 +77,7 @@ export function compact(value: object): object { for (const [key, val] of Object.entries(value)) { if (val !== null && val !== undefined && val !== 'nil' && val !== '') { if (typeof val === 'object') { + if (Object.keys(val).length === 0) continue; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument newObj[key] = compact(val); } else { From 483bc68d653407f34c9c9234040b26965232345f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 25 Jan 2023 14:08:19 +0100 Subject: [PATCH 091/160] :pencil2: Update docs --- packages/workflow/src/Extensions/StringExtensions.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 597ad634cf89f..f32879394ccf2 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -377,19 +377,20 @@ isNotEmpty.doc = { extractEmail.doc = { name: 'extractEmail', - description: 'Extracts an email from a string', + description: 'Extracts an email from a string. Returns undefined if none is found.', returnType: 'string', }; extractDomain.doc = { name: 'extractDomain', - description: 'Extracts a domain from a string', + description: + 'Extracts a domain from a string containing a valid URL. Returns undefined if none is found.', returnType: 'string', }; extractUrl.doc = { name: 'extractUrl', - description: 'Extracts a URL from a string', + description: 'Extracts a URL from a string. Returns undefined if none is found.', returnType: 'string', }; From 59e56389add680f452d177872522348011f0d721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 25 Jan 2023 14:21:09 +0100 Subject: [PATCH 092/160] :truck: Rename `stripTags` to `removeTags` --- packages/workflow/src/Extensions/StringExtensions.ts | 8 ++++---- .../test/ExpressionExtensions/StringExtensions.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index f32879394ccf2..b25ddd5576aca 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -108,7 +108,7 @@ function removeMarkdown(value: string): string { return output; } -function stripTags(value: string): string { +function removeTags(value: string): string { return value.replace(/<[^>]*>?/gm, ''); } @@ -275,8 +275,8 @@ removeMarkdown.doc = { returnType: 'string', }; -stripTags.doc = { - name: 'stripTags', +removeTags.doc = { + name: 'removeTags', description: 'Removes tags, such as HTML or XML, from a string', returnType: 'string', }; @@ -416,7 +416,7 @@ export const stringExtensions: ExtensionMap = { functions: { hash, removeMarkdown, - stripTags, + removeTags, toDate, toDecimalNumber: toFloat, toFloat, diff --git a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts index 4af2333767c53..11230cb205555 100644 --- a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts @@ -52,8 +52,8 @@ describe('Data Transformation Functions', () => { ); }); - test('.stripTags should work correctly on a string', () => { - expect(evaluate('={{ "test".stripTags() }}')).toEqual('test'); + test('.removeTags should work correctly on a string', () => { + expect(evaluate('={{ "test".removeTags() }}')).toEqual('test'); }); test('.removeMarkdown should work correctly on a string', () => { From 2634f02c915266f413c2ab83e27feaa9158d1615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 26 Jan 2023 10:09:32 +0100 Subject: [PATCH 093/160] :zap: Do not inject whitespace inside resolvable --- .../codemirror/inputHandlers/expression.inputHandler.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts b/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts index 7942772aedf8b..a18e8280f3b9b 100644 --- a/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts +++ b/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts @@ -63,7 +63,13 @@ const handler = EditorView.inputHandler.of((view, from, to, insert) => { view.state.sliceDoc(cursor - 1, cursor) === '{' && view.state.sliceDoc(cursor, cursor + 1) === '}'; - if (isBraceSetup) { + const { head } = view.state.selection.main; + + const isInsideResolvable = + view.state.sliceDoc(0, head).includes('{{') && + view.state.sliceDoc(head, view.state.doc.length).includes('}}'); + + if (isBraceSetup && !isInsideResolvable) { view.dispatch({ changes: { from: cursor, insert: ' ' } }); return true; From 065f79d785b8c4485337b51e3e5bbec18e49deeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 26 Jan 2023 10:10:20 +0100 Subject: [PATCH 094/160] :zap: Make completions aware of `()` --- .../completions/blank.completions.ts | 2 +- .../completions/datatype.completions.ts | 28 ++++++++++++------- .../completions/dollar.completions.ts | 9 ++++-- .../completions/luxon.completions.ts | 20 +++++++++---- .../plugins/codemirror/completions/utils.ts | 4 +++ 5 files changed, 43 insertions(+), 20 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts index dee2b327a1b2f..df73d22a33974 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts @@ -17,7 +17,7 @@ export function blankCompletions(context: CompletionContext): CompletionResult | return { from: word.to, - options: dollarOptions(), + options: dollarOptions(context), filter: false, }; } diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 520894a7c6b4e..53b2d576b9615 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -10,6 +10,7 @@ import { longestCommonPrefix, splitBaseTail, isPseudoParam, + noParensAfterCursor, } from './utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import type { Resolved } from './types'; @@ -65,7 +66,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul let options: Completion[] = []; try { - options = datatypeOptions(resolved, base); + options = datatypeOptions(resolved, base, context); } catch (_) { return null; } @@ -88,19 +89,19 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul }; } -function datatypeOptions(resolved: Resolved, toResolve: string) { +function datatypeOptions(resolved: Resolved, toResolve: string, context: CompletionContext) { if (resolved === null) return []; - if (typeof resolved === 'number') return extensions('number'); + if (typeof resolved === 'number') return extensions('number', context); - if (typeof resolved === 'string') return extensions('string'); + if (typeof resolved === 'string') return extensions('string', context); - if (resolved instanceof Date) return extensions('date'); + if (resolved instanceof Date) return extensions('date', context); if (Array.isArray(resolved)) { if (toResolve.endsWith('all()')) return []; - const arrayExtensions = extensions('array'); + const arrayExtensions = extensions('array', context); if (resolved.length > 0 && resolved.some((i) => typeof i !== 'number')) { const NUMBER_ONLY_ARRAY_EXTENSIONS = new Set(['max()', 'min()', 'sum()', 'average()']); @@ -131,6 +132,8 @@ function datatypeOptions(resolved: Resolved, toResolve: string) { rawKeys = Object.keys(Object.getOwnPropertyDescriptors(Math)); } + const noParens = noParensAfterCursor(context); + const keys = bringToStart(rawKeys, BOOST) .filter((key) => !SKIP.has(key) && isAllowedInDotNotation(key) && !isPseudoParam(key)) .map((key) => { @@ -139,7 +142,7 @@ function datatypeOptions(resolved: Resolved, toResolve: string) { const isFunction = typeof resolved[key] === 'function'; const option: Completion = { - label: isFunction ? key + '()' : key, + label: isFunction && noParens ? key + '()' : key, type: isFunction ? 'function' : 'keyword', }; @@ -162,23 +165,28 @@ function datatypeOptions(resolved: Resolved, toResolve: string) { if (skipObjectExtensions) return keys; - return [...keys, ...extensions('object')]; + return [...keys, ...extensions('object', context)]; } return []; } -export const extensions = (typeName: 'number' | 'string' | 'date' | 'array' | 'object') => { +export const extensions = ( + typeName: 'number' | 'string' | 'date' | 'array' | 'object', + context: CompletionContext, +) => { const extensions = ExpressionExtensions.find((ee) => ee.typeName.toLowerCase() === typeName); if (!extensions) return []; + const noParens = noParensAfterCursor(context); + return Object.entries(extensions.functions) .filter(([_, fn]) => fn.length === 1) // @TODO: Remove in next phase .sort((a, b) => a[0].localeCompare(b[0])) .map(([name, fn]) => { const option: Completion = { - label: name + '()', + label: noParens ? name + '()' : name, type: 'function', }; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts index a13d7d842c41c..6c3c0fca66121 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts @@ -5,6 +5,7 @@ import { longestCommonPrefix, bringToStart, prefixMatch, + noParensAfterCursor, } from './utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; @@ -18,7 +19,7 @@ export function dollarCompletions(context: CompletionContext): CompletionResult if (word.from === word.to && !context.explicit) return null; - let options = dollarOptions(); + let options = dollarOptions(context); const userInput = word.text; @@ -43,7 +44,7 @@ export function dollarCompletions(context: CompletionContext): CompletionResult }; } -export function dollarOptions() { +export function dollarOptions(context: CompletionContext) { const BOOST = ['$input', '$json']; const SKIP = new Set(); const DOLLAR_FUNCTIONS = ['$jmespath']; @@ -52,13 +53,15 @@ export function dollarOptions() { const keys = Object.keys(i18n.rootVars).sort((a, b) => a.localeCompare(b)); + const noParens = noParensAfterCursor(context); + return bringToStart(keys, BOOST) .filter((key) => !SKIP.has(key)) .map((key) => { const isFunction = DOLLAR_FUNCTIONS.includes(key); const option: Completion = { - label: isFunction ? key + '()' : key, + label: isFunction && noParens ? key + '()' : key, type: isFunction ? 'function' : 'keyword', }; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts index 16e6858f93a21..947f9b422824a 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts @@ -1,5 +1,5 @@ import { i18n } from '@/plugins/i18n'; -import { prefixMatch, longestCommonPrefix, splitBaseTail } from './utils'; +import { prefixMatch, longestCommonPrefix, splitBaseTail, noParensAfterCursor } from './utils'; import { DateTime } from 'luxon'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; @@ -19,7 +19,7 @@ export function luxonCompletions(context: CompletionContext): CompletionResult | const [base, tail] = splitBaseTail(word.text); - let options = base === 'DateTime' ? dateTimeOptions() : nowTodayOptions(); + let options = base === 'DateTime' ? dateTimeOptions(context) : nowTodayOptions(context); if (tail !== '') { options = options.filter((o) => prefixMatch(o.label, tail)); @@ -39,9 +39,11 @@ export function luxonCompletions(context: CompletionContext): CompletionResult | }; } -export const nowTodayOptions = () => { +export const nowTodayOptions = (context: CompletionContext) => { const SKIP = new Set(['constructor', 'get', 'invalidExplanation', 'invalidReason']); + const noParens = noParensAfterCursor(context); + return Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype)) .filter(([key]) => !SKIP.has(key)) .sort(([a], [b]) => a.localeCompare(b)) @@ -49,7 +51,7 @@ export const nowTodayOptions = () => { const isFunction = typeof descriptor.value === 'function'; const option: Completion = { - label: isFunction ? key + '()' : key, + label: isFunction && noParens ? key + '()' : key, type: isFunction ? 'function' : 'keyword', }; @@ -61,14 +63,20 @@ export const nowTodayOptions = () => { }); }; -export const dateTimeOptions = () => { +export const dateTimeOptions = (context: CompletionContext) => { const SKIP = new Set(['prototype', 'name', 'length', 'invalid']); + const noParens = noParensAfterCursor(context); + return Object.keys(Object.getOwnPropertyDescriptors(DateTime)) .filter((key) => !SKIP.has(key) && !key.includes('_')) .sort((a, b) => a.localeCompare(b)) .map((key) => { - const option: Completion = { label: key + '()', type: 'function' }; + const option: Completion = { + label: noParens ? key + '()' : key, + type: 'function', + }; + const info = i18n.luxonStatic[key]; if (info) option.info = info; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts index 0a9946e0fc117..64b0982aaf10d 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -2,6 +2,7 @@ import { NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION } from '@/components/CodeNodeEd import { SPLIT_IN_BATCHES_NODE_TYPE } from '@/constants'; import { useWorkflowsStore } from '@/stores/workflows'; import { resolveParameter } from '@/mixins/workflowHelpers'; +import { CompletionContext } from '@codemirror/autocomplete'; /** * Split user input into base (to resolve) and tail (to filter). @@ -94,3 +95,6 @@ export function autocompletableNodeNames() { .allNodes.filter((node) => !NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION.includes(node.type)) .map((node) => node.name); } + +export const noParensAfterCursor = (context: CompletionContext) => + context.state.sliceDoc(context.pos, context.pos + 2) !== '()'; From cf2266bfd70e514521b91a53d35eb83382c97442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 26 Jan 2023 10:18:27 +0100 Subject: [PATCH 095/160] :pencil2: Add note --- .../codemirror/completions/__tests__/completions.test.ts | 3 +++ 1 file changed, 3 insertions(+) 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 3fdea7cc8ea07..8f920bab6846b 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 @@ -19,8 +19,11 @@ beforeEach(() => { setActivePinia(createTestingPinia()); vi.spyOn(utils, 'receivesNoBinaryData').mockReturnValue(true); // hide $binary vi.spyOn(utils, 'isSplitInBatchesAbsent').mockReturnValue(false); // show context + vi.spyOn(utils, 'noParensAfterCursor').mockReturnValue(true); // show context }); +// @TODO: Fix tests + describe('Top-level completions', () => { test('should return blank completions for: {{ | }}', () => { expect(completions('{{ | }}')).toHaveLength(dollarOptions().length); From 0826772e58953dc45f0e70feb38ac51b0eb200ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 26 Jan 2023 14:07:38 +0100 Subject: [PATCH 096/160] :zap: Update sorting --- .../src/plugins/codemirror/completions/dollar.completions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts index 6c3c0fca66121..5c0cb8cde1a26 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts @@ -45,7 +45,7 @@ export function dollarCompletions(context: CompletionContext): CompletionResult } export function dollarOptions(context: CompletionContext) { - const BOOST = ['$input', '$json']; + const BOOST = ['$json', '$input']; const SKIP = new Set(); const DOLLAR_FUNCTIONS = ['$jmespath']; From 21fe3008bce82efc539c18ba0a4e09b38fbb5b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 26 Jan 2023 14:12:42 +0100 Subject: [PATCH 097/160] :zap: Hide active node name from node selector --- .../src/plugins/codemirror/completions/utils.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts index 64b0982aaf10d..a5a4ac69a701d 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -3,6 +3,7 @@ import { SPLIT_IN_BATCHES_NODE_TYPE } from '@/constants'; import { useWorkflowsStore } from '@/stores/workflows'; import { resolveParameter } from '@/mixins/workflowHelpers'; import { CompletionContext } from '@codemirror/autocomplete'; +import { useNDVStore } from '@/stores/ndv'; /** * Split user input into base (to resolve) and tail (to filter). @@ -92,7 +93,13 @@ export function hasNoParams(toResolve: string) { export function autocompletableNodeNames() { return useWorkflowsStore() - .allNodes.filter((node) => !NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION.includes(node.type)) + .allNodes.filter((node) => { + const activeNodeName = useNDVStore().activeNode?.name; + + return ( + !NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION.includes(node.type) && node.name !== activeNodeName + ); + }) .map((node) => node.name); } From 3670cc2905f181583326b1778010485b1dcc1cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 26 Jan 2023 14:20:15 +0100 Subject: [PATCH 098/160] :fire: Remove `length()` and its aliases --- .../src/Extensions/ArrayExtensions.ts | 14 ---------- .../workflow/src/Extensions/DateExtensions.ts | 27 +------------------ 2 files changed, 1 insertion(+), 40 deletions(-) diff --git a/packages/workflow/src/Extensions/ArrayExtensions.ts b/packages/workflow/src/Extensions/ArrayExtensions.ts index 649f2e468de53..bda8f769dc5f0 100644 --- a/packages/workflow/src/Extensions/ArrayExtensions.ts +++ b/packages/workflow/src/Extensions/ArrayExtensions.ts @@ -99,10 +99,6 @@ function last(value: unknown[]): unknown { return value[value.length - 1]; } -function length(value: unknown[]): number { - return Array.isArray(value) ? value.length : 0; -} - function pluck(value: unknown[], extraArgs: unknown[]): unknown[] { if (!Array.isArray(extraArgs)) { throw new ExpressionError('arguments must be passed to pluck'); @@ -374,13 +370,6 @@ compact.doc = { returnType: 'array', }; -length.doc = { - name: 'length', - description: 'Returns the number of elements in the array', - returnType: 'number', - aliases: ['count', 'size'], -}; - isEmpty.doc = { name: 'isEmpty', description: 'Checks if the array doesn’t have any elements', @@ -481,15 +470,12 @@ union.doc = { export const arrayExtensions: ExtensionMap = { typeName: 'Array', functions: { - count: length, removeDuplicates: unique, first, last, - length, pluck, unique, randomItem, - size: length, sum, min, max, diff --git a/packages/workflow/src/Extensions/DateExtensions.ts b/packages/workflow/src/Extensions/DateExtensions.ts index 90b55a4a2d87f..88eb2ca80a59b 100644 --- a/packages/workflow/src/Extensions/DateExtensions.ts +++ b/packages/workflow/src/Extensions/DateExtensions.ts @@ -1,13 +1,6 @@ /* eslint-disable @typescript-eslint/unbound-method */ /* eslint-disable @typescript-eslint/explicit-member-accessibility */ -import { - DateTime, - DateTimeFormatOptions, - DateTimeUnit, - DurationLike, - DurationObjectUnits, - LocaleOptions, -} from 'luxon'; +import { DateTime, DateTimeUnit, DurationLike, DurationObjectUnits, LocaleOptions } from 'luxon'; import type { ExtensionMap } from './Extensions'; type DurationUnit = @@ -198,18 +191,6 @@ function plus(date: Date | DateTime, extraArgs: unknown[]): Date | DateTime { return DateTime.fromJSDate(date).plus(generateDurationObject(durationValue, unit)).toJSDate(); } -function toLocaleString(date: Date | DateTime, extraArgs: unknown[]): string { - const [locale, dateFormat = { timeStyle: 'short', dateStyle: 'short' }] = extraArgs as [ - string | undefined, - DateTimeFormatOptions, - ]; - - if (isDateTime(date)) { - return date.toLocaleString(dateFormat, { locale }); - } - return DateTime.fromJSDate(date).toLocaleString(dateFormat, { locale }); -} - endOfMonth.doc = { name: 'endOfMonth', returnType: 'Date', @@ -265,11 +246,6 @@ plus.doc = { returnType: 'Date', }; -toLocaleString.doc = { - name: 'toLocaleString', - returnType: 'string', -}; - export const dateExtensions: ExtensionMap = { typeName: 'Date', functions: { @@ -283,6 +259,5 @@ export const dateExtensions: ExtensionMap = { minus, plus, format, - toLocaleString, }, }; From c26c83eb8a7a099477a5fd62bfb4b7ecf64beda9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 26 Jan 2023 14:29:25 +0100 Subject: [PATCH 099/160] :zap: Validate non-zero for `chunk` --- packages/workflow/src/Extensions/ArrayExtensions.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/workflow/src/Extensions/ArrayExtensions.ts b/packages/workflow/src/Extensions/ArrayExtensions.ts index bda8f769dc5f0..7298c9c6b91d7 100644 --- a/packages/workflow/src/Extensions/ArrayExtensions.ts +++ b/packages/workflow/src/Extensions/ArrayExtensions.ts @@ -246,6 +246,9 @@ function chunk(value: unknown[], extraArgs: number[]) { if (typeof chunkSize !== 'number') { throw new ExpressionExtensionError('chunk requires 1 parameter: chunkSize. e.g. .chunk(5)'); } + if (chunkSize === 0) { + throw new ExpressionExtensionError('chunk: arg must be higher than 0, e.g. .chunk(5)'); + } const chunks: unknown[][] = []; for (let i = 0; i < value.length; i += chunkSize) { // I have no clue why eslint thinks 2 numbers could be anything but that but here we are From 3827b031cddc959759a3a49f3e81700bd287ca69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 26 Jan 2023 14:41:28 +0100 Subject: [PATCH 100/160] :pencil2: Reword all error messages --- .../src/Extensions/ArrayExtensions.ts | 33 ++++++++----------- .../src/Extensions/ObjectExtensions.ts | 4 +-- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/packages/workflow/src/Extensions/ArrayExtensions.ts b/packages/workflow/src/Extensions/ArrayExtensions.ts index 7298c9c6b91d7..89e8cd1a298de 100644 --- a/packages/workflow/src/Extensions/ArrayExtensions.ts +++ b/packages/workflow/src/Extensions/ArrayExtensions.ts @@ -145,14 +145,14 @@ function unique(value: unknown[], extraArgs: string[]): unknown[] { }, []); } -const ensureNumberArray = (arr: unknown[]) => { +const ensureNumberArray = (arr: unknown[], { fnName }: { fnName: string }) => { if (arr.some((i) => typeof i !== 'number')) { - throw new ExpressionExtensionError('all array elements must be of type number'); + throw new ExpressionExtensionError(`${fnName}(): all array elements must be numbers`); } }; function sum(value: unknown[]): number { - ensureNumberArray(value); + ensureNumberArray(value, { fnName: 'sum' }); return value.reduce((p: number, c: unknown) => { if (typeof c === 'string') { @@ -166,7 +166,7 @@ function sum(value: unknown[]): number { } function min(value: unknown[]): number { - ensureNumberArray(value); + ensureNumberArray(value, { fnName: 'min' }); return Math.min( ...value.map((v) => { @@ -182,7 +182,7 @@ function min(value: unknown[]): number { } function max(value: unknown[]): number { - ensureNumberArray(value); + ensureNumberArray(value, { fnName: 'max' }); return Math.max( ...value.map((v) => { @@ -198,7 +198,7 @@ function max(value: unknown[]): number { } export function average(value: unknown[]) { - ensureNumberArray(value); + ensureNumberArray(value, { fnName: 'average' }); // This would usually be NaN but I don't think users // will expect that @@ -227,7 +227,7 @@ function smartJoin(value: unknown[], extraArgs: string[]): object { const [keyField, valueField] = extraArgs; if (!keyField || !valueField || typeof keyField !== 'string' || typeof valueField !== 'string') { throw new ExpressionExtensionError( - 'smartJoin requires 2 arguments: keyField and nameField. e.g. .smartJoin("name", "value")', + 'smartJoin(): expected two string args, e.g. .smartJoin("name", "value")', ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return @@ -243,11 +243,8 @@ function smartJoin(value: unknown[], extraArgs: string[]): object { function chunk(value: unknown[], extraArgs: number[]) { const [chunkSize] = extraArgs; - if (typeof chunkSize !== 'number') { - throw new ExpressionExtensionError('chunk requires 1 parameter: chunkSize. e.g. .chunk(5)'); - } - if (chunkSize === 0) { - throw new ExpressionExtensionError('chunk: arg must be higher than 0, e.g. .chunk(5)'); + if (typeof chunkSize !== 'number' || chunkSize === 0) { + throw new ExpressionExtensionError('chunk(): expected non-zero numeric arg, e.g. .chunk(5)'); } const chunks: unknown[][] = []; for (let i = 0; i < value.length; i += chunkSize) { @@ -261,7 +258,7 @@ function chunk(value: unknown[], extraArgs: number[]) { function renameKeys(value: unknown[], extraArgs: string[]): unknown[] { if (extraArgs.length === 0 || extraArgs.length % 2 !== 0) { throw new ExpressionExtensionError( - 'renameKeys requires an even amount of arguments: from1, to1 [, from2, to2, ...]. e.g. .renameKeys("name", "title")', + 'renameKeys(): expected an even amount of args: from1, to1 [, from2, to2, ...]. e.g. .renameKeys("name", "title")', ); } return value.map((v) => { @@ -288,7 +285,7 @@ function merge(value: unknown[], extraArgs: unknown[][]): unknown[] { const [others] = extraArgs; if (!Array.isArray(others)) { throw new ExpressionExtensionError( - 'merge requires 1 argument that is an array. e.g. .merge([{ id: 1, otherValue: 3 }])', + 'merge(): expected array arg, e.g. .merge([{ id: 1, otherValue: 3 }])', ); } const listLength = value.length > others.length ? value.length : others.length; @@ -311,9 +308,7 @@ function merge(value: unknown[], extraArgs: unknown[][]): unknown[] { function union(value: unknown[], extraArgs: unknown[][]): unknown[] { const [others] = extraArgs; if (!Array.isArray(others)) { - throw new ExpressionExtensionError( - 'union requires 1 argument that is an array. e.g. .union([1, 2, 3, 4])', - ); + throw new ExpressionExtensionError('union(): expected array arg, e.g. .union([1, 2, 3, 4])'); } const newArr: unknown[] = Array.from(value); for (const v of others) { @@ -328,7 +323,7 @@ function difference(value: unknown[], extraArgs: unknown[][]): unknown[] { const [others] = extraArgs; if (!Array.isArray(others)) { throw new ExpressionExtensionError( - 'difference requires 1 argument that is an array. e.g. .difference([1, 2, 3, 4])', + 'difference(): expected array arg, e.g. .difference([1, 2, 3, 4])', ); } const newArr: unknown[] = []; @@ -344,7 +339,7 @@ function intersection(value: unknown[], extraArgs: unknown[][]): unknown[] { const [others] = extraArgs; if (!Array.isArray(others)) { throw new ExpressionExtensionError( - 'intersection requires 1 argument that is an array. e.g. .intersection([1, 2, 3, 4])', + 'intersection(): expected array arg, e.g. .intersection([1, 2, 3, 4])', ); } const newArr: unknown[] = []; diff --git a/packages/workflow/src/Extensions/ObjectExtensions.ts b/packages/workflow/src/Extensions/ObjectExtensions.ts index bbc4d91867b05..2f027c996e4f0 100644 --- a/packages/workflow/src/Extensions/ObjectExtensions.ts +++ b/packages/workflow/src/Extensions/ObjectExtensions.ts @@ -4,7 +4,7 @@ import type { ExtensionMap } from './Extensions'; export function merge(value: object, extraArgs: unknown[]): unknown { const [other] = extraArgs; if (typeof other !== 'object' || !other) { - throw new ExpressionExtensionError('argument of merge must be an object'); + throw new ExpressionExtensionError('merge(): expected object arg'); } // eslint-disable-next-line @typescript-eslint/no-explicit-any const newObject: any = { ...value }; @@ -44,7 +44,7 @@ function removeField(value: object, extraArgs: string[]): object { function removeFieldsContaining(value: object, extraArgs: string[]): object { const [match] = extraArgs; if (typeof match !== 'string') { - throw new ExpressionExtensionError('argument of removeFieldsContaining must be a string'); + throw new ExpressionExtensionError('removeFieldsContaining(): expected string arg'); } const newObject = { ...value }; for (const [key, val] of Object.entries(value)) { From 00816a47be51ff163ea657111f19f0007bee71bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 26 Jan 2023 15:02:48 +0100 Subject: [PATCH 101/160] :bug: Fix `$now` and `$today` --- packages/workflow/src/Expression.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow/src/Expression.ts b/packages/workflow/src/Expression.ts index 57fea4c8fde81..0e99cdbea5442 100644 --- a/packages/workflow/src/Expression.ts +++ b/packages/workflow/src/Expression.ts @@ -61,7 +61,7 @@ export class Expression { convertObjectValueToString(value: object): string { const typeName = Array.isArray(value) ? 'Array' : 'Object'; - if (value instanceof DateTime && value.invalidReason !== undefined) { + if (value instanceof DateTime && value.invalidReason !== null) { throw new Error('invalid DateTime'); } From b8e72032ea9643c2e51ac16cd20528294b85d451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 27 Jan 2023 20:25:48 +0100 Subject: [PATCH 102/160] :zap: Simplify with `stripExcessParens` --- .../completions/__tests__/completions.test.ts | 10 ++++-- .../completions/blank.completions.ts | 3 +- .../completions/datatype.completions.ts | 32 ++++++++----------- .../completions/dollar.completions.ts | 10 +++--- .../completions/luxon.completions.ts | 18 +++++------ .../completions/nonDollar.completions.ts | 2 +- .../plugins/codemirror/completions/utils.ts | 17 ++++++++-- 7 files changed, 50 insertions(+), 42 deletions(-) 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 8f920bab6846b..77f541e766b1b 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 @@ -19,11 +19,8 @@ beforeEach(() => { setActivePinia(createTestingPinia()); vi.spyOn(utils, 'receivesNoBinaryData').mockReturnValue(true); // hide $binary vi.spyOn(utils, 'isSplitInBatchesAbsent').mockReturnValue(false); // show context - vi.spyOn(utils, 'noParensAfterCursor').mockReturnValue(true); // show context }); -// @TODO: Fix tests - describe('Top-level completions', () => { test('should return blank completions for: {{ | }}', () => { expect(completions('{{ | }}')).toHaveLength(dollarOptions().length); @@ -80,18 +77,21 @@ describe('Luxon method completions', () => { describe('Resolution-based completions', () => { describe('literals', () => { test('should return completions for string literal', () => { + // @ts-ignore vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('abc'); expect(completions('{{ "abc".| }}')).toHaveLength(extensions('string').length); }); test('should return completions for number literal', () => { + // @ts-ignore vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(123); expect(completions('{{ (123).| }}')).toHaveLength(extensions('number').length); }); test('should return completions for array literal', () => { + // @ts-ignore vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce([1, 2, 3]); expect(completions('{{ [1, 2, 3].| }}')).toHaveLength(extensions('array').length); @@ -134,6 +134,7 @@ describe('Resolution-based completions', () => { ); test('should return no completions for: {{ $input.all().| }}', () => { + // @ts-ignore vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue([mock.item]); expect(completions('{{ $input.all().| }}')).toBeNull(); @@ -153,18 +154,21 @@ describe('Resolution-based completions', () => { }); test('should return completions for: {{ $input.item.json.str.| }}', () => { + // @ts-ignore vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.item.json.str); expect(completions('{{ $input.item.json.str.| }}')).toHaveLength(extensions('string').length); }); test('should return completions for: {{ $input.item.json.num.| }}', () => { + // @ts-ignore vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.item.json.num); expect(completions('{{ $input.item.json.num.| }}')).toHaveLength(extensions('number').length); }); test('should return completions for: {{ $input.item.json.arr.| }}', () => { + // @ts-ignore vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.item.json.arr); expect(completions('{{ $input.item.json.arr.| }}')).toHaveLength(extensions('array').length); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts index df73d22a33974..eed640f644227 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/blank.completions.ts @@ -1,5 +1,6 @@ import { dollarOptions } from './dollar.completions'; import type { CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import { stripExcessParens } from './utils'; /** * Completions offered at the blank position: `{{ | }}` @@ -17,7 +18,7 @@ export function blankCompletions(context: CompletionContext): CompletionResult | return { from: word.to, - options: dollarOptions(context), + options: dollarOptions().map(stripExcessParens(context)), filter: false, }; } diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 53b2d576b9615..1b34ae8b942ed 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -10,7 +10,7 @@ import { longestCommonPrefix, splitBaseTail, isPseudoParam, - noParensAfterCursor, + stripExcessParens, } from './utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import type { Resolved } from './types'; @@ -66,7 +66,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul let options: Completion[] = []; try { - options = datatypeOptions(resolved, base, context); + options = datatypeOptions(resolved, base).map(stripExcessParens(context)); } catch (_) { return null; } @@ -89,19 +89,23 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul }; } -function datatypeOptions(resolved: Resolved, toResolve: string, context: CompletionContext) { +function datatypeOptions( + resolved: Resolved, + toResolve: string, + { noParens } = { noParens: false }, +) { if (resolved === null) return []; - if (typeof resolved === 'number') return extensions('number', context); + if (typeof resolved === 'number') return extensions('number'); - if (typeof resolved === 'string') return extensions('string', context); + if (typeof resolved === 'string') return extensions('string'); - if (resolved instanceof Date) return extensions('date', context); + if (resolved instanceof Date) return extensions('date'); if (Array.isArray(resolved)) { if (toResolve.endsWith('all()')) return []; - const arrayExtensions = extensions('array', context); + const arrayExtensions = extensions('array'); if (resolved.length > 0 && resolved.some((i) => typeof i !== 'number')) { const NUMBER_ONLY_ARRAY_EXTENSIONS = new Set(['max()', 'min()', 'sum()', 'average()']); @@ -132,8 +136,6 @@ function datatypeOptions(resolved: Resolved, toResolve: string, context: Complet rawKeys = Object.keys(Object.getOwnPropertyDescriptors(Math)); } - const noParens = noParensAfterCursor(context); - const keys = bringToStart(rawKeys, BOOST) .filter((key) => !SKIP.has(key) && isAllowedInDotNotation(key) && !isPseudoParam(key)) .map((key) => { @@ -165,33 +167,27 @@ function datatypeOptions(resolved: Resolved, toResolve: string, context: Complet if (skipObjectExtensions) return keys; - return [...keys, ...extensions('object', context)]; + return [...keys, ...extensions('object')]; } return []; } -export const extensions = ( - typeName: 'number' | 'string' | 'date' | 'array' | 'object', - context: CompletionContext, -) => { +export const extensions = (typeName: 'number' | 'string' | 'date' | 'array' | 'object') => { const extensions = ExpressionExtensions.find((ee) => ee.typeName.toLowerCase() === typeName); if (!extensions) return []; - const noParens = noParensAfterCursor(context); - return Object.entries(extensions.functions) .filter(([_, fn]) => fn.length === 1) // @TODO: Remove in next phase .sort((a, b) => a[0].localeCompare(b[0])) .map(([name, fn]) => { const option: Completion = { - label: noParens ? name + '()' : name, + label: name + '()', type: 'function', }; option.info = () => { - // @TODO: Tooltip will be refactored completely in next phase const tooltipContainer = document.createElement('div'); if (!fn.doc?.description) return null; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts index 5c0cb8cde1a26..9d21170fa2faa 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts @@ -5,7 +5,7 @@ import { longestCommonPrefix, bringToStart, prefixMatch, - noParensAfterCursor, + stripExcessParens, } from './utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; @@ -19,7 +19,7 @@ export function dollarCompletions(context: CompletionContext): CompletionResult if (word.from === word.to && !context.explicit) return null; - let options = dollarOptions(context); + let options = dollarOptions().map(stripExcessParens(context)); const userInput = word.text; @@ -44,7 +44,7 @@ export function dollarCompletions(context: CompletionContext): CompletionResult }; } -export function dollarOptions(context: CompletionContext) { +export function dollarOptions() { const BOOST = ['$json', '$input']; const SKIP = new Set(); const DOLLAR_FUNCTIONS = ['$jmespath']; @@ -53,15 +53,13 @@ export function dollarOptions(context: CompletionContext) { const keys = Object.keys(i18n.rootVars).sort((a, b) => a.localeCompare(b)); - const noParens = noParensAfterCursor(context); - return bringToStart(keys, BOOST) .filter((key) => !SKIP.has(key)) .map((key) => { const isFunction = DOLLAR_FUNCTIONS.includes(key); const option: Completion = { - label: isFunction && noParens ? key + '()' : key, + label: isFunction ? key + '()' : key, type: isFunction ? 'function' : 'keyword', }; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts index 947f9b422824a..616598e6f0945 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts @@ -1,5 +1,5 @@ import { i18n } from '@/plugins/i18n'; -import { prefixMatch, longestCommonPrefix, splitBaseTail, noParensAfterCursor } from './utils'; +import { prefixMatch, longestCommonPrefix, splitBaseTail, stripExcessParens } from './utils'; import { DateTime } from 'luxon'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; @@ -19,7 +19,9 @@ export function luxonCompletions(context: CompletionContext): CompletionResult | const [base, tail] = splitBaseTail(word.text); - let options = base === 'DateTime' ? dateTimeOptions(context) : nowTodayOptions(context); + let options = (base === 'DateTime' ? dateTimeOptions() : nowTodayOptions()).map( + stripExcessParens(context), + ); if (tail !== '') { options = options.filter((o) => prefixMatch(o.label, tail)); @@ -39,11 +41,9 @@ export function luxonCompletions(context: CompletionContext): CompletionResult | }; } -export const nowTodayOptions = (context: CompletionContext) => { +export const nowTodayOptions = () => { const SKIP = new Set(['constructor', 'get', 'invalidExplanation', 'invalidReason']); - const noParens = noParensAfterCursor(context); - return Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype)) .filter(([key]) => !SKIP.has(key)) .sort(([a], [b]) => a.localeCompare(b)) @@ -51,7 +51,7 @@ export const nowTodayOptions = (context: CompletionContext) => { const isFunction = typeof descriptor.value === 'function'; const option: Completion = { - label: isFunction && noParens ? key + '()' : key, + label: isFunction ? key + '()' : key, type: isFunction ? 'function' : 'keyword', }; @@ -63,17 +63,15 @@ export const nowTodayOptions = (context: CompletionContext) => { }); }; -export const dateTimeOptions = (context: CompletionContext) => { +export const dateTimeOptions = () => { const SKIP = new Set(['prototype', 'name', 'length', 'invalid']); - const noParens = noParensAfterCursor(context); - return Object.keys(Object.getOwnPropertyDescriptors(DateTime)) .filter((key) => !SKIP.has(key) && !key.includes('_')) .sort((a, b) => a.localeCompare(b)) .map((key) => { const option: Completion = { - label: noParens ? key + '()' : key, + label: key + '()', type: 'function', }; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts index fb2f93e0f4a87..2223025c07d16 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts @@ -3,7 +3,7 @@ import type { CompletionContext, CompletionResult } from '@codemirror/autocomple import { prefixMatch } from './utils'; /** - * Completions offered at the base position for any char other than `$`. + * Completions offered at the initial position for any char other than `$`. * * Currently only `D...` for `DateTime` and `M...` for `Math` */ diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts index a5a4ac69a701d..9a96b00123928 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -2,8 +2,8 @@ import { NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION } from '@/components/CodeNodeEd import { SPLIT_IN_BATCHES_NODE_TYPE } from '@/constants'; import { useWorkflowsStore } from '@/stores/workflows'; import { resolveParameter } from '@/mixins/workflowHelpers'; -import { CompletionContext } from '@codemirror/autocomplete'; import { useNDVStore } from '@/stores/ndv'; +import type { Completion, CompletionContext } from '@codemirror/autocomplete'; /** * Split user input into base (to resolve) and tail (to filter). @@ -103,5 +103,16 @@ export function autocompletableNodeNames() { .map((node) => node.name); } -export const noParensAfterCursor = (context: CompletionContext) => - context.state.sliceDoc(context.pos, context.pos + 2) !== '()'; +/** + * Remove excess parens from an option label when the cursor is followed + * by parens already, e.g. `$json.myStr.|()` -> `isNumeric` + */ +export const stripExcessParens = (context: CompletionContext) => (option: Completion) => { + const followedByParens = context.state.sliceDoc(context.pos, context.pos + 2) === '()'; + + if (option.label.endsWith('()') && followedByParens) { + option.label = option.label.slice(0, -2); + } + + return option; +}; From 486d6564265ff50f87d91459931b28fc2ede7e89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 27 Jan 2023 21:09:23 +0100 Subject: [PATCH 103/160] :zap: Fold luxon into datatype --- .../completions/__tests__/completions.test.ts | 28 +- .../completions/bracketAccess.completions.ts | 4 +- .../completions/datatype.completions.ts | 253 +++++++++++------- .../completions/dollar.completions.ts | 8 +- .../completions/luxon.completions.ts | 84 ------ .../plugins/codemirror/completions/types.ts | 2 + .../plugins/codemirror/completions/utils.ts | 40 ++- .../src/plugins/codemirror/n8nLang.ts | 2 - 8 files changed, 195 insertions(+), 226 deletions(-) delete mode 100644 packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts 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 77f541e766b1b..de271f78e665a 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 @@ -6,14 +6,11 @@ import { v4 as uuidv4 } from 'uuid'; import { n8nLang } from '@/plugins/codemirror/n8nLang'; import { dollarOptions } from '@/plugins/codemirror/completions/dollar.completions'; -import { - dateTimeOptions, - nowTodayOptions, -} from '@/plugins/codemirror/completions/luxon.completions'; import * as utils from '@/plugins/codemirror/completions/utils'; import * as workflowHelpers from '@/mixins/workflowHelpers'; -import { extensions } from '../datatype.completions'; +import { extensions, luxonInstanceOptions, luxonStaticOptions } from '../datatype.completions'; import { mock } from './mock'; +import { DateTime } from 'luxon'; beforeEach(() => { setActivePinia(createTestingPinia()); @@ -61,16 +58,25 @@ describe('Top-level completions', () => { }); describe('Luxon method completions', () => { - test('should return static method completions for: {{ DateTime.| }}', () => { - expect(completions('{{ DateTime.| }}')).toHaveLength(dateTimeOptions().length); + test('should return class completions for: {{ DateTime.| }}', () => { + // @ts-ignore + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime); + + expect(completions('{{ DateTime.| }}')).toHaveLength(luxonStaticOptions().length); }); - test('should return instance method completions for: {{ $now.| }}', () => { - expect(completions('{{ $now.| }}')).toHaveLength(nowTodayOptions().length); + test('should return instance completions for: {{ $now.| }}', () => { + // @ts-ignore + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now()); + + expect(completions('{{ $now.| }}')).toHaveLength(luxonInstanceOptions().length); }); - test('should return instance method completions for: {{ $today.| }}', () => { - expect(completions('{{ $today.| }}')).toHaveLength(nowTodayOptions().length); + test('should return instance completions for: {{ $today.| }}', () => { + // @ts-ignore + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now()); + + expect(completions('{{ $today.| }}')).toHaveLength(luxonInstanceOptions().length); }); }); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts index a479aa9341086..af1937df2769e 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts @@ -38,7 +38,7 @@ export function bracketAccessCompletions(context: CompletionContext): Completion if (resolved === null || resolved === undefined) return null; - let options = objectBracketOptions(resolved); + let options = bracketAccessOptions(resolved); if (tail !== '') { options = options.filter((o) => prefixMatch(o.label, tail)); @@ -58,7 +58,7 @@ export function bracketAccessCompletions(context: CompletionContext): Completion }; } -function objectBracketOptions(resolved: IDataObject) { +function bracketAccessOptions(resolved: IDataObject) { const SKIP = new Set(['__ob__', 'pairedItem']); return Object.keys(resolved) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 1b34ae8b942ed..890b961db8a72 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -1,8 +1,9 @@ import { ExpressionExtensions, IDataObject } from 'n8n-workflow'; +import { DateTime } from 'luxon'; import { i18n } from '@/plugins/i18n'; import { resolveParameter } from '@/mixins/workflowHelpers'; import { - bringToStart, + setRank, hasNoParams, prefixMatch, isAllowedInDotNotation, @@ -13,62 +14,40 @@ import { stripExcessParens, } from './utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; -import type { Resolved } from './types'; +import type { ExtensionTypeName, Resolved } from './types'; /** * Resolution-based completions offered according to datatype. */ export function datatypeCompletions(context: CompletionContext): CompletionResult | null { - const generalReference = /\$[^$]+\.([^{\s])*/; // $input. - const nodeSelectorReference = /\$\(['"][\S\s]+['"]\)\..*/; // $('nodeName') - const numberLiteral = /\((\d+)\.?(\d*)\)\.([^{\s])*/; // (123). or (123.4). - const stringLiteral = /(".+"|('.+'))\.([^{\s])*/; // 'abc'. or "abc". - const dateLiteral = /\(?new Date\(\(?.*?\)\)?\.([^{\s])*/; // new Date(). or (new Date()). - const arrayLiteral = /(\[.+\])\.([^{\s])*/; // [1, 2, 3]. - const objectLiteral = /\(\{.*\}\)\.([^{\s])*/; // ({}). - const mathGlobal = /Math\.([^{\s])*/; // Math. - - const combinedRegex = new RegExp( - [ - generalReference.source, - nodeSelectorReference.source, - numberLiteral.source, - stringLiteral.source, - dateLiteral.source, - arrayLiteral.source, - objectLiteral.source, - mathGlobal.source, - ].join('|'), - ); - - const word = context.matchBefore(combinedRegex); + const word = context.matchBefore(DATATYPE_REGEX); if (!word) return null; if (word.from === word.to && !context.explicit) return null; - const skipDatatypeCompletions = ['$now.', '$today.']; - - if (skipDatatypeCompletions.includes(word.text)) return null; - const [base, tail] = splitBaseTail(word.text); - let resolved: Resolved; + let options: Completion[] = []; - try { - resolved = resolveParameter(`={{ ${base} }}`); - } catch (_) { - return null; - } + if (base === 'DateTime') { + options = luxonStaticOptions().map(stripExcessParens(context)); + } else { + let resolved: Resolved; - if (resolved === null) return null; + try { + resolved = resolveParameter(`={{ ${base} }}`); + } catch (_) { + return null; + } - let options: Completion[] = []; + if (resolved === null) return null; - try { - options = datatypeOptions(resolved, base).map(stripExcessParens(context)); - } catch (_) { - return null; + try { + options = datatypeOptions(resolved, base).map(stripExcessParens(context)); + } catch (_) { + return null; + } } if (options.length === 0) return null; @@ -89,17 +68,15 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul }; } -function datatypeOptions( - resolved: Resolved, - toResolve: string, - { noParens } = { noParens: false }, -) { +function datatypeOptions(resolved: Resolved, toResolve: string) { if (resolved === null) return []; if (typeof resolved === 'number') return extensions('number'); if (typeof resolved === 'string') return extensions('string'); + if (['$now', '$today'].includes(toResolve)) return luxonInstanceOptions(); + if (resolved instanceof Date) return extensions('date'); if (Array.isArray(resolved)) { @@ -116,64 +93,12 @@ function datatypeOptions( return arrayExtensions; } - if (typeof resolved === 'object') { - const BOOST = ['item', 'all', 'first', 'last']; - const SKIP = new Set(['__ob__', 'pairedItem']); - - if (isSplitInBatchesAbsent()) SKIP.add('context'); - - const name = toResolve.startsWith('$(') ? '$()' : toResolve; - - if (['$input', '$()'].includes(name) && hasNoParams(toResolve)) SKIP.add('params'); - - let rawKeys = Object.keys(resolved); - - if (name === '$()' || resolved.isMockProxy) { - rawKeys = Reflect.ownKeys(resolved) as string[]; - } - - if (toResolve === 'Math') { - rawKeys = Object.keys(Object.getOwnPropertyDescriptors(Math)); - } - - const keys = bringToStart(rawKeys, BOOST) - .filter((key) => !SKIP.has(key) && isAllowedInDotNotation(key) && !isPseudoParam(key)) - .map((key) => { - ensureKeyCanBeResolved(resolved, key); - - const isFunction = typeof resolved[key] === 'function'; - - const option: Completion = { - label: isFunction && noParens ? key + '()' : key, - type: isFunction ? 'function' : 'keyword', - }; - - const infoKey = [name, key].join('.'); - const info = i18n.proxyVars[infoKey]; - - if (info) option.info = info; - - return option; - }); - - const skipObjectExtensions = - resolved.isProxy || - resolved.json || - /json('])?$/.test(toResolve) || - toResolve === '$execution' || - toResolve.endsWith('params') || - resolved.isMockProxy || - resolved.__isMockObject; - - if (skipObjectExtensions) return keys; - - return [...keys, ...extensions('object')]; - } + if (typeof resolved === 'object') return objectOptions(toResolve, resolved); return []; } -export const extensions = (typeName: 'number' | 'string' | 'date' | 'array' | 'object') => { +export const extensions = (typeName: ExtensionTypeName) => { const extensions = ExpressionExtensions.find((ee) => ee.typeName.toLowerCase() === typeName); if (!extensions) return []; @@ -181,9 +106,9 @@ export const extensions = (typeName: 'number' | 'string' | 'date' | 'array' | 'o return Object.entries(extensions.functions) .filter(([_, fn]) => fn.length === 1) // @TODO: Remove in next phase .sort((a, b) => a[0].localeCompare(b[0])) - .map(([name, fn]) => { + .map(([fnName, fn]) => { const option: Completion = { - label: name + '()', + label: fnName + '()', type: 'function', }; @@ -224,6 +149,62 @@ export const extensions = (typeName: 'number' | 'string' | 'date' | 'array' | 'o }); }; +const objectOptions = (toResolve: string, resolved: IDataObject) => { + const rank = setRank(['item', 'all', 'first', 'last']); + const SKIP = new Set(['__ob__', 'pairedItem']); + + if (isSplitInBatchesAbsent()) SKIP.add('context'); + + const name = toResolve.startsWith('$(') ? '$()' : toResolve; + + if (['$input', '$()'].includes(name) && hasNoParams(toResolve)) SKIP.add('params'); + + let rawKeys = Object.keys(resolved); + + if (name === '$()' || resolved.isMockProxy) { + rawKeys = Reflect.ownKeys(resolved) as string[]; + } + + if (toResolve === 'Math') { + const descriptors = Object.getOwnPropertyDescriptors(Math); + rawKeys = Object.keys(descriptors).sort((a, b) => a.localeCompare(b)); + } + + const keys = rank(rawKeys) + .filter((key) => !SKIP.has(key) && isAllowedInDotNotation(key) && !isPseudoParam(key)) + .map((key) => { + ensureKeyCanBeResolved(resolved, key); + + const isFunction = typeof resolved[key] === 'function'; + + const option: Completion = { + label: isFunction ? key + '()' : key, + type: isFunction ? 'function' : 'keyword', + }; + + const infoKey = [name, key].join('.'); + const info = i18n.proxyVars[infoKey]; + + if (info) option.info = info; + + return option; + }); + + const skipObjectExtensions = + resolved.isProxy || + resolved.json || + /json('])?$/.test(toResolve) || + toResolve === '$execution' || + toResolve.endsWith('params') || + toResolve === 'Math' || + resolved.isMockProxy || + resolved.__isMockObject; + + if (skipObjectExtensions) return keys; + + return [...keys, ...extensions('object')]; +}; + function ensureKeyCanBeResolved(obj: IDataObject, key: string) { try { obj[key]; @@ -232,3 +213,71 @@ function ensureKeyCanBeResolved(obj: IDataObject, key: string) { throw new Error('Cannot generate options', { cause: error }); } } + +/** + * Methods and fields defined on a Luxon `DateTime` class instance. + */ +export const luxonInstanceOptions = () => { + const SKIP = new Set(['constructor', 'get', 'invalidExplanation', 'invalidReason']); + + return Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype)) + .filter(([key]) => !SKIP.has(key)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, descriptor]) => { + const isFunction = typeof descriptor.value === 'function'; + + const option: Completion = { + label: isFunction ? key + '()' : key, + type: isFunction ? 'function' : 'keyword', + }; + + const info = i18n.luxonInstance[key]; + + if (info) option.info = info; + + return option; + }); +}; + +/** + * Methods defined on a Luxon `DateTime` class. + */ +export const luxonStaticOptions = () => { + const SKIP = new Set(['prototype', 'name', 'length', 'invalid']); + + return Object.keys(Object.getOwnPropertyDescriptors(DateTime)) + .filter((key) => !SKIP.has(key) && !key.includes('_')) + .sort((a, b) => a.localeCompare(b)) + .map((key) => { + const option: Completion = { + label: key + '()', + type: 'function', + }; + + const info = i18n.luxonStatic[key]; + + if (info) option.info = info; + + return option; + }); +}; + +const regexes = { + generalRef: /\$[^$]+\.([^{\s])*/, // $input. or $json. or similar ones + selectorRef: /\$\(['"][\S\s]+['"]\)\.([^{\s])*/, // $('nodeName'). + + numberLiteral: /\((\d+)\.?(\d*)\)\.([^{\s])*/, // (123). or (123.4). + stringLiteral: /(".+"|('.+'))\.([^{\s])*/, // 'abc'. or "abc". + dateLiteral: /\(?new Date\(\(?.*?\)\)?\.([^{\s])*/, // new Date(). or (new Date()). + arrayLiteral: /(\[.+\])\.([^{\s])*/, // [1, 2, 3]. + objectLiteral: /\(\{.*\}\)\.([^{\s])*/, // ({}). + + mathGlobal: /Math\.([^{\s])*/, // Math. + datetimeGlobal: /DateTime\.[^.}]*/, // DateTime. +}; + +const DATATYPE_REGEX = new RegExp( + Object.values(regexes) + .map((regex) => regex.source) + .join('|'), +); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts index 9d21170fa2faa..e421cc5060e25 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts @@ -3,7 +3,7 @@ import { autocompletableNodeNames, receivesNoBinaryData, longestCommonPrefix, - bringToStart, + setRank, prefixMatch, stripExcessParens, } from './utils'; @@ -24,7 +24,7 @@ export function dollarCompletions(context: CompletionContext): CompletionResult const userInput = word.text; /** - * If user typed anything after `$`, whittle down options based on user input. + * If user typed anything after `$`, narrow down options based on user input. */ if (userInput !== '$') { options = options.filter((o) => prefixMatch(o.label, userInput)); @@ -45,7 +45,7 @@ export function dollarCompletions(context: CompletionContext): CompletionResult } export function dollarOptions() { - const BOOST = ['$json', '$input']; + const rank = setRank(['$json', '$input']); const SKIP = new Set(); const DOLLAR_FUNCTIONS = ['$jmespath']; @@ -53,7 +53,7 @@ export function dollarOptions() { const keys = Object.keys(i18n.rootVars).sort((a, b) => a.localeCompare(b)); - return bringToStart(keys, BOOST) + return rank(keys) .filter((key) => !SKIP.has(key)) .map((key) => { const isFunction = DOLLAR_FUNCTIONS.includes(key); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts deleted file mode 100644 index 616598e6f0945..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { i18n } from '@/plugins/i18n'; -import { prefixMatch, longestCommonPrefix, splitBaseTail, stripExcessParens } from './utils'; -import { DateTime } from 'luxon'; -import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; - -/** - * Completions offered at the end position of a Luxon entity. - * - * - `DateTime.` - * - `$now.` - * - `$today.` - */ -export function luxonCompletions(context: CompletionContext): CompletionResult | null { - const word = context.matchBefore(/(DateTime|\$now|\$today)+\.[^.}]*/); - - if (!word) return null; - - if (word.from === word.to && !context.explicit) return null; - - const [base, tail] = splitBaseTail(word.text); - - let options = (base === 'DateTime' ? dateTimeOptions() : nowTodayOptions()).map( - stripExcessParens(context), - ); - - if (tail !== '') { - options = options.filter((o) => prefixMatch(o.label, tail)); - } - - if (options.length === 0) return null; - - return { - from: word.to - tail.length, - options, - filter: false, - getMatch(completion: Completion) { - const lcp = longestCommonPrefix(tail, completion.label); - - return [0, lcp.length]; - }, - }; -} - -export const nowTodayOptions = () => { - const SKIP = new Set(['constructor', 'get', 'invalidExplanation', 'invalidReason']); - - return Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype)) - .filter(([key]) => !SKIP.has(key)) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, descriptor]) => { - const isFunction = typeof descriptor.value === 'function'; - - const option: Completion = { - label: isFunction ? key + '()' : key, - type: isFunction ? 'function' : 'keyword', - }; - - const info = i18n.luxonInstance[key]; - - if (info) option.info = info; - - return option; - }); -}; - -export const dateTimeOptions = () => { - const SKIP = new Set(['prototype', 'name', 'length', 'invalid']); - - return Object.keys(Object.getOwnPropertyDescriptors(DateTime)) - .filter((key) => !SKIP.has(key) && !key.includes('_')) - .sort((a, b) => a.localeCompare(b)) - .map((key) => { - const option: Completion = { - label: key + '()', - type: 'function', - }; - - const info = i18n.luxonStatic[key]; - - if (info) option.info = info; - - return option; - }); -}; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/types.ts b/packages/editor-ui/src/plugins/codemirror/completions/types.ts index f77aafc0fe961..e1ff722580845 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/types.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/types.ts @@ -1,3 +1,5 @@ import { resolveParameter } from '@/mixins/workflowHelpers'; export type Resolved = ReturnType; + +export type ExtensionTypeName = 'number' | 'string' | 'date' | 'array' | 'object'; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts index 9a96b00123928..7e660d6369901 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -7,11 +7,6 @@ import type { Completion, CompletionContext } from '@codemirror/autocomplete'; /** * Split user input into base (to resolve) and tail (to filter). - * - * ``` - * DateTime. -> ['DateTime', ''] - * DateTime.fr -> ['DateTime', 'fr'] - * ``` */ export function splitBaseTail(userInput: string): [string, string] { const parts = userInput.split('.'); @@ -40,23 +35,22 @@ export const prefixMatch = (first: string, second: string) => first.startsWith(second) && first !== second; /** - * Move selected elements to the start of an array, in order. - * Selected elements are assumed to be in the array. + * Make a function to bring selected elements to the start of an array, in order. */ -export function bringToStart(array: string[], selected: string[]) { - const copy = [...array]; +export const setRank = (selected: string[]) => (full: string[]) => { + const fullCopy = [...full]; [...selected].reverse().forEach((s) => { - const index = copy.indexOf(s); + const index = fullCopy.indexOf(s); - if (index !== -1) copy.unshift(copy.splice(index, 1)[0]); + if (index !== -1) fullCopy.unshift(fullCopy.splice(index, 1)[0]); }); - return copy; -} + return fullCopy; +}; export const isPseudoParam = (candidate: string) => { - const PSEUDO_PARAMS = ['notice']; // not real params, user input disallowed + const PSEUDO_PARAMS = ['notice']; // user input disallowed return PSEUDO_PARAMS.includes(candidate); }; @@ -71,12 +65,9 @@ export const isAllowedInDotNotation = (str: string) => { }; // ---------------------------------- -// state-based utils +// resolution-based utils // ---------------------------------- -export const isSplitInBatchesAbsent = () => - !useWorkflowsStore().workflow.nodes.some((node) => node.type === SPLIT_IN_BATCHES_NODE_TYPE); - export function receivesNoBinaryData() { return resolveParameter('={{ $binary }}')?.data === undefined; } @@ -91,6 +82,13 @@ export function hasNoParams(toResolve: string) { return paramKeys.length === 1 && isPseudoParam(paramKeys[0]); } +// ---------------------------------- +// state-based utils +// ---------------------------------- + +export const isSplitInBatchesAbsent = () => + !useWorkflowsStore().workflow.nodes.some((node) => node.type === SPLIT_IN_BATCHES_NODE_TYPE); + export function autocompletableNodeNames() { return useWorkflowsStore() .allNodes.filter((node) => { @@ -104,14 +102,14 @@ export function autocompletableNodeNames() { } /** - * Remove excess parens from an option label when the cursor is followed - * by parens already, e.g. `$json.myStr.|()` -> `isNumeric` + * Remove excess parens from an option label when the cursor is already + * followed by parens, e.g. `$json.myStr.|()` -> `isNumeric` */ export const stripExcessParens = (context: CompletionContext) => (option: Completion) => { const followedByParens = context.state.sliceDoc(context.pos, context.pos + 2) === '()'; if (option.label.endsWith('()') && followedByParens) { - option.label = option.label.slice(0, -2); + option.label = option.label.slice(0, '()'.length * -1); } return option; diff --git a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts index 6bd39535a8d48..174055bb37bf4 100644 --- a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts +++ b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts @@ -8,7 +8,6 @@ import { blankCompletions } from './completions/blank.completions'; import { bracketAccessCompletions } from './completions/bracketAccess.completions'; import { datatypeCompletions } from './completions/datatype.completions'; import { dollarCompletions } from './completions/dollar.completions'; -import { luxonCompletions } from './completions/luxon.completions'; import { nonDollarCompletions } from './completions/nonDollar.completions'; const n8nParserWithNestedJsParser = n8nParser.configure({ @@ -29,7 +28,6 @@ export function n8nLang() { bracketAccessCompletions, datatypeCompletions, dollarCompletions, - luxonCompletions, nonDollarCompletions, ].map((group) => n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], group) })); From cffd642ec93386cd8e57559761d7837d5b610b50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 10:39:44 +0100 Subject: [PATCH 104/160] :test_tube: Clean up tests --- .../completions/__tests__/completions.test.ts | 236 ++++++------ .../codemirror/completions/__tests__/mock.ts | 362 +++++++++++++++--- .../codemirror/completions/__tests__/utils.ts | 34 ++ .../completions/datatype.completions.ts | 12 +- .../src/Extensions/StringExtensions.ts | 11 +- 5 files changed, 485 insertions(+), 170 deletions(-) create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/utils.ts 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 de271f78e665a..8e611aae61c08 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 @@ -1,16 +1,18 @@ -import { CompletionContext, CompletionResult, CompletionSource } from '@codemirror/autocomplete'; -import { EditorState } from '@codemirror/state'; import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; -import { v4 as uuidv4 } from 'uuid'; +import { DateTime } from 'luxon'; -import { n8nLang } from '@/plugins/codemirror/n8nLang'; +import * as workflowHelpers from '@/mixins/workflowHelpers'; import { dollarOptions } from '@/plugins/codemirror/completions/dollar.completions'; import * as utils from '@/plugins/codemirror/completions/utils'; -import * as workflowHelpers from '@/mixins/workflowHelpers'; -import { extensions, luxonInstanceOptions, luxonStaticOptions } from '../datatype.completions'; -import { mock } from './mock'; -import { DateTime } from 'luxon'; +import { + extensions, + luxonInstanceOptions, + luxonStaticOptions, +} from '@/plugins/codemirror/completions/datatype.completions'; + +import { mockNodes, mockProxy } from './mock'; +import { completions } from './utils'; beforeEach(() => { setActivePinia(createTestingPinia()); @@ -18,6 +20,16 @@ beforeEach(() => { vi.spyOn(utils, 'isSplitInBatchesAbsent').mockReturnValue(false); // show context }); +describe('No completions', () => { + test('should not return completions mid-word: {{ "ab|c" }}', () => { + expect(completions('{{ "ab|c" }}')).toBeNull(); + }); + + test('should not return completions for isolated dot: {{ "abc. |" }}', () => { + expect(completions('{{ "abc. |" }}')).toBeNull(); + }); +}); + describe('Top-level completions', () => { test('should return blank completions for: {{ | }}', () => { expect(completions('{{ | }}')).toHaveLength(dollarOptions().length); @@ -32,73 +44,68 @@ describe('Top-level completions', () => { }); test('should return node selector completions for: {{ $(| }}', () => { - const nodes = [ - { - id: uuidv4(), - name: 'Manual', - position: [0, 0], - type: 'n8n-nodes-base.manualTrigger', - typeVersion: 1, - }, - { - id: uuidv4(), - name: 'Set', - position: [0, 0], - type: 'n8n-nodes-base.set', - typeVersion: 1, - }, - ]; - - const initialState = { workflows: { workflow: { nodes } } }; + const initialState = { workflows: { workflow: { nodes: mockNodes } } }; setActivePinia(createTestingPinia({ initialState })); - expect(completions('{{ $(| }}')).toHaveLength(nodes.length); + expect(completions('{{ $(| }}')).toHaveLength(mockNodes.length); }); }); +/** + * @ts-expect-error below is needed as long as `resolveParameter` is mistyped + */ + describe('Luxon method completions', () => { + const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter'); + test('should return class completions for: {{ DateTime.| }}', () => { - // @ts-ignore - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime); + // @ts-expect-error + resolveParameterSpy.mockReturnValueOnce(DateTime); expect(completions('{{ DateTime.| }}')).toHaveLength(luxonStaticOptions().length); }); test('should return instance completions for: {{ $now.| }}', () => { - // @ts-ignore - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now()); + // @ts-expect-error + resolveParameterSpy.mockReturnValueOnce(DateTime.now()); - expect(completions('{{ $now.| }}')).toHaveLength(luxonInstanceOptions().length); + expect(completions('{{ $now.| }}')).toHaveLength( + luxonInstanceOptions().length + extensions('date').length, + ); }); test('should return instance completions for: {{ $today.| }}', () => { - // @ts-ignore - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now()); + // @ts-expect-error + resolveParameterSpy.mockReturnValueOnce(DateTime.now()); - expect(completions('{{ $today.| }}')).toHaveLength(luxonInstanceOptions().length); + expect(completions('{{ $today.| }}')).toHaveLength( + luxonInstanceOptions().length + extensions('date').length, + ); }); }); describe('Resolution-based completions', () => { + const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter'); + describe('literals', () => { - test('should return completions for string literal', () => { - // @ts-ignore - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('abc'); + test('should return completions for string literal: {{ "abc".| }}', () => { + // @ts-expect-error + resolveParameterSpy.mockReturnValueOnce('abc'); expect(completions('{{ "abc".| }}')).toHaveLength(extensions('string').length); }); - test('should return completions for number literal', () => { - // @ts-ignore - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(123); + test('should return completions for number literal: {{ (123).| }}', () => { + // @ts-expect-error + resolveParameterSpy.mockReturnValueOnce(123); expect(completions('{{ (123).| }}')).toHaveLength(extensions('number').length); }); - test('should return completions for array literal', () => { - // @ts-ignore - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce([1, 2, 3]); + test('should return completions for array literal: {{ [1, 2, 3].| }}', () => { + // @ts-expect-error + resolveParameterSpy.mockReturnValueOnce([1, 2, 3]); expect(completions('{{ [1, 2, 3].| }}')).toHaveLength(extensions('array').length); }); @@ -106,7 +113,7 @@ describe('Resolution-based completions', () => { test('should return completions for object literal', () => { const object = { a: 1 }; - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(object); + resolveParameterSpy.mockReturnValueOnce(object); expect(completions('{{ ({ a: 1 }).| }}')).toHaveLength( Object.keys(object).length + extensions('object').length, @@ -115,136 +122,135 @@ describe('Resolution-based completions', () => { }); describe('references', () => { + const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter'); + const { $input, $ } = mockProxy; + test('should return completions for: {{ $input.| }}', () => { - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.inputProxy); + resolveParameterSpy.mockReturnValue($input); - expect(completions('{{ $input.| }}')).toHaveLength(Reflect.ownKeys(mock.inputProxy).length); + expect(completions('{{ $input.| }}')).toHaveLength(Reflect.ownKeys($input).length); }); test("should return completions for: {{ $('nodeName').| }}", () => { - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.nodeSelectorProxy); + resolveParameterSpy.mockReturnValue($('Rename')); - expect(completions('{{ $nodeName.| }}')).toHaveLength( - Reflect.ownKeys(mock.nodeSelectorProxy).length, + expect(completions('{{ $("Rename").| }}')).toHaveLength( + Reflect.ownKeys($('Rename')).length - ['pairedItem'].length, ); }); - ['{{ $input.item.| }}', '{{ $input.first().| }}', '{{ $input.last().| }}'].forEach( - (expression) => { - test(`should return completions for: ${expression}`, () => { - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.item); + test('should return completions for: {{ $input.item.| }}', () => { + resolveParameterSpy.mockReturnValue($input.item); - expect(completions(expression)).toHaveLength(1); // json - }); - }, - ); + expect(completions('{{ $input.item.| }}')).toHaveLength(1); // json + }); + + test('should return completions for: {{ $input.first().| }}', () => { + resolveParameterSpy.mockReturnValue($input.first()); + + expect(completions('{{ $input.first().| }}')).toHaveLength(1); // json + }); + + test('should return completions for: {{ $input.last().| }}', () => { + resolveParameterSpy.mockReturnValue($input.last()); + + expect(completions('{{ $input.last().| }}')).toHaveLength(1); // json + }); test('should return no completions for: {{ $input.all().| }}', () => { - // @ts-ignore - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue([mock.item]); + // @ts-expect-error + resolveParameterSpy.mockReturnValue([$input.item]); expect(completions('{{ $input.all().| }}')).toBeNull(); }); - [ - '{{ $input.item.| }}', - '{{ $input.first().| }}', - '{{ $input.last().| }}', - '{{ $input.all()[0].| }}', - ].forEach((expression) => { - test(`should return completions for: ${expression}`, () => { - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.item.json); + test("should return completions for: '{{ $input.item.| }}'", () => { + resolveParameterSpy.mockReturnValue($input.item.json); - expect(completions(expression)).toHaveLength(Object.keys(mock.item.json).length); - }); + expect(completions('{{ $input.item.| }}')).toHaveLength( + Object.keys($input.item.json).length + extensions('object').length, + ); + }); + + test("should return completions for: '{{ $input.first().| }}'", () => { + resolveParameterSpy.mockReturnValue($input.first().json); + + expect(completions('{{ $input.first().| }}')).toHaveLength( + Object.keys($input.first().json).length + extensions('object').length, + ); + }); + + test("should return completions for: '{{ $input.last().| }}'", () => { + resolveParameterSpy.mockReturnValue($input.last().json); + + expect(completions('{{ $input.last().| }}')).toHaveLength( + Object.keys($input.last().json).length + extensions('object').length, + ); + }); + + test("should return completions for: '{{ $input.all()[0].| }}'", () => { + resolveParameterSpy.mockReturnValue($input.all()[0].json); + + expect(completions('{{ $input.all()[0].| }}')).toHaveLength( + Object.keys($input.all()[0].json).length + extensions('object').length, + ); }); test('should return completions for: {{ $input.item.json.str.| }}', () => { - // @ts-ignore - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.item.json.str); + resolveParameterSpy.mockReturnValue($input.item.json.str); expect(completions('{{ $input.item.json.str.| }}')).toHaveLength(extensions('string').length); }); test('should return completions for: {{ $input.item.json.num.| }}', () => { - // @ts-ignore - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.item.json.num); + resolveParameterSpy.mockReturnValue($input.item.json.num); expect(completions('{{ $input.item.json.num.| }}')).toHaveLength(extensions('number').length); }); test('should return completions for: {{ $input.item.json.arr.| }}', () => { - // @ts-ignore - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.item.json.arr); + resolveParameterSpy.mockReturnValue($input.item.json.arr); expect(completions('{{ $input.item.json.arr.| }}')).toHaveLength(extensions('array').length); }); test('should return completions for: {{ $input.item.json.obj.| }}', () => { - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.item.json.obj); + resolveParameterSpy.mockReturnValue($input.item.json.obj); expect(completions('{{ $input.item.json.obj.| }}')).toHaveLength( - Object.keys(mock.item.json.obj).length + extensions('object').length, + Object.keys($input.item.json.obj).length + extensions('object').length, ); }); }); describe('bracket access', () => { + const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter'); + const { $input } = mockProxy; + ['{{ $input.item.json[| }}', '{{ $json[| }}'].forEach((expression) => { test(`should return completions for: ${expression}`, () => { - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.item.json); + resolveParameterSpy.mockReturnValue($input.item.json); const found = completions(expression); - if (!found) throw new Error('Expected bracket access completions'); + if (!found) throw new Error('Expected to find bracket access completions'); - expect(found).toHaveLength(Object.keys(mock.item.json).length); + expect(found).toHaveLength(Object.keys($input.item.json).length); expect(found.map((c) => c.label).every((l) => l.endsWith(']'))); }); }); ["{{ $input.item.json['obj'][| }}", "{{ $json['obj'][| }}"].forEach((expression) => { test(`should return completions for: ${expression}`, () => { - vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue(mock.item.json.obj); + resolveParameterSpy.mockReturnValue($input.item.json.obj); const found = completions(expression); - if (!found) throw new Error('Expected bracket access completions'); + if (!found) throw new Error('Expected to find bracket access completions'); - expect(found).toHaveLength(Object.keys(mock.item.json.obj).length); + expect(found).toHaveLength(Object.keys($input.item.json.obj).length); expect(found.map((c) => c.label).every((l) => l.endsWith(']'))); }); }); }); }); - -function completions(docWithCursor: string) { - const cursorPosition = docWithCursor.indexOf('|'); - - const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1); - - const state = EditorState.create({ - doc, - selection: { anchor: cursorPosition }, - extensions: [n8nLang()], - }); - - const context = new CompletionContext(state, cursorPosition, false); - - for (const completionSource of state.languageDataAt( - 'autocomplete', - cursorPosition, - )) { - const result = completionSource(context); - - if (isCompletionResult(result)) return result.options; - } - - return null; -} - -function isCompletionResult( - candidate: ReturnType, -): candidate is CompletionResult { - return candidate !== null && 'from' in candidate && 'options' in candidate; -} diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/mock.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/mock.ts index 9b6b2d89cb678..811e4276af4ff 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/mock.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/mock.ts @@ -1,61 +1,331 @@ -const inputProxy = new Proxy( - {}, - { - ownKeys() { - return ['all', 'context', 'first', 'item', 'last', 'params']; +import { v4 as uuidv4 } from 'uuid'; +import { + INode, + IConnections, + IRunExecutionData, + Workflow, + IExecuteData, + WorkflowDataProxy, + INodeType, + INodeTypeData, + INodeTypes, + IVersionedNodeType, + NodeHelpers, +} from 'n8n-workflow'; + +class NodeTypesClass implements INodeTypes { + nodeTypes: INodeTypeData = { + 'test.set': { + sourcePath: '', + type: { + description: { + displayName: 'Set', + name: 'set', + group: ['input'], + version: 1, + description: 'Sets a value', + defaults: { + name: 'Set', + color: '#0000FF', + }, + inputs: ['main'], + outputs: ['main'], + properties: [ + { + displayName: 'Value1', + name: 'value1', + type: 'string', + default: 'default-value1', + }, + { + displayName: 'Value2', + name: 'value2', + type: 'string', + default: 'default-value2', + }, + ], + }, + }, }, - get(_, property) { - if (property === 'isMockProxy') return true; + }; - if (property === 'all') return []; - if (property === 'context') return {}; - if (property === 'first') return {}; - if (property === 'item') return {}; - if (property === 'last') return {}; - if (property === 'params') return {}; + getByName(nodeType: string): INodeType | IVersionedNodeType { + return this.nodeTypes[nodeType].type; + } - return undefined; - }, - }, -); + getByNameAndVersion(nodeType: string, version?: number): INodeType { + return NodeHelpers.getVersionedNodeType(this.nodeTypes[nodeType].type, version); + } +} -const nodeSelectorProxy = new Proxy( - {}, +const nodes: INode[] = [ + { + name: 'Start', + type: 'test.set', + parameters: {}, + typeVersion: 1, + id: 'uuid-1', + position: [100, 200], + }, { - ownKeys() { - return ['all', 'context', 'first', 'item', 'last', 'params', 'itemMatching']; + name: 'Function', + type: 'test.set', + parameters: { + functionCode: + '// Code here will run only once, no matter how many input items there are.\n// More info and help: https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.function/\nconst { DateTime, Duration, Interval } = require("luxon");\n\nconst data = [\n {\n "length": 105\n },\n {\n "length": 160\n },\n {\n "length": 121\n },\n {\n "length": 275\n },\n {\n "length": 950\n },\n];\n\nreturn data.map(fact => ({json: fact}));', }, - get(_, property) { - if (property === 'isMockProxy') return true; - - if (property === 'all') return []; - if (property === 'context') return {}; - if (property === 'first') return {}; - if (property === 'item') return {}; - if (property === 'last') return {}; - if (property === 'params') return {}; - if (property === 'itemMatching') return {}; - - return undefined; + typeVersion: 1, + id: 'uuid-2', + position: [280, 200], + }, + { + name: 'Rename', + type: 'test.set', + parameters: { + value1: 'data', + value2: 'initialName', }, + typeVersion: 1, + id: 'uuid-3', + position: [460, 200], }, -); + { + name: 'End', + type: 'test.set', + parameters: {}, + typeVersion: 1, + id: 'uuid-4', + position: [640, 200], + }, +]; + +const connections: IConnections = { + Start: { + main: [ + [ + { + node: 'Function', + type: 'main', + index: 0, + }, + ], + ], + }, + Function: { + main: [ + [ + { + node: 'Rename', + type: 'main', + index: 0, + }, + ], + ], + }, + Rename: { + main: [ + [ + { + node: 'End', + type: 'main', + index: 0, + }, + ], + ], + }, +}; -const item = { - json: { __isMockObject: true, str: 'abc', num: 123, arr: [1, 2, 3], obj: { a: 123 } }, - pairedItem: { item: 0, input: 0 }, +const runExecutionData: IRunExecutionData = { + resultData: { + runData: { + Start: [ + { + startTime: 1, + executionTime: 1, + data: { + main: [ + [ + { + json: {}, + }, + ], + ], + }, + source: [], + }, + ], + Function: [ + { + startTime: 1, + executionTime: 1, + data: { + main: [ + [ + { + json: { initialName: 105, str: 'abc' }, + pairedItem: { item: 0 }, + }, + { + json: { initialName: 160 }, + pairedItem: { item: 0 }, + }, + { + json: { initialName: 121 }, + pairedItem: { item: 0 }, + }, + { + json: { initialName: 275 }, + pairedItem: { item: 0 }, + }, + { + json: { initialName: 950 }, + pairedItem: { item: 0 }, + }, + ], + ], + }, + source: [ + { + previousNode: 'Start', + }, + ], + }, + ], + Rename: [ + { + startTime: 1, + executionTime: 1, + data: { + main: [ + [ + { + json: { data: 105 }, + pairedItem: { item: 0 }, + }, + { + json: { data: 160 }, + pairedItem: { item: 1 }, + }, + { + json: { data: 121 }, + pairedItem: { item: 2 }, + }, + { + json: { data: 275 }, + pairedItem: { item: 3 }, + }, + { + json: { data: 950 }, + pairedItem: { item: 4 }, + }, + ], + ], + }, + source: [ + { + previousNode: 'Function', + }, + ], + }, + ], + End: [ + { + startTime: 1, + executionTime: 1, + data: { + main: [ + [ + { + json: { + data: 105, + str: 'abc', + num: 123, + arr: [1, 2, 3], + obj: { a: 'hello' }, + }, + pairedItem: { item: 0 }, + }, + { + json: { data: 160 }, + pairedItem: { item: 1 }, + }, + { + json: { data: 121 }, + pairedItem: { item: 2 }, + }, + { + json: { data: 275 }, + pairedItem: { item: 3 }, + }, + { + json: { data: 950 }, + pairedItem: { item: 4 }, + }, + ], + ], + }, + source: [ + { + previousNode: 'Rename', + }, + ], + }, + ], + }, + }, }; -Object.defineProperty(item, '__isMockObject', { - enumerable: false, +const workflow = new Workflow({ + id: '123', + name: 'test workflow', + nodes, + connections, + active: false, + nodeTypes: new NodeTypesClass(), }); -Object.defineProperty(item.json, '__isMockObject', { - enumerable: false, -}); +const lastNodeName = 'End'; -export const mock = { - inputProxy, - nodeSelectorProxy, - item, +const lastNodeConnectionInputData = + runExecutionData.resultData.runData[lastNodeName][0].data!.main[0]; + +const executeData: IExecuteData = { + data: runExecutionData.resultData.runData[lastNodeName][0].data!, + node: nodes.find((node) => node.name === lastNodeName) as INode, + source: { + main: runExecutionData.resultData.runData[lastNodeName][0].source!, + }, }; + +const dataProxy = new WorkflowDataProxy( + workflow, + runExecutionData, + 0, + 0, + lastNodeName, + lastNodeConnectionInputData || [], + {}, + 'manual', + 'America/New_York', + {}, + executeData, +); + +export const mockProxy = dataProxy.getDataProxy(); + +export const mockNodes = [ + { + id: uuidv4(), + name: 'Manual', + position: [0, 0], + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + }, + { + id: uuidv4(), + name: 'Set', + position: [0, 0], + type: 'n8n-nodes-base.set', + typeVersion: 1, + }, +]; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/utils.ts new file mode 100644 index 0000000000000..56330df19961f --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/utils.ts @@ -0,0 +1,34 @@ +import { CompletionContext, CompletionSource, CompletionResult } from '@codemirror/autocomplete'; +import { EditorState } from '@codemirror/state'; +import { n8nLang } from '../../n8nLang'; + +export function completions(docWithCursor: string) { + const cursorPosition = docWithCursor.indexOf('|'); + + const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1); + + const state = EditorState.create({ + doc, + selection: { anchor: cursorPosition }, + extensions: [n8nLang()], + }); + + const context = new CompletionContext(state, cursorPosition, false); + + for (const completionSource of state.languageDataAt( + 'autocomplete', + cursorPosition, + )) { + const result = completionSource(context); + + if (isCompletionResult(result)) return result.options; + } + + return null; +} + +function isCompletionResult( + candidate: ReturnType, +): candidate is CompletionResult { + return candidate !== null && 'from' in candidate && 'options' in candidate; +} diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 890b961db8a72..56a292bfe3f06 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -75,7 +75,9 @@ function datatypeOptions(resolved: Resolved, toResolve: string) { if (typeof resolved === 'string') return extensions('string'); - if (['$now', '$today'].includes(toResolve)) return luxonInstanceOptions(); + if (['$now', '$today'].includes(toResolve)) { + return [...luxonInstanceOptions(), ...extensions('date')]; + } if (resolved instanceof Date) return extensions('date'); @@ -161,7 +163,7 @@ const objectOptions = (toResolve: string, resolved: IDataObject) => { let rawKeys = Object.keys(resolved); - if (name === '$()' || resolved.isMockProxy) { + if (name === '$()') { rawKeys = Reflect.ownKeys(resolved) as string[]; } @@ -196,9 +198,7 @@ const objectOptions = (toResolve: string, resolved: IDataObject) => { /json('])?$/.test(toResolve) || toResolve === '$execution' || toResolve.endsWith('params') || - toResolve === 'Math' || - resolved.isMockProxy || - resolved.__isMockObject; + toResolve === 'Math'; if (skipObjectExtensions) return keys; @@ -209,7 +209,7 @@ function ensureKeyCanBeResolved(obj: IDataObject, key: string) { try { obj[key]; } catch (error) { - // e.g. attempting to access non-parent node with `$()` + // e.g. attempt to access disconnected node with `$()` throw new Error('Cannot generate options', { cause: error }); } } diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index b25ddd5576aca..219e991190155 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -183,7 +183,14 @@ function isUrl(value: string) { } catch (_error) { return false; } - return url.protocol === 'http:' || url.protocol === 'https:'; + + // URL constructor tolerates missing `//` after protocol + + if (url.protocol === 'http:' && value.slice(5, 7) === '//') return true; + + if (url.protocol === 'https:' && value.slice(6, 8) === '//') return true; + + return false; } function isDomain(value: string) { @@ -360,7 +367,6 @@ isUrl.doc = { name: 'isUrl', description: 'Checks if a string is a valid URL', returnType: 'boolean', - aliases: ['isURL'], }; isEmpty.doc = { @@ -434,7 +440,6 @@ export const stringExtensions: ExtensionMap = { isEmail, isNumeric, isUrl, - isURL: isUrl, isEmpty, isNotEmpty, extractEmail, From 65f86803309c8ed69cfa64df102d0b412d7f75d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 11:53:45 +0100 Subject: [PATCH 105/160] :fire: Remove tests for removed methods --- .../ExpressionExtensions/ArrayExtensions.test.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts index 7a8764773bd6f..b3e3748e1bbd2 100644 --- a/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts @@ -50,18 +50,6 @@ describe('Data Transformation Functions', () => { expect(evaluate('={{ [1].isEmpty() }}')).toEqual(false); }); - test('.length() should work correctly on an array', () => { - expect(evaluate('={{ [].length() }}')).toEqual(0); - }); - - test('.count() should work correctly on an array', () => { - expect(evaluate('={{ [1].count() }}')).toEqual(1); - }); - - test('.size() should work correctly on an array', () => { - expect(evaluate('={{ [1,2].size() }}')).toEqual(2); - }); - test('.last() should work correctly on an array', () => { expect(evaluate('={{ ["repeat","repeat","a","b","c"].last() }}')).toEqual('c'); }); From 1d2c9479a539c71cb1780c5bef2518e9971319d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 12:13:30 +0100 Subject: [PATCH 106/160] :shirt: Fix type --- packages/workflow/src/Extensions/ObjectExtensions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow/src/Extensions/ObjectExtensions.ts b/packages/workflow/src/Extensions/ObjectExtensions.ts index 2f027c996e4f0..dc2b8946a0ea5 100644 --- a/packages/workflow/src/Extensions/ObjectExtensions.ts +++ b/packages/workflow/src/Extensions/ObjectExtensions.ts @@ -77,7 +77,7 @@ export function compact(value: object): object { for (const [key, val] of Object.entries(value)) { if (val !== null && val !== undefined && val !== 'nil' && val !== '') { if (typeof val === 'object') { - if (Object.keys(val).length === 0) continue; + if (Object.keys(val as object).length === 0) continue; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument newObj[key] = compact(val); } else { From 7c308115e593f3511708bf668c7a13c26e7d53ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 12:35:15 +0100 Subject: [PATCH 107/160] :arrow_up: Upgrade lang pack --- packages/editor-ui/package.json | 2 +- .../src/plugins/codemirror/n8nLang.ts | 2 +- .../codemirror/parser-with-unicode/index.cjs | 57 ------------------- .../parser-with-unicode/index.d.cts | 5 -- .../codemirror/parser-with-unicode/index.d.ts | 5 -- .../codemirror/parser-with-unicode/index.js | 51 ----------------- pnpm-lock.yaml | 8 +-- 7 files changed, 6 insertions(+), 124 deletions(-) delete mode 100644 packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.cjs delete mode 100644 packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.d.cts delete mode 100644 packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.d.ts delete mode 100644 packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.js diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index ba1845153a555..f3762b6a28415 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -41,7 +41,7 @@ "@fortawesome/vue-fontawesome": "^2.0.2", "axios": "^0.21.1", "codemirror-lang-html-n8n": "^1.0.0", - "codemirror-lang-n8n-expression": "^0.1.0", + "codemirror-lang-n8n-expression": "^0.2.0", "dateformat": "^3.0.3", "esprima-next": "5.8.4", "fast-json-stable-stringify": "^2.1.0", diff --git a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts index 174055bb37bf4..b72c332ab7d16 100644 --- a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts +++ b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts @@ -1,4 +1,4 @@ -import { parserWithMetaData as n8nParser } from './parser-with-unicode'; // @TODO: Update lib +import { parserWithMetaData as n8nParser } from 'codemirror-lang-n8n-expression'; import { LanguageSupport, LRLanguage } from '@codemirror/language'; import { parseMixed } from '@lezer/common'; import { javascriptLanguage } from '@codemirror/lang-javascript'; diff --git a/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.cjs b/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.cjs deleted file mode 100644 index 136fa8aac9165..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.cjs +++ /dev/null @@ -1,57 +0,0 @@ -'use strict'; - -Object.defineProperty(exports, '__esModule', { value: true }); - -var autocomplete = require('@codemirror/autocomplete'); -var lr = require('@lezer/lr'); -var language = require('@codemirror/language'); -var highlight = require('@lezer/highlight'); - -// This file was generated by lezer-generator. You probably shouldn't edit it. -const parser = lr.LRParser.deserialize({ - version: 14, - states: "nQQOPOOOOOO'#Cc'#CcOOOO'#Ca'#CaQQOPOOOOOO-E6_-E6_", - stateData: "]~OQPORPOSPO~O", - goto: "cWPPPPPXP_QRORSRTQOR", - nodeNames: "⚠ Program Plaintext Resolvable BrokenResolvable", - maxTerm: 7, - skippedNodes: [0], - repeatNodeCount: 1, - tokenData: "&U~RRO#o[#o#p{#p~[~aRQ~O#o[#o#pj#p~[~mRO#o[#p~[~~v~{OQ~~!OSO#o[#o#p![#p~[~~v~!aUS~O#O![#O#P!s#P#q![#q#r%v#rG|![G|~%c~!xUS~O#O![#O#P!s#P#q![#q#r#[#rG|![G|~%c~#aRS~O#q#j#q#r$l#r~#j~#mTO#O#j#O#P#|#P#q#j#q#r%Q#rG|#j~$PTO#O#j#O#P#|#P#q#j#q#r$`#rG|#j~$cRO#q#j#q#r$l#r~#j~$qTR~O#O#j#O#P#|#P#q#j#q#r%Q#rG|#j~%TRO#q#j#q#r%^#r~#j~%cOR~~%hRS~O#q%c#q#r%q#r~%c~%vOS~~%{RS~O#q#j#q#r%^#r~#j", - tokenizers: [0], - topRules: {"Program":[0,1]}, - tokenPrec: 0 -}); - -const parserWithMetaData = parser.configure({ - props: [ - language.foldNodeProp.add({ - Application: language.foldInside, - }), - highlight.styleTags({ - OpenMarker: highlight.tags.brace, - CloseMarker: highlight.tags.brace, - Plaintext: highlight.tags.content, - Resolvable: highlight.tags.string, - BrokenResolvable: highlight.tags.className, - }), - ], -}); -const n8nLanguage = language.LRLanguage.define({ - parser: parserWithMetaData, - languageData: { - commentTokens: { line: ";" }, - }, -}); -const completions = n8nLanguage.data.of({ - autocomplete: autocomplete.completeFromList([ - // { label: "test", type: "keyword" }, // to add in future - ]), -}); -function n8nExpression() { - return new language.LanguageSupport(n8nLanguage, [completions]); -} - -exports.n8nExpression = n8nExpression; -exports.n8nLanguage = n8nLanguage; -exports.parserWithMetaData = parserWithMetaData; diff --git a/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.d.cts b/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.d.cts deleted file mode 100644 index 961b8c8fca186..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.d.cts +++ /dev/null @@ -1,5 +0,0 @@ -import { LRLanguage, LanguageSupport } from "@codemirror/language"; -declare const parserWithMetaData: import("@lezer/lr").LRParser; -declare const n8nLanguage: LRLanguage; -declare function n8nExpression(): LanguageSupport; -export { parserWithMetaData, n8nLanguage, n8nExpression }; diff --git a/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.d.ts b/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.d.ts deleted file mode 100644 index 961b8c8fca186..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { LRLanguage, LanguageSupport } from "@codemirror/language"; -declare const parserWithMetaData: import("@lezer/lr").LRParser; -declare const n8nLanguage: LRLanguage; -declare function n8nExpression(): LanguageSupport; -export { parserWithMetaData, n8nLanguage, n8nExpression }; diff --git a/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.js b/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.js deleted file mode 100644 index f62765e8cce65..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/parser-with-unicode/index.js +++ /dev/null @@ -1,51 +0,0 @@ -import { completeFromList } from '@codemirror/autocomplete'; -import { LRParser } from '@lezer/lr'; -import { foldNodeProp, foldInside, LRLanguage, LanguageSupport } from '@codemirror/language'; -import { styleTags, tags } from '@lezer/highlight'; - -// This file was generated by lezer-generator. You probably shouldn't edit it. -const parser = LRParser.deserialize({ - version: 14, - states: "nQQOPOOOOOO'#Cc'#CcOOOO'#Ca'#CaQQOPOOOOOO-E6_-E6_", - stateData: "]~OQPORPOSPO~O", - goto: "cWPPPPPXP_QRORSRTQOR", - nodeNames: "⚠ Program Plaintext Resolvable BrokenResolvable", - maxTerm: 7, - skippedNodes: [0], - repeatNodeCount: 1, - tokenData: "&U~RRO#o[#o#p{#p~[~aRQ~O#o[#o#pj#p~[~mRO#o[#p~[~~v~{OQ~~!OSO#o[#o#p![#p~[~~v~!aUS~O#O![#O#P!s#P#q![#q#r%v#rG|![G|~%c~!xUS~O#O![#O#P!s#P#q![#q#r#[#rG|![G|~%c~#aRS~O#q#j#q#r$l#r~#j~#mTO#O#j#O#P#|#P#q#j#q#r%Q#rG|#j~$PTO#O#j#O#P#|#P#q#j#q#r$`#rG|#j~$cRO#q#j#q#r$l#r~#j~$qTR~O#O#j#O#P#|#P#q#j#q#r%Q#rG|#j~%TRO#q#j#q#r%^#r~#j~%cOR~~%hRS~O#q%c#q#r%q#r~%c~%vOS~~%{RS~O#q#j#q#r%^#r~#j", - tokenizers: [0], - topRules: {"Program":[0,1]}, - tokenPrec: 0 -}); - -const parserWithMetaData = parser.configure({ - props: [ - foldNodeProp.add({ - Application: foldInside, - }), - styleTags({ - OpenMarker: tags.brace, - CloseMarker: tags.brace, - Plaintext: tags.content, - Resolvable: tags.string, - BrokenResolvable: tags.className, - }), - ], -}); -const n8nLanguage = LRLanguage.define({ - parser: parserWithMetaData, - languageData: { - commentTokens: { line: ";" }, - }, -}); -const completions = n8nLanguage.data.of({ - autocomplete: completeFromList([ - // { label: "test", type: "keyword" }, // to add in future - ]), -}); -function n8nExpression() { - return new LanguageSupport(n8nLanguage, [completions]); -} - -export { n8nExpression, n8nLanguage, parserWithMetaData }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7844cff8b0458..8a62e4bb43842 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -551,7 +551,7 @@ importers: axios: ^0.21.1 c8: ^7.12.0 codemirror-lang-html-n8n: ^1.0.0 - codemirror-lang-n8n-expression: ^0.1.0 + codemirror-lang-n8n-expression: ^0.2.0 dateformat: ^3.0.3 esprima-next: 5.8.4 fast-json-stable-stringify: ^2.1.0 @@ -616,7 +616,7 @@ importers: '@fortawesome/vue-fontawesome': 2.0.8_dh3wzfumpzw6zsszdpw5cxouqy axios: 0.21.4 codemirror-lang-html-n8n: 1.0.0 - codemirror-lang-n8n-expression: 0.1.0_zyklskjzaprvz25ee7sq7godcq + codemirror-lang-n8n-expression: 0.2.0_zyklskjzaprvz25ee7sq7godcq dateformat: 3.0.3 esprima-next: 5.8.4 fast-json-stable-stringify: 2.1.0 @@ -9405,8 +9405,8 @@ packages: '@lezer/lr': 1.2.3 dev: false - /codemirror-lang-n8n-expression/0.1.0_zyklskjzaprvz25ee7sq7godcq: - resolution: {integrity: sha512-20ss5p0koTu5bfivr1sBHYs7cpjWT2JhVB5gn7TX9WWPt+v/9p9tEcYSOyL/sm+OFuWh698Cgnmrba4efQnMCQ==} + /codemirror-lang-n8n-expression/0.2.0_zyklskjzaprvz25ee7sq7godcq: + resolution: {integrity: sha512-kdlpzevdCpWcpbNcwES9YZy+rDFwWOdO6Z78SWxT6jMhCPmdHQmO+gJ39aXAXlUI7OGLfOBtg1/ONxPjRpEIYQ==} dependencies: '@codemirror/autocomplete': 6.4.0_wo7q3lvweq5evsu423o7qzum5i '@codemirror/language': 6.2.1 From 19b480f1f859be8988ff5257c99e4a2f3892f698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 12:50:24 +0100 Subject: [PATCH 108/160] :rewind: Undo change to `vitest` command --- packages/editor-ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index f3762b6a28415..acaeacd75afab 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -22,7 +22,7 @@ "lintfix": "eslint --ext .js,.ts,.vue src --fix", "format": "prettier --write . --ignore-path ../../.prettierignore", "serve": "cross-env VUE_APP_URL_BASE_API=http://localhost:5678/ vite --host 0.0.0.0 --port 8080 dev", - "test": "vitest", + "test": "vitest run", "test:ci": "vitest run --coverage", "test:dev": "vitest" }, From 42be2f5169773faf84d77535e8124d4a71965b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 12:57:39 +0100 Subject: [PATCH 109/160] :fire: Remove unused method --- packages/editor-ui/src/mixins/expressionManager.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/editor-ui/src/mixins/expressionManager.ts b/packages/editor-ui/src/mixins/expressionManager.ts index d87ac557585c2..ae3f794000d95 100644 --- a/packages/editor-ui/src/mixins/expressionManager.ts +++ b/packages/editor-ui/src/mixins/expressionManager.ts @@ -68,11 +68,6 @@ export const expressionManager = mixins(workflowHelpers).extend({ return this.segments.filter((s): s is Html => s.kind !== 'resolvable'); }, - // @TODO: Used? - cursorPosition(): number { - return this.editor.state.selection.ranges[0].from; - }, - segments(): Segment[] { const rawSegments: RawSegment[] = []; From ed4a74b2e435d09cff3af0fb604ae49a83eb5007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 12:58:31 +0100 Subject: [PATCH 110/160] :zap: Separate `return` line --- packages/editor-ui/src/mixins/expressionManager.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/editor-ui/src/mixins/expressionManager.ts b/packages/editor-ui/src/mixins/expressionManager.ts index ae3f794000d95..0a61d6d6116b5 100644 --- a/packages/editor-ui/src/mixins/expressionManager.ts +++ b/packages/editor-ui/src/mixins/expressionManager.ts @@ -96,7 +96,9 @@ export const expressionManager = mixins(workflowHelpers).extend({ const { from, to, text, token } = segment; if (token === 'Plaintext') { - return acc.push({ kind: 'plaintext', from, to, plaintext: text }), acc; + acc.push({ kind: 'plaintext', from, to, plaintext: text }); + + return acc; } const { resolved, error, fullError } = this.resolve(text, this.hoveringItem); From 5f0d8a243e975532a2025c70a4f9312f0e1aad47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 13:00:46 +0100 Subject: [PATCH 111/160] :pencil2: Improve description --- .../codemirror/completions/__tests__/completions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8e611aae61c08..198a743212666 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 @@ -31,7 +31,7 @@ describe('No completions', () => { }); describe('Top-level completions', () => { - test('should return blank completions for: {{ | }}', () => { + test('should return dollar completions for blank position: {{ | }}', () => { expect(completions('{{ | }}')).toHaveLength(dollarOptions().length); }); From 797c1b46c70baa4f73e4b55178c4579fee7cc7b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 13:11:29 +0100 Subject: [PATCH 112/160] :test_tube: Expand tests for initial-only completions --- .../completions/__tests__/completions.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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 198a743212666..066cf84136ed0 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 @@ -35,8 +35,22 @@ describe('Top-level completions', () => { expect(completions('{{ | }}')).toHaveLength(dollarOptions().length); }); - test('should return non-dollar completions for: {{ D| }}', () => { - expect(completions('{{ D| }}')).toHaveLength(1); // DateTime + test('should return DateTime completion for: {{ D| }}', () => { + const found = completions('{{ D| }}'); + + if (!found) throw new Error('Expected to find DateTime completion'); + + expect(found).toHaveLength(1); + expect(found[0].label).toBe('DateTime'); + }); + + test('should return Math completion for: {{ M| }}', () => { + const found = completions('{{ M| }}'); + + if (!found) throw new Error('Expected to find Math completion'); + + expect(found).toHaveLength(1); + expect(found[0].label).toBe('Math'); }); test('should return dollar completions for: {{ $| }}', () => { From 034d5e235befb1c90d67c64bf15c01000fbb5cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 13:21:08 +0100 Subject: [PATCH 113/160] :test_tube: Add bracket-aware completions --- .../completions/__tests__/completions.test.ts | 102 ++++++++++++++++-- .../codemirror/completions/__tests__/utils.ts | 34 ------ 2 files changed, 94 insertions(+), 42 deletions(-) delete mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/utils.ts 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 066cf84136ed0..d7c5a06258999 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 @@ -12,7 +12,9 @@ import { } from '@/plugins/codemirror/completions/datatype.completions'; import { mockNodes, mockProxy } from './mock'; -import { completions } from './utils'; +import { CompletionContext, CompletionSource, CompletionResult } from '@codemirror/autocomplete'; +import { EditorState } from '@codemirror/state'; +import { n8nLang } from '@/plugins/codemirror/n8nLang'; beforeEach(() => { setActivePinia(createTestingPinia()); @@ -38,7 +40,7 @@ describe('Top-level completions', () => { test('should return DateTime completion for: {{ D| }}', () => { const found = completions('{{ D| }}'); - if (!found) throw new Error('Expected to find DateTime completion'); + if (!found) throw new Error('Expected to find completion'); expect(found).toHaveLength(1); expect(found[0].label).toBe('DateTime'); @@ -47,7 +49,7 @@ describe('Top-level completions', () => { test('should return Math completion for: {{ M| }}', () => { const found = completions('{{ M| }}'); - if (!found) throw new Error('Expected to find Math completion'); + if (!found) throw new Error('Expected to find completion'); expect(found).toHaveLength(1); expect(found[0].label).toBe('Math'); @@ -135,6 +137,44 @@ describe('Resolution-based completions', () => { }); }); + describe('bracket-aware completions', () => { + const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter'); + const { $input } = mockProxy; + + test('should return bracket-aware completions for: {{ $input.item.json.str.| }}', () => { + resolveParameterSpy.mockReturnValue($input.item.json.str); + + const found = completions('{{ $input.item.json.str.|() }}'); + + if (!found) throw new Error('Expected to find completions'); + + expect(found).toHaveLength(extensions('string').length); + expect(found.map((c) => c.label).every((l) => !l.endsWith('()'))); + }); + + test('should return bracket-aware completions for: {{ $input.item.json.num.| }}', () => { + resolveParameterSpy.mockReturnValue($input.item.json.num); + + const found = completions('{{ $input.item.json.num.|() }}'); + + if (!found) throw new Error('Expected to find completions'); + + expect(found).toHaveLength(extensions('number').length); + expect(found.map((c) => c.label).every((l) => !l.endsWith('()'))); + }); + + test('should return bracket-aware completions for: {{ $input.item.json.arr.| }}', () => { + resolveParameterSpy.mockReturnValue($input.item.json.arr); + + const found = completions('{{ $input.item.json.arr.|() }}'); + + if (!found) throw new Error('Expected to find completions'); + + expect(found).toHaveLength(extensions('array').length); + expect(found.map((c) => c.label).every((l) => !l.endsWith('()'))); + }); + }); + describe('references', () => { const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter'); const { $input, $ } = mockProxy; @@ -156,19 +196,34 @@ describe('Resolution-based completions', () => { test('should return completions for: {{ $input.item.| }}', () => { resolveParameterSpy.mockReturnValue($input.item); - expect(completions('{{ $input.item.| }}')).toHaveLength(1); // json + const found = completions('{{ $input.item.| }}'); + + if (!found) throw new Error('Expected to find completion'); + + expect(found).toHaveLength(1); + expect(found[0].label).toBe('json'); }); test('should return completions for: {{ $input.first().| }}', () => { resolveParameterSpy.mockReturnValue($input.first()); - expect(completions('{{ $input.first().| }}')).toHaveLength(1); // json + const found = completions('{{ $input.first().| }}'); + + if (!found) throw new Error('Expected to find completion'); + + expect(found).toHaveLength(1); + expect(found[0].label).toBe('json'); }); test('should return completions for: {{ $input.last().| }}', () => { resolveParameterSpy.mockReturnValue($input.last()); - expect(completions('{{ $input.last().| }}')).toHaveLength(1); // json + const found = completions('{{ $input.last().| }}'); + + if (!found) throw new Error('Expected to find completion'); + + expect(found).toHaveLength(1); + expect(found[0].label).toBe('json'); }); test('should return no completions for: {{ $input.all().| }}', () => { @@ -247,7 +302,7 @@ describe('Resolution-based completions', () => { const found = completions(expression); - if (!found) throw new Error('Expected to find bracket access completions'); + if (!found) throw new Error('Expected to find completions'); expect(found).toHaveLength(Object.keys($input.item.json).length); expect(found.map((c) => c.label).every((l) => l.endsWith(']'))); @@ -260,7 +315,7 @@ describe('Resolution-based completions', () => { const found = completions(expression); - if (!found) throw new Error('Expected to find bracket access completions'); + if (!found) throw new Error('Expected to find completions'); expect(found).toHaveLength(Object.keys($input.item.json.obj).length); expect(found.map((c) => c.label).every((l) => l.endsWith(']'))); @@ -268,3 +323,34 @@ describe('Resolution-based completions', () => { }); }); }); + +export function completions(docWithCursor: string) { + const cursorPosition = docWithCursor.indexOf('|'); + + const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1); + + const state = EditorState.create({ + doc, + selection: { anchor: cursorPosition }, + extensions: [n8nLang()], + }); + + const context = new CompletionContext(state, cursorPosition, false); + + for (const completionSource of state.languageDataAt( + 'autocomplete', + cursorPosition, + )) { + const result = completionSource(context); + + if (isCompletionResult(result)) return result.options; + } + + return null; +} + +function isCompletionResult( + candidate: ReturnType, +): candidate is CompletionResult { + return candidate !== null && 'from' in candidate && 'options' in candidate; +} diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/utils.ts deleted file mode 100644 index 56330df19961f..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/utils.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { CompletionContext, CompletionSource, CompletionResult } from '@codemirror/autocomplete'; -import { EditorState } from '@codemirror/state'; -import { n8nLang } from '../../n8nLang'; - -export function completions(docWithCursor: string) { - const cursorPosition = docWithCursor.indexOf('|'); - - const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1); - - const state = EditorState.create({ - doc, - selection: { anchor: cursorPosition }, - extensions: [n8nLang()], - }); - - const context = new CompletionContext(state, cursorPosition, false); - - for (const completionSource of state.languageDataAt( - 'autocomplete', - cursorPosition, - )) { - const result = completionSource(context); - - if (isCompletionResult(result)) return result.options; - } - - return null; -} - -function isCompletionResult( - candidate: ReturnType, -): candidate is CompletionResult { - return candidate !== null && 'from' in candidate && 'options' in candidate; -} From 21eb8dc21d12e812509305dabf715d4b52d2b618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 13:27:34 +0100 Subject: [PATCH 114/160] :zap: Make check for `all()` stricter --- .../src/plugins/codemirror/completions/datatype.completions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 56a292bfe3f06..b176a9c6f35a4 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -82,7 +82,7 @@ function datatypeOptions(resolved: Resolved, toResolve: string) { if (resolved instanceof Date) return extensions('date'); if (Array.isArray(resolved)) { - if (toResolve.endsWith('all()')) return []; + if (/all\(.*?\)/.test(toResolve)) return []; const arrayExtensions = extensions('array'); From 1e5b58575ed13e46ea4a928e8fec22b05e955344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 13:32:25 +0100 Subject: [PATCH 115/160] :pencil2: Adjust explanatory comments --- .../completions/bracketAccess.completions.ts | 12 ++++++------ .../codemirror/completions/dollar.completions.ts | 5 +---- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts index af1937df2769e..4c3254b4b62ae 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts @@ -7,12 +7,12 @@ import type { Resolved } from './types'; /** * Resolution-based completions offered at the start of bracket access notation. * - * - `$json[` - * - `$input.item.json[` - * - `$json['field'][` - * - `$json.myObj[` - * - `$('Test').last().json.myArr[` - * - `$input.first().json.myStr[` + * - `$json[|` + * - `$input.item.json[|` + * - `$json['field'][|` + * - `$json.myObj[|` + * - `$('Test').last().json.myArr[|` + * - `$input.first().json.myStr[|` */ export function bracketAccessCompletions(context: CompletionContext): CompletionResult | null { const word = context.matchBefore(/\$[\S\s]*\[.*/); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts index e421cc5060e25..ec27ff801927f 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts @@ -10,7 +10,7 @@ import { import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; /** - * Completions offered at the dollar position: `$` + * Completions offered at the dollar position: `$|` */ export function dollarCompletions(context: CompletionContext): CompletionResult | null { const word = context.matchBefore(/\$[^$]*/); @@ -23,9 +23,6 @@ export function dollarCompletions(context: CompletionContext): CompletionResult const userInput = word.text; - /** - * If user typed anything after `$`, narrow down options based on user input. - */ if (userInput !== '$') { options = options.filter((o) => prefixMatch(o.label, userInput)); } From 82f831738bbd3ad05d49aa8958b8210bcfb554c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 13:35:01 +0100 Subject: [PATCH 116/160] :fire: Remove unneded copy --- .../src/plugins/codemirror/completions/dollar.completions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts index ec27ff801927f..cb15ab8d1f28c 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts @@ -67,7 +67,7 @@ export function dollarOptions() { return option; }) .concat( - ...autocompletableNodeNames().map((nodeName) => ({ + autocompletableNodeNames().map((nodeName) => ({ label: `$('${nodeName}')`, type: 'keyword', info: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }), From a6797f746180c2529523cc598cb9c41a01a04040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 13:38:50 +0100 Subject: [PATCH 117/160] :fire: Remove outdated comment --- packages/workflow/src/Expression.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/workflow/src/Expression.ts b/packages/workflow/src/Expression.ts index 76c959a42e04d..4b17f4a3656d0 100644 --- a/packages/workflow/src/Expression.ts +++ b/packages/workflow/src/Expression.ts @@ -28,7 +28,6 @@ import { extendedFunctions } from './Extensions/ExtendedFunctions'; // Set it to use double curly brackets instead of single ones tmpl.brackets.set('{{ }}'); -// @TODO: Duplicated below, remove? // Make sure that error get forwarded tmpl.tmpl.errorHandler = (error: Error) => { if (error instanceof ExpressionError) { From ad8cf66802856147f748914ca258b6230c085969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 13:41:27 +0100 Subject: [PATCH 118/160] :zap: Make naming consistent --- packages/editor-ui/src/mixins/completionManager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/editor-ui/src/mixins/completionManager.ts b/packages/editor-ui/src/mixins/completionManager.ts index b49b422141e90..4015ebbc32c86 100644 --- a/packages/editor-ui/src/mixins/completionManager.ts +++ b/packages/editor-ui/src/mixins/completionManager.ts @@ -19,8 +19,8 @@ export const completionManager = mixins(expressionManager).extend({ */ expressionExtensionsCategories() { return ExpressionExtensions.reduce>((acc, cur) => { - for (const funcName of Object.keys(cur.functions)) { - acc[funcName] = cur.typeName; + for (const fnName of Object.keys(cur.functions)) { + acc[fnName] = cur.typeName; } return acc; From 19d9945c3de0ea90d00721a38d92e50b0ff72ae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 13:43:10 +0100 Subject: [PATCH 119/160] :pencil2: Update comments --- packages/workflow/src/Extensions/ArrayExtensions.ts | 2 +- packages/workflow/src/Extensions/DateExtensions.ts | 9 ++------- packages/workflow/src/Extensions/NumberExtensions.ts | 2 +- packages/workflow/src/Extensions/ObjectExtensions.ts | 2 +- packages/workflow/src/Extensions/StringExtensions.ts | 2 +- 5 files changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/workflow/src/Extensions/ArrayExtensions.ts b/packages/workflow/src/Extensions/ArrayExtensions.ts index 89e8cd1a298de..ee2a2efa9441b 100644 --- a/packages/workflow/src/Extensions/ArrayExtensions.ts +++ b/packages/workflow/src/Extensions/ArrayExtensions.ts @@ -423,7 +423,7 @@ sum.doc = { returnType: 'number', }; -// @TODO: Extensions below will be documented in next phase +// @TODO: Extensions below will be surfaced in next phase chunk.doc = { name: 'chunk', diff --git a/packages/workflow/src/Extensions/DateExtensions.ts b/packages/workflow/src/Extensions/DateExtensions.ts index 5909fb7f295c8..5472689735455 100644 --- a/packages/workflow/src/Extensions/DateExtensions.ts +++ b/packages/workflow/src/Extensions/DateExtensions.ts @@ -1,12 +1,7 @@ /* eslint-disable @typescript-eslint/unbound-method */ /* eslint-disable @typescript-eslint/explicit-member-accessibility */ import { DateTime } from 'luxon'; -import type { - DateTimeUnit, - DurationLike, - DurationObjectUnits, - LocaleOptions, -} from 'luxon'; +import type { DateTimeUnit, DurationLike, DurationObjectUnits, LocaleOptions } from 'luxon'; import type { ExtensionMap } from './Extensions'; type DurationUnit = @@ -215,7 +210,7 @@ isWeekend.doc = { description: 'Checks if the Date falls on a Saturday or Sunday', }; -// @TODO: Extensions below will be documented in next phase +// @TODO: Extensions below will be surfaced in next phase beginningOf.doc = { name: 'beginningOf', diff --git a/packages/workflow/src/Extensions/NumberExtensions.ts b/packages/workflow/src/Extensions/NumberExtensions.ts index 91521231b1c04..c941514e76042 100644 --- a/packages/workflow/src/Extensions/NumberExtensions.ts +++ b/packages/workflow/src/Extensions/NumberExtensions.ts @@ -58,7 +58,7 @@ isOdd.doc = { returnType: 'boolean', }; -// @TODO: Extensions below will be documented in next phase +// @TODO: Extensions below will be surfaced in next phase format.doc = { name: 'format', diff --git a/packages/workflow/src/Extensions/ObjectExtensions.ts b/packages/workflow/src/Extensions/ObjectExtensions.ts index dc2b8946a0ea5..0b42f53f2a52b 100644 --- a/packages/workflow/src/Extensions/ObjectExtensions.ts +++ b/packages/workflow/src/Extensions/ObjectExtensions.ts @@ -118,7 +118,7 @@ urlEncode.doc = { returnType: 'string', }; -// @TODO: Extensions below will be documented in next phase +// @TODO: Extensions below will be surfaced in next phase merge.doc = { name: 'merge', diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 219e991190155..2273b9c429914 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -400,7 +400,7 @@ extractUrl.doc = { returnType: 'string', }; -// @TODO: Extensions below will be documented in next phase +// @TODO: Extensions below will be surfaced in next phase hash.doc = { name: 'hash', From a49f6cc1e8cd10f1b1a4eee01b6498bd3a98cf7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 13:47:52 +0100 Subject: [PATCH 120/160] :zap: Improve URL scheme check --- .../workflow/src/Extensions/StringExtensions.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 2273b9c429914..238a2a49b3e87 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -184,11 +184,15 @@ function isUrl(value: string) { return false; } - // URL constructor tolerates missing `//` after protocol - - if (url.protocol === 'http:' && value.slice(5, 7) === '//') return true; - - if (url.protocol === 'https:' && value.slice(6, 8) === '//') return true; + // URL constructor tolerates missing `//` after protocol so check manually + for (const scheme of ['http:', 'https:']) { + if ( + url.protocol === scheme && + value.slice(scheme.length, scheme.length + '//'.length) === '//' + ) { + return true; + } + } return false; } From fc241d195bf01940b921d3932fb5349f472ce8db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 13:49:06 +0100 Subject: [PATCH 121/160] :pencil2: Add comment --- packages/workflow/src/Extensions/StringExtensions.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 238a2a49b3e87..683f76a98db07 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -204,6 +204,7 @@ function isDomain(value: string) { function isEmail(value: string) { const result = EMAIL_REGEXP.test(value); + // email regex is loose so check manually for now if (result && value.includes(' ')) { return false; } From 4f1fd0b33636093674267d659b548611c4908cf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 13:50:28 +0100 Subject: [PATCH 122/160] :truck: Move extension --- packages/workflow/src/Extensions/StringExtensions.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 683f76a98db07..2a8c2281b2d02 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -212,6 +212,10 @@ function isEmail(value: string) { return result; } +function toTitleCase(value: string) { + return titleCase(value); +} + function replaceSpecialChars(value: string) { return transliterate(value, { unknown: '?' }); } @@ -277,10 +281,6 @@ function extractUrl(value: string) { return matched[0]; } -function toTitleCase(value: string) { - return titleCase(value); -} - removeMarkdown.doc = { name: 'removeMarkdown', description: 'Removes Markdown formatting from a string', From f0bdef21eaac0995b985ce6958c40ff3d951c12c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 13:54:37 +0100 Subject: [PATCH 123/160] :pencil2: Update `BREAKING-CHANGES.md` --- packages/cli/BREAKING-CHANGES.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/BREAKING-CHANGES.md b/packages/cli/BREAKING-CHANGES.md index 115afc1d75089..1ce33929968d3 100644 --- a/packages/cli/BREAKING-CHANGES.md +++ b/packages/cli/BREAKING-CHANGES.md @@ -6,13 +6,13 @@ This list shows all the versions which include breaking changes and how to upgra ### What changed? -@TODO: Check if more breaking changes were introduced - In expressions, `DateTime.fromHTTP()`, `DateTime.fromISO()` and `DateTime.fromJSDate()` require an argument. Before, they returned `null` when called without an argument; now, they throw an error. +Similarly, `$jmespath()` requires two argument. Before, they returned `null` when called without the needed number of arguments; now, it throws an error. + ### When is action necessary? -If you were relying on the above behavior, review your workflow to ensure the argument being passed in cannot be `undefined`. +If you are relying on the above behavior, review your workflow to ensure you are passing in the required number of arguments. ## 0.202.0 From ce7c9eb769593569bacb7f7f6a3e813e7c7aa867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 14:12:07 +0100 Subject: [PATCH 124/160] :pencil2: Update upcoming version --- packages/cli/BREAKING-CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/BREAKING-CHANGES.md b/packages/cli/BREAKING-CHANGES.md index 1ce33929968d3..4abd90bcb4414 100644 --- a/packages/cli/BREAKING-CHANGES.md +++ b/packages/cli/BREAKING-CHANGES.md @@ -2,7 +2,7 @@ This list shows all the versions which include breaking changes and how to upgrade. -## 0.213.0 +## 0.214.0 ### What changed? From 14b61434d954fcf8e13aa47c7a841cd5c71bc81e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 14:12:31 +0100 Subject: [PATCH 125/160] :pencil2: Fix grammar --- packages/cli/BREAKING-CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/BREAKING-CHANGES.md b/packages/cli/BREAKING-CHANGES.md index 4abd90bcb4414..eb4f49b473278 100644 --- a/packages/cli/BREAKING-CHANGES.md +++ b/packages/cli/BREAKING-CHANGES.md @@ -8,7 +8,7 @@ This list shows all the versions which include breaking changes and how to upgra In expressions, `DateTime.fromHTTP()`, `DateTime.fromISO()` and `DateTime.fromJSDate()` require an argument. Before, they returned `null` when called without an argument; now, they throw an error. -Similarly, `$jmespath()` requires two argument. Before, they returned `null` when called without the needed number of arguments; now, it throws an error. +Similarly, `$jmespath()` requires two arguments. Before, it returned `null` when called without the needed number of arguments; now, it throws an error. ### When is action necessary? From 0c986f834e4c2c22475235ca71e938521f00586c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 30 Jan 2023 14:14:55 +0100 Subject: [PATCH 126/160] :pencil2: Shorten message --- packages/cli/BREAKING-CHANGES.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli/BREAKING-CHANGES.md b/packages/cli/BREAKING-CHANGES.md index eb4f49b473278..22cae5156967d 100644 --- a/packages/cli/BREAKING-CHANGES.md +++ b/packages/cli/BREAKING-CHANGES.md @@ -6,9 +6,7 @@ This list shows all the versions which include breaking changes and how to upgra ### What changed? -In expressions, `DateTime.fromHTTP()`, `DateTime.fromISO()` and `DateTime.fromJSDate()` require an argument. Before, they returned `null` when called without an argument; now, they throw an error. - -Similarly, `$jmespath()` requires two arguments. Before, it returned `null` when called without the needed number of arguments; now, it throws an error. +In expressions, `DateTime.fromHTTP()`, `DateTime.fromISO()` and `DateTime.fromJSDate()` require an argument. Before, they all resolved `null` when called without an argument; now, they throw an error when called without an argument. ### When is action necessary? From e3e1dc2305052d92de77fdb80a6f98ed52a3eef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 31 Jan 2023 09:33:42 +0100 Subject: [PATCH 127/160] :bug: Fix `Esc` behavior --- .../ExpressionEditorModalInput.vue | 22 ++++++++++++++----- .../InlineExpressionEditorInput.vue | 18 ++++++++++++--- .../editor-ui/src/mixins/completionManager.ts | 22 +------------------ 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue index e319486578e84..2bfcb2f1a05d0 100644 --- a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue +++ b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue @@ -1,10 +1,10 @@ From 6904fb15a88b27718a64c768aea68eb551f8730d Mon Sep 17 00:00:00 2001 From: Milorad Filipovic Date: Fri, 3 Feb 2023 14:28:16 +0100 Subject: [PATCH 148/160] =?UTF-8?q?=E2=9A=A1=20Updating=20`.pluck()`=20to?= =?UTF-8?q?=20return=20full=20array=20if=20no=20arguments=20are=20passed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/workflow/src/Extensions/ArrayExtensions.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/workflow/src/Extensions/ArrayExtensions.ts b/packages/workflow/src/Extensions/ArrayExtensions.ts index 61a09614baaad..a711812b91aa5 100644 --- a/packages/workflow/src/Extensions/ArrayExtensions.ts +++ b/packages/workflow/src/Extensions/ArrayExtensions.ts @@ -24,6 +24,9 @@ function pluck(value: unknown[], extraArgs: unknown[]): unknown[] { throw new ExpressionError('arguments must be passed to pluck'); } const fieldsToPluck = extraArgs; + if (!fieldsToPluck || fieldsToPluck.length === 0) { + return value; + } const plucked = value.reduce((pluckedFromObject, current) => { if (current && typeof current === 'object') { const p: unknown[] = []; From 5265dbd23e75d9ac5a0325927da6411c991d8c3d Mon Sep 17 00:00:00 2001 From: Milorad Filipovic Date: Fri, 3 Feb 2023 14:45:04 +0100 Subject: [PATCH 149/160] =?UTF-8?q?=E2=9A=A1=20Updating=20`keepFieldsConta?= =?UTF-8?q?ining`=20and=20`merge`=20object=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/workflow/src/Extensions/ObjectExtensions.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/workflow/src/Extensions/ObjectExtensions.ts b/packages/workflow/src/Extensions/ObjectExtensions.ts index 7b196dd2384b8..df98e1392d5fc 100644 --- a/packages/workflow/src/Extensions/ObjectExtensions.ts +++ b/packages/workflow/src/Extensions/ObjectExtensions.ts @@ -3,9 +3,15 @@ import type { ExtensionMap } from './Extensions'; export function merge(value: object, extraArgs: unknown[]): unknown { const [other] = extraArgs; - if (typeof other !== 'object' || !other) { + + if (!other) { + return value; + } + + if (typeof other !== 'object') { throw new ExpressionExtensionError('merge(): expected object arg'); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any const newObject: any = { ...value }; for (const [key, val] of Object.entries(other)) { @@ -65,7 +71,7 @@ function keepFieldsContaining(value: object, extraArgs: string[]): object { } const newObject = { ...value }; for (const [key, val] of Object.entries(value)) { - if (typeof val === 'string' && !val.includes(match)) { + if (typeof val !== 'string' || (typeof val === 'string' && !val.includes(match))) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any delete (newObject as any)[key]; } From 5cc4857256cb86473a42017811681cc652b378a2 Mon Sep 17 00:00:00 2001 From: Milorad Filipovic Date: Fri, 3 Feb 2023 16:53:40 +0100 Subject: [PATCH 150/160] =?UTF-8?q?=E2=9A=A1=20Using=20week=20as=20default?= =?UTF-8?q?=20for=20`date.extract()`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/workflow/src/Extensions/DateExtensions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow/src/Extensions/DateExtensions.ts b/packages/workflow/src/Extensions/DateExtensions.ts index 42c41cd959193..686f04e324e89 100644 --- a/packages/workflow/src/Extensions/DateExtensions.ts +++ b/packages/workflow/src/Extensions/DateExtensions.ts @@ -96,7 +96,7 @@ function endOfMonth(date: Date | DateTime): Date { } function extract(inputDate: Date | DateTime, extraArgs: DatePart[]): number | Date { - let [part] = extraArgs; + let [part = 'week'] = extraArgs; let date = inputDate; if (isDateTime(date)) { date = date.toJSDate(); From ba713e1b0c62fbe7b262f87f99089027616a5642 Mon Sep 17 00:00:00 2001 From: Milorad Filipovic Date: Fri, 3 Feb 2023 17:07:00 +0100 Subject: [PATCH 151/160] =?UTF-8?q?=E2=9C=85=20Adding=20more=20test=20case?= =?UTF-8?q?s=20for=20DT=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ArrayExtensions.test.ts | 24 +++++++++++++++++++ .../DateExtensions.test.ts | 5 ++++ .../ObjectExtensions.test.ts | 17 +++++++++++++ 3 files changed, 46 insertions(+) diff --git a/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts index 026b6e5951c11..fbd62432e4bc5 100644 --- a/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts @@ -54,6 +54,30 @@ describe('Data Transformation Functions', () => { ); }); + test('.pluck() should work return everything with no args', () => { + expect( + evaluate(`={{ [ + { value: 1, string: '1' }, + { value: 2, string: '2' }, + { value: 3, string: '3' }, + { value: 4, string: '4' }, + { value: 5, string: '5' }, + { value: 6, string: '6' }, + { value: { something: 'else' } } + ].pluck() }}`), + ).toEqual( + expect.arrayContaining([ + { value: 1, string: '1' }, + { value: 2, string: '2' }, + { value: 3, string: '3' }, + { value: 4, string: '4' }, + { value: 5, string: '5' }, + { value: 6, string: '6' }, + { value: { something: 'else' } } + ]), + ); + }); + test('.unique() should work correctly on an array', () => { expect(evaluate('={{ ["repeat","repeat","a","b","c"].unique() }}')).toEqual( expect.arrayContaining(['repeat', 'repeat', 'a', 'b', 'c']), diff --git a/packages/workflow/test/ExpressionExtensions/DateExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/DateExtensions.test.ts index 61252842e0c1e..917c20cb48870 100644 --- a/packages/workflow/test/ExpressionExtensions/DateExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/DateExtensions.test.ts @@ -57,6 +57,11 @@ describe('Data Transformation Functions', () => { expect(evaluate('={{ DateTime.local(2023, 1, 20).extract("day") }}')).toEqual(20); }); + test('.extract() should extract week for no args', () => { + expect(evaluate('={{ DateTime.local(2023, 1, 20).extract() }}')).toEqual(3); + }); + + test('.format("yyyy LLL dd") should work correctly on a date', () => { expect(evaluate('={{ DateTime.local(2023, 1, 16).format("yyyy LLL dd") }}')).toEqual( '2023 Jan 16', diff --git a/packages/workflow/test/ExpressionExtensions/ObjectExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/ObjectExtensions.test.ts index c5004cf4f0987..a34cb67a88c8e 100644 --- a/packages/workflow/test/ExpressionExtensions/ObjectExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/ObjectExtensions.test.ts @@ -15,6 +15,13 @@ describe('Data Transformation Functions', () => { }); }); + test('.merge should return whole object for no args', () => { + expect(evaluate('={{ ({ test1: 1, test2: 2 }).merge() }}')).toEqual({ + test1: 1, + test2: 2, + }); + }); + test('.hasField should work on an object', () => { expect(evaluate('={{ ({ test1: 1 }).hasField("test1") }}')).toEqual(true); expect(evaluate('={{ ({ test1: 1 }).hasField("test2") }}')).toEqual(false); @@ -62,6 +69,16 @@ describe('Data Transformation Functions', () => { }); }); + test('.keepFieldsContaining should work on a nested object', () => { + expect( + evaluate( + '={{ ({ test1: "i exist", test2: "i should be removed", test3: { test4: "me too" } }).keepFieldsContaining("exist") }}', + ), + ).toEqual({ + test1: 'i exist', + }); + }); + test('.keepFieldsContaining should not work for empty string', () => { expect( () => evaluate( From adc2a038f162e93555adc952a6b4432f19c1182b Mon Sep 17 00:00:00 2001 From: Milorad Filipovic Date: Mon, 13 Feb 2023 12:00:15 +0100 Subject: [PATCH 152/160] =?UTF-8?q?=E2=9A=A1=20Removing=20`Object.merge`?= =?UTF-8?q?=20extension=20function.=20Adding=20missing=20`deep-equal`=20de?= =?UTF-8?q?pendency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/workflow/package.json | 1 + .../src/Extensions/ArrayExtensions.ts | 28 ++++++++++-- .../src/Extensions/ObjectExtensions.ts | 28 ------------ pnpm-lock.yaml | 44 +++---------------- 4 files changed, 32 insertions(+), 69 deletions(-) diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 56049a9456990..e0813fe807644 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -55,6 +55,7 @@ "@n8n_io/riot-tmpl": "^2.0.0", "ast-types": "0.15.2", "crypto-js": "^4.1.1", + "deep-equal": "^2.2.0", "esprima-next": "5.8.4", "jmespath": "^0.16.0", "js-base64": "^3.7.2", diff --git a/packages/workflow/src/Extensions/ArrayExtensions.ts b/packages/workflow/src/Extensions/ArrayExtensions.ts index a711812b91aa5..35d262dd73f09 100644 --- a/packages/workflow/src/Extensions/ArrayExtensions.ts +++ b/packages/workflow/src/Extensions/ArrayExtensions.ts @@ -1,6 +1,6 @@ import { ExpressionError, ExpressionExtensionError } from '../ExpressionError'; import type { ExtensionMap } from './Extensions'; -import { compact as oCompact, merge as oMerge } from './ObjectExtensions'; +import { compact as oCompact } from './ObjectExtensions'; import deepEqual from 'deep-equal'; function first(value: unknown[]): unknown { @@ -211,6 +211,28 @@ function renameKeys(value: unknown[], extraArgs: string[]): unknown[] { }); } +function mergeObjects(value: object, extraArgs: unknown[]): unknown { + const [other] = extraArgs; + + if (!other) { + return value; + } + + if (typeof other !== 'object') { + throw new ExpressionExtensionError('merge(): expected object arg'); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const newObject: any = { ...value }; + for (const [key, val] of Object.entries(other)) { + if (!(key in newObject)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + newObject[key] = val; + } + } + return newObject; +} + function merge(value: unknown[], extraArgs: unknown[][]): unknown { const [others] = extraArgs; @@ -218,7 +240,7 @@ function merge(value: unknown[], extraArgs: unknown[][]): unknown { // If there are no arguments passed, merge all objects within the array const merged = value.reduce((combined, current) => { if (current !== null && typeof current === 'object' && !Array.isArray(current)) { - combined = oMerge(combined as object, [current]); + combined = mergeObjects(combined as object, [current]); } return combined; }, {}); @@ -235,7 +257,7 @@ function merge(value: unknown[], extraArgs: unknown[][]): unknown { for (let i = 0; i < listLength; i++) { if (value[i] !== undefined) { if (typeof value[i] === 'object' && typeof others[i] === 'object') { - merged = Object.assign(merged, oMerge(value[i] as object, [others[i]])); + merged = Object.assign(merged, mergeObjects(value[i] as object, [others[i]])); } } } diff --git a/packages/workflow/src/Extensions/ObjectExtensions.ts b/packages/workflow/src/Extensions/ObjectExtensions.ts index df98e1392d5fc..22523a1181754 100644 --- a/packages/workflow/src/Extensions/ObjectExtensions.ts +++ b/packages/workflow/src/Extensions/ObjectExtensions.ts @@ -1,28 +1,6 @@ import { ExpressionExtensionError } from '../ExpressionError'; import type { ExtensionMap } from './Extensions'; -export function merge(value: object, extraArgs: unknown[]): unknown { - const [other] = extraArgs; - - if (!other) { - return value; - } - - if (typeof other !== 'object') { - throw new ExpressionExtensionError('merge(): expected object arg'); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const newObject: any = { ...value }; - for (const [key, val] of Object.entries(other)) { - if (!(key in newObject)) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - newObject[key] = val; - } - } - return newObject; -} - function isEmpty(value: object): boolean { return Object.keys(value).length === 0; } @@ -128,11 +106,6 @@ urlEncode.doc = { // @TODO_NEXT_PHASE: Surface extensions below which take args -merge.doc = { - name: 'merge', - returnType: 'object', -}; - hasField.doc = { name: 'hasField', returnType: 'boolean', @@ -158,7 +131,6 @@ export const objectExtensions: ExtensionMap = { functions: { isEmpty, isNotEmpty, - merge, hasField, removeField, removeFieldsContaining, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0f39178ce110..c86987dfb04f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -937,6 +937,7 @@ importers: '@types/xml2js': ^0.4.3 ast-types: 0.15.2 crypto-js: ^4.1.1 + deep-equal: ^2.2.0 esprima-next: 5.8.4 jmespath: ^0.16.0 js-base64: ^3.7.2 @@ -953,6 +954,7 @@ importers: '@n8n_io/riot-tmpl': 2.0.0 ast-types: 0.15.2 crypto-js: 4.1.1 + deep-equal: 2.2.0 esprima-next: 5.8.4 jmespath: 0.16.0 js-base64: 3.7.2 @@ -10748,14 +10750,14 @@ packages: has: 1.0.3 has-property-descriptors: 1.0.0 has-symbols: 1.0.3 - internal-slot: 1.0.3 + internal-slot: 1.0.4 is-callable: 1.2.7 is-negative-zero: 2.0.2 is-regex: 1.1.4 is-shared-array-buffer: 1.0.2 is-string: 1.0.7 is-weakref: 1.0.2 - object-inspect: 1.12.2 + object-inspect: 1.12.3 object-keys: 1.1.1 object.assign: 4.1.4 regexp.prototype.flags: 1.4.3 @@ -13243,14 +13245,6 @@ packages: through: 2.3.8 dev: false - /internal-slot/1.0.3: - resolution: {integrity: sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==} - engines: {node: '>= 0.4'} - dependencies: - get-intrinsic: 1.1.3 - has: 1.0.3 - side-channel: 1.0.4 - /internal-slot/1.0.4: resolution: {integrity: sha512-tA8URYccNzMo94s5MQZgH8NB/XTa6HsOo0MLfXTKKEnHVVdegzaQoFZ7Jp44bdvLvY2waT5dc+j5ICEswhi7UQ==} engines: {node: '>= 0.4'} @@ -13723,17 +13717,6 @@ packages: gopd: 1.0.1 has-tostringtag: 1.0.0 - /is-typed-array/1.1.9: - resolution: {integrity: sha512-kfrlnTTn8pZkfpJMUgYD7YZ3qzeJgWUn8XfVYBARc4wnmNOmLbmuuaAs3q5fvB0UJOn6yHAKaGTPM7d6ezoD/A==} - engines: {node: '>= 0.4'} - dependencies: - available-typed-arrays: 1.0.5 - call-bind: 1.0.2 - es-abstract: 1.20.4 - for-each: 0.3.3 - has-tostringtag: 1.0.0 - dev: false - /is-typedarray/1.0.0: resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} @@ -16629,9 +16612,6 @@ packages: kind-of: 3.2.2 dev: true - /object-inspect/1.12.2: - resolution: {integrity: sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==} - /object-inspect/1.12.3: resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} @@ -21600,9 +21580,9 @@ packages: inherits: 2.0.4 is-arguments: 1.1.1 is-generator-function: 1.0.10 - is-typed-array: 1.1.9 + is-typed-array: 1.1.10 safe-buffer: 5.2.1 - which-typed-array: 1.1.8 + which-typed-array: 1.1.9 dev: false /util/0.12.5: @@ -22627,18 +22607,6 @@ packages: resolution: {integrity: sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==} dev: true - /which-typed-array/1.1.8: - resolution: {integrity: sha512-Jn4e5PItbcAHyLoRDwvPj1ypu27DJbtdYXUa5zsinrUx77Uvfb0cXwwnGMTn7cjUfhhqgVQnVJCwF+7cgU7tpw==} - engines: {node: '>= 0.4'} - dependencies: - available-typed-arrays: 1.0.5 - call-bind: 1.0.2 - es-abstract: 1.20.4 - for-each: 0.3.3 - has-tostringtag: 1.0.0 - is-typed-array: 1.1.9 - dev: false - /which-typed-array/1.1.9: resolution: {integrity: sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==} engines: {node: '>= 0.4'} From d70227b2ac552295fbd72f81158f37e707ed4209 Mon Sep 17 00:00:00 2001 From: Milorad Filipovic Date: Mon, 13 Feb 2023 13:23:47 +0100 Subject: [PATCH 153/160] =?UTF-8?q?=E2=9A=A1=20Handling=20`toDate`=20case?= =?UTF-8?q?=20when=20time=20component=20is=20not=20specified?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/workflow/src/Extensions/StringExtensions.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 125a61eeb0350..f7955de83db48 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -118,7 +118,10 @@ function toDate(value: string): Date { if (date.toString() === 'Invalid Date') { throw new ExpressionError.ExpressionExtensionError('cannot convert to date'); } - + // If time component is not specified, force 00:00h + if (!/:/.test(value)) { + date.setHours(0, 0, 0); + } return date; } From 5cddb1a8f68d55f4f1f35389a0f73f3be276fb89 Mon Sep 17 00:00:00 2001 From: Milorad Filipovic Date: Mon, 13 Feb 2023 15:41:40 +0100 Subject: [PATCH 154/160] =?UTF-8?q?=E2=9A=A1=20Using=20workflow's=20timezo?= =?UTF-8?q?ne=20to=20render=20dates=20in=20output=20panel,=20updated=20uni?= =?UTF-8?q?t=20tests=20after=20removing=20`Object.merge`=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/editor-ui/src/components/RunDataJson.vue | 14 +++++++++++--- .../editor-ui/src/components/RunDataTable.vue | 7 ++++++- packages/editor-ui/src/utils/typesUtils.ts | 12 ++++++++++++ .../ExpressionExtensions/ObjectExtensions.test.ts | 15 --------------- 4 files changed, 29 insertions(+), 19 deletions(-) diff --git a/packages/editor-ui/src/components/RunDataJson.vue b/packages/editor-ui/src/components/RunDataJson.vue index 15a6c0289ca0a..c1d003f88d9ce 100644 --- a/packages/editor-ui/src/components/RunDataJson.vue +++ b/packages/editor-ui/src/components/RunDataJson.vue @@ -73,13 +73,14 @@ import VueJsonPretty from 'vue-json-pretty'; import { LOCAL_STORAGE_MAPPING_FLAG } from '@/constants'; import { IDataObject, INodeExecutionData } from 'n8n-workflow'; import Draggable from '@/components/Draggable.vue'; -import { convertPath, executionDataToJson, isString, shorten } from '@/utils'; +import { parseDate, executionDataToJson, isString, shorten } from '@/utils'; import { INodeUi } from '@/Interface'; import { externalHooks } from '@/mixins/externalHooks'; import { mapStores } from 'pinia'; import { useNDVStore } from '@/stores/ndv'; import MappingPill from './MappingPill.vue'; import { getMappedExpression } from '@/utils/mappingUtils'; +import { useWorkflowsStore } from '@/stores/workflows'; const runDataJsonActions = () => import('@/components/RunDataJsonActions.vue'); @@ -149,7 +150,10 @@ export default mixins(externalHooks).extend({ } }, computed: { - ...mapStores(useNDVStore), + ...mapStores( + useNDVStore, + useWorkflowsStore, + ), jsonData(): IDataObject[] { return executionDataToJson(this.inputData); }, @@ -209,7 +213,11 @@ export default mixins(externalHooks).extend({ }, 1000); // ensure dest data gets set if drop }, getContent(value: unknown): string { - return isString(value) ? `"${value}"` : JSON.stringify(value); + if (isString(value)) { + const parsedDate = parseDate(value); + return parsedDate ? parsedDate.toString() : `"${value}"`; + } + return JSON.stringify(value); }, getListItemName(path: string): string { return path.replace(/^(\["?\d"?]\.?)/g, ''); diff --git a/packages/editor-ui/src/components/RunDataTable.vue b/packages/editor-ui/src/components/RunDataTable.vue index 3f2c1e5b7c5b0..4241153d94a1b 100644 --- a/packages/editor-ui/src/components/RunDataTable.vue +++ b/packages/editor-ui/src/components/RunDataTable.vue @@ -159,7 +159,7 @@