feat(Code Node): create Code node (#3965)

* Introduce node deprecation (#3930)

 Introduce node deprecation

* 🚧 Scaffold out Code node

* 👕 Fix lint

* 📘 Create types file

* 🚚 Rename theme

* 🔥 Remove unneeded prop

*  Override keybindings

*  Expand lintings

*  Create editor content getter

* 🚚 Ensure all helpers use `$`

*  Add autocompletion

*  Filter out welcome note node

*  Convey error line number

*  Highlight error line

*  Restore logging from node

*  More autocompletions

*  Streamline completions

* ✏️ Update placeholders

*  Update linter to new methods

* 🔥 Remove `$nodeItem` completions

*  Re-update placeholders

* 🎨 Fix formatting

* 📦 Update `package-lock.json`

*  Refresh with multi-line empty string

*  Account for syntax errors

* 🔥 Remove unneeded variant

*  Minor improvements

*  Add more autocompletions

* 🚚 Rename extension

* 🔥 Remove outdated comments

* 🚚 Rename field

*  More autocompletions

*  Fix up error display when empty text

* 🔥 Remove logging

*  More error validation

* 🐛 Fix `pairedItem` to `pairedItem()`

*  Add item to validation info

* 📦 Update `package-lock.json`

*  Leftover fixes

*  Set `insertNewlineAndIndent`

* 📦 Update `package-lock.json`

* 📦 Re-update `package-lock.json`

* 👕 Add lint exception

* 📘 Add type to mixin type

* Clean up comment

*  Refactor completion per new requirements

*  Adjust placeholders

*  Add `json` autocompletions for `$input`

* 🎨 Set border

*  Restore local completion source

*  Implement autocompletion for imports

*  Add `.*` to follow user typing on autocompletion

* 📘 Fix typings in autocompletions

* 👕 Add linting for use of `item()`

* 📦 Update `package-lock.json`

* 🐛 Fix for `$items(nodeName)[0]`

*  Filter down built-in modules list

*  Refactor error handling

*  Linter and validation improvements

*  Apply review feedback

* ♻️ More general refactorings

*  Add dot notation utility

* Customize input handler

*  Support `.json.` completions

*  Adjust placeholder

*  Sort imports

* 🔥 Remove blank rows addition

*  Add more error validation

* 📦 Update `package-lock.json`

*  Make date logging consistent

* 🔧 Adjust linting highlight range

*  Add line numbers to each item mode errors

*  Allow for links in error descriptions

*  More input validation

*  Expand linting to loops

*  Deprecate Function and Function Item nodes

* 🐛 Fix placeholder syntax

* 📘 Narrow down type

* 🚚 Rename using kebab-case

* 🔥 Remove `mapGetters`

* ✏️ Fix casing

*  Adjust import for type

* ✏️ Fix quotes

* 🐛 Fix `activeNode` reference

*  Use constant

* 🔥 Remove logging

* ✏️ Fix typo

*  Add missing `notice`

* ✏️ Add tags

* ✏️ Fix alias

* ✏️ Update copy

* 🔥 Remove wrong linting

* ✏️ Update copy

*  Add validation for `null`

*  Add validation for non-object and non-array

*  Add validation for non-array with json

* ✏️ Intentionally use wrong spelling

*  More validation

* ✏️ More copy updates

* ✏️ Placeholder updates

*  Restore spelling

*  Fix var name

* ✏️ More copy updates

*  Add luxon autocompletions

*  Make scrollable

*  Fix comma from merge conflict resolution

* 📦 Update `package-lock.json`

* 👕 Fix lint detail

* 🎨 Set font family

*  Bring in expressions fix

* ♻️ Address feedback

*  Exclude codemirror packages from render chunks

* 🐛 Fix placeholder not showing on first load

* feat(editor-ui): Replace `lezer` with `esprima` in client linter (#4192)

* 🔥 Remove addition from misresolved conflict

*  Replace `lezer` with `esprima` in client linter

*  Add missing key

* 📦 Update `package-lock.json`

*  Match dependencies

* 📦 Update `package-lock.json`

* 📦 Re-update `package-lock.json`

*  Match whitespace

* 🐛 Fix selection

*  Expand validation

* 🔥 Remove validation

* ✏️ Update copy

* 🚚 Move to constants

*  More `null` validation

*  Support `all()` with index to access item

*  Gloss over n8n syntax error

* 🎨 Re-style diagnostic button

* 🔥 Remove `item` as `itemAlias`

*  Add linting for `item.json` in single item mode

*  Refactor to add label info descriptions

*  More autocompletions

* 👕 Fix lint

*  Simplify typings

* feat(nodes-base): Multiline autocompletion for `code-node-editor` (#4220)

*  Simplify typings

*  Consolidate helpers in utils

*  Multiline autocompletion for standalone vars

* 🔥 Remove unneeded mixins

* ✏️ Update copy

* ✏️ Prep TODOs

*  Multiline completion for `$input.method` + `$input.item`

* 🔥 Remove unused method

* 🔥 Remove another unused method

* 🚚 Move luxon strings to helpers

*  Multiline autocompletion for methods output

*  Refactor to use optional chaining

* 👕 Fix lint

* ✏️ Update TODOs

*  Multiline autocompletion for `json` fields

* 📘 Add typings

*  De-duplicate callback to forEach

* 🐛 Fix autocompletions not working with leading whitespace

* 🌐 Apply i18n

* 👕 Fix lint

* :constructor: Second-period var usage completions

* 👕 Fix lint

* 👕 Add exception

*  Add completion telemetry

* 📘 Add typing

*  Major refactoring to organize

* 🐛 Fix multiline `.all()[index]`

* 🐛 Do not autoclose square brackets prior to `.json`

* 🐛 Fix accessor for multiline `jsonField` completions

*  Add completions for half-assignments

* 🐛 Fix `jsonField` completions for `x.json`

* ✏️ Improve comments

* 🐛 Fix `.json[field]` for multiline matches

*  Cleanup

* 📦 Update `package-lock.json`

* 👕 Fix lint

* 🐛 Rely on original value for custom matcher

*  Create `customMatcherJsonFieldCompletions` to simplify setup

* 🐛 Include selector in `customMatcherJsonField` completions

* ✏️ Make naming consistent

* ✏️ Add docline

*  Finish self-review cleanup

* 🔥 Remove outdated comment

* 📌 Pin luxon to major-minor

* ✏️ Fix typo

* 📦 Update `package-lock.json`

* 📦 Update `package-lock.json`

* 📦 Re-update `package-lock.json`

*  Add `luxon` for Gmail node

* 📦 Update `package-lock.json`

*  Replace Function with Code in suggested nodes

* 🐛 Fix `$prevNode` completions

* ✏️ Update `$execution.mode` copy

*  Separate luxon getters from methods

*  Adjusting linter to tolerate `.binary`

*  Adjust top-level item keys check

*  Anticipate user expecting `item` to pre-exist

*  Add linting for legacy item access

*  Add hint for attempted `items` access

*  Add keybinding for toggling comments

* ✏️ Update copy of `all`, `first`, `last` and `itemMatching`

* 🐛 Make `input.all()` etc act on copies

* 📦 Update `package-lock.json`

* 🐛 Fix guard in `$input.last()`

* ♻️ Address Jan's feedback

* ⬆️ Upgrade `eslint-plugin-n8n-nodes-base`

* 📦 Update `package-lock.json`

* 🔥 Remove unneeded exceptions

*  Restore placeholder logic

*  Add placeholders to client

*  Account for shadow item

* ✏️ More completion info labels

* 👕 Fix lint

* ✏️ Update copy

* ✏️ Update copy

* ✏️ More copy updates

* 📦 Update `package-lock.json`

*  Add more validation

*  Add placheolder on first load

* Replace `Cmd` with `Mod`

* 📦 Update `package-lock.json`
This commit is contained in:
Iván Ovejero
2022-10-13 14:28:02 +02:00
committed by GitHub
parent 12e821528b
commit 1db4fa2bf8
54 changed files with 5127 additions and 1400 deletions

View File

@@ -0,0 +1,117 @@
import Vue 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 type { CodeNodeEditorMixin } from '../types';
function getAutocompletableNodeNames(nodes: INodeUi[]) {
return nodes
.filter((node: INodeUi) => !NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION.includes(node.type))
.map((node: INodeUi) => node.name);
}
export const baseCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* - Complete `$` to `$execution $input $prevNode $runIndex $workflow $now $today
* $jmespath $('nodeName')` in both modes.
* - Complete `$` to `$json $binary $itemIndex` in single-item mode.
*/
baseCompletions(context: CompletionContext): CompletionResult | null {
const preCursor = context.matchBefore(/\$\w*/);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const TOP_LEVEL_COMPLETIONS_IN_BOTH_MODES: Completion[] = [
{
label: '$execution',
info: this.$locale.baseText('codeNodeEditor.completer.$execution'),
},
{ label: '$input', info: this.$locale.baseText('codeNodeEditor.completer.$input') },
{
label: '$prevNode',
info: this.$locale.baseText('codeNodeEditor.completer.$prevNode'),
},
{
label: '$workflow',
info: this.$locale.baseText('codeNodeEditor.completer.$workflow'),
},
{
label: '$now',
info: this.$locale.baseText('codeNodeEditor.completer.$now'),
},
{
label: '$today',
info: this.$locale.baseText('codeNodeEditor.completer.$today'),
},
{
label: '$jmespath()',
info: this.$locale.baseText('codeNodeEditor.completer.$jmespath'),
},
{
label: '$runIndex',
info: this.$locale.baseText('codeNodeEditor.completer.$runIndex'),
},
];
const options: Completion[] = TOP_LEVEL_COMPLETIONS_IN_BOTH_MODES.map(addVarType);
options.push(
...getAutocompletableNodeNames(this.$store.getters.allNodes).map((nodeName) => {
return {
label: `$('${nodeName}')`,
type: 'variable',
info: this.$locale.baseText('codeNodeEditor.completer.$()', {
interpolate: { nodeName },
}),
};
}),
);
if (this.mode === 'runOnceForEachItem') {
const TOP_LEVEL_COMPLETIONS_IN_SINGLE_ITEM_MODE = [
{ label: '$json' },
{ label: '$binary' },
{
label: '$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 preCursor = context.matchBefore(/\$\(.*/);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = getAutocompletableNodeNames(this.$store.getters.allNodes).map(
(nodeName) => {
return {
label: `$('${nodeName}')`,
type: 'variable',
info: this.$locale.baseText('codeNodeEditor.completer.$()', {
interpolate: { nodeName },
}),
};
},
);
return {
from: preCursor.from,
options,
};
},
},
});

View File

@@ -0,0 +1,42 @@
import Vue from 'vue';
import { addVarType, escape } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
export const executionCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* Complete `$execution.` to `.id .mode .resumeUrl`
*/
executionCompletions(
context: CompletionContext,
matcher = '$execution',
): 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.$execution.id'),
},
{
label: `${matcher}.mode`,
info: this.$locale.baseText('codeNodeEditor.completer.$execution.mode'),
},
{
label: `${matcher}.resumeUrl`,
info: this.$locale.baseText('codeNodeEditor.completer.$execution.resumeUrl'),
},
];
return {
from: preCursor.from,
options: options.map(addVarType),
};
},
},
});

