From 277511a9b500314f2513998ca8e88775db981186 Mon Sep 17 00:00:00 2001 From: oleg Date: Tue, 21 May 2024 12:07:04 +0200 Subject: [PATCH 1/9] refactor(editor): Refactor code completions mixins to composables (no-changelog) (#9459) Signed-off-by: Oleg Ivaniv --- .../fixtures/Multiple_trigger_node_rerun.json | 6 +- .../components/CodeNodeEditor/completer.ts | 136 +++-- .../completions/base.completions.ts | 294 +++++----- .../completions/execution.completions.ts | 138 +++-- .../completions/itemField.completions.ts | 286 ++++----- .../completions/itemIndex.completions.ts | 267 ++++----- .../completions/jsonField.completions.ts | 548 +++++++++--------- .../completions/luxon.completions.ts | 153 ++--- .../completions/prevNode.completions.ts | 75 ++- .../completions/require.completions.ts | 73 ++- .../completions/secrets.completions.ts | 99 ++-- .../completions/variables.completions.ts | 57 +- .../completions/workflow.completions.ts | 78 +-- .../__tests__/base.completions.test.ts | 130 +++++ .../__tests__/itemField.completions.test.ts | 105 ++++ .../__tests__/itemIndex.completions.test.ts | 55 ++ .../__tests__/jsonField.completions.test.ts | 108 ++++ .../__tests__/prevNode.completions.test.ts | 56 ++ .../__tests__/require.completions.test.ts | 126 ++++ .../__tests__/variables.completions.test.ts | 64 ++ 20 files changed, 1771 insertions(+), 1083 deletions(-) create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/base.completions.test.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/itemField.completions.test.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/itemIndex.completions.test.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/jsonField.completions.test.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/prevNode.completions.test.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/require.completions.test.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/variables.completions.test.ts diff --git a/cypress/fixtures/Multiple_trigger_node_rerun.json b/cypress/fixtures/Multiple_trigger_node_rerun.json index 39d231a894c5c..f956be3742f7a 100644 --- a/cypress/fixtures/Multiple_trigger_node_rerun.json +++ b/cypress/fixtures/Multiple_trigger_node_rerun.json @@ -14,7 +14,7 @@ }, { "parameters": { - "url": "https://random-data-api.com/api/v2/users?size=5", + "url": "https://internal.users.n8n.cloud/webhook/random-data-api", "options": {} }, "id": "22511d75-ab54-49e1-b8af-08b8b3372373", @@ -28,7 +28,7 @@ }, { "parameters": { - "jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.first_name_reversed = item.json = {\n firstName: item.json.first_name,\n firstnNameReversed: item.json.first_name_BUG.split(\"\").reverse().join(\"\")\n };\n}\n\nreturn $input.all();" + "jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.first_name_reversed = item.json = {\n firstName: item.json.firstname,\n firstnNameReversed: item.json.firstname.split(\"\").reverse().join(\"\")\n };\n}\n\nreturn $input.all();" }, "id": "4b66b15a-1685-46c1-a5e3-ebf8cdb11d21", "name": "do something with them", @@ -130,4 +130,4 @@ }, "id": "PymcwIrbqgNh3O0K", "tags": [] -} \ No newline at end of file +} diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completer.ts b/packages/editor-ui/src/components/CodeNodeEditor/completer.ts index 6cb2866a7565b..05e85f4cb937b 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completer.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completer.ts @@ -1,36 +1,57 @@ +import type { PropType } from 'vue'; import { defineComponent } from 'vue'; import { autocompletion } from '@codemirror/autocomplete'; import { localCompletionSource } from '@codemirror/lang-javascript'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import type { Extension } from '@codemirror/state'; -import { baseCompletions } from './completions/base.completions'; +import { useBaseCompletions } from './completions/base.completions'; import { jsSnippets } from './completions/js.snippets'; -import { requireCompletions } from './completions/require.completions'; -import { executionCompletions } from './completions/execution.completions'; -import { workflowCompletions } from './completions/workflow.completions'; -import { prevNodeCompletions } from './completions/prevNode.completions'; -import { luxonCompletions } from './completions/luxon.completions'; -import { itemIndexCompletions } from './completions/itemIndex.completions'; -import { itemFieldCompletions } from './completions/itemField.completions'; -import { jsonFieldCompletions } from './completions/jsonField.completions'; -import { variablesCompletions } from './completions/variables.completions'; + +import type { CodeExecutionMode } from 'n8n-workflow'; +import { CODE_EXECUTION_MODES } from 'n8n-workflow'; +import { useExecutionCompletions } from './completions/execution.completions'; +import { useItemFieldCompletions } from './completions/itemField.completions'; +import { useItemIndexCompletions } from './completions/itemIndex.completions'; +import { useJsonFieldCompletions } from './completions/jsonField.completions'; +import { useLuxonCompletions } from './completions/luxon.completions'; +import { usePrevNodeCompletions } from './completions/prevNode.completions'; +import { useRequireCompletions } from './completions/require.completions'; +import { useVariablesCompletions } from './completions/variables.completions'; +import { useWorkflowCompletions } from './completions/workflow.completions'; +import type { EditorView } from '@codemirror/view'; export const completerExtension = defineComponent({ - mixins: [ - baseCompletions, - requireCompletions, - executionCompletions, - workflowCompletions, - variablesCompletions, - prevNodeCompletions, - luxonCompletions, - itemIndexCompletions, - itemFieldCompletions, - jsonFieldCompletions, - ], + props: { + mode: { + type: String as PropType, + validator: (value: CodeExecutionMode): boolean => CODE_EXECUTION_MODES.includes(value), + required: true, + }, + }, + data() { + return { + editor: null as EditorView | null, + }; + }, methods: { autocompletionExtension(language: 'javaScript' | 'python'): Extension { + // Base completions + const { baseCompletions, itemCompletions, nodeSelectorCompletions } = useBaseCompletions( + this.mode, + language, + ); + const { executionCompletions } = useExecutionCompletions(); + const { inputMethodCompletions, selectorMethodCompletions } = + useItemFieldCompletions(language); + const { inputCompletions, selectorCompletions } = useItemIndexCompletions(this.mode); + const { inputJsonFieldCompletions, selectorJsonFieldCompletions } = useJsonFieldCompletions(); + const { dateTimeCompletions, nowCompletions, todayCompletions } = useLuxonCompletions(); + const { prevNodeCompletions } = usePrevNodeCompletions(); + const { requireCompletions } = useRequireCompletions(); + const { variablesCompletions } = useVariablesCompletions(); + const { workflowCompletions } = useWorkflowCompletions(); + const completions = []; if (language === 'javaScript') { completions.push(jsSnippets, localCompletionSource); @@ -47,31 +68,31 @@ export const completerExtension = defineComponent({ ...completions, // core - this.itemCompletions, - this.baseCompletions, - this.requireCompletions, - this.nodeSelectorCompletions, - this.prevNodeCompletions, - this.workflowCompletions, - this.variablesCompletions, - this.executionCompletions, + itemCompletions, + baseCompletions, + requireCompletions, + nodeSelectorCompletions, + prevNodeCompletions, + workflowCompletions, + variablesCompletions, + executionCompletions, // luxon - this.todayCompletions, - this.nowCompletions, - this.dateTimeCompletions, + todayCompletions, + nowCompletions, + dateTimeCompletions, // item index - this.inputCompletions, - this.selectorCompletions, + inputCompletions, + selectorCompletions, // item field - this.inputMethodCompletions, - this.selectorMethodCompletions, + inputMethodCompletions, + selectorMethodCompletions, // item json field - this.inputJsonFieldCompletions, - this.selectorJsonFieldCompletions, + inputJsonFieldCompletions, + selectorJsonFieldCompletions, // multiline this.multilineCompletions, @@ -109,17 +130,17 @@ export const completerExtension = defineComponent({ const varNames = Object.keys(variablesToValues); const uses = this.extendedUses(docLines, varNames); - + const { matcherItemFieldCompletions } = useItemFieldCompletions('javaScript'); for (const use of uses.itemField) { const matcher = use.replace(/\.$/, ''); - const completions = this.matcherItemFieldCompletions(context, matcher, variablesToValues); + const completions = matcherItemFieldCompletions(context, matcher, variablesToValues); if (completions) return completions; } for (const use of uses.jsonField) { const matcher = use.replace(/(\.|\[)$/, ''); - const completions = this.matcherJsonFieldCompletions(context, matcher, variablesToValues); + const completions = matcherItemFieldCompletions(context, matcher, variablesToValues); if (completions) return completions; } @@ -168,25 +189,32 @@ export const completerExtension = defineComponent({ all: /\$\((?['"][\w\s]+['"])\)\.all\(\)\[(?\w+)\]\.json$/, }); + const { executionCompletions } = useExecutionCompletions(); + const { inputCompletions, selectorCompletions } = useItemIndexCompletions(this.mode); + const { matcherJsonFieldCompletions } = useJsonFieldCompletions(); + const { dateTimeCompletions, nowCompletions, todayCompletions } = useLuxonCompletions(); + const { variablesCompletions } = useVariablesCompletions(); + const { workflowCompletions } = useWorkflowCompletions(); + for (const [variable, value] of Object.entries(variablesToValues)) { - // core + const { prevNodeCompletions } = usePrevNodeCompletions(variable); - if (value === '$execution') return this.executionCompletions(context, variable); - if (value === '$vars') return this.variablesCompletions(context, variable); + if (value === '$execution') return executionCompletions(context, variable); + if (value === '$vars') return variablesCompletions(context, variable); - if (value === '$workflow') return this.workflowCompletions(context, variable); - if (value === '$prevNode') return this.prevNodeCompletions(context, variable); + if (value === '$workflow') return workflowCompletions(context, variable); + if (value === '$prevNode') return prevNodeCompletions(context); // luxon - if (value === '$now') return this.nowCompletions(context, variable); - if (value === '$today') return this.todayCompletions(context, variable); - if (value === 'DateTime') return this.dateTimeCompletions(context, variable); + if (value === '$now') return nowCompletions(context, variable); + if (value === '$today') return todayCompletions(context, variable); + if (value === 'DateTime') return dateTimeCompletions(context, variable); // item index - if (value === '$input') return this.inputCompletions(context, variable); - if (SELECTOR_REGEX.test(value)) return this.selectorCompletions(context, variable); + if (value === '$input') return inputCompletions(context, variable); + if (SELECTOR_REGEX.test(value)) return selectorCompletions(context, variable); // json field @@ -194,7 +222,7 @@ export const completerExtension = defineComponent({ const selectorJsonMatched = SELECTOR_JSON_REGEXES.some((regex) => regex.test(value)); if (inputJsonMatched || selectorJsonMatched) { - return this.matcherJsonFieldCompletions(context, variable, variablesToValues); + return matcherJsonFieldCompletions(context, variable, variablesToValues); } // item field @@ -203,7 +231,7 @@ export const completerExtension = defineComponent({ const selectorMethodMatched = SELECTOR_METHOD_REGEXES.some((regex) => regex.test(value)); if (inputMethodMatched || selectorMethodMatched) { - return this.matcherItemFieldCompletions(context, variable, variablesToValues); + return matcherItemFieldCompletions(context, variable, variablesToValues); } } diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts index ed8687616bbd3..8c19880f485e4 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts @@ -1,11 +1,10 @@ -import { defineComponent } from 'vue'; import { NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION } from '../constants'; import { addVarType } from '../utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import type { INodeUi } from '@/Interface'; -import { mapStores } from 'pinia'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { escapeMappingString } from '@/utils/mappingUtils'; +import { useI18n } from '@/composables/useI18n'; function getAutoCompletableNodeNames(nodes: INodeUi[]) { return nodes @@ -13,149 +12,156 @@ function getAutoCompletableNodeNames(nodes: INodeUi[]) { .map((node: INodeUi) => node.name); } -export const baseCompletions = defineComponent({ - computed: { - ...mapStores(useWorkflowsStore), - }, - methods: { - itemCompletions(context: CompletionContext): CompletionResult | null { - const preCursor = context.matchBefore(/i\w*/); - - if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - - const options: Completion[] = []; - - if (this.mode === 'runOnceForEachItem') { - options.push({ - label: 'item', - info: this.$locale.baseText('codeNodeEditor.completer.$input.item'), - }); - } else if (this.mode === 'runOnceForAllItems') { - options.push({ - label: 'items', - info: this.$locale.baseText('codeNodeEditor.completer.$input.all'), - }); - } - - return { - from: preCursor.from, - options, - }; - }, - - /** - * - Complete `$` to `$execution $input $prevNode $runIndex $workflow $now $today - * $jmespath $ifEmpt $('nodeName')` in both modes. - * - Complete `$` to `$json $binary $itemIndex` in single-item mode. - */ - baseCompletions(context: CompletionContext): CompletionResult | null { - const prefix = this.language === 'python' ? '_' : '$'; - const preCursor = context.matchBefore(new RegExp(`\\${prefix}\\w*`)); - - if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - - const TOP_LEVEL_COMPLETIONS_IN_BOTH_MODES: Completion[] = [ +export function useBaseCompletions( + mode: 'runOnceForEachItem' | 'runOnceForAllItems', + language: string, +) { + const i18n = useI18n(); + const workflowsStore = useWorkflowsStore(); + + const itemCompletions = (context: CompletionContext): CompletionResult | null => { + const preCursor = context.matchBefore(/i\w*/); + + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; + + const options: Completion[] = []; + + if (mode === 'runOnceForEachItem') { + options.push({ + label: 'item', + info: i18n.baseText('codeNodeEditor.completer.$input.item'), + }); + } else if (mode === 'runOnceForAllItems') { + options.push({ + label: 'items', + info: i18n.baseText('codeNodeEditor.completer.$input.all'), + }); + } + + return { + from: preCursor.from, + options, + }; + }; + + /** + * - Complete `$` to `$execution $input $prevNode $runIndex $workflow $now $today + * $jmespath $ifEmpt $('nodeName')` in both modes. + * - Complete `$` to `$json $binary $itemIndex` in single-item mode. + */ + const baseCompletions = (context: CompletionContext): CompletionResult | null => { + const prefix = language === 'python' ? '_' : '$'; + const preCursor = context.matchBefore(new RegExp(`\\${prefix}\\w*`)); + + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; + + const TOP_LEVEL_COMPLETIONS_IN_BOTH_MODES: Completion[] = [ + { + label: `${prefix}execution`, + info: i18n.baseText('codeNodeEditor.completer.$execution'), + }, + { + label: `${prefix}ifEmpty()`, + info: i18n.baseText('codeNodeEditor.completer.$ifEmpty'), + }, + { label: `${prefix}input`, info: i18n.baseText('codeNodeEditor.completer.$input') }, + { + label: `${prefix}prevNode`, + info: i18n.baseText('codeNodeEditor.completer.$prevNode'), + }, + { + label: `${prefix}workflow`, + info: i18n.baseText('codeNodeEditor.completer.$workflow'), + }, + { + label: `${prefix}vars`, + info: i18n.baseText('codeNodeEditor.completer.$vars'), + }, + { + label: `${prefix}now`, + info: i18n.baseText('codeNodeEditor.completer.$now'), + }, + { + label: `${prefix}today`, + info: i18n.baseText('codeNodeEditor.completer.$today'), + }, + { + label: `${prefix}jmespath()`, + info: i18n.baseText('codeNodeEditor.completer.$jmespath'), + }, + { + label: `${prefix}runIndex`, + info: i18n.baseText('codeNodeEditor.completer.$runIndex'), + }, + { + label: `${prefix}nodeVersion`, + info: i18n.baseText('codeNodeEditor.completer.$nodeVersion'), + }, + ]; + + const options: Completion[] = TOP_LEVEL_COMPLETIONS_IN_BOTH_MODES.map(addVarType); + + options.push( + ...getAutoCompletableNodeNames(workflowsStore.allNodes).map((nodeName) => { + return { + label: `${prefix}('${escapeMappingString(nodeName)}')`, + type: 'variable', + info: i18n.baseText('codeNodeEditor.completer.$()', { + interpolate: { nodeName }, + }), + }; + }), + ); + + if (mode === 'runOnceForEachItem') { + const TOP_LEVEL_COMPLETIONS_IN_SINGLE_ITEM_MODE = [ + { label: `${prefix}json` }, + { label: `${prefix}binary` }, { - label: `${prefix}execution`, - info: this.$locale.baseText('codeNodeEditor.completer.$execution'), - }, - { - label: `${prefix}ifEmpty()`, - info: this.$locale.baseText('codeNodeEditor.completer.$ifEmpty'), - }, - { label: `${prefix}input`, info: this.$locale.baseText('codeNodeEditor.completer.$input') }, - { - label: `${prefix}prevNode`, - info: this.$locale.baseText('codeNodeEditor.completer.$prevNode'), - }, - { - label: `${prefix}workflow`, - info: this.$locale.baseText('codeNodeEditor.completer.$workflow'), - }, - { - label: `${prefix}vars`, - info: this.$locale.baseText('codeNodeEditor.completer.$vars'), - }, - { - label: `${prefix}now`, - info: this.$locale.baseText('codeNodeEditor.completer.$now'), - }, - { - label: `${prefix}today`, - info: this.$locale.baseText('codeNodeEditor.completer.$today'), - }, - { - label: `${prefix}jmespath()`, - info: this.$locale.baseText('codeNodeEditor.completer.$jmespath'), - }, - { - label: `${prefix}runIndex`, - info: this.$locale.baseText('codeNodeEditor.completer.$runIndex'), - }, - { - label: `${prefix}nodeVersion`, - info: this.$locale.baseText('codeNodeEditor.completer.$nodeVersion'), + label: `${prefix}itemIndex`, + info: i18n.baseText('codeNodeEditor.completer.$itemIndex'), }, ]; - const options: Completion[] = TOP_LEVEL_COMPLETIONS_IN_BOTH_MODES.map(addVarType); - - options.push( - ...getAutoCompletableNodeNames(this.workflowsStore.allNodes).map((nodeName) => { - return { - label: `${prefix}('${escapeMappingString(nodeName)}')`, - type: 'variable', - info: this.$locale.baseText('codeNodeEditor.completer.$()', { - interpolate: { nodeName }, - }), - }; - }), - ); - - if (this.mode === 'runOnceForEachItem') { - const TOP_LEVEL_COMPLETIONS_IN_SINGLE_ITEM_MODE = [ - { label: `${prefix}json` }, - { label: `${prefix}binary` }, - { - label: `${prefix}itemIndex`, - info: this.$locale.baseText('codeNodeEditor.completer.$itemIndex'), - }, - ]; - - options.push(...TOP_LEVEL_COMPLETIONS_IN_SINGLE_ITEM_MODE.map(addVarType)); - } - - return { - from: preCursor.from, - options, - }; - }, - - /** - * Complete `$(` to `$('nodeName')`. - */ - nodeSelectorCompletions(context: CompletionContext): CompletionResult | null { - const prefix = this.language === 'python' ? '_' : '$'; - const preCursor = context.matchBefore(new RegExp(`\\${prefix}\\(.*`)); - - if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - - const options: Completion[] = getAutoCompletableNodeNames(this.workflowsStore.allNodes).map( - (nodeName) => { - return { - label: `${prefix}('${escapeMappingString(nodeName)}')`, - type: 'variable', - info: this.$locale.baseText('codeNodeEditor.completer.$()', { - interpolate: { nodeName }, - }), - }; - }, - ); - - return { - from: preCursor.from, - options, - }; - }, - }, -}); + options.push(...TOP_LEVEL_COMPLETIONS_IN_SINGLE_ITEM_MODE.map(addVarType)); + } + + return { + from: preCursor.from, + options, + }; + }; + + /** + * Complete `$(` to `$('nodeName')`. + */ + const nodeSelectorCompletions = (context: CompletionContext): CompletionResult | null => { + const prefix = language === 'python' ? '_' : '$'; + const preCursor = context.matchBefore(new RegExp(`\\${prefix}\\(.*`)); + + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; + + const options: Completion[] = getAutoCompletableNodeNames(workflowsStore.allNodes).map( + (nodeName) => { + return { + label: `${prefix}('${escapeMappingString(nodeName)}')`, + type: 'variable', + info: i18n.baseText('codeNodeEditor.completer.$()', { + interpolate: { nodeName }, + }), + }; + }, + ); + + return { + from: preCursor.from, + options, + }; + }; + + return { + itemCompletions, + baseCompletions, + nodeSelectorCompletions, + }; +} diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts index 502221b48ff70..fcd229a86d920 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts @@ -1,78 +1,76 @@ -import { defineComponent } from 'vue'; import { addVarType, escape } from '../utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import { useI18n } from '@/composables/useI18n'; -export const executionCompletions = defineComponent({ - methods: { - /** - * Complete `$execution.` to `.id .mode .resumeUrl .resumeFormUrl` - */ - executionCompletions( - context: CompletionContext, - matcher = '$execution', - ): CompletionResult | null { - const pattern = new RegExp(`${escape(matcher)}\..*`); +export function useExecutionCompletions() { + const i18n = useI18n(); - const preCursor = context.matchBefore(pattern); + /** + * Complete `$execution.` to `.id .mode .resumeUrl .resumeFormUrl` + */ + const executionCompletions = ( + context: CompletionContext, + matcher = '$execution', + ): CompletionResult | null => { + const pattern = new RegExp(`${escape(matcher)}\..*`); - if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; + const preCursor = context.matchBefore(pattern); - const buildLinkNode = (text: string) => { - const wrapper = document.createElement('span'); - // This is being loaded from the locales file. This could - // cause an XSS of some kind but multiple other locales strings - // do the same thing. - wrapper.innerHTML = text; - return () => wrapper; - }; + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - const options: Completion[] = [ - { - label: `${matcher}.id`, - info: this.$locale.baseText('codeNodeEditor.completer.$execution.id'), - }, - { - label: `${matcher}.mode`, - info: this.$locale.baseText('codeNodeEditor.completer.$execution.mode'), - }, - { - label: `${matcher}.resumeUrl`, - info: this.$locale.baseText('codeNodeEditor.completer.$execution.resumeUrl'), - }, - { - label: `${matcher}.resumeFormUrl`, - info: this.$locale.baseText('codeNodeEditor.completer.$execution.resumeFormUrl'), - }, - { - label: `${matcher}.customData.set("key", "value")`, - info: buildLinkNode( - this.$locale.baseText('codeNodeEditor.completer.$execution.customData.set()'), - ), - }, - { - label: `${matcher}.customData.get("key")`, - info: buildLinkNode( - this.$locale.baseText('codeNodeEditor.completer.$execution.customData.get()'), - ), - }, - { - label: `${matcher}.customData.setAll({})`, - info: buildLinkNode( - this.$locale.baseText('codeNodeEditor.completer.$execution.customData.setAll()'), - ), - }, - { - label: `${matcher}.customData.getAll()`, - info: buildLinkNode( - this.$locale.baseText('codeNodeEditor.completer.$execution.customData.getAll()'), - ), - }, - ]; + const buildLinkNode = (text: string) => { + const wrapper = document.createElement('span'); + // This is being loaded from the locales file. This could + // cause an XSS of some kind but multiple other locales strings + // do the same thing. + wrapper.innerHTML = text; + return () => wrapper; + }; - return { - from: preCursor.from, - options: options.map(addVarType), - }; - }, - }, -}); + const options: Completion[] = [ + { + label: `${matcher}.id`, + info: i18n.baseText('codeNodeEditor.completer.$execution.id'), + }, + { + label: `${matcher}.mode`, + info: i18n.baseText('codeNodeEditor.completer.$execution.mode'), + }, + { + label: `${matcher}.resumeUrl`, + info: i18n.baseText('codeNodeEditor.completer.$execution.resumeUrl'), + }, + { + label: `${matcher}.resumeFormUrl`, + info: i18n.baseText('codeNodeEditor.completer.$execution.resumeFormUrl'), + }, + { + label: `${matcher}.customData.set("key", "value")`, + info: buildLinkNode(i18n.baseText('codeNodeEditor.completer.$execution.customData.set()')), + }, + { + label: `${matcher}.customData.get("key")`, + info: buildLinkNode(i18n.baseText('codeNodeEditor.completer.$execution.customData.get()')), + }, + { + label: `${matcher}.customData.setAll({})`, + info: buildLinkNode( + i18n.baseText('codeNodeEditor.completer.$execution.customData.setAll()'), + ), + }, + { + label: `${matcher}.customData.getAll()`, + info: buildLinkNode( + i18n.baseText('codeNodeEditor.completer.$execution.customData.getAll()'), + ), + }, + ]; + + return { + from: preCursor.from, + options: options.map(addVarType), + }; + }; + + return { executionCompletions }; +} diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/itemField.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/itemField.completions.ts index 728a3ba03b5ea..cd73580402a1e 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/itemField.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/itemField.completions.ts @@ -1,38 +1,94 @@ -import { defineComponent } from 'vue'; import { addVarType, escape } from '../utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import { useI18n } from '@/composables/useI18n'; + +export function useItemFieldCompletions(language: 'python' | 'javaScript') { + const i18n = useI18n(); + + /** + * - Complete `x.first().` to `.json .binary` + * - Complete `x.last().` to `.json .binary` + * - Complete `x.all()[index].` to `.json .binary` + * - Complete `x.item.` to `.json .binary`. + */ + const matcherItemFieldCompletions = ( + context: CompletionContext, + matcher: string, + variablesToValues: Record, + ): CompletionResult | null => { + const preCursor = context.matchBefore(new RegExp(`${escape(matcher)}\..*`)); + + if (!preCursor) return null; + + const [varName] = preCursor.text.split('.'); + + const originalValue = variablesToValues[varName]; + + if (!originalValue) return null; + + const options: Completion[] = [ + { + label: `${matcher}.json`, + info: i18n.baseText('codeNodeEditor.completer.json'), + }, + { + label: `${matcher}.binary`, + info: i18n.baseText('codeNodeEditor.completer.binary'), + }, + ]; + + return { + from: preCursor.from, + options: options.map(addVarType), + }; + }; + + /** + * - Complete `$input.first().` to `.json .binary`. + * - Complete `$input.last().` to `.json .binary`. + * - Complete `$input.all()[index].` to `.json .binary`. + * - Complete `$input.item.` to `.json .binary`. + */ + const inputMethodCompletions = (context: CompletionContext): CompletionResult | null => { + const prefix = language === 'python' ? '_' : '$'; + const patterns = { + first: new RegExp(`\\${prefix}input\\.first\\(\\)\\..*`), + last: new RegExp(`\\${prefix}input\\.last\\(\\)\\..*`), + item: new RegExp(`\\${prefix}item\\.first\\(\\)\\..*`), + all: /\$input\.all\(\)\[(?\w+)\]\..*/, + }; + + for (const [name, regex] of Object.entries(patterns)) { + const preCursor = context.matchBefore(regex); + + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) continue; + + let replacementBase = ''; + + if (name === 'item') replacementBase = `${prefix}input.item`; + + if (name === 'first') replacementBase = `${prefix}input.first()`; + + if (name === 'last') replacementBase = `${prefix}input.last()`; + + if (name === 'all') { + const match = preCursor.text.match(regex); -export const itemFieldCompletions = defineComponent({ - methods: { - /** - * - Complete `x.first().` to `.json .binary` - * - Complete `x.last().` to `.json .binary` - * - Complete `x.all()[index].` to `.json .binary` - * - Complete `x.item.` to `.json .binary`. - */ - matcherItemFieldCompletions( - context: CompletionContext, - matcher: string, - variablesToValues: Record, - ) { - const preCursor = context.matchBefore(new RegExp(`${escape(matcher)}\..*`)); - - if (!preCursor) return null; - - const [varName] = preCursor.text.split('.'); + if (!match?.groups?.index) continue; - const originalValue = variablesToValues[varName]; + const { index } = match.groups; - if (!originalValue) return null; + replacementBase = `${prefix}input.all()[${index}]`; + } const options: Completion[] = [ { - label: `${matcher}.json`, - info: this.$locale.baseText('codeNodeEditor.completer.json'), + label: `${replacementBase}.json`, + info: i18n.baseText('codeNodeEditor.completer.json'), }, { - label: `${matcher}.binary`, - info: this.$locale.baseText('codeNodeEditor.completer.binary'), + label: `${replacementBase}.binary`, + info: i18n.baseText('codeNodeEditor.completer.binary'), }, ]; @@ -40,130 +96,80 @@ export const itemFieldCompletions = defineComponent({ from: preCursor.from, options: options.map(addVarType), }; - }, - - /** - * - Complete `$input.first().` to `.json .binary`. - * - Complete `$input.last().` to `.json .binary`. - * - Complete `$input.all()[index].` to `.json .binary`. - * - Complete `$input.item.` to `.json .binary`. - */ - inputMethodCompletions(context: CompletionContext): CompletionResult | null { - const prefix = this.language === 'python' ? '_' : '$'; - const patterns = { - first: new RegExp(`\\${prefix}input\\.first\\(\\)\\..*`), - last: new RegExp(`\\${prefix}input\\.last\\(\\)\\..*`), - item: new RegExp(`\\${prefix}item\\.first\\(\\)\\..*`), - all: /\$input\.all\(\)\[(?\w+)\]\..*/, - }; - - for (const [name, regex] of Object.entries(patterns)) { - const preCursor = context.matchBefore(regex); - - if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) continue; - - let replacementBase = ''; - - if (name === 'item') replacementBase = `${prefix}input.item`; - - if (name === 'first') replacementBase = `${prefix}input.first()`; - - if (name === 'last') replacementBase = `${prefix}input.last()`; - - if (name === 'all') { - const match = preCursor.text.match(regex); - - if (!match?.groups?.index) continue; - - const { index } = match.groups; - - replacementBase = `${prefix}input.all()[${index}]`; - } - - const options: Completion[] = [ - { - label: `${replacementBase}.json`, - info: this.$locale.baseText('codeNodeEditor.completer.json'), - }, - { - label: `${replacementBase}.binary`, - info: this.$locale.baseText('codeNodeEditor.completer.binary'), - }, - ]; - - return { - from: preCursor.from, - options: options.map(addVarType), - }; + } + + return null; + }; + + /** + * - Complete `$('nodeName').first().` to `.json .binary`. + * - Complete `$('nodeName').last().` to `.json .binary`. + * - Complete `$('nodeName').all()[index].` to `.json .binary`. + * - Complete `$('nodeName').item.` to `.json .binary`. + */ + const selectorMethodCompletions = ( + context: CompletionContext, + matcher: string | null = null, + ): CompletionResult | null => { + const patterns = { + first: /\$\((?['"][\w\s]+['"])\)\.first\(\)\..*/, + last: /\$\((?['"][\w\s]+['"])\)\.last\(\)\..*/, + item: /\$\((?['"][\w\s]+['"])\)\.item\..*/, + all: /\$\((?['"][\w\s]+['"])\)\.all\(\)\[(?\w+)\]\..*/, + }; + + for (const [name, regex] of Object.entries(patterns)) { + const preCursor = context.matchBefore(regex); + + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) continue; + + const match = preCursor.text.match(regex); + + let start = ''; + + if (!matcher && match?.groups?.quotedNodeName) { + start = `$(${match.groups.quotedNodeName})`; } - return null; - }, - - /** - * - Complete `$('nodeName').first().` to `.json .binary`. - * - Complete `$('nodeName').last().` to `.json .binary`. - * - Complete `$('nodeName').all()[index].` to `.json .binary`. - * - Complete `$('nodeName').item.` to `.json .binary`. - */ - selectorMethodCompletions( - context: CompletionContext, - matcher: string | null = null, - ): CompletionResult | null { - const patterns = { - first: /\$\((?['"][\w\s]+['"])\)\.first\(\)\..*/, - last: /\$\((?['"][\w\s]+['"])\)\.last\(\)\..*/, - item: /\$\((?['"][\w\s]+['"])\)\.item\..*/, - all: /\$\((?['"][\w\s]+['"])\)\.all\(\)\[(?\w+)\]\..*/, - }; + let replacementBase = ''; - for (const [name, regex] of Object.entries(patterns)) { - const preCursor = context.matchBefore(regex); + if (name === 'item') replacementBase = `${start}.item`; - if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) continue; + if (name === 'first') replacementBase = `${start}.first()`; - const match = preCursor.text.match(regex); + if (name === 'last') replacementBase = `${start}.last()`; - let start = ''; - - if (!matcher && match?.groups?.quotedNodeName) { - start = `$(${match.groups.quotedNodeName})`; - } - - let replacementBase = ''; - - if (name === 'item') replacementBase = `${start}.item`; - - if (name === 'first') replacementBase = `${start}.first()`; - - if (name === 'last') replacementBase = `${start}.last()`; + if (name === 'all') { + const match = preCursor.text.match(regex); - if (name === 'all') { - const match = preCursor.text.match(regex); + if (!match?.groups?.index) continue; - if (!match?.groups?.index) continue; + replacementBase = `${start}.all()[${match.groups.index}]`; + } - replacementBase = `${start}.all()[${match.groups.index}]`; - } + const options: Completion[] = [ + { + label: `${replacementBase}.json`, + info: i18n.baseText('codeNodeEditor.completer.json'), + }, + { + label: `${replacementBase}.binary`, + info: i18n.baseText('codeNodeEditor.completer.binary'), + }, + ]; - const options: Completion[] = [ - { - label: `${replacementBase}.json`, - info: this.$locale.baseText('codeNodeEditor.completer.json'), - }, - { - label: `${replacementBase}.binary`, - info: this.$locale.baseText('codeNodeEditor.completer.binary'), - }, - ]; + return { + from: preCursor.from, + options: options.map(addVarType), + }; + } - return { - from: preCursor.from, - options: options.map(addVarType), - }; - } + return null; + }; - return null; - }, - }, -}); + return { + matcherItemFieldCompletions, + inputMethodCompletions, + selectorMethodCompletions, + }; +} diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/itemIndex.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/itemIndex.completions.ts index e31942a75b6b8..f4e980cec018b 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/itemIndex.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/itemIndex.completions.ts @@ -1,136 +1,143 @@ import { escape } from '../utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; -import { defineComponent } from 'vue'; - -export const itemIndexCompletions = defineComponent({ - methods: { - /** - * - Complete `$input.` to `.first() .last() .all() .itemMatching()` in all-items mode. - * - Complete `$input.` to `.item` in single-item mode. - */ - inputCompletions(context: CompletionContext, matcher = '$input'): CompletionResult | null { - const pattern = new RegExp(`${escape(matcher)}\..*`); - - const preCursor = context.matchBefore(pattern); - - if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - - const options: Completion[] = []; - - if (this.mode === 'runOnceForAllItems') { - options.push( - { - label: `${matcher}.first()`, - type: 'function', - info: this.$locale.baseText('codeNodeEditor.completer.$input.first'), - }, - { - label: `${matcher}.last()`, - type: 'function', - info: this.$locale.baseText('codeNodeEditor.completer.$input.last'), - }, - { - label: `${matcher}.all()`, - type: 'function', - info: this.$locale.baseText('codeNodeEditor.completer.$input.all'), - }, - { - label: `${matcher}.itemMatching()`, - type: 'function', - info: this.$locale.baseText('codeNodeEditor.completer.$input.itemMatching'), - }, - ); - } - - if (this.mode === 'runOnceForEachItem') { - options.push({ - label: `${matcher}.item`, - type: 'variable', - info: this.$locale.baseText('codeNodeEditor.completer.$input.item'), - }); - } - - return { - from: preCursor.from, - options, - }; - }, - - /** - * - Complete `$('nodeName').` to `.params .context` in both modes. - * - Complete `$('nodeName').` to `.first() .last() .all() .itemMatching()` in all-items mode. - * - Complete `$('nodeName').` to `.item` in single-item mode. - */ - selectorCompletions(context: CompletionContext, matcher: string | null = null) { - const pattern = - matcher === null - ? /\$\((?['"][\S\s]+['"])\)\..*/ // $('nodeName'). - : new RegExp(`${matcher}\..*`); - - const preCursor = context.matchBefore(pattern); - - if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - - const match = preCursor.text.match(pattern); - - let replacementBase = ''; - - if (matcher === null && match?.groups?.quotedNodeName) { - replacementBase = `$(${match.groups.quotedNodeName})`; - } else if (matcher) { - replacementBase = matcher; - } - - const options: Completion[] = [ +import { useI18n } from '@/composables/useI18n'; + +export function useItemIndexCompletions(mode: 'runOnceForEachItem' | 'runOnceForAllItems') { + const i18n = useI18n(); + /** + * - Complete `$input.` to `.first() .last() .all() .itemMatching()` in all-items mode. + * - Complete `$input.` to `.item` in single-item mode. + */ + const inputCompletions = ( + context: CompletionContext, + matcher = '$input', + ): CompletionResult | null => { + const pattern = new RegExp(`${escape(matcher)}\..*`); + + const preCursor = context.matchBefore(pattern); + + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; + + const options: Completion[] = []; + + if (mode === 'runOnceForAllItems') { + options.push( + { + label: `${matcher}.first()`, + type: 'function', + info: i18n.baseText('codeNodeEditor.completer.$input.first'), + }, + { + label: `${matcher}.last()`, + type: 'function', + info: i18n.baseText('codeNodeEditor.completer.$input.last'), + }, + { + label: `${matcher}.all()`, + type: 'function', + info: i18n.baseText('codeNodeEditor.completer.$input.all'), + }, + { + label: `${matcher}.itemMatching()`, + type: 'function', + info: i18n.baseText('codeNodeEditor.completer.$input.itemMatching'), + }, + ); + } + + if (mode === 'runOnceForEachItem') { + options.push({ + label: `${matcher}.item`, + type: 'variable', + info: i18n.baseText('codeNodeEditor.completer.$input.item'), + }); + } + + return { + from: preCursor.from, + options, + }; + }; + + /** + * - Complete `$('nodeName').` to `.params .context` in both modes. + * - Complete `$('nodeName').` to `.first() .last() .all() .itemMatching()` in all-items mode. + * - Complete `$('nodeName').` to `.item` in single-item mode. + */ + const selectorCompletions = (context: CompletionContext, matcher: string | null = null) => { + const pattern = + matcher === null + ? /\$\((?['"][\S\s]+['"])\)\..*/ // $('nodeName'). + : new RegExp(`${matcher}\..*`); + + const preCursor = context.matchBefore(pattern); + + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; + + const match = preCursor.text.match(pattern); + + let replacementBase = ''; + + if (matcher === null && match?.groups?.quotedNodeName) { + replacementBase = `$(${match.groups.quotedNodeName})`; + } else if (matcher) { + replacementBase = matcher; + } + + const options: Completion[] = [ + { + label: `${replacementBase}.params`, + type: 'variable', + info: i18n.baseText('codeNodeEditor.completer.selector.params'), + }, + { + label: `${replacementBase}.context`, + type: 'variable', + info: i18n.baseText('codeNodeEditor.completer.selector.context'), + }, + ]; + + if (mode === 'runOnceForAllItems') { + options.push( + { + label: `${replacementBase}.first()`, + type: 'function', + info: i18n.baseText('codeNodeEditor.completer.$input.first'), + }, + { + label: `${replacementBase}.last()`, + type: 'function', + info: i18n.baseText('codeNodeEditor.completer.$input.last'), + }, { - label: `${replacementBase}.params`, - type: 'variable', - info: this.$locale.baseText('codeNodeEditor.completer.selector.params'), + label: `${replacementBase}.all()`, + type: 'function', + info: i18n.baseText('codeNodeEditor.completer.$input.all'), }, { - label: `${replacementBase}.context`, - type: 'variable', - info: this.$locale.baseText('codeNodeEditor.completer.selector.context'), + label: `${replacementBase}.itemMatching()`, + type: 'function', + info: i18n.baseText('codeNodeEditor.completer.selector.itemMatching'), }, - ]; - - if (this.mode === 'runOnceForAllItems') { - options.push( - { - label: `${replacementBase}.first()`, - type: 'function', - info: this.$locale.baseText('codeNodeEditor.completer.$input.first'), - }, - { - label: `${replacementBase}.last()`, - type: 'function', - info: this.$locale.baseText('codeNodeEditor.completer.$input.last'), - }, - { - label: `${replacementBase}.all()`, - type: 'function', - info: this.$locale.baseText('codeNodeEditor.completer.$input.all'), - }, - { - label: `${replacementBase}.itemMatching()`, - type: 'function', - info: this.$locale.baseText('codeNodeEditor.completer.selector.itemMatching'), - }, - ); - } - - if (this.mode === 'runOnceForEachItem') { - options.push({ - label: `${replacementBase}.item`, - type: 'variable', - info: this.$locale.baseText('codeNodeEditor.completer.selector.item'), - }); - } - - return { - from: preCursor.from, - options, - }; - }, - }, -}); + ); + } + + if (mode === 'runOnceForEachItem') { + options.push({ + label: `${replacementBase}.item`, + type: 'variable', + info: i18n.baseText('codeNodeEditor.completer.selector.item'), + }); + } + + return { + from: preCursor.from, + options, + }; + }; + + return { + inputCompletions, + selectorCompletions, + }; +} diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/jsonField.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/jsonField.completions.ts index 8e81e26d06771..a28c5d2e2cabf 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/jsonField.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/jsonField.completions.ts @@ -1,325 +1,301 @@ -import { defineComponent } from 'vue'; -import { escape, toVariableOption } from '../utils'; +import { escape } from '../utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; -import type { IDataObject, IPinData, IRunData } from 'n8n-workflow'; -import { mapStores } from 'pinia'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useNDVStore } from '@/stores/ndv.store'; import { isAllowedInDotNotation } from '@/plugins/codemirror/completions/utils'; - -export const jsonFieldCompletions = defineComponent({ - computed: { - ...mapStores(useNDVStore, useWorkflowsStore), - }, - methods: { - /** - * - Complete `x.first().json.` to `.field`. - * - Complete `x.last().json.` to `.field`. - * - Complete `x.all()[index].json.` to `.field`. - * - Complete `x.item.json.` to `.field`. - * - * - Complete `x.first().json[` to `['field']`. - * - Complete `x.last().json[` to `['field']`. - * - Complete `x.all()[index].json[` to `['field']`. - * - Complete `x.item.json[` to `['field']`. - */ - matcherJsonFieldCompletions( - context: CompletionContext, - matcher: string, - variablesToValues: Record, - ): CompletionResult | null { - const pattern = new RegExp(`(${escape(matcher)})\..*`); - - const preCursor = context.matchBefore(pattern); - - if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - - const inputNodeName = this.getInputNodeName(); - - if (!inputNodeName) return null; - - const [varName] = preCursor.text.split('.'); - - const originalValue = variablesToValues[varName]; - - if (!originalValue) return null; - - for (const accessor of ['first', 'last', 'item']) { - /** - * const x = $input.first(); // accessor in original value - * x.json - * - * const x = $input; - * x.first().json // accessor in preCursor.text - */ - if (originalValue.includes(accessor) || preCursor.text.includes(accessor)) { - const jsonOutput = this.getJsonOutput(inputNodeName, { accessor }); - - if (!jsonOutput) return null; - - return this.toJsonFieldCompletions(preCursor, jsonOutput, matcher); - } +import { useI18n } from '@/composables/useI18n'; +import type { IPinData, IRunData, IDataObject } from 'n8n-workflow'; + +function useJsonFieldCompletions() { + const i18n = useI18n(); + const ndvStore = useNDVStore(); + const workflowsStore = useWorkflowsStore(); + + /** + * - Complete `x.first().json.` to `.field`. + * - Complete `x.last().json.` to `.field`. + * - Complete `x.all()[index].json.` to `.field`. + * - Complete `x.item.json.` to `.field`. + * + * - Complete `x.first().json[` to `['field']`. + * - Complete `x.last().json[` to `['field']`. + * - Complete `x.all()[index].json[` to `['field']`. + * - Complete `x.item.json[` to `['field']`. + */ + const matcherJsonFieldCompletions = ( + context: CompletionContext, + matcher: string, + variablesToValues: Record, + ): CompletionResult | null => { + const pattern = new RegExp(`(${escape(matcher)})\..*`); + const preCursor = context.matchBefore(pattern); + + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; + + const inputNodeName = getInputNodeName(); + if (!inputNodeName) return null; + + const [varName] = preCursor.text.split('.'); + const originalValue = variablesToValues[varName]; + if (!originalValue) return null; + + for (const accessor of ['first', 'last', 'item']) { + if (originalValue.includes(accessor) || preCursor.text.includes(accessor)) { + const jsonOutput = getJsonOutput(inputNodeName, { accessor }); + if (!jsonOutput) return null; + return toJsonFieldCompletions(preCursor, jsonOutput, matcher); + } + } + + if (originalValue.includes('all')) { + const match = originalValue.match(/\$(input|\(.*\))\.all\(\)\[(?.+)\]$/); + if (!match?.groups?.index) return null; + + const { index } = match.groups; + const jsonOutput = getJsonOutput(inputNodeName, { index: Number(index) }); + if (!jsonOutput) return null; + + return toJsonFieldCompletions(preCursor, jsonOutput, matcher); + } + + return null; + }; + + /** + * - Complete `$input.first().json.` to `.field`. + * - Complete `$input.last().json.` to `.field`. + * - Complete `$input.all()[index].json.` to `.field`. + * - Complete `$input.item.json.` to `.field`. + * + * - Complete `$input.first().json[` to `['field']`. + * - Complete `$input.last().json[` to `['field']`. + * - Complete `$input.all()[index].json[` to `['field']`. + * - Complete `$input.item.json[` to `['field']`. + */ + const inputJsonFieldCompletions = (context: CompletionContext): CompletionResult | null => { + console.log('🚀 ~ inputJsonFieldCompletions ~ context:', context); + const patterns = { + first: /\$input\.first\(\)\.json(\[|\.).*/, + last: /\$input\.last\(\)\.json(\[|\.).*/, + item: /\$input\.item\.json(\[|\.).*/, + all: /\$input\.all\(\)\[(?\w+)\]\.json(\[|\.).*/, + }; + + for (const [name, regex] of Object.entries(patterns)) { + const preCursor = context.matchBefore(regex); + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) continue; + + const inputNodeName = getInputNodeName(); + if (!inputNodeName) continue; + + if (name === 'first' || name === 'last') { + const jsonOutput = getJsonOutput(inputNodeName, { accessor: name }); + if (!jsonOutput) continue; + return toJsonFieldCompletions(preCursor, jsonOutput, `$input.${name}().json`); } - if (originalValue.includes('all')) { - const match = originalValue.match(/\$(input|\(.*\))\.all\(\)\[(?.+)\]$/); + if (name === 'item') { + const jsonOutput = getJsonOutput(inputNodeName, { accessor: 'item' }); + if (!jsonOutput) continue; + return toJsonFieldCompletions(preCursor, jsonOutput, '$input.item.json'); + } - if (!match?.groups?.index) return null; + if (name === 'all') { + const match = preCursor.text.match(regex); + if (!match?.groups?.index) continue; const { index } = match.groups; + const jsonOutput = getJsonOutput(inputNodeName, { index: Number(index) }); + if (!jsonOutput) continue; - const jsonOutput = this.getJsonOutput(inputNodeName, { index: Number(index) }); - - if (!jsonOutput) return null; - - return this.toJsonFieldCompletions(preCursor, jsonOutput, matcher); + return toJsonFieldCompletions(preCursor, jsonOutput, `$input.all()[${index}].json`); + } + } + + return null; + }; + + /** + * Complete `$('nodeName').first().json.` to `.field`. + * Complete `$('nodeName').last().json.` to `.field`. + * Complete `$('nodeName').all()[index].json.` to `.field`. + * Complete `$('nodeName').item.json.` to `.field`. + * + * Complete `$('nodeName').first().json[` to `['field']`. + * Complete `$('nodeName').last().json[` to `['field']`. + * Complete `$('nodeName').all()[index].json[` to `['field']`. + * Complete `$('nodeName').item.json[` to `['field']`. + */ + const selectorJsonFieldCompletions = (context: CompletionContext): CompletionResult | null => { + const patterns = { + first: /\$\((?['"][\w\s]+['"])\)\.first\(\)\.json(\[|\.).*/, + last: /\$\((?['"][\w\s]+['"])\)\.last\(\)\.json(\[|\.).*/, + item: /\$\((?['"][\w\s]+['"])\)\.item\.json(\[|\.).*/, + all: /\$\((?['"][\w\s]+['"])\)\.all\(\)\[(?\w+)\]\.json(\[|\.).*/, + }; + + for (const [name, regex] of Object.entries(patterns)) { + const preCursor = context.matchBefore(regex); + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) continue; + + const match = preCursor.text.match(regex); + if (!match?.groups?.quotedNodeName) continue; + + const { quotedNodeName } = match.groups; + const selector = `$(${match.groups.quotedNodeName})`; + + if (name === 'first' || name === 'last') { + const jsonOutput = getJsonOutput(quotedNodeName, { accessor: name }); + if (!jsonOutput) continue; + return toJsonFieldCompletions(preCursor, jsonOutput, `${selector}.${name}().json`); } - return null; - }, - - /** - * - Complete `$input.first().json.` to `.field`. - * - Complete `$input.last().json.` to `.field`. - * - Complete `$input.all()[index].json.` to `.field`. - * - Complete `$input.item.json.` to `.field`. - * - * - Complete `$input.first().json[` to `['field']`. - * - Complete `$input.last().json[` to `['field']`. - * - Complete `$input.all()[index].json[` to `['field']`. - * - Complete `$input.item.json[` to `['field']`. - */ - inputJsonFieldCompletions(context: CompletionContext): CompletionResult | null { - const patterns = { - first: /\$input\.first\(\)\.json(\[|\.).*/, - last: /\$input\.last\(\)\.json(\[|\.).*/, - item: /\$input\.item\.json(\[|\.).*/, - all: /\$input\.all\(\)\[(?\w+)\]\.json(\[|\.).*/, - }; - - for (const [name, regex] of Object.entries(patterns)) { - const preCursor = context.matchBefore(regex); - - if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) continue; - - const inputNodeName = this.getInputNodeName(); - - if (!inputNodeName) continue; - - if (name === 'first' || name === 'last') { - const jsonOutput = this.getJsonOutput(inputNodeName, { accessor: name }); - - if (!jsonOutput) continue; - - return this.toJsonFieldCompletions(preCursor, jsonOutput, `$input.${name}().json`); - } - - if (name === 'item') { - const jsonOutput = this.getJsonOutput(inputNodeName, { accessor: 'item' }); - - if (!jsonOutput) continue; - - return this.toJsonFieldCompletions(preCursor, jsonOutput, '$input.item.json'); - } - - if (name === 'all') { - const match = preCursor.text.match(regex); - - if (!match?.groups?.index) continue; - - const { index } = match.groups; + if (name === 'item') { + const jsonOutput = getJsonOutput(quotedNodeName, { accessor: 'item' }); + if (!jsonOutput) continue; + return toJsonFieldCompletions(preCursor, jsonOutput, `${selector}.item.json`); + } - const jsonOutput = this.getJsonOutput(inputNodeName, { index: Number(index) }); + if (name === 'all') { + const regexMatch = preCursor.text.match(regex); + console.log('🚀 ~ selectorJsonFieldCompletions ~ regexMatch:', regexMatch); + if (!regexMatch?.groups?.index) continue; - if (!jsonOutput) continue; + const { index } = regexMatch.groups; + const jsonOutput = getJsonOutput(quotedNodeName, { index: Number(index) }); + if (!jsonOutput) continue; - return this.toJsonFieldCompletions(preCursor, jsonOutput, `$input.all()[${index}].json`); - } + return toJsonFieldCompletions(preCursor, jsonOutput, `${selector}.all()[${index}].json`); } - + } + + return null; + }; + + const getInputNodeName = (): string | null => { + try { + const activeNode = ndvStore.activeNode; + if (activeNode) { + const workflow = workflowsStore.getCurrentWorkflow(); + const input = workflow.connectionsByDestinationNode[activeNode.name]; + return input.main[0][0].node; + } + } catch (e) { + console.error(e); return null; - }, - - /** - * Complete `$('nodeName').first().json.` to `.field`. - * Complete `$('nodeName').last().json.` to `.field`. - * Complete `$('nodeName').all()[index].json.` to `.field`. - * Complete `$('nodeName').item.json.` to `.field`. - * - * Complete `$('nodeName').first().json[` to `['field']`. - * Complete `$('nodeName').last().json[` to `['field']`. - * Complete `$('nodeName').all()[index].json[` to `['field']`. - * Complete `$('nodeName').item.json[` to `['field']`. - */ - selectorJsonFieldCompletions(context: CompletionContext): CompletionResult | null { - const patterns = { - first: /\$\((?['"][\w\s]+['"])\)\.first\(\)\.json(\[|\.).*/, - last: /\$\((?['"][\w\s]+['"])\)\.last\(\)\.json(\[|\.).*/, - item: /\$\((?['"][\w\s]+['"])\)\.item\.json(\[|\.).*/, - all: /\$\((?['"][\w\s]+['"])\)\.all\(\)\[(?\w+)\]\.json(\[|\.).*/, + } + return null; + }; + + /** + * .json -> .json['field'] + * .json -> .json.field + */ + const toJsonFieldCompletions = ( + preCursor: NonNullable>, + jsonOutput: IDataObject, + matcher: string, + ): CompletionResult | null => { + if ( + /\.json\[/.test(preCursor.text) || + new RegExp(`(${escape(matcher)})\\[`).test(preCursor.text) + ) { + const options: Completion[] = Object.keys(jsonOutput) + .map((field) => `${matcher}['${field}']`) + .map((label) => ({ + label, + info: i18n.baseText('codeNodeEditor.completer.json'), + })); + + return { + from: preCursor.from, + options, }; + } - for (const [name, regex] of Object.entries(patterns)) { - const preCursor = context.matchBefore(regex); - - if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) continue; - - const match = preCursor.text.match(regex); - - if (!match?.groups?.quotedNodeName) continue; - - const { quotedNodeName } = match.groups; - - const selector = `$(${match.groups.quotedNodeName})`; - - if (name === 'first' || name === 'last') { - const jsonOutput = this.getJsonOutput(quotedNodeName, { accessor: name }); - - if (!jsonOutput) continue; - - return this.toJsonFieldCompletions(preCursor, jsonOutput, `${selector}.${name}().json`); - } - - if (name === 'item') { - const jsonOutput = this.getJsonOutput(quotedNodeName, { accessor: 'item' }); - - if (!jsonOutput) continue; + if ( + /\.json\./.test(preCursor.text) || + new RegExp(`(${escape(matcher)})\.`).test(preCursor.text) + ) { + const options: Completion[] = Object.keys(jsonOutput) + .filter(isAllowedInDotNotation) + .map((field) => `${matcher}.${field}`) + .map((label) => ({ + label, + info: i18n.baseText('codeNodeEditor.completer.json'), + })); + + return { + from: preCursor.from, + options, + }; + } - return this.toJsonFieldCompletions(preCursor, jsonOutput, `${selector}.item.json`); - } + return null; + }; - if (name === 'all') { - const match = preCursor.text.match(regex); + /** + * Get the `json` output of a node from `runData` or `pinData`. + * + * `accessor` is the method or property used to find the item index. + * `index` is only passed for `all()`. + */ + const getJsonOutput = ( + quotedNodeName: string, + options?: { accessor?: string; index?: number }, + ) => { + let nodeName = quotedNodeName; - if (!match?.groups?.index) continue; + const isSingleQuoteWrapped = quotedNodeName.startsWith("'") && quotedNodeName.endsWith("'"); + const isDoubleQuoteWrapped = quotedNodeName.startsWith('"') && quotedNodeName.endsWith('"'); - const { index } = match.groups; + if (isSingleQuoteWrapped) { + nodeName = quotedNodeName.replace(/^'/, '').replace(/'$/, ''); + } else if (isDoubleQuoteWrapped) { + nodeName = quotedNodeName.replace(/^"/, '').replace(/"$/, ''); + } - const jsonOutput = this.getJsonOutput(quotedNodeName, { index: Number(index) }); + const pinData: IPinData | undefined = useWorkflowsStore().pinnedWorkflowData; - if (!jsonOutput) continue; + const nodePinData = pinData?.[nodeName]; - return this.toJsonFieldCompletions( - preCursor, - jsonOutput, - `${selector}.all()[${index}].json`, - ); - } - } - - return null; - }, - - getInputNodeName() { + if (nodePinData) { try { - const activeNode = this.ndvStore.activeNode; - if (activeNode) { - const workflow = this.getCurrentWorkflow(); - const input = workflow.connectionsByDestinationNode[activeNode.name]; + let itemIndex = options?.index ?? 0; - return input.main[0][0].node; + if (options?.accessor === 'last') { + itemIndex = nodePinData.length - 1; } - } catch { - return null; - } - }, - - /** - * .json -> .json['field'] - * .json -> .json.field - */ - toJsonFieldCompletions( - preCursor: NonNullable>, - jsonOutput: IDataObject, - matcher: string, // e.g. `$input.first().json` or `x` (user-defined variable) - ) { - if ( - /\.json\[/.test(preCursor.text) || - new RegExp(`(${escape(matcher)})\\[`).test(preCursor.text) - ) { - const options: Completion[] = Object.keys(jsonOutput) - .map((field) => `${matcher}['${field}']`) - .map((label) => ({ - label, - info: this.$locale.baseText('codeNodeEditor.completer.json'), - })); - - return { - from: preCursor.from, - options, - }; - } - if ( - /\.json\./.test(preCursor.text) || - new RegExp(`(${escape(matcher)})\.`).test(preCursor.text) - ) { - const options: Completion[] = Object.keys(jsonOutput) - .filter(isAllowedInDotNotation) - .map((field) => `${matcher}.${field}`) - .map(toVariableOption); - - return { - from: preCursor.from, - options, - }; - } + return nodePinData[itemIndex].json; + } catch {} + } - return null; - }, - - /** - * Get the `json` output of a node from `runData` or `pinData`. - * - * `accessor` is the method or property used to find the item index. - * `index` is only passed for `all()`. - */ - getJsonOutput(quotedNodeName: string, options?: { accessor?: string; index?: number }) { - let nodeName = quotedNodeName; - - const isSingleQuoteWrapped = quotedNodeName.startsWith("'") && quotedNodeName.endsWith("'"); - const isDoubleQuoteWrapped = quotedNodeName.startsWith('"') && quotedNodeName.endsWith('"'); - - if (isSingleQuoteWrapped) { - nodeName = quotedNodeName.replace(/^'/, '').replace(/'$/, ''); - } else if (isDoubleQuoteWrapped) { - nodeName = quotedNodeName.replace(/^"/, '').replace(/"$/, ''); - } + const runData: IRunData | null = useWorkflowsStore().getWorkflowRunData; - const pinData: IPinData | undefined = this.workflowsStore.pinnedWorkflowData; + const nodeRunData = runData?.[nodeName]; - const nodePinData = pinData?.[nodeName]; + if (!nodeRunData) return null; - if (nodePinData) { - try { - let itemIndex = options?.index ?? 0; + try { + let itemIndex = options?.index ?? 0; - if (options?.accessor === 'last') { - itemIndex = nodePinData.length - 1; - } - - return nodePinData[itemIndex].json; - } catch {} + if (options?.accessor === 'last') { + const inputItems = nodeRunData[0].data?.main[0] ?? []; + itemIndex = inputItems.length - 1; } - const runData: IRunData | null = this.workflowsStore.getWorkflowRunData; - - const nodeRunData = runData && runData[nodeName]; - - if (!nodeRunData) return null; - - try { - let itemIndex = options?.index ?? 0; + return nodeRunData[0].data?.main[0]?.[itemIndex].json; + } catch { + return null; + } + }; - if (options?.accessor === 'last') { - const inputItems = nodeRunData[0].data!.main[0]!; - itemIndex = inputItems.length - 1; - } + return { + matcherJsonFieldCompletions, + inputJsonFieldCompletions, + selectorJsonFieldCompletions, + }; +} - return nodeRunData[0].data!.main[0]![itemIndex].json; - } catch { - return null; - } - }, - }, -}); +export { useJsonFieldCompletions }; diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/luxon.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/luxon.completions.ts index 4935f220b34f8..53cf82b64fdaa 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/luxon.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/luxon.completions.ts @@ -1,88 +1,101 @@ import { escape } from '../utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; -import { defineComponent } from 'vue'; import { createInfoBoxRenderer } from '@/plugins/codemirror/completions/infoBoxRenderer'; import { luxonStaticDocs } from '@/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.static.docs'; import { luxonInstanceDocs } from '@/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.instance.docs'; -export const luxonCompletions = defineComponent({ - methods: { - /** - * Complete `$today.` with luxon `DateTime` instance methods. - */ - todayCompletions(context: CompletionContext, matcher = '$today'): CompletionResult | null { - const pattern = new RegExp(`${escape(matcher)}\..*`); +export function useLuxonCompletions() { + /** + * Complete `$today.` with luxon `DateTime` instance methods. + */ + const todayCompletions = ( + context: CompletionContext, + matcher = '$today', + ): CompletionResult | null => { + const pattern = new RegExp(`${escape(matcher)}\..*`); - const preCursor = context.matchBefore(pattern); + const preCursor = context.matchBefore(pattern); - if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - return { - from: preCursor.from, - options: this.instanceCompletions(matcher), - }; - }, + return { + from: preCursor.from, + options: instanceCompletions(matcher), + }; + }; - /** - * Complete `$now.` with luxon `DateTime` instance methods. - */ - nowCompletions(context: CompletionContext, matcher = '$now'): CompletionResult | null { - const pattern = new RegExp(`${escape(matcher)}\..*`); + /** + * Complete `$now.` with luxon `DateTime` instance methods. + */ + const nowCompletions = ( + context: CompletionContext, + matcher = '$now', + ): CompletionResult | null => { + const pattern = new RegExp(`${escape(matcher)}\..*`); - const preCursor = context.matchBefore(pattern); + const preCursor = context.matchBefore(pattern); - if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - return { - from: preCursor.from, - options: this.instanceCompletions(matcher), - }; - }, + return { + from: preCursor.from, + options: instanceCompletions(matcher), + }; + }; - /** - * Complete `DateTime` with luxon `DateTime` static methods. - */ - dateTimeCompletions(context: CompletionContext, matcher = 'DateTime'): CompletionResult | null { - const pattern = new RegExp(`${escape(matcher)}\..*`); + /** + * Complete `DateTime` with luxon `DateTime` static methods. + */ + const dateTimeCompletions = ( + context: CompletionContext, + matcher = 'DateTime', + ): CompletionResult | null => { + const pattern = new RegExp(`${escape(matcher)}\..*`); - const preCursor = context.matchBefore(pattern); + const preCursor = context.matchBefore(pattern); - if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - const options: Completion[] = Object.entries(luxonStaticDocs.functions) - .filter(([_, { doc }]) => doc && !doc.hidden) - .map(([method, { doc }]) => { - return { - label: `DateTime.${method}()`, - type: 'function', - info: createInfoBoxRenderer(doc, true), - }; - }); + const options: Completion[] = Object.entries(luxonStaticDocs.functions) + .filter(([_, { doc }]) => doc && !doc.hidden) + .map(([method, { doc }]) => { + return { + label: `DateTime.${method}()`, + type: 'function', + info: createInfoBoxRenderer(doc, true), + }; + }); - return { - from: preCursor.from, - options, - }; - }, - instanceCompletions(matcher: string): Completion[] { - return Object.entries(luxonInstanceDocs.properties) - .filter(([_, { doc }]) => doc && !doc.hidden) - .map(([getter, { doc }]) => { - return { - label: `${matcher}.${getter}`, - info: createInfoBoxRenderer(doc), - }; - }) - .concat( - Object.entries(luxonInstanceDocs.functions) - .filter(([_, { doc }]) => doc && !doc.hidden) - .map(([method, { doc }]) => { - return { - label: `${matcher}.${method}()`, - info: createInfoBoxRenderer(doc, true), - }; - }), - ); - }, - }, -}); + return { + from: preCursor.from, + options, + }; + }; + + const instanceCompletions = (matcher: string): Completion[] => { + return Object.entries(luxonInstanceDocs.properties) + .filter(([_, { doc }]) => doc && !doc.hidden) + .map(([getter, { doc }]) => { + return { + label: `${matcher}.${getter}`, + info: createInfoBoxRenderer(doc), + }; + }) + .concat( + Object.entries(luxonInstanceDocs.functions) + .filter(([_, { doc }]) => doc && !doc.hidden) + .map(([method, { doc }]) => { + return { + label: `${matcher}.${method}()`, + info: createInfoBoxRenderer(doc, true), + }; + }), + ); + }; + + return { + todayCompletions, + nowCompletions, + dateTimeCompletions, + }; +} diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/prevNode.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/prevNode.completions.ts index 6ae4e2e8f9dec..13b6fc69e47c3 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/prevNode.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/prevNode.completions.ts @@ -1,45 +1,44 @@ import { addVarType } from '../utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; -import { defineComponent } from 'vue'; +import { useI18n } from '@/composables/useI18n'; const DEFAULT_MATCHER = '$prevNode'; const escape = (str: string) => str.replace('$', '\\$'); -export const prevNodeCompletions = defineComponent({ - methods: { - /** - * Complete `$prevNode.` to `.name .outputIndex .runIndex`. - */ - prevNodeCompletions( - context: CompletionContext, - matcher = DEFAULT_MATCHER, - ): CompletionResult | null { - const pattern = new RegExp(`${escape(matcher)}\..*`); - - const preCursor = context.matchBefore(pattern); - - if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - - const options: Completion[] = [ - { - label: `${matcher}.name`, - info: this.$locale.baseText('codeNodeEditor.completer.$prevNode.name'), - }, - { - label: `${matcher}.outputIndex`, - info: this.$locale.baseText('codeNodeEditor.completer.$prevNode.outputIndex'), - }, - { - label: `${matcher}.runIndex`, - info: this.$locale.baseText('codeNodeEditor.completer.$prevNode.runIndex'), - }, - ]; - - return { - from: preCursor.from, - options: options.map(addVarType), - }; - }, - }, -}); +export function usePrevNodeCompletions(matcher = DEFAULT_MATCHER) { + const i18n = useI18n(); + + /** + * Complete `$prevNode.` to `.name .outputIndex .runIndex`. + */ + const prevNodeCompletions = (context: CompletionContext): CompletionResult | null => { + const pattern = new RegExp(`${escape(matcher)}\..*`); + + const preCursor = context.matchBefore(pattern); + + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; + + const options: Completion[] = [ + { + label: `${matcher}.name`, + info: i18n.baseText('codeNodeEditor.completer.$prevNode.name'), + }, + { + label: `${matcher}.outputIndex`, + info: i18n.baseText('codeNodeEditor.completer.$prevNode.outputIndex'), + }, + { + label: `${matcher}.runIndex`, + info: i18n.baseText('codeNodeEditor.completer.$prevNode.runIndex'), + }, + ]; + + return { + from: preCursor.from, + options: options.map(addVarType), + }; + }; + + return { prevNodeCompletions }; +} diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/require.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/require.completions.ts index c183a739fc3fb..b309dc8adb70b 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/require.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/require.completions.ts @@ -1,45 +1,44 @@ -import { defineComponent } from 'vue'; import { AUTOCOMPLETABLE_BUILT_IN_MODULES_JS } from '../constants'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import { useSettingsStore } from '@/stores/settings.store'; -export const requireCompletions = defineComponent({ - methods: { - /** - * Complete `req` to `require('moduleName')` based on modules available in context. - */ - requireCompletions(context: CompletionContext): CompletionResult | null { - const preCursor = context.matchBefore(/req.*/); - - if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - - const options: Completion[] = []; - const settingsStore = useSettingsStore(); - const allowedModules = settingsStore.allowedModules; - - const toOption = (moduleName: string) => ({ - label: `require('${moduleName}');`, - type: 'variable', - }); - - if (allowedModules.builtIn) { - if (allowedModules.builtIn.includes('*')) { - options.push(...AUTOCOMPLETABLE_BUILT_IN_MODULES_JS.map(toOption)); - } else if (allowedModules?.builtIn?.length > 0) { - options.push(...allowedModules.builtIn.map(toOption)); - } +export function useRequireCompletions() { + const settingsStore = useSettingsStore(); + const allowedModules = settingsStore.allowedModules; + + const toOption = (moduleName: string): Completion => ({ + label: `require('${moduleName}');`, + type: 'variable', + }); + /** + * Complete `req` to `require('moduleName')` based on modules available in context. + */ + const requireCompletions = (context: CompletionContext): CompletionResult | null => { + const preCursor = context.matchBefore(/req.*/); + + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; + + const options: Completion[] = []; + + if (allowedModules.builtIn) { + if (allowedModules.builtIn.includes('*')) { + options.push(...AUTOCOMPLETABLE_BUILT_IN_MODULES_JS.map(toOption)); + } else if (allowedModules?.builtIn?.length > 0) { + options.push(...allowedModules.builtIn.map(toOption)); } + } - if (allowedModules.external) { - if (allowedModules?.external?.length > 0) { - options.push(...allowedModules.external.map(toOption)); - } + if (allowedModules.external) { + if (allowedModules?.external?.length > 0) { + options.push(...allowedModules.external.map(toOption)); } + } + + return { + from: preCursor.from, + options, + }; + }; - return { - from: preCursor.from, - options, - }; - }, - }, -}); + return { requireCompletions }; +} diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/secrets.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/secrets.completions.ts index 7bba9b3fa3eae..8d70fe7acc26f 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/secrets.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/secrets.completions.ts @@ -1,53 +1,58 @@ import { addVarType } from '../utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store'; -import { defineComponent } from 'vue'; const escape = (str: string) => str.replace('$', '\\$'); -export const secretsCompletions = defineComponent({ - methods: { - /** - * Complete `$secrets.` to `$secrets.providerName` and `$secrets.providerName.secretName`. - */ - secretsCompletions(context: CompletionContext, matcher = '$secrets'): CompletionResult | null { - const pattern = new RegExp(`${escape(matcher)}\..*`); - const preCursor = context.matchBefore(pattern); - - if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - - const provider = preCursor.text.split('.')[1]; - const externalSecretsStore = useExternalSecretsStore(); - let options: Completion[]; - - const optionsForObject = (leftSide: string, object: object): Completion[] => { - return Object.entries(object).flatMap(([key, value]) => { - if (typeof value === 'object' && value !== null) { - return optionsForObject(`${leftSide}.${key}`, value); - } - return { - label: `${leftSide}.${key}`, - info: '*******', - }; - }); - }; - - if (provider) { - options = optionsForObject( - `${matcher}.${provider}`, - externalSecretsStore.secretsAsObject[provider], - ); - } else { - options = Object.keys(externalSecretsStore.secretsAsObject).map((provider) => ({ - label: `${matcher}.${provider}`, - info: JSON.stringify(externalSecretsStore.secretsAsObject[provider]), - })); - } - - return { - from: preCursor.from, - options: options.map(addVarType), - }; - }, - }, -}); +export function useSecretsCompletions() { + const externalSecretsStore = useExternalSecretsStore(); + + /** + * Complete `$secrets.` to `$secrets.providerName` and `$secrets.providerName.secretName`. + */ + const secretsCompletions = ( + context: CompletionContext, + matcher = '$secrets', + ): CompletionResult | null => { + const pattern = new RegExp(`${escape(matcher)}\..*`); + const preCursor = context.matchBefore(pattern); + + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; + + const provider = preCursor.text.split('.')[1]; + let options: Completion[]; + + const optionsForObject = (leftSide: string, object: Record): Completion[] => { + return Object.entries(object).flatMap(([key, value]) => { + if (typeof value === 'object' && value !== null) { + return optionsForObject(`${leftSide}.${key}`, value as Record); + } + return { + label: `${leftSide}.${key}`, + info: '*******', + }; + }); + }; + + if (provider) { + options = optionsForObject( + `${matcher}.${provider}`, + externalSecretsStore.secretsAsObject[provider] as Record, + ); + } else { + options = Object.keys(externalSecretsStore.secretsAsObject).map((providerB) => ({ + label: `${matcher}.${providerB}`, + info: JSON.stringify(externalSecretsStore.secretsAsObject[providerB]), + })); + } + + return { + from: preCursor.from, + options: options.map(addVarType), + }; + }; + + return { + secretsCompletions, + }; +} diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/variables.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/variables.completions.ts index c6f7283f91be7..2f363e27ce91c 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/variables.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/variables.completions.ts @@ -1,32 +1,37 @@ import { addVarType } from '../utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import { useEnvironmentsStore } from '@/stores/environments.ee.store'; -import { defineComponent } from 'vue'; const escape = (str: string) => str.replace('$', '\\$'); -export const variablesCompletions = defineComponent({ - methods: { - /** - * Complete `$vars.` to `$vars.VAR_NAME`. - */ - variablesCompletions(context: CompletionContext, matcher = '$vars'): CompletionResult | null { - const pattern = new RegExp(`${escape(matcher)}\..*`); - - const preCursor = context.matchBefore(pattern); - - if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - - const environmentsStore = useEnvironmentsStore(); - const options: Completion[] = environmentsStore.variables.map((variable) => ({ - label: `${matcher}.${variable.key}`, - info: variable.value, - })); - - return { - from: preCursor.from, - options: options.map(addVarType), - }; - }, - }, -}); +export function useVariablesCompletions() { + const environmentsStore = useEnvironmentsStore(); + + /** + * Complete `$vars.` to `$vars.VAR_NAME`. + */ + const variablesCompletions = ( + context: CompletionContext, + matcher = '$vars', + ): CompletionResult | null => { + const pattern = new RegExp(`${escape(matcher)}\..*`); + + const preCursor = context.matchBefore(pattern); + + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; + + const options: Completion[] = environmentsStore.variables.map((variable) => ({ + label: `${matcher}.${variable.key}`, + info: variable.value, + })); + + return { + from: preCursor.from, + options: options.map(addVarType), + }; + }; + + return { + variablesCompletions, + }; +} diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/workflow.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/workflow.completions.ts index c367fb8c3cf4e..c0160bc66f371 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/workflow.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/workflow.completions.ts @@ -1,43 +1,45 @@ -import { defineComponent } from 'vue'; import { addVarType } from '../utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import { useI18n } from '@/composables/useI18n'; const escape = (str: string) => str.replace('$', '\\$'); -export const workflowCompletions = defineComponent({ - methods: { - /** - * Complete `$workflow.` to `.id .name .active`. - */ - workflowCompletions( - context: CompletionContext, - matcher = '$workflow', - ): CompletionResult | null { - const pattern = new RegExp(`${escape(matcher)}\..*`); - - const preCursor = context.matchBefore(pattern); - - if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - - const options: Completion[] = [ - { - label: `${matcher}.id`, - info: this.$locale.baseText('codeNodeEditor.completer.$workflow.id'), - }, - { - label: `${matcher}.name`, - info: this.$locale.baseText('codeNodeEditor.completer.$workflow.name'), - }, - { - label: `${matcher}.active`, - info: this.$locale.baseText('codeNodeEditor.completer.$workflow.active'), - }, - ]; - - return { - from: preCursor.from, - options: options.map(addVarType), - }; - }, - }, -}); +export function useWorkflowCompletions() { + const i18n = useI18n(); + + /** + * Complete `$workflow.` to `.id .name .active`. + */ + const workflowCompletions = ( + context: CompletionContext, + matcher = '$workflow', + ): CompletionResult | null => { + const pattern = new RegExp(`${escape(matcher)}\..*`); + + const preCursor = context.matchBefore(pattern); + + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; + + const options: Completion[] = [ + { + label: `${matcher}.id`, + info: i18n.baseText('codeNodeEditor.completer.$workflow.id'), + }, + { + label: `${matcher}.name`, + info: i18n.baseText('codeNodeEditor.completer.$workflow.name'), + }, + { + label: `${matcher}.active`, + info: i18n.baseText('codeNodeEditor.completer.$workflow.active'), + }, + ]; + + return { + from: preCursor.from, + options: options.map(addVarType), + }; + }; + + return { workflowCompletions }; +} diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/base.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/base.completions.test.ts new file mode 100644 index 0000000000000..b2436bfd48fe7 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/base.completions.test.ts @@ -0,0 +1,130 @@ +import { createTestingPinia } from '@pinia/testing'; +import { setActivePinia } from 'pinia'; +import { DateTime } from 'luxon'; + +import * as workflowHelpers from '@/composables/useWorkflowHelpers'; +import * as utils from '@/plugins/codemirror/completions/utils'; +import { + extensions, + luxonInstanceOptions, + natives, +} from '@/plugins/codemirror/completions/datatype.completions'; + +import type { CompletionSource, CompletionResult } from '@codemirror/autocomplete'; +import { CompletionContext } from '@codemirror/autocomplete'; +import { EditorState } from '@codemirror/state'; +import { n8nLang } from '@/plugins/codemirror/n8nLang'; +import { LUXON_RECOMMENDED_OPTIONS, STRING_RECOMMENDED_OPTIONS } from '../constants'; +import { uniqBy } from 'lodash-es'; + +beforeEach(async () => { + setActivePinia(createTestingPinia()); + + vi.spyOn(utils, 'receivesNoBinaryData').mockReturnValue(true); // hide $binary + vi.spyOn(utils, 'isSplitInBatchesAbsent').mockReturnValue(false); // show context + vi.spyOn(utils, 'hasActiveNode').mockReturnValue(true); +}); + +describe('Additional Tests', () => { + describe('Edge Case Completions', () => { + test('should return no completions for empty string: {{ ""| }}', () => { + expect(completions('{{ ""| }}')).toBeNull(); + }); + + test('should return no completions for null value: {{ null.| }}', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(null); + expect(completions('{{ null.| }}')).toBeNull(); + }); + + test('should return no completions for undefined value: {{ undefined.| }}', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(undefined); + expect(completions('{{ undefined.| }}')).toBeNull(); + }); + + test('should return completions for deeply nested object: {{ $json.deep.nested.value.| }}', () => { + const nestedObject = { deep: { nested: { value: 'test' } } }; + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce( + nestedObject.deep.nested.value, + ); + expect(completions('{{ $json.deep.nested.value.| }}')).toHaveLength( + natives({ typeName: 'string' }).length + + extensions({ typeName: 'string' }).length + + STRING_RECOMMENDED_OPTIONS.length, + ); + }); + }); + + describe('Special Characters', () => { + test('should handle completions for strings with special characters: {{ "special@char!".| }}', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('special@char!'); + expect(completions('{{ "special@char!".| }}')).toHaveLength( + natives({ typeName: 'string' }).length + + extensions({ typeName: 'string' }).length + + STRING_RECOMMENDED_OPTIONS.length, + ); + }); + + test('should handle completions for strings with escape sequences: {{ "escape\\nsequence".| }}', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('escape\nsequence'); + expect(completions('{{ "escape\\nsequence".| }}')).toHaveLength( + natives({ typeName: 'string' }).length + + extensions({ typeName: 'string' }).length + + STRING_RECOMMENDED_OPTIONS.length, + ); + }); + }); + + describe('Function Calls', () => { + test('should return completions for function call results: {{ Math.abs(-5).| }}', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(5); + expect(completions('{{ Math.abs(-5).| }}')).toHaveLength( + natives({ typeName: 'number' }).length + + extensions({ typeName: 'number' }).length + + ['isEven()', 'isOdd()'].length, + ); + }); + + test('should return completions for chained function calls: {{ $now.plus({ days: 1 }).| }}', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce( + DateTime.now().plus({ days: 1 }), + ); + expect(completions('{{ $now.plus({ days: 1 }).| }}')).toHaveLength( + uniqBy( + luxonInstanceOptions().concat(extensions({ typeName: 'date' })), + (option) => option.label, + ).length + LUXON_RECOMMENDED_OPTIONS.length, + ); + }); + }); +}); + +export function completions(docWithCursor: string, explicit = false) { + 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, explicit); + + 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__/itemField.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/itemField.completions.test.ts new file mode 100644 index 0000000000000..a568848235fa7 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/itemField.completions.test.ts @@ -0,0 +1,105 @@ +import { createTestingPinia } from '@pinia/testing'; +import { setActivePinia } from 'pinia'; +import { CompletionContext } from '@codemirror/autocomplete'; +import { EditorState } from '@codemirror/state'; +import { n8nLang } from '@/plugins/codemirror/n8nLang'; +import { useItemFieldCompletions } from '@/components/CodeNodeEditor/completions/itemField.completions'; + +describe('itemFieldCompletions', () => { + let context: CompletionContext; + + beforeEach(() => { + setActivePinia(createTestingPinia()); + }); + + describe('matcherItemFieldCompletions', () => { + test('should return null if no match found', () => { + const doc = '{{ $input.noMatch.| }}'; + const cursorPosition = doc.indexOf('|'); + const state = EditorState.create({ + doc: doc.replace('|', ''), + selection: { anchor: cursorPosition }, + extensions: [n8nLang()], + }); + context = new CompletionContext(state, cursorPosition, true); + + const result = useItemFieldCompletions('javaScript').matcherItemFieldCompletions( + context, + '$input.noMatch', + { '$input.item': 'item' }, + ); + + expect(result).toBeNull(); + }); + }); + + describe('inputMethodCompletions', () => { + test('should return completions for $input.first().', () => { + const doc = '{{ $input.first().| }}'; + const cursorPosition = doc.indexOf('|'); + const state = EditorState.create({ + doc: doc.replace('|', ''), + selection: { anchor: cursorPosition }, + extensions: [n8nLang()], + }); + context = new CompletionContext(state, cursorPosition, true); + + const result = useItemFieldCompletions('javaScript').inputMethodCompletions(context); + + expect(result).not.toBeNull(); + expect(result?.options).toHaveLength(2); + expect(result?.options[0].label).toBe('$input.first().json'); + expect(result?.options[1].label).toBe('$input.first().binary'); + }); + + test('should return null if no match found', () => { + const doc = '{{ $input.noMatch().| }}'; + const cursorPosition = doc.indexOf('|'); + const state = EditorState.create({ + doc: doc.replace('|', ''), + selection: { anchor: cursorPosition }, + extensions: [n8nLang()], + }); + context = new CompletionContext(state, cursorPosition, true); + + const result = useItemFieldCompletions('javaScript').inputMethodCompletions(context); + + expect(result).toBeNull(); + }); + }); + + describe('selectorMethodCompletions', () => { + test("should return completions for $('nodeName').first().", () => { + const doc = "{{ $('nodeName').first().| }}"; + const cursorPosition = doc.indexOf('|'); + const state = EditorState.create({ + doc: doc.replace('|', ''), + selection: { anchor: cursorPosition }, + extensions: [n8nLang()], + }); + context = new CompletionContext(state, cursorPosition, true); + + const result = useItemFieldCompletions('javaScript').selectorMethodCompletions(context); + + expect(result).not.toBeNull(); + expect(result?.options).toHaveLength(2); + expect(result?.options[0].label).toBe("$('nodeName').first().json"); + expect(result?.options[1].label).toBe("$('nodeName').first().binary"); + }); + + test('should return null if no match found', () => { + const doc = "{{ $('noMatch').noMatch().| }}"; + const cursorPosition = doc.indexOf('|'); + const state = EditorState.create({ + doc: doc.replace('|', ''), + selection: { anchor: cursorPosition }, + extensions: [n8nLang()], + }); + context = new CompletionContext(state, cursorPosition, true); + + const result = useItemFieldCompletions('javaScript').selectorMethodCompletions(context); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/itemIndex.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/itemIndex.completions.test.ts new file mode 100644 index 0000000000000..91f56c026a534 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/itemIndex.completions.test.ts @@ -0,0 +1,55 @@ +import { createTestingPinia } from '@pinia/testing'; +import { setActivePinia } from 'pinia'; +import { EditorState } from '@codemirror/state'; +import { CompletionContext } from '@codemirror/autocomplete'; +import { describe, test, expect, beforeEach } from 'vitest'; +import { useItemIndexCompletions } from '@/components/CodeNodeEditor/completions/itemIndex.completions'; + +let mode: 'runOnceForEachItem' | 'runOnceForAllItems'; + +beforeEach(() => { + setActivePinia(createTestingPinia()); + mode = 'runOnceForAllItems'; +}); + +describe('itemIndexCompletions', () => { + test('should return completions for $input. in all-items mode', () => { + const state = EditorState.create({ doc: '$input.', selection: { anchor: 7 } }); + const context = new CompletionContext(state, 7, true); + const result = useItemIndexCompletions(mode).inputCompletions.call({ mode }, context); + expect(result).not.toBeNull(); + expect(result?.options).toHaveLength(4); + expect(result?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: '$input.first()' }), + expect.objectContaining({ label: '$input.last()' }), + expect.objectContaining({ label: '$input.all()' }), + expect.objectContaining({ label: '$input.itemMatching()' }), + ]), + ); + }); + + test('should return completions for $input. in single-item mode', () => { + mode = 'runOnceForEachItem'; + const state = EditorState.create({ doc: '$input.', selection: { anchor: 7 } }); + const context = new CompletionContext(state, 7, true); + const result = useItemIndexCompletions(mode).inputCompletions.call({ mode }, context); + expect(result).not.toBeNull(); + expect(result?.options).toHaveLength(1); + expect(result?.options).toEqual( + expect.arrayContaining([expect.objectContaining({ label: '$input.item' })]), + ); + }); + + test('should return null for non-matching context', () => { + const state = EditorState.create({ doc: 'randomText', selection: { anchor: 10 } }); + const context = new CompletionContext(state, 10, true); + expect(useItemIndexCompletions(mode).inputCompletions.call({ mode }, context)).toBeNull(); + }); + + test('should return null for non-matching selector context', () => { + const state = EditorState.create({ doc: 'randomText', selection: { anchor: 10 } }); + const context = new CompletionContext(state, 10, true); + expect(useItemIndexCompletions(mode).selectorCompletions.call({ mode }, context)).toBeNull(); + }); +}); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/jsonField.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/jsonField.completions.test.ts new file mode 100644 index 0000000000000..285f5f2a86351 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/jsonField.completions.test.ts @@ -0,0 +1,108 @@ +import { createTestingPinia } from '@pinia/testing'; +import { setActivePinia } from 'pinia'; + +import * as workflowHelpers from '@/composables/useWorkflowHelpers'; +import * as utils from '@/plugins/codemirror/completions/utils'; +import { extensions, natives } from '@/plugins/codemirror/completions/datatype.completions'; + +import type { CompletionSource, CompletionResult } from '@codemirror/autocomplete'; +import { CompletionContext } from '@codemirror/autocomplete'; +import { EditorState } from '@codemirror/state'; +import { n8nLang } from '@/plugins/codemirror/n8nLang'; +import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store'; +import { useUIStore } from '@/stores/ui.store'; +import { useSettingsStore } from '@/stores/settings.store'; + +let externalSecretsStore: ReturnType; +let uiStore: ReturnType; +let settingsStore: ReturnType; + +beforeEach(async () => { + setActivePinia(createTestingPinia()); + + externalSecretsStore = useExternalSecretsStore(); + uiStore = useUIStore(); + settingsStore = useSettingsStore(); + + vi.spyOn(utils, 'receivesNoBinaryData').mockReturnValue(true); // hide $binary + vi.spyOn(utils, 'isSplitInBatchesAbsent').mockReturnValue(false); // show context + vi.spyOn(utils, 'hasActiveNode').mockReturnValue(true); +}); + +export function completions(docWithCursor: string, explicit = false) { + 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, explicit); + + 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; +} + +describe('jsonField.completions', () => { + test('should return null for invalid syntax: {{ $input.item.json..| }}', () => { + expect(completions('{{ $input.item.json..| }}')).toBeNull(); + }); + + test('should return null for non-existent node: {{ $("NonExistentNode").item.json.| }}', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(null); + expect(completions('{{ $("NonExistentNode").item.json.| }}')).toBeNull(); + }); + + test('should return completions for complex expressions: {{ Math.max($input.item.json.num1, $input.item.json.num2).| }}', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(123); + const result = completions('{{ Math.max($input.item.json.num1, $input.item.json.num2).| }}'); + expect(result).toHaveLength( + extensions({ typeName: 'number' }).length + + natives({ typeName: 'number' }).length + + ['isEven()', 'isOdd()'].length, + ); + }); + + test('should return completions for boolean expressions: {{ $input.item.json.flag && $input.item.json.| }}', () => { + const json = { flag: true, key1: 'value1', key2: 'value2' }; + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(json); + const result = completions('{{ $input.item.json.flag && $input.item.json.| }}'); + expect(result).toHaveLength( + Object.keys(json).length + extensions({ typeName: 'object' }).length, + ); + }); + + test('should return null for undefined values: {{ $input.item.json.undefinedValue.| }}', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(undefined); + expect(completions('{{ $input.item.json.undefinedValue.| }}')).toBeNull(); + }); + + test('should return completions for large JSON objects: {{ $input.item.json.largeObject.| }}', () => { + const largeObject: { [key: string]: string } = {}; + for (let i = 0; i < 1000; i++) { + largeObject[`key${i}`] = `value${i}`; + } + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(largeObject); + const result = completions('{{ $input.item.json.largeObject.| }}'); + expect(result).toHaveLength( + Object.keys(largeObject).length + extensions({ typeName: 'object' }).length, + ); + }); +}); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/prevNode.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/prevNode.completions.test.ts new file mode 100644 index 0000000000000..f887852635c39 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/prevNode.completions.test.ts @@ -0,0 +1,56 @@ +import { createTestingPinia } from '@pinia/testing'; +import { setActivePinia } from 'pinia'; +import { CompletionContext } from '@codemirror/autocomplete'; +import { EditorState } from '@codemirror/state'; +import { usePrevNodeCompletions } from '@/components/CodeNodeEditor/completions/prevNode.completions'; + +describe('prevNodeCompletions', () => { + const { prevNodeCompletions } = usePrevNodeCompletions(); + beforeEach(() => { + setActivePinia(createTestingPinia()); + }); + + test('should return completions for explicit empty context', () => { + const state = EditorState.create({ doc: '$prevNode.', selection: { anchor: 10 } }); + const context = new CompletionContext(state, 10, true); + const result = prevNodeCompletions(context); + + expect(result).not.toBeNull(); + expect(result?.options).toHaveLength(3); + expect(result?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: '$prevNode.name' }), + expect.objectContaining({ label: '$prevNode.outputIndex' }), + expect.objectContaining({ label: '$prevNode.runIndex' }), + ]), + ); + }); + + test('should return null for non-matching context', () => { + const state = EditorState.create({ doc: 'randomText', selection: { anchor: 10 } }); + const context = new CompletionContext(state, 10, true); + expect(prevNodeCompletions(context)).toBeNull(); + }); + + test('should return completions for partial match', () => { + const state = EditorState.create({ doc: '$prevNode.n', selection: { anchor: 11 } }); + const context = new CompletionContext(state, 11, true); + const result = prevNodeCompletions(context); + + expect(result).not.toBeNull(); + expect(result?.options).toHaveLength(3); + expect(result?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: '$prevNode.name' }), + expect.objectContaining({ label: '$prevNode.outputIndex' }), + expect.objectContaining({ label: '$prevNode.runIndex' }), + ]), + ); + }); + + test('should return null for empty matcher', () => { + const state = EditorState.create({ doc: '.', selection: { anchor: 1 } }); + const context = new CompletionContext(state, 1, true); + expect(prevNodeCompletions(context)).toBeNull(); + }); +}); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/require.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/require.completions.test.ts new file mode 100644 index 0000000000000..dfc622e55f3e0 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/require.completions.test.ts @@ -0,0 +1,126 @@ +import { createTestingPinia } from '@pinia/testing'; +import { setActivePinia } from 'pinia'; +import { CompletionContext } from '@codemirror/autocomplete'; +import { EditorState } from '@codemirror/state'; +import { useSettingsStore } from '@/stores/settings.store'; +import { useRequireCompletions } from '@/components/CodeNodeEditor/completions/require.completions'; +import { AUTOCOMPLETABLE_BUILT_IN_MODULES_JS } from '@/components/CodeNodeEditor/constants'; +import * as utils from '@/plugins/codemirror/completions/utils'; + +let settingsStore: ReturnType; + +describe('requireCompletions', () => { + beforeEach(() => { + setActivePinia(createTestingPinia()); + settingsStore = useSettingsStore(); + + vi.spyOn(utils, 'receivesNoBinaryData').mockReturnValue(true); // hide $binary + vi.spyOn(utils, 'isSplitInBatchesAbsent').mockReturnValue(false); // show context + vi.spyOn(utils, 'hasActiveNode').mockReturnValue(true); + }); + + it('should return completions for explicit empty context', () => { + vi.spyOn(settingsStore, 'allowedModules', 'get').mockReturnValue({ + builtIn: ['fs', 'path'], + external: ['lodash'], + }); + const state = EditorState.create({ doc: 'req', selection: { anchor: 3 } }); + const context = new CompletionContext(state, 3, true); + const result = useRequireCompletions().requireCompletions(context); + expect(result).not.toBeNull(); + expect(result?.options).toHaveLength(3); + expect(result?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: "require('fs');" }), + expect.objectContaining({ label: "require('path');" }), + expect.objectContaining({ label: "require('lodash');" }), + ]), + ); + }); + + it('should return completions for partial match', () => { + vi.spyOn(settingsStore, 'allowedModules', 'get').mockReturnValue({ + builtIn: ['fs', 'path'], + external: ['lodash'], + }); + const state = EditorState.create({ doc: 'req', selection: { anchor: 3 } }); + const context = new CompletionContext(state, 3, true); + const result = useRequireCompletions().requireCompletions(context); + expect(result).not.toBeNull(); + expect(result?.options).toHaveLength(3); + expect(result?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: "require('fs');" }), + expect.objectContaining({ label: "require('path');" }), + expect.objectContaining({ label: "require('lodash');" }), + ]), + ); + }); + + it('should handle built-in wildcard', () => { + vi.spyOn(settingsStore, 'allowedModules', 'get').mockReturnValue({ + builtIn: ['*'], + external: [], + }); + const state = EditorState.create({ doc: 'req', selection: { anchor: 3 } }); + const context = new CompletionContext(state, 3, true); + const result = useRequireCompletions().requireCompletions(context); + expect(result).not.toBeNull(); + expect(result?.options).toHaveLength(AUTOCOMPLETABLE_BUILT_IN_MODULES_JS.length); + expect(result?.options).toEqual( + expect.arrayContaining( + AUTOCOMPLETABLE_BUILT_IN_MODULES_JS.map((module) => + expect.objectContaining({ label: `require('${module}');` }), + ), + ), + ); + }); + + it('should return null for non-matching context', () => { + const state = EditorState.create({ doc: 'randomText', selection: { anchor: 10 } }); + const context = new CompletionContext(state, 10, true); + expect(useRequireCompletions().requireCompletions(context)).toBeNull(); + }); + + it('should return completions for mixed built-in and external modules', () => { + vi.spyOn(settingsStore, 'allowedModules', 'get').mockReturnValue({ + builtIn: ['fs'], + external: ['lodash', 'axios'], + }); + const state = EditorState.create({ doc: 'req', selection: { anchor: 3 } }); + const context = new CompletionContext(state, 3, true); + const result = useRequireCompletions().requireCompletions(context); + expect(result).not.toBeNull(); + expect(result?.options).toHaveLength(3); + expect(result?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: "require('fs');" }), + expect.objectContaining({ label: "require('lodash');" }), + expect.objectContaining({ label: "require('axios');" }), + ]), + ); + }); + + it('should handle empty allowedModules gracefully', () => { + vi.spyOn(settingsStore, 'allowedModules', 'get').mockReturnValue({ + builtIn: [], + external: [], + }); + const state = EditorState.create({ doc: 'req', selection: { anchor: 3 } }); + const context = new CompletionContext(state, 3, true); + const result = useRequireCompletions().requireCompletions(context); + expect(result).not.toBeNull(); + expect(result?.options).toHaveLength(0); + }); + + it('should handle missing allowedModules gracefully', () => { + vi.spyOn(settingsStore, 'allowedModules', 'get').mockReturnValue({ + builtIn: undefined, + external: undefined, + }); + const state = EditorState.create({ doc: 'req', selection: { anchor: 3 } }); + const context = new CompletionContext(state, 3, true); + const result = useRequireCompletions().requireCompletions(context); + expect(result?.options).toHaveLength(0); + }); +}); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/variables.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/variables.completions.test.ts new file mode 100644 index 0000000000000..681709737fcdf --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/variables.completions.test.ts @@ -0,0 +1,64 @@ +import { createTestingPinia } from '@pinia/testing'; +import { setActivePinia } from 'pinia'; +import { EditorState } from '@codemirror/state'; +import { CompletionContext } from '@codemirror/autocomplete'; +import { useEnvironmentsStore } from '@/stores/environments.ee.store'; +import { useVariablesCompletions } from '@/components/CodeNodeEditor/completions/variables.completions'; +let environmentsStore: ReturnType; + +beforeEach(() => { + setActivePinia(createTestingPinia()); + environmentsStore = useEnvironmentsStore(); +}); + +describe('variablesCompletions', () => { + test('should return completions for $vars prefix', () => { + environmentsStore.variables = [ + { key: 'VAR1', value: 'Value1', id: 1 }, + { key: 'VAR2', value: 'Value2', id: 2 }, + ]; + + const state = EditorState.create({ doc: '$vars.', selection: { anchor: 6 } }); + const context = new CompletionContext(state, 6, true); + const result = useVariablesCompletions().variablesCompletions(context); + + expect(result).not.toBeNull(); + expect(result?.options).toHaveLength(2); + expect(result?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: '$vars.VAR1', info: 'Value1' }), + expect.objectContaining({ label: '$vars.VAR2', info: 'Value2' }), + ]), + ); + }); + + test('should return null for non-matching context', () => { + const state = EditorState.create({ doc: 'randomText', selection: { anchor: 10 } }); + const context = new CompletionContext(state, 10, true); + expect(useVariablesCompletions().variablesCompletions(context)).toBeNull(); + }); + + test('should escape special characters in matcher', () => { + environmentsStore.variables = [{ key: 'VAR1', value: 'Value1', id: 1 }]; + + const state = EditorState.create({ doc: '$vars.', selection: { anchor: 6 } }); + const context = new CompletionContext(state, 6, true); + const result = useVariablesCompletions().variablesCompletions(context, '$var$'); + + expect(result).toBeNull(); + }); + + test('should return completions for custom matcher', () => { + environmentsStore.variables = [{ key: 'VAR1', value: 'Value1', id: 1 }]; + + const state = EditorState.create({ doc: '$custom.', selection: { anchor: 8 } }); + const context = new CompletionContext(state, 8, true); + const result = useVariablesCompletions().variablesCompletions(context, '$custom'); + + expect(result).not.toBeNull(); + expect(result?.options).toHaveLength(1); + expect(result?.options).toEqual( + expect.arrayContaining([expect.objectContaining({ label: '$custom.VAR1', info: 'Value1' })]), + ); + }); +}); From cd751e7cc88d57eed1077f3bec80fd339e26168b Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Tue, 21 May 2024 13:19:56 +0200 Subject: [PATCH 2/9] fix(editor): Fix rundata type errors (no-changelog) (#9443) --- .../editor-ui/src/components/Draggable.vue | 1 + .../editor-ui/src/components/InputPanel.vue | 7 +- .../src/components/NodeExecuteButton.vue | 7 +- .../editor-ui/src/components/OutputPanel.vue | 15 ++-- packages/editor-ui/src/components/RunData.vue | 73 ++++++++++++------- .../editor-ui/src/components/RunDataHtml.vue | 4 +- .../editor-ui/src/components/RunDataJson.vue | 6 +- .../src/components/RunDataJsonActions.vue | 7 +- .../src/components/RunDataSchemaItem.vue | 10 ++- .../editor-ui/src/components/RunDataTable.vue | 10 ++- .../src/composables/usePinnedData.ts | 7 +- .../src/plugins/i18n/locales/en.json | 1 + packages/workflow/src/Interfaces.ts | 2 +- 13 files changed, 95 insertions(+), 55 deletions(-) diff --git a/packages/editor-ui/src/components/Draggable.vue b/packages/editor-ui/src/components/Draggable.vue index 8b65ed35d9abe..6a700000b8e06 100644 --- a/packages/editor-ui/src/components/Draggable.vue +++ b/packages/editor-ui/src/components/Draggable.vue @@ -29,6 +29,7 @@ export default defineComponent({ }, type: { type: String, + required: true, }, data: { type: String, diff --git a/packages/editor-ui/src/components/InputPanel.vue b/packages/editor-ui/src/components/InputPanel.vue index ae1a6c2206ce4..b538c12bc730f 100644 --- a/packages/editor-ui/src/components/InputPanel.vue +++ b/packages/editor-ui/src/components/InputPanel.vue @@ -1,5 +1,6 @@