View File

@@ -0,0 +1,169 @@
import Vue from 'vue';
import { addVarType, escape } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
export const itemFieldCompletions = (Vue as CodeNodeEditorMixin).extend({
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<string, string>,
) {
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: this.$locale.baseText('codeNodeEditor.completer.json'),
},
{
label: `${matcher}.binary`,
info: this.$locale.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`.
*/
inputMethodCompletions(context: CompletionContext): CompletionResult | null {
const patterns = {
first: /\$input\.first\(\)\..*/,
last: /\$input\.last\(\)\..*/,
item: /\$input\.item\..*/,
all: /\$input\.all\(\)\[(?<index>\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 = '$input.item';
if (name === 'first') replacementBase = '$input.first()';
if (name === 'last') replacementBase = '$input.last()';
if (name === 'all') {
const match = preCursor.text.match(regex);
if (!match?.groups?.index) continue;
const { index } = match.groups;
replacementBase = `$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`.
*/
selectorMethodCompletions(
context: CompletionContext,
matcher: string | null = null,
): CompletionResult | null {
const patterns = {
first: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.first\(\)\..*/,
last: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.last\(\)\..*/,
item: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.item\..*/,
all: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.all\(\)\[(?<index>\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})`;
}
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 (!match?.groups?.index) continue;
replacementBase = `${start}.all()[${match.groups.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;
},
},
});

View File

@@ -0,0 +1,137 @@
import Vue from 'vue';
import { escape } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
export const itemIndexCompletions = (Vue as CodeNodeEditorMixin).extend({
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
? /\$\((?<quotedNodeName>['"][\w\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: this.$locale.baseText('codeNodeEditor.completer.selector.params'),
},
{
label: `${replacementBase}.context`,
type: 'variable',
info: this.$locale.baseText('codeNodeEditor.completer.selector.context'),
},
];
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,
};
},
},
});

View File

@@ -0,0 +1,13 @@
import { snippets } from '@codemirror/lang-javascript';
import { completeFromList, snippetCompletion } from "@codemirror/autocomplete";
/**
* https://github.com/codemirror/lang-javascript/blob/main/src/snippets.ts
*/
export const jsSnippets = completeFromList([
...snippets.filter((snippet) => snippet.label !== 'class'),
snippetCompletion('console.log(${arg})', { label: 'console.log()' }),
snippetCompletion('DateTime', { label: 'DateTime' }),
snippetCompletion('Interval', { label: 'Interval' }),
snippetCompletion('Duration', { label: 'Duration' }),
]);

View File

@@ -0,0 +1,302 @@
import Vue from 'vue';
import { isAllowedInDotNotation, escape, toVariableOption } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { IDataObject, IPinData, IRunData } from 'n8n-workflow';
import type { CodeNodeEditorMixin } from '../types';
export const jsonFieldCompletions = (Vue as CodeNodeEditorMixin).extend({
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<string, string>,
): 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);
}
}
if (originalValue.includes('all')) {
const match = originalValue.match(/\$(input|\(.*\))\.all\(\)\[(?<index>.+)\]$/);
if (!match?.groups?.index) return null;
const { index } = match.groups;
const jsonOutput = this.getJsonOutput(inputNodeName, { index: Number(index) });
if (!jsonOutput) return null;
return this.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']`.
*/
inputJsonFieldCompletions(context: CompletionContext): CompletionResult | null {
const patterns = {
first: /\$input\.first\(\)\.json(\[|\.).*/,
last: /\$input\.last\(\)\.json(\[|\.).*/,
item: /\$input\.item\.json(\[|\.).*/,
all: /\$input\.all\(\)\[(?<index>\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;
const jsonOutput = this.getJsonOutput(inputNodeName, { index: Number(index) });
if (!jsonOutput) continue;
return this.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']`.
*/
selectorJsonFieldCompletions(context: CompletionContext): CompletionResult | null {
const patterns = {
first: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.first\(\)\.json(\[|\.).*/,
last: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.last\(\)\.json(\[|\.).*/,
item: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.item\.json(\[|\.).*/,
all: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.all\(\)\[(?<index>\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 = 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;
return this.toJsonFieldCompletions(preCursor, jsonOutput, `${selector}.item.json`);
}
if (name === 'all') {
const match = preCursor.text.match(regex);
if (!match?.groups?.index) continue;
const { index } = match.groups;
const jsonOutput = this.getJsonOutput(quotedNodeName, { index: Number(index) });
if (!jsonOutput) continue;
return this.toJsonFieldCompletions(
preCursor,
jsonOutput,
`${selector}.all()[${index}].json`,
);
}
}
return null;
},
getInputNodeName() {
try {
const activeNode = this.$store.getters.activeNode;
const workflow = this.getCurrentWorkflow();
const input = workflow.connectionsByDestinationNode[activeNode.name];
return input.main[0][0].node;
} catch (_) {
return null;
}
},
/**
* .json -> .json['field']
* .json -> .json.field
*/
toJsonFieldCompletions(
preCursor: NonNullable<ReturnType<CompletionContext['matchBefore']>>,
jsonOutput: IDataObject,
matcher: string, // e.g. `$input.first().json` or `x` (user-defined variable)
) {
if (preCursor.text.endsWith('.json[') || preCursor.text.endsWith(`${matcher}[`)) {
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 (preCursor.text.endsWith('.json.') || preCursor.text.endsWith(`${matcher}.`)) {
const options: Completion[] = Object.keys(jsonOutput)
.filter(isAllowedInDotNotation)
.map((field) => `${matcher}.${field}`)
.map(toVariableOption);
return {
from: preCursor.from,
options,
};
}
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 }) {
const nodeName = quotedNodeName.replace(/['"]/g, '');
const pinData: IPinData | undefined = this.$store.getters.pinData;
const nodePinData = pinData && pinData[nodeName];
if (nodePinData) {
try {
let itemIndex = options?.index ?? 0;
if (options?.accessor === 'last') {
itemIndex = nodePinData.length - 1;
}
return nodePinData[itemIndex].json;
} catch (_) {}
}
const runData: IRunData | null = this.$store.getters.getWorkflowRunData;
const nodeRunData = runData && runData[nodeName];
if (!nodeRunData) return null;
try {
let itemIndex = options?.index ?? 0;
if (options?.accessor === 'last') {
const inputItems = nodeRunData[0].data!.main[0]!;
itemIndex = inputItems.length - 1;
}
return nodeRunData[0].data!.main[0]![itemIndex].json;
} catch (_) {
return null;
}
},
},
});

View File

@@ -0,0 +1,284 @@
import Vue from 'vue';
import { escape } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
export const luxonCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* Complete `$today.` with luxon `DateTime` instance methods.
*/
todayCompletions(context: CompletionContext, matcher = '$today'): 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[] = this.luxonInstanceGetters().map(([getter, description]) => {
return {
label: `${matcher}.${getter}`,
type: 'function',
info: description,
};
});
options.push(...this.luxonInstanceMethods().map(([method, description]) => {
return {
label: `${matcher}.${method}()`,
type: 'function',
info: description,
};
}));
return {
from: preCursor.from,
options,
};
},
/**
* Complete `$now.` with luxon `DateTime` instance methods.
*/
nowCompletions(context: CompletionContext, matcher = '$now'): 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[] = this.luxonInstanceGetters().map(([getter, description]) => {
return {
label: `${matcher}.${getter}`,
type: 'function',
info: description,
};
});
options.push(...this.luxonInstanceMethods().map(([method, description]) => {
return {
label: `${matcher}.${method}()`,
type: 'function',
info: description,
};
}));
return {
from: preCursor.from,
options,
};
},
/**
* Complete `DateTime` with luxon `DateTime` static methods.
*/
dateTimeCompltions(context: CompletionContext, matcher = 'DateTime'): 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[] = this.luxonDateTimeStaticMethods().map(
([method, description]) => {
return {
label: `DateTime.${method}()`,
type: 'function',
info: description,
};
},
);
return {
from: preCursor.from,
options,
};
},
luxonDateTimeStaticMethods() {
return Object.entries({
now: this.$locale.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.now'),
local: this.$locale.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.local'),
utc: this.$locale.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.utc'),
fromJSDate: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromJSDate',
),
fromMillis: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromMillis',
),
fromSeconds: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSeconds',
),
fromObject: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromObject',
),
fromISO: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromISO',
),
fromRFC2822: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromRFC2822',
),
fromHTTP: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromHTTP',
),
fromFormat: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromFormat',
),
fromSQL: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSQL',
),
invalid: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.invalid',
),
isDateTime: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.isDateTime',
),
});
},
luxonInstanceGetters() {
return Object.entries({
isValid: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.isValid'),
invalidReason: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.invalidReason',
),
invalidExplanation: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.invalidExplanation',
),
locale: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.locale'),
numberingSystem: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.numberingSystem',
),
outputCalendar: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.outputCalendar',
),
zone: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.zone'),
zoneName: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.zoneName'),
year: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.year'),
quarter: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.quarter'),
month: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.month'),
day: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.day'),
hour: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.hour'),
minute: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.minute'),
second: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.second'),
millisecond: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.millisecond',
),
weekYear: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekYear'),
weekNumber: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.weekNumber',
),
weekday: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekday'),
ordinal: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.ordinal'),
monthShort: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.monthShort',
),
monthLong: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.monthLong',
),
weekdayShort: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.weekdayShort',
),
weekdayLong: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.weekdayLong',
),
offset: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.offset'),
offsetNumber: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.offsetNumber',
),
offsetNameShort: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.offsetNameShort',
),
offsetNameLong: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.offsetNameLong',
),
isOffsetFixed: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.isOffsetFixed',
),
isInDST: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.isInDST'),
isInLeapYear: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.isInLeapYear',
),
daysInMonth: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.daysInMonth',
),
daysInYear: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.daysInYear',
),
weeksInWeekYear: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.weeksInWeekYear',
),
});
},
luxonInstanceMethods() {
return Object.entries({
toUTC: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toUTC'),
toLocal: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toLocal'),
setZone: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.setZone'),
setLocale: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.setLocale',
),
set: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.set'),
plus: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.plus'),
minus: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.minus'),
startOf: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.startOf'),
endOf: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.endOf'),
toFormat: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toFormat'),
toLocaleString: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toLocaleString',
),
toLocaleParts: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toLocaleParts',
),
toISO: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toISO'),
toISODate: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toISODate',
),
toISOWeekDate: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toISOWeekDate',
),
toISOTime: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toISOTime',
),
toRFC2822: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toRFC2822',
),
toHTTP: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toHTTP'),
toSQLDate: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toSQLDate',
),
toSQLTime: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toSQLTime',
),
toSQL: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toSQL'),
toString: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toString'),
valueOf: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.valueOf'),
toMillis: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toMillis'),
toSeconds: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toSeconds',
),
toUnixInteger: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toUnixInteger',
),
toJSON: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toJSON'),
toBSON: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toBSON'),
toObject: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toObject'),
toJsDate: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toJsDate'),
diff: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.diff'),
diffNow: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.diffNow'),
until: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.until'),
hasSame: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.hasSame'),
equals: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.equals'),
toRelative: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toRelative',
),
toRelativeCalendar: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toRelativeCalendar',
),
min: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.min'),
max: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.max'),
});
},
},
});

View File

@@ -0,0 +1,46 @@
import Vue from 'vue';
import { addVarType } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
const DEFAULT_MATCHER = '$prevNode';
const escape = (str: string) => str.replace('$', '\\$');
export const prevNodeCompletions = (Vue as CodeNodeEditorMixin).extend({
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),
};
},
},
});

View File

@@ -0,0 +1,41 @@
import Vue from 'vue';
import { AUTOCOMPLETABLE_BUILT_IN_MODULES } from '../constants';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
export const requireCompletions = (Vue as CodeNodeEditorMixin).extend({
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 allowedModules = this.$store.getters['settings/allowedModules'];
const toOption = (moduleName: string) => ({
label: `require('${moduleName}');`,
type: 'variable',
});
if (allowedModules?.builtIn?.includes('*')) {
options.push(...AUTOCOMPLETABLE_BUILT_IN_MODULES.map(toOption));
} else if (allowedModules?.builtIn?.length > 0) {
options.push(...allowedModules.builtIn.map(toOption));
}
if (allowedModules?.external?.length > 0) {
options.push(...allowedModules.external.map(toOption));
}
return {
from: preCursor.from,
options,
};
},
},
});

View File

@@ -0,0 +1,44 @@
import Vue from 'vue';
import { addVarType } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
const escape = (str: string) => str.replace('$', '\\$');
export const workflowCompletions = (Vue as CodeNodeEditorMixin).extend({
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),
};
},
},
});