feat(editor): Completions for extensions in expression editor (#5130)

* 🔥 Remove test extensions

* 🚧 Add test description

* 📘 Expand types

*  Export extensions

*  Export collection

*  Mark all proxies

* ✏️ Rename for clarity

*  Export from barrel

*  Create datatype completions

*  Mount datatype completions

* 🧪 Adjust tests

*  Add `path` prop

* 🔥 Remove `()` from completion labels

*  Filter out completions for pseudo-proxies

* 🐛 Fix method error

*  Add metrics

* ✏️ Improve naming

*  Start completion on empty resolvable

*  Implement completion previews

*  Break out completion manager

*  Implement in expression editor modal

* ✏️ Improve naming

*  Filter out irrelevant completions

*  Add preview hint

* ✏️ Improve comments

* 🎨 Style preview hint

*  Expand `hasNoParams`

*  Add spacing for readability

*  Add error codes

* ✏️ Add comment

* 🐛 Fix Esc behavior

*  Parse Unicode

*  Throw on invalid `DateTime`

*  Fix second root completion detection

*  Switch message at completable prefix position

* 🐛 Fix function names for non-dev build

* 🐛 Fix `json` handling

* 🔥 Comment out previews

* ♻️ Apply feedback

* 🔥 Remove extensions

* 🚚 Rename extensions

*  Adjust some implementations

* 🔥 Remove dummy extensions

* 🐛 Fix object regex

* ♻️ Apply feedback

* ✏️ Fix typos

* ✏️ Add `fn is not a function` message

* 🔥 Remove check

*  Add `isNotEmpty` for objects

* 🚚 Rename `global` to `alpha`

* 🔥 Remove `encrypt`

*  Restore `is not a function` error

*  Support `week` on `extract()`

* 🧪 Fix tests

*  Add validation to some string extensions

*  Validate number arrays in some extensions

* 🧪 Fix tests

* ✏️ Improve error message

*  Revert extensions framework changes

* 🧹 Previews cleanup

*  Condense blank completions

*  Refactor dollar completions

*  Refactor non-dollar completions

*  Refactor Luxon completions

*  Refactor datatype completions

*  Use `DATETIMEUNIT_MAP`

* ✏️ Update test description

*  Revert "Use `DATETIMEUNIT_MAP`"

This reverts commit 472a77df5cd789905d162f3c3db02ac767b89b4e.

* 🧪 Add tests

* ♻️ Restore generic extensions

* 🔥 Remove logs

* 🧪 Expand tests

*  Add `Math` completions

* ✏️ List breaking change

*  Add doc tooltips

* 🐛 Fix node selector regex

* 🐛 Fix `context` resolution

* 🐛 Allow dollar completions in args

*  Make numeric array methods context-dependent

* 📝 Adjust docs

* 🐛 Fix selector ref

*  Surface error for valid URL

* 🐛 Disallow whitespace in `isEmail` check

* 🧪 Fix test for `isUrl`

*  Add comma validator in `toFloat`

*  Add validation to `$jmespath()`

*  Revert valid URL error

*  Adjust `$jmespath()` validation

* 🧪 Adjust `isUrl` test

*  Remove `{}` and `[]` from compact

* ✏️ Update docs

* 🚚 Rename `stripTags` to `removeTags`

*  Do not inject whitespace inside resolvable

*  Make completions aware of `()`

* ✏️ Add note

*  Update sorting

*  Hide active node name from node selector

* 🔥 Remove `length()` and its aliases

*  Validate non-zero for `chunk`

* ✏️ Reword all error messages

* 🐛 Fix `$now` and `$today`

*  Simplify with `stripExcessParens`

*  Fold luxon into datatype

* 🧪 Clean up tests

* 🔥 Remove tests for removed methods

* 👕 Fix type

* ⬆️ Upgrade lang pack

*  Undo change to `vitest` command

* 🔥 Remove unused method

*  Separate `return` line

* ✏️ Improve description

* 🧪 Expand tests for initial-only completions

* 🧪 Add bracket-aware completions

*  Make check for `all()` stricter

* ✏️ Adjust explanatory comments

* 🔥 Remove unneded copy

* 🔥 Remove outdated comment

*  Make naming consistent

* ✏️ Update comments

*  Improve URL scheme check

* ✏️ Add comment

* 🚚 Move extension

* ✏️ Update `BREAKING-CHANGES.md`

* ✏️ Update upcoming version

* ✏️ Fix grammar

* ✏️ Shorten message

* 🐛 Fix `Esc` behavior

* 🐛 Fix `isNumeric`

*  Support native methods

* 🧪 Skip Pinia tests

* ✏️ Shorten description

* 🔥 Remove outdated comment

* 🧪 Unskip Pinia tests

* ✏️ Add comments

* 🧪 Expand tests to natives

* ✏️ Add clarifying comments

*  Use `setTimeout` to make telemetry non-blocking

* 🐛 Account for no active node in cred modal

*  Resolve without workflow

* 🔥 Remove `Esc` handling on NDV

*  Use `isDateTime`

* 🚚 Move `unique` to next phase

This array extension takes optional args.

*  Merge export

* 🧪 Fix tests

*  Restore check

* ✏️ Make breaking change description more accurate

* 🧪 Fix e2e tests
This commit is contained in:
Iván Ovejero
2023-02-02 12:35:38 +01:00
committed by GitHub
parent ee210e8507
commit 6d811f0d9f
58 changed files with 2269 additions and 1240 deletions

View File

@@ -36,12 +36,16 @@ describe('Inline expression editor', () => {
it('should resolve object resolvables', () => {
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('{{} a: 1');
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^\[Object: \{"a":1\}\]$/);
WorkflowPage.getters
.inlineExpressionEditorInput()
.type('{ a: 1 }', { parseSpecialCharSequences: false });
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^\[Object: \{"a": 1\}\]$/);
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('{{} a: 1 }.a{del}{del}');
WorkflowPage.getters
.inlineExpressionEditorInput()
.type('{ a: 1 }.a', { parseSpecialCharSequences: false });
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^1$/);
});

View File

@@ -17,49 +17,45 @@ describe('Expression editor modal', () => {
});
it('should resolve primitive resolvables', () => {
WorkflowPage.getters.expressionModalInput().type('{{');
WorkflowPage.getters.expressionModalInput().type('1 + 2');
WorkflowPage.getters.expressionModalInput().type('{{ 1 + 2');
WorkflowPage.getters.expressionModalOutput().contains(/^3$/);
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{');
WorkflowPage.getters.expressionModalInput().type('"ab" + "cd"');
WorkflowPage.getters.expressionModalInput().type('{{ "ab" + "cd"');
WorkflowPage.getters.expressionModalOutput().contains(/^abcd$/);
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{');
WorkflowPage.getters.expressionModalInput().type('true && false');
WorkflowPage.getters.expressionModalInput().type('{{ true && false');
WorkflowPage.getters.expressionModalOutput().contains(/^false$/);
});
it('should resolve object resolvables', () => {
WorkflowPage.getters.expressionModalInput().type('{{');
WorkflowPage.getters.expressionModalInput().type('{{} a: 1');
WorkflowPage.getters.expressionModalOutput().contains(/^\[Object: \{"a":1\}\]$/);
WorkflowPage.getters
.expressionModalInput()
.type('{{ { a : 1 }', { parseSpecialCharSequences: false });
WorkflowPage.getters.expressionModalOutput().contains(/^\[Object: \{"a": 1\}\]$/);
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{');
WorkflowPage.getters.expressionModalInput().type('{{} a: 1 }.a{del}{del}');
WorkflowPage.getters
.expressionModalInput()
.type('{{ { a : 1 }.a', { parseSpecialCharSequences: false });
WorkflowPage.getters.expressionModalOutput().contains(/^1$/);
});
it('should resolve array resolvables', () => {
WorkflowPage.getters.expressionModalInput().type('{{');
WorkflowPage.getters.expressionModalInput().type('[1, 2, 3]');
WorkflowPage.getters.expressionModalInput().type('{{ [1, 2, 3]');
WorkflowPage.getters.expressionModalOutput().contains(/^\[Array: \[1,2,3\]\]$/);
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{');
WorkflowPage.getters.expressionModalInput().type('[1, 2, 3][0]');
WorkflowPage.getters.expressionModalInput().type('{{ [1, 2, 3][0]');
WorkflowPage.getters.expressionModalOutput().contains(/^1$/);
});
it('should resolve $parameter[]', () => {
WorkflowPage.getters.expressionModalInput().type('{{');
WorkflowPage.getters.expressionModalInput().type('$parameter["operation"]');
WorkflowPage.getters.expressionModalInput().type('{{ $parameter["operation"]');
WorkflowPage.getters.expressionModalOutput().contains(/^get$/);
});
});

View File

@@ -2,6 +2,16 @@
This list shows all the versions which include breaking changes and how to upgrade.
## 0.214.0
### What changed?
Invalid Luxon datetimes no longer resolve to `null`. Now they throw the error `invalid DateTime`.
### When is action necessary?
If you are relying on the above behavior, review your workflow to ensure you handle invalid Luxon datetimes.
## 0.202.0
### What changed?

View File

@@ -46,7 +46,7 @@
"@jsplumb/util": "^5.13.2",
"axios": "^0.21.1",
"codemirror-lang-html-n8n": "^1.0.0",
"codemirror-lang-n8n-expression": "^0.1.0",
"codemirror-lang-n8n-expression": "^0.2.0",
"dateformat": "^3.0.3",
"esprima-next": "5.8.4",
"fast-json-stable-stringify": "^2.1.0",

View File

@@ -46,6 +46,7 @@
<ExpressionEditorModalInput
:value="value"
:isReadOnly="isReadOnly"
:path="path"
@change="valueChanged"
@close="closeDialog"
ref="inputFieldExpression"

View File

@@ -1,30 +1,34 @@
<template>
<div ref="root" class="ph-no-capture" @keydown.stop @keydown.esc="onClose"></div>
<div ref="root" class="ph-no-capture" @keydown.stop></div>
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { EditorState, Prec } from '@codemirror/state';
import { history } from '@codemirror/commands';
import { workflowHelpers } from '@/mixins/workflowHelpers';
import { expressionManager } from '@/mixins/expressionManager';
import { completionManager } from '@/mixins/completionManager';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { n8nLang } from '@/plugins/codemirror/n8nLang';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { inputTheme } from './theme';
import type { IVariableItemSelected } from '@/Interface';
import { forceParse } from '@/utils/forceParse';
import { autocompletion } from '@codemirror/autocomplete';
export default mixins(expressionManager, workflowHelpers).extend({
import type { IVariableItemSelected } from '@/Interface';
export default mixins(expressionManager, completionManager, workflowHelpers).extend({
name: 'ExpressionEditorModalInput',
props: {
value: {
type: String,
},
path: {
type: String,
},
isReadOnly: {
type: Boolean,
},
@@ -38,6 +42,20 @@ export default mixins(expressionManager, workflowHelpers).extend({
const extensions = [
inputTheme(),
autocompletion(),
Prec.highest(
keymap.of([
{
any: (_: EditorView, event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation();
this.$emit('close');
}
return false;
},
},
]),
),
n8nLang(),
history(),
expressionInputHandler(),
@@ -50,14 +68,17 @@ export default mixins(expressionManager, workflowHelpers).extend({
highlighter.removeColor(this.editor, this.plaintextSegments);
highlighter.addColor(this.editor, this.resolvableSegments);
setTimeout(() => this.editor?.focus()); // prevent blur on paste
setTimeout(() => {
this.$emit('change', {
value: this.unresolvedExpression,
segments: this.displayableSegments,
});
}, this.evaluationDelay);
this.editor?.focus(); // prevent blur on paste
try {
this.trackCompletion(viewUpdate, this.path);
} catch {}
});
this.$emit('change', {
value: this.unresolvedExpression,
segments: this.displayableSegments,
});
}),
];
@@ -86,9 +107,6 @@ export default mixins(expressionManager, workflowHelpers).extend({
this.editor?.destroy();
},
methods: {
onClose() {
this.$emit('close');
},
itemSelected({ variable }: IVariableItemSelected) {
if (!this.editor || this.isReadOnly) return;

View File

@@ -15,6 +15,7 @@
:isReadOnly="isReadOnly"
:targetItem="hoveringItem"
:isSingleLine="isForRecordLocator"
:path="path"
@focus="onFocus"
@blur="onBlur"
@change="onChange"
@@ -93,6 +94,9 @@ export default Vue.extend({
};
},
props: {
path: {
type: String,
},
value: {
type: String,
},

View File

@@ -5,9 +5,10 @@
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { mapStores } from 'pinia';
import { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { EditorState, Prec } from '@codemirror/state';
import { history } from '@codemirror/commands';
import { autocompletion, completionStatus } from '@codemirror/autocomplete';
import { useNDVStore } from '@/stores/ndv';
import { workflowHelpers } from '@/mixins/workflowHelpers';
@@ -15,10 +16,10 @@ import { expressionManager } from '@/mixins/expressionManager';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { inputTheme } from './theme';
import { autocompletion, ifIn } from '@codemirror/autocomplete';
import { n8nLang } from '@/plugins/codemirror/n8nLang';
import { completionManager } from '@/mixins/completionManager';
export default mixins(expressionManager, workflowHelpers).extend({
export default mixins(completionManager, expressionManager, workflowHelpers).extend({
name: 'InlineExpressionEditorInput',
props: {
value: {
@@ -32,11 +33,9 @@ export default mixins(expressionManager, workflowHelpers).extend({
type: Boolean,
default: false,
},
},
data() {
return {
cursorPosition: 0,
};
path: {
type: String,
},
},
watch: {
value(newValue) {
@@ -77,6 +76,19 @@ export default mixins(expressionManager, workflowHelpers).extend({
mounted() {
const extensions = [
inputTheme({ isSingleLine: this.isSingleLine }),
Prec.highest(
keymap.of([
{
any(view: EditorView, event: KeyboardEvent) {
if (event.key === 'Escape' && completionStatus(view.state) !== null) {
event.stopPropagation();
}
return false;
},
},
]),
),
autocompletion(),
n8nLang(),
history(),
@@ -94,14 +106,16 @@ export default mixins(expressionManager, workflowHelpers).extend({
highlighter.removeColor(this.editor, this.plaintextSegments);
highlighter.addColor(this.editor, this.resolvableSegments);
this.cursorPosition = viewUpdate.view.state.selection.ranges[0].from;
setTimeout(() => {
this.$emit('change', {
value: this.unresolvedExpression,
segments: this.displayableSegments,
});
}, this.evaluationDelay);
try {
this.trackCompletion(viewUpdate, this.path);
} catch {}
});
this.$emit('change', {
value: this.unresolvedExpression,
segments: this.displayableSegments,
});
}),
];

View File

@@ -38,6 +38,7 @@
:value="expressionDisplayValue"
:title="displayTitle"
:isReadOnly="isReadOnly"
:path="path"
@valueChanged="expressionUpdated"
@modalOpenerClick="openExpressionEditorModal"
@focus="setFocus"

View File

@@ -81,6 +81,7 @@
<ExpressionParameterInput
v-if="isValueExpression || forceShowExpression"
:value="expressionDisplayValue"
:path="path"
isForRecordLocator
@valueChanged="onInputChange"
@modalOpenerClick="$emit('modalOpenerClick')"

View File

@@ -0,0 +1,76 @@
import mixins from 'vue-typed-mixins';
import { ExpressionExtensions } from 'n8n-workflow';
import { EditorView, ViewUpdate } from '@codemirror/view';
import { expressionManager } from './expressionManager';
export const completionManager = mixins(expressionManager).extend({
data() {
return {
editor: {} as EditorView,
};
},
computed: {
expressionExtensionsCategories() {
return ExpressionExtensions.reduce<Record<string, string | undefined>>((acc, cur) => {
for (const fnName of Object.keys(cur.functions)) {
acc[fnName] = cur.typeName;
}
return acc;
}, {});
},
},
methods: {
trackCompletion(viewUpdate: ViewUpdate, parameterPath: string) {
const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete'));
if (!completionTx) return;
let completion = '';
let completionBase = '';
viewUpdate.changes.iterChanges((_: number, __: number, fromB: number, toB: number) => {
completion = this.editor.state.doc.slice(fromB, toB).toString();
const index = this.findCompletionBaseStartIndex(fromB);
completionBase = this.editor.state.doc
.slice(index, fromB - 1)
.toString()
.trim();
});
const category = this.expressionExtensionsCategories[completion];
const payload = {
instance_id: this.rootStore.instanceId,
node_type: this.ndvStore.activeNode?.type,
field_name: parameterPath,
field_type: 'expression',
context: completionBase,
inserted_text: completion,
category: category ?? 'n/a', // only applicable if expression extension completion
};
this.$telemetry.track('User autocompleted code', payload);
},
findCompletionBaseStartIndex(fromIndex: number) {
const INDICATORS = [
' $', // proxy
'{ ', // primitive
];
const doc = this.editor.state.doc.toString();
for (let index = fromIndex; index > 0; index--) {
if (INDICATORS.some((indicator) => indicator === doc[index] + doc[index + 1])) {
return index + 1;
}
}
return -1;
},
},
});

View File

@@ -1,4 +1,5 @@
import mixins from 'vue-typed-mixins';
import { Expression, ExpressionExtensions } from 'n8n-workflow';
import { mapStores } from 'pinia';
import { ensureSyntaxTree } from '@codemirror/language';
@@ -19,8 +20,7 @@ export const expressionManager = mixins(workflowHelpers).extend({
},
data() {
return {
editor: null as EditorView | null,
errorsInSuccession: 0,
editor: {} as EditorView,
};
},
watch: {
@@ -56,13 +56,19 @@ export const expressionManager = mixins(workflowHelpers).extend({
return this.segments.filter((s): s is Plaintext => s.kind === 'plaintext');
},
expressionExtensionNames(): Set<string> {
return new Set(
ExpressionExtensions.reduce<string[]>((acc, cur) => {
return [...acc, ...Object.keys(cur.functions)];
}, []),
);
},
htmlSegments(): Html[] {
return this.segments.filter((s): s is Html => s.kind !== 'resolvable');
},
segments(): Segment[] {
if (!this.editor) return [];
const rawSegments: RawSegment[] = [];
const fullTree = ensureSyntaxTree(
@@ -76,76 +82,49 @@ export const expressionManager = mixins(workflowHelpers).extend({
}
fullTree.cursor().iterate((node) => {
if (!this.editor || node.type.name === 'Program') return;
if (node.type.name === 'Program') return;
rawSegments.push({
from: node.from,
to: node.to,
text: this.editor.state.sliceDoc(node.from, node.to),
type: node.type.name,
token: node.type.name,
});
});
return rawSegments.reduce<Segment[]>((acc, segment) => {
const { from, to, text, type } = segment;
const { from, to, text, token } = segment;
if (type === 'Resolvable') {
const { resolved, error, fullError } = this.resolve(text, this.hoveringItem);
acc.push({ kind: 'resolvable', from, to, resolvable: text, resolved, error, fullError });
if (token === 'Plaintext') {
acc.push({ kind: 'plaintext', from, to, plaintext: text });
return acc;
}
acc.push({ kind: 'plaintext', from, to, plaintext: text });
const { resolved, error, fullError } = this.resolve(text, this.hoveringItem);
acc.push({ kind: 'resolvable', from, to, resolvable: text, resolved, error, fullError });
return acc;
}, []);
},
evaluationDelay() {
const DEFAULT_EVALUATION_DELAY = 300; // ms
const prevErrorsInSuccession = this.errorsInSuccession;
if (this.resolvableSegments.filter((s) => s.error).length > 0) {
this.errorsInSuccession += 1;
} else {
this.errorsInSuccession = 0;
}
const addsNewError = this.errorsInSuccession > prevErrorsInSuccession;
let delay = DEFAULT_EVALUATION_DELAY;
if (addsNewError && this.errorsInSuccession > 1 && this.errorsInSuccession < 5) {
delay = DEFAULT_EVALUATION_DELAY * this.errorsInSuccession;
} else if (addsNewError && this.errorsInSuccession >= 5) {
delay = 0;
}
return delay;
},
/**
* Some segments are conditionally displayed, i.e. not displayed when they are
* _part_ of the result, but displayed when they are the _entire_ result.
* Segments to display in the output of an expression editor.
*
* Example:
* - Expression `This is a {{ [] }} test` is displayed as `This is a test`.
* - Expression `{{ [] }}` is displayed as `[Array: []]`.
* Some segments are not displayed when they are _part_ of the result,
* but displayed when they are the _entire_ result:
*
* Conditionally displayed segments:
* - `[Array: []]`
* - `[empty]` (from `''`, not from `undefined`)
* - `This is a {{ [] }} test` displays as `This is a test`.
* - `{{ [] }}` displays as `[Array: []]`.
*
* Exceptionally, for two segments, display differs based on context:
* - Date is displayed as
* - `Mon Nov 14 2022 17:26:13 GMT+0100 (CST)` when part of the result
* - `[Object: "2022-11-14T17:26:13.130Z"]` when the entire result
* - Non-empty array is displayed as
* - `1,2,3` when part of the result
* - `[Array: [1, 2, 3]]` when the entire result
* Some segments display differently based on context:
*
* Date displays as
* - `Mon Nov 14 2022 17:26:13 GMT+0100 (CST)` when part of the result
* - `[Object: "2022-11-14T17:26:13.130Z"]` when the entire result
*
* Only needed in order to mimic behavior of `ParameterInputHint`.
*/
displayableSegments(): Segment[] {
return this.segments
@@ -191,12 +170,17 @@ export const expressionManager = mixins(workflowHelpers).extend({
};
try {
result.resolved = this.resolveExpression('=' + resolvable, undefined, {
targetItem: targetItem ?? undefined,
inputNodeName: this.ndvStore.ndvInputNodeName,
inputRunIndex: this.ndvStore.ndvInputRunIndex,
inputBranchIndex: this.ndvStore.ndvInputBranchIndex,
});
if (!useNDVStore().activeNode) {
// e.g. credential modal
result.resolved = Expression.resolveWithoutWorkflow(resolvable);
} else {
result.resolved = this.resolveExpression('=' + resolvable, undefined, {
targetItem: targetItem ?? undefined,
inputNodeName: this.ndvStore.ndvInputNodeName,
inputRunIndex: this.ndvStore.ndvInputRunIndex,
inputBranchIndex: this.ndvStore.ndvInputBranchIndex,
});
}
} catch (error) {
result.resolved = `[${error.message}]`;
result.error = true;
@@ -212,7 +196,10 @@ export const expressionManager = mixins(workflowHelpers).extend({
}
if (result.resolved === undefined) {
result.resolved = this.$locale.baseText('expressionModalInput.undefined');
result.resolved = this.isUncalledExpressionExtension(resolvable)
? this.$locale.baseText('expressionEditor.uncalledFunction')
: this.$locale.baseText('expressionModalInput.undefined');
result.error = true;
}
@@ -222,5 +209,15 @@ export const expressionManager = mixins(workflowHelpers).extend({
return result;
},
isUncalledExpressionExtension(resolvable: string) {
const end = resolvable
.replace(/^{{|}}$/g, '')
.trim()
.split('.')
.pop();
return end !== undefined && this.expressionExtensionNames.has(end);
},
},
});

View File

@@ -82,6 +82,7 @@ export function resolveParameter(
const inputName = 'main';
const activeNode = useNDVStore().activeNode;
const workflow = getCurrentWorkflow();
const workflowRunData = useWorkflowsStore().getWorkflowRunData;
let parentNode = workflow.getParentNodes(activeNode!.name, inputName, 1);

View File

@@ -1,30 +0,0 @@
import { alphaCompletions } from '../alpha.completions';
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
const EXPLICIT = false;
test('should return alphabetic char completion options: D', () => {
const doc = '{{ D }}';
const position = doc.indexOf('D') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = alphaCompletions(context);
if (!result) throw new Error('Expected D completion options');
const { options, from } = result;
expect(options.map((o) => o.label)).toEqual(['DateTime']);
expect(from).toEqual(position - 1);
});
test('should not return alphabetic char completion options: $input.D', () => {
const doc = '{{ $input.D }}';
const position = doc.indexOf('D') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = alphaCompletions(context);
expect(result).toBeNull();
});

View File

@@ -0,0 +1,360 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { DateTime } from 'luxon';
import * as workflowHelpers from '@/mixins/workflowHelpers';
import { dollarOptions } from '@/plugins/codemirror/completions/dollar.completions';
import * as utils from '@/plugins/codemirror/completions/utils';
import {
extensions,
luxonInstanceOptions,
luxonStaticOptions,
natives,
} from '@/plugins/codemirror/completions/datatype.completions';
import { mockNodes, mockProxy } from './mock';
import { CompletionContext, CompletionSource, CompletionResult } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import { n8nLang } from '@/plugins/codemirror/n8nLang';
beforeEach(() => {
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('No completions', () => {
test('should not return completions mid-word: {{ "ab|c" }}', () => {
expect(completions('{{ "ab|c" }}')).toBeNull();
});
test('should not return completions for isolated dot: {{ "abc. |" }}', () => {
expect(completions('{{ "abc. |" }}')).toBeNull();
});
});
describe('Top-level completions', () => {
test('should return dollar completions for blank position: {{ | }}', () => {
expect(completions('{{ | }}')).toHaveLength(dollarOptions().length);
});
test('should return DateTime completion for: {{ D| }}', () => {
const found = completions('{{ D| }}');
if (!found) throw new Error('Expected to find completion');
expect(found).toHaveLength(1);
expect(found[0].label).toBe('DateTime');
});
test('should return Math completion for: {{ M| }}', () => {
const found = completions('{{ M| }}');
if (!found) throw new Error('Expected to find completion');
expect(found).toHaveLength(1);
expect(found[0].label).toBe('Math');
});
test('should return dollar completions for: {{ $| }}', () => {
expect(completions('{{ $| }}')).toHaveLength(dollarOptions().length);
});
test('should return node selector completions for: {{ $(| }}', () => {
const initialState = { workflows: { workflow: { nodes: mockNodes } } };
setActivePinia(createTestingPinia({ initialState }));
expect(completions('{{ $(| }}')).toHaveLength(mockNodes.length);
});
});
describe('Luxon method completions', () => {
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
test('should return class completions for: {{ DateTime.| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce(DateTime);
expect(completions('{{ DateTime.| }}')).toHaveLength(luxonStaticOptions().length);
});
test('should return instance completions for: {{ $now.| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce(DateTime.now());
expect(completions('{{ $now.| }}')).toHaveLength(
luxonInstanceOptions().length + extensions('date').length,
);
});
test('should return instance completions for: {{ $today.| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce(DateTime.now());
expect(completions('{{ $today.| }}')).toHaveLength(
luxonInstanceOptions().length + extensions('date').length,
);
});
});
describe('Resolution-based completions', () => {
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
describe('literals', () => {
test('should return completions for string literal: {{ "abc".| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce('abc');
expect(completions('{{ "abc".| }}')).toHaveLength(
natives('string').length + extensions('string').length,
);
});
test('should return completions for number literal: {{ (123).| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce(123);
expect(completions('{{ (123).| }}')).toHaveLength(
natives('number').length + extensions('number').length,
);
});
test('should return completions for array literal: {{ [1, 2, 3].| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce([1, 2, 3]);
expect(completions('{{ [1, 2, 3].| }}')).toHaveLength(
natives('array').length + extensions('array').length,
);
});
test('should return completions for object literal', () => {
const object = { a: 1 };
resolveParameterSpy.mockReturnValueOnce(object);
expect(completions('{{ ({ a: 1 }).| }}')).toHaveLength(
Object.keys(object).length + natives('object').length + extensions('object').length,
);
});
});
describe('bracket-aware completions', () => {
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
const { $input } = mockProxy;
test('should return bracket-aware completions for: {{ $input.item.json.str.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.str);
const found = completions('{{ $input.item.json.str.|() }}');
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(extensions('string').length);
expect(found.map((c) => c.label).every((l) => !l.endsWith('()')));
});
test('should return bracket-aware completions for: {{ $input.item.json.num.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.num);
const found = completions('{{ $input.item.json.num.|() }}');
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(extensions('number').length);
expect(found.map((c) => c.label).every((l) => !l.endsWith('()')));
});
test('should return bracket-aware completions for: {{ $input.item.json.arr.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.arr);
const found = completions('{{ $input.item.json.arr.|() }}');
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(extensions('array').length);
expect(found.map((c) => c.label).every((l) => !l.endsWith('()')));
});
});
describe('references', () => {
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
const { $input, $ } = mockProxy;
test('should return completions for: {{ $input.| }}', () => {
resolveParameterSpy.mockReturnValue($input);
expect(completions('{{ $input.| }}')).toHaveLength(Reflect.ownKeys($input).length);
});
test("should return completions for: {{ $('nodeName').| }}", () => {
resolveParameterSpy.mockReturnValue($('Rename'));
expect(completions('{{ $("Rename").| }}')).toHaveLength(
Reflect.ownKeys($('Rename')).length - ['pairedItem'].length,
);
});
test('should return completions for: {{ $input.item.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item);
const found = completions('{{ $input.item.| }}');
if (!found) throw new Error('Expected to find completion');
expect(found).toHaveLength(1);
expect(found[0].label).toBe('json');
});
test('should return completions for: {{ $input.first().| }}', () => {
resolveParameterSpy.mockReturnValue($input.first());
const found = completions('{{ $input.first().| }}');
if (!found) throw new Error('Expected to find completion');
expect(found).toHaveLength(1);
expect(found[0].label).toBe('json');
});
test('should return completions for: {{ $input.last().| }}', () => {
resolveParameterSpy.mockReturnValue($input.last());
const found = completions('{{ $input.last().| }}');
if (!found) throw new Error('Expected to find completion');
expect(found).toHaveLength(1);
expect(found[0].label).toBe('json');
});
test('should return no completions for: {{ $input.all().| }}', () => {
// @ts-expect-error
resolveParameterSpy.mockReturnValue([$input.item]);
expect(completions('{{ $input.all().| }}')).toBeNull();
});
test("should return completions for: '{{ $input.item.| }}'", () => {
resolveParameterSpy.mockReturnValue($input.item.json);
expect(completions('{{ $input.item.| }}')).toHaveLength(
Object.keys($input.item.json).length + extensions('object').length,
);
});
test("should return completions for: '{{ $input.first().| }}'", () => {
resolveParameterSpy.mockReturnValue($input.first().json);
expect(completions('{{ $input.first().| }}')).toHaveLength(
Object.keys($input.first().json).length + extensions('object').length,
);
});
test("should return completions for: '{{ $input.last().| }}'", () => {
resolveParameterSpy.mockReturnValue($input.last().json);
expect(completions('{{ $input.last().| }}')).toHaveLength(
Object.keys($input.last().json).length + extensions('object').length,
);
});
test("should return completions for: '{{ $input.all()[0].| }}'", () => {
resolveParameterSpy.mockReturnValue($input.all()[0].json);
expect(completions('{{ $input.all()[0].| }}')).toHaveLength(
Object.keys($input.all()[0].json).length + extensions('object').length,
);
});
test('should return completions for: {{ $input.item.json.str.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.str);
expect(completions('{{ $input.item.json.str.| }}')).toHaveLength(extensions('string').length);
});
test('should return completions for: {{ $input.item.json.num.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.num);
expect(completions('{{ $input.item.json.num.| }}')).toHaveLength(extensions('number').length);
});
test('should return completions for: {{ $input.item.json.arr.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.arr);
expect(completions('{{ $input.item.json.arr.| }}')).toHaveLength(extensions('array').length);
});
test('should return completions for: {{ $input.item.json.obj.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.obj);
expect(completions('{{ $input.item.json.obj.| }}')).toHaveLength(
Object.keys($input.item.json.obj).length + extensions('object').length,
);
});
});
describe('bracket access', () => {
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
const { $input } = mockProxy;
['{{ $input.item.json[| }}', '{{ $json[| }}'].forEach((expression) => {
test(`should return completions for: ${expression}`, () => {
resolveParameterSpy.mockReturnValue($input.item.json);
const found = completions(expression);
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(Object.keys($input.item.json).length);
expect(found.map((c) => c.label).every((l) => l.endsWith(']')));
});
});
["{{ $input.item.json['obj'][| }}", "{{ $json['obj'][| }}"].forEach((expression) => {
test(`should return completions for: ${expression}`, () => {
resolveParameterSpy.mockReturnValue($input.item.json.obj);
const found = completions(expression);
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(Object.keys($input.item.json.obj).length);
expect(found.map((c) => c.label).every((l) => l.endsWith(']')));
});
});
});
});
export function completions(docWithCursor: string) {
const cursorPosition = docWithCursor.indexOf('|');
const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1);
const state = EditorState.create({
doc,
selection: { anchor: cursorPosition },
extensions: [n8nLang()],
});
const context = new CompletionContext(state, cursorPosition, false);
for (const completionSource of state.languageDataAt<CompletionSource>(
'autocomplete',
cursorPosition,
)) {
const result = completionSource(context);
if (isCompletionResult(result)) return result.options;
}
return null;
}
function isCompletionResult(
candidate: ReturnType<CompletionSource>,
): candidate is CompletionResult {
return candidate !== null && 'from' in candidate && 'options' in candidate;
}

View File

@@ -1,37 +0,0 @@
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import { dateTimeOptions, nowTodayOptions, luxonCompletions } from '../luxon.completions';
const EXPLICIT = false;
test('should return luxon completion options: $now, $today', () => {
['$now', '$today'].forEach((luxonVar) => {
const doc = `{{ ${luxonVar}. }}`;
const position = doc.indexOf('.') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = luxonCompletions(context);
if (!result) throw new Error(`Expected luxon ${luxonVar} completion options`);
const { options, from } = result;
expect(options.map((o) => o.label)).toEqual(nowTodayOptions().map((o) => o.label));
expect(from).toEqual(position);
});
});
test('should return luxon completion options: DateTime', () => {
const doc = '{{ DateTime. }}';
const position = doc.indexOf('.') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = luxonCompletions(context);
if (!result) throw new Error('Expected luxon completion options');
const { options, from } = result;
expect(options.map((o) => o.label)).toEqual(dateTimeOptions().map((o) => o.label));
expect(from).toEqual(position);
});

View File

@@ -0,0 +1,331 @@
import { v4 as uuidv4 } from 'uuid';
import {
INode,
IConnections,
IRunExecutionData,
Workflow,
IExecuteData,
WorkflowDataProxy,
INodeType,
INodeTypeData,
INodeTypes,
IVersionedNodeType,
NodeHelpers,
} from 'n8n-workflow';
class NodeTypesClass implements INodeTypes {
nodeTypes: INodeTypeData = {
'test.set': {
sourcePath: '',
type: {
description: {
displayName: 'Set',
name: 'set',
group: ['input'],
version: 1,
description: 'Sets a value',
defaults: {
name: 'Set',
color: '#0000FF',
},
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Value1',
name: 'value1',
type: 'string',
default: 'default-value1',
},
{
displayName: 'Value2',
name: 'value2',
type: 'string',
default: 'default-value2',
},
],
},
},
},
};
getByName(nodeType: string): INodeType | IVersionedNodeType {
return this.nodeTypes[nodeType].type;
}
getByNameAndVersion(nodeType: string, version?: number): INodeType {
return NodeHelpers.getVersionedNodeType(this.nodeTypes[nodeType].type, version);
}
}
const nodes: INode[] = [
{
name: 'Start',
type: 'test.set',
parameters: {},
typeVersion: 1,
id: 'uuid-1',
position: [100, 200],
},
{
name: 'Function',
type: 'test.set',
parameters: {
functionCode:
'// Code here will run only once, no matter how many input items there are.\n// More info and help: https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.function/\nconst { DateTime, Duration, Interval } = require("luxon");\n\nconst data = [\n {\n "length": 105\n },\n {\n "length": 160\n },\n {\n "length": 121\n },\n {\n "length": 275\n },\n {\n "length": 950\n },\n];\n\nreturn data.map(fact => ({json: fact}));',
},
typeVersion: 1,
id: 'uuid-2',
position: [280, 200],
},
{
name: 'Rename',
type: 'test.set',
parameters: {
value1: 'data',
value2: 'initialName',
},
typeVersion: 1,
id: 'uuid-3',
position: [460, 200],
},
{
name: 'End',
type: 'test.set',
parameters: {},
typeVersion: 1,
id: 'uuid-4',
position: [640, 200],
},
];
const connections: IConnections = {
Start: {
main: [
[
{
node: 'Function',
type: 'main',
index: 0,
},
],
],
},
Function: {
main: [
[
{
node: 'Rename',
type: 'main',
index: 0,
},
],
],
},
Rename: {
main: [
[
{
node: 'End',
type: 'main',
index: 0,
},
],
],
},
};
const runExecutionData: IRunExecutionData = {
resultData: {
runData: {
Start: [
{
startTime: 1,
executionTime: 1,
data: {
main: [
[
{
json: {},
},
],
],
},
source: [],
},
],
Function: [
{
startTime: 1,
executionTime: 1,
data: {
main: [
[
{
json: { initialName: 105, str: 'abc' },
pairedItem: { item: 0 },
},
{
json: { initialName: 160 },
pairedItem: { item: 0 },
},
{
json: { initialName: 121 },
pairedItem: { item: 0 },
},
{
json: { initialName: 275 },
pairedItem: { item: 0 },
},
{
json: { initialName: 950 },
pairedItem: { item: 0 },
},
],
],
},
source: [
{
previousNode: 'Start',
},
],
},
],
Rename: [
{
startTime: 1,
executionTime: 1,
data: {
main: [
[
{
json: { data: 105 },
pairedItem: { item: 0 },
},
{
json: { data: 160 },
pairedItem: { item: 1 },
},
{
json: { data: 121 },
pairedItem: { item: 2 },
},
{
json: { data: 275 },
pairedItem: { item: 3 },
},
{
json: { data: 950 },
pairedItem: { item: 4 },
},
],
],
},
source: [
{
previousNode: 'Function',
},
],
},
],
End: [
{
startTime: 1,
executionTime: 1,
data: {
main: [
[
{
json: {
data: 105,
str: 'abc',
num: 123,
arr: [1, 2, 3],
obj: { a: 'hello' },
},
pairedItem: { item: 0 },
},
{
json: { data: 160 },
pairedItem: { item: 1 },
},
{
json: { data: 121 },
pairedItem: { item: 2 },
},
{
json: { data: 275 },
pairedItem: { item: 3 },
},
{
json: { data: 950 },
pairedItem: { item: 4 },
},
],
],
},
source: [
{
previousNode: 'Rename',
},
],
},
],
},
},
};
const workflow = new Workflow({
id: '123',
name: 'test workflow',
nodes,
connections,
active: false,
nodeTypes: new NodeTypesClass(),
});
const lastNodeName = 'End';
const lastNodeConnectionInputData =
runExecutionData.resultData.runData[lastNodeName][0].data!.main[0];
const executeData: IExecuteData = {
data: runExecutionData.resultData.runData[lastNodeName][0].data!,
node: nodes.find((node) => node.name === lastNodeName) as INode,
source: {
main: runExecutionData.resultData.runData[lastNodeName][0].source!,
},
};
const dataProxy = new WorkflowDataProxy(
workflow,
runExecutionData,
0,
0,
lastNodeName,
lastNodeConnectionInputData || [],
{},
'manual',
'America/New_York',
{},
executeData,
);
export const mockProxy = dataProxy.getDataProxy();
export const mockNodes = [
{
id: uuidv4(),
name: 'Manual',
position: [0, 0],
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
},
{
id: uuidv4(),
name: 'Set',
position: [0, 0],
type: 'n8n-nodes-base.set',
typeVersion: 1,
},
];

View File

@@ -1,146 +0,0 @@
import { proxyCompletions } from '../proxy.completions';
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import * as workflowHelpers from '@/mixins/workflowHelpers';
import {
executionProxy,
inputProxy,
itemProxy,
nodeSelectorProxy,
prevNodeProxy,
workflowProxy,
} from './proxyMocks';
import { IDataObject } from 'n8n-workflow';
const EXPLICIT = false;
beforeEach(() => {
setActivePinia(createTestingPinia());
});
function testCompletionOptions(proxy: IDataObject, toResolve: string) {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(proxy);
const doc = `{{ ${toResolve}. }}`;
const position = doc.indexOf('.') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = proxyCompletions(context);
if (!result) throw new Error(`Expected ${toResolve} completion options`);
const { options: actual, from } = result;
expect(actual.map((o) => o.label)).toEqual(Reflect.ownKeys(proxy));
expect(from).toEqual(position);
}
// input proxy
test('should return proxy completion options: $input', () => {
testCompletionOptions(inputProxy, '$input');
});
// item proxy
test('should return proxy completion options: $input.first()', () => {
testCompletionOptions(itemProxy, '$input.first()');
});
test('should return proxy completion options: $input.last()', () => {
testCompletionOptions(itemProxy, '$input.last()');
});
test('should return proxy completion options: $input.item', () => {
testCompletionOptions(itemProxy, '$input.item');
});
test('should return proxy completion options: $input.all()[0]', () => {
testCompletionOptions(itemProxy, '$input.all()[0]');
});
// json proxy
test('should return proxy completion options: $json', () => {
testCompletionOptions(workflowProxy, '$json');
});
// prevNode proxy
test('should return proxy completion options: $prevNode', () => {
testCompletionOptions(prevNodeProxy, '$prevNode');
});
// execution proxy
test('should return proxy completion options: $execution', () => {
testCompletionOptions(executionProxy, '$execution');
});
// workflow proxy
test('should return proxy completion options: $workflow', () => {
testCompletionOptions(workflowProxy, '$workflow');
});
// node selector proxy
test('should return proxy completion options: $()', () => {
const firstNodeName = 'Manual';
const secondNodeName = 'Set';
const nodes = [
{
id: uuidv4(),
name: firstNodeName,
position: [0, 0],
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
},
{
id: uuidv4(),
name: secondNodeName,
position: [0, 0],
type: 'n8n-nodes-base.set',
typeVersion: 1,
},
];
const connections = {
Manual: {
main: [
[
{
node: 'Set',
type: 'main',
index: 0,
},
],
],
},
};
const initialState = { workflows: { workflow: { nodes, connections } } };
setActivePinia(createTestingPinia({ initialState }));
testCompletionOptions(nodeSelectorProxy, "$('Set')");
});
// no proxy
test('should not return completion options for non-existing proxies', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(null);
const doc = '{{ $hello. }}';
const position = doc.indexOf('.') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = proxyCompletions(context);
expect(result).toBeNull();
});

View File

@@ -1,97 +0,0 @@
export const inputProxy = new Proxy(
{},
{
ownKeys() {
return ['all', 'context', 'first', 'item', 'last', 'params'];
},
get(_, property) {
if (property === 'all') return [];
if (property === 'context') return {};
if (property === 'first') return {};
if (property === 'item') return {};
if (property === 'last') return {};
if (property === 'params') return {};
return undefined;
},
},
);
export const nodeSelectorProxy = new Proxy(
{},
{
ownKeys() {
return ['all', 'context', 'first', 'item', 'last', 'params', 'itemMatching'];
},
get(_, property) {
if (property === 'all') return [];
if (property === 'context') return {};
if (property === 'first') return {};
if (property === 'item') return {};
if (property === 'last') return {};
if (property === 'params') return {};
if (property === 'itemMatching') return {};
return undefined;
},
},
);
export const itemProxy = new Proxy(
{ json: {} },
{
get(_, property) {
if (property === 'json') return {};
return undefined;
},
},
);
export const prevNodeProxy = new Proxy(
{},
{
ownKeys() {
return ['name', 'outputIndex', 'runIndex'];
},
get(_, property) {
if (property === 'name') return '';
if (property === 'outputIndex') return 0;
if (property === 'runIndex') return 0;
return undefined;
},
},
);
export const executionProxy = new Proxy(
{},
{
ownKeys() {
return ['id', 'mode', 'resumeUrl'];
},
get(_, property) {
if (property === 'id') return '';
if (property === 'mode') return '';
if (property === 'resumeUrl') return '';
return undefined;
},
},
);
export const workflowProxy = new Proxy(
{},
{
ownKeys() {
return ['active', 'id', 'name'];
},
get(_, property) {
if (property === 'active') return false;
if (property === 'id') return '';
if (property === 'name') return '';
return undefined;
},
},
);

View File

@@ -1,80 +0,0 @@
import { rootCompletions } from '../root.completions';
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { v4 as uuidv4 } from 'uuid';
import { i18n } from '@/plugins/i18n';
const EXPLICIT = false;
test('should return completion options: $', () => {
setActivePinia(createTestingPinia());
const doc = '{{ $ }}';
const position = doc.indexOf('$') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = rootCompletions(context);
if (!result) throw new Error('Expected dollar-sign completion options');
const { options, from } = result;
const rootKeys = [...Object.keys(i18n.rootVars), '$parameter'].sort((a, b) => a.localeCompare(b));
expect(options.map((o) => o.label)).toEqual(rootKeys);
expect(from).toEqual(position - 1);
});
test('should return completion options: $(', () => {
const firstNodeName = 'Manual Trigger';
const secondNodeName = 'Set';
const nodes = [
{
id: uuidv4(),
name: firstNodeName,
position: [0, 0],
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
},
{
id: uuidv4(),
name: secondNodeName,
position: [0, 0],
type: 'n8n-nodes-base.set',
typeVersion: 1,
},
];
const initialState = { workflows: { workflow: { nodes } } };
setActivePinia(createTestingPinia({ initialState }));
const doc = '{{ $( }}';
const position = doc.indexOf('(') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = rootCompletions(context);
if (!result) throw new Error('Expected dollar-sign-selector completion options');
const { options, from } = result;
expect(options).toHaveLength(nodes.length);
expect(options[0].label).toEqual(`$('${firstNodeName}')`);
expect(options[1].label).toEqual(`$('${secondNodeName}')`);
expect(from).toEqual(position - 2);
});
test('should not return completion options for regular strings', () => {
setActivePinia(createTestingPinia());
const doc = '{{ hello }}';
const position = doc.indexOf('o') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = rootCompletions(context);
expect(result).toBeNull();
});

View File

@@ -1,50 +0,0 @@
import { i18n } from '@/plugins/i18n';
import { longestCommonPrefix } from './utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
/**
* Completions from alphabetic char, e.g. `D` -> `DateTime`.
*/
export function alphaCompletions(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(/(\s+)D[ateTim]*/);
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
let options = generateOptions();
const userInput = word.text.trim();
if (userInput !== '' && userInput !== '$') {
options = options.filter((o) => o.label.startsWith(userInput) && userInput !== o.label);
}
return {
from: word.to - userInput.length,
options,
filter: false,
getMatch(completion: Completion) {
const lcp = longestCommonPrefix([userInput, completion.label]);
return [0, lcp.length];
},
};
}
function generateOptions() {
const emptyKeys = ['DateTime'];
return emptyKeys.map((key) => {
const option: Completion = {
label: key,
type: key.endsWith('()') ? 'function' : 'keyword',
};
const info = i18n.rootVars[key];
if (info) option.info = info;
return option;
});
}

View File

@@ -0,0 +1,24 @@
import { dollarOptions } from './dollar.completions';
import type { CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { stripExcessParens } from './utils';
/**
* Completions offered at the blank position: `{{ | }}`
*/
export function blankCompletions(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(/\{\{\s/);
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
const afterCursor = context.state.sliceDoc(context.pos, context.pos + ' }}'.length);
if (afterCursor !== ' }}') return null;
return {
from: word.to,
options: dollarOptions().map(stripExcessParens(context)),
filter: false,
};
}

View File

@@ -0,0 +1,74 @@
import { resolveParameter } from '@/mixins/workflowHelpers';
import { prefixMatch, longestCommonPrefix } from './utils';
import type { IDataObject } from 'n8n-workflow';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { Resolved } from './types';
/**
* Resolution-based completions offered at the start of bracket access notation.
*
* - `$json[|`
* - `$input.item.json[|`
* - `$json['field'][|`
* - `$json.myObj[|`
* - `$('Test').last().json.myArr[|`
* - `$input.first().json.myStr[|`
*/
export function bracketAccessCompletions(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(/\$[\S\s]*\[.*/);
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
const skipBracketAccessCompletions = ['$input[', '$now[', '$today['];
if (skipBracketAccessCompletions.includes(word.text)) return null;
const base = word.text.substring(0, word.text.lastIndexOf('['));
const tail = word.text.split('[').pop() ?? '';
let resolved: Resolved;
try {
resolved = resolveParameter(`={{ ${base} }}`);
} catch (_) {
return null;
}
if (resolved === null || resolved === undefined) return null;
let options = bracketAccessOptions(resolved);
if (tail !== '') {
options = options.filter((o) => prefixMatch(o.label, tail));
}
if (options.length === 0) return null;
return {
from: word.to - tail.length,
options,
filter: false,
getMatch(completion: Completion) {
const lcp = longestCommonPrefix(tail, completion.label);
return [0, lcp.length];
},
};
}
function bracketAccessOptions(resolved: IDataObject) {
const SKIP = new Set(['__ob__', 'pairedItem']);
return Object.keys(resolved)
.filter((key) => !SKIP.has(key))
.map((key) => {
const isNumber = !isNaN(parseInt(key)); // array or string index
return {
label: isNumber ? `${key}]` : `'${key}']`,
type: 'keyword',
};
});
}

View File

@@ -0,0 +1,308 @@
import { ExpressionExtensions, NativeMethods, IDataObject } from 'n8n-workflow';
import { DateTime } from 'luxon';
import { i18n } from '@/plugins/i18n';
import { resolveParameter } from '@/mixins/workflowHelpers';
import {
setRank,
hasNoParams,
prefixMatch,
isAllowedInDotNotation,
isSplitInBatchesAbsent,
longestCommonPrefix,
splitBaseTail,
isPseudoParam,
stripExcessParens,
} from './utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { ExtensionTypeName, FnToDoc, Resolved } from './types';
/**
* Resolution-based completions offered according to datatype.
*/
export function datatypeCompletions(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(DATATYPE_REGEX);
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
const [base, tail] = splitBaseTail(word.text);
let options: Completion[] = [];
if (base === 'DateTime') {
options = luxonStaticOptions().map(stripExcessParens(context));
} else {
let resolved: Resolved;
try {
resolved = resolveParameter(`={{ ${base} }}`);
} catch (_) {
return null;
}
if (resolved === null) return null;
try {
options = datatypeOptions(resolved, base).map(stripExcessParens(context));
} catch (_) {
return null;
}
}
if (options.length === 0) return null;
if (tail !== '') {
options = options.filter((o) => prefixMatch(o.label, tail));
}
return {
from: word.to - tail.length,
options,
filter: false,
getMatch(completion: Completion) {
const lcp = longestCommonPrefix(tail, completion.label);
return [0, lcp.length];
},
};
}
function datatypeOptions(resolved: Resolved, toResolve: string) {
if (resolved === null) return [];
if (typeof resolved === 'number') {
return [...natives('number'), ...extensions('number')];
}
if (typeof resolved === 'string') {
return [...natives('string'), ...extensions('string')];
}
if (['$now', '$today'].includes(toResolve)) {
return [...luxonInstanceOptions(), ...extensions('date')];
}
if (resolved instanceof Date) {
return [...natives('date'), ...extensions('date')];
}
if (Array.isArray(resolved)) {
if (/all\(.*?\)/.test(toResolve)) return [];
const arrayMethods = [...natives('array'), ...extensions('array')];
if (resolved.length > 0 && resolved.some((i) => typeof i !== 'number')) {
const NUMBER_ONLY_ARRAY_EXTENSIONS = new Set(['max()', 'min()', 'sum()', 'average()']);
return arrayMethods.filter((m) => !NUMBER_ONLY_ARRAY_EXTENSIONS.has(m.label));
}
return arrayMethods;
}
if (typeof resolved === 'object') {
return objectOptions(toResolve, resolved);
}
return [];
}
export const natives = (typeName: ExtensionTypeName): Completion[] => {
const natives = NativeMethods.find((ee) => ee.typeName.toLowerCase() === typeName);
if (!natives) return [];
return toOptions(natives.functions, typeName);
};
export const extensions = (typeName: ExtensionTypeName) => {
const extensions = ExpressionExtensions.find((ee) => ee.typeName.toLowerCase() === typeName);
if (!extensions) return [];
const fnToDoc = Object.entries(extensions.functions).reduce<FnToDoc>((acc, [fnName, fn]) => {
if (fn.length !== 1) return acc; // @TODO_NEXT_PHASE: Remove to allow extensions which take args
return { ...acc, [fnName]: { doc: fn.doc } };
}, {});
return toOptions(fnToDoc, typeName);
};
export const toOptions = (fnToDoc: FnToDoc, typeName: ExtensionTypeName) => {
return Object.entries(fnToDoc)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([fnName, fn]) => {
const option: Completion = {
label: fnName + '()',
type: 'function',
};
option.info = () => {
const tooltipContainer = document.createElement('div');
if (!fn.doc?.description) return null;
tooltipContainer.style.display = 'flex';
tooltipContainer.style.flexDirection = 'column';
tooltipContainer.style.paddingTop = 'var(--spacing-4xs)';
tooltipContainer.style.paddingBottom = 'var(--spacing-4xs)';
const header = document.createElement('div');
header.style.marginBottom = 'var(--spacing-2xs)';
const typeNameSpan = document.createElement('span');
typeNameSpan.innerHTML = typeName.slice(0, 1).toUpperCase() + typeName.slice(1) + '.';
const functionNameSpan = document.createElement('span');
functionNameSpan.innerHTML = fn.doc.name + '()';
functionNameSpan.style.fontWeight = 'var(--font-weight-bold)';
const returnTypeSpan = document.createElement('span');
returnTypeSpan.innerHTML = ': ' + fn.doc.returnType;
header.appendChild(typeNameSpan);
header.appendChild(functionNameSpan);
header.appendChild(returnTypeSpan);
tooltipContainer.appendChild(header);
tooltipContainer.appendChild(document.createTextNode(fn.doc.description));
return tooltipContainer;
};
return option;
});
};
const objectOptions = (toResolve: string, resolved: IDataObject) => {
const rank = setRank(['item', 'all', 'first', 'last']);
const SKIP = new Set(['__ob__', 'pairedItem']);
if (isSplitInBatchesAbsent()) SKIP.add('context');
const name = toResolve.startsWith('$(') ? '$()' : toResolve;
if (['$input', '$()'].includes(name) && hasNoParams(toResolve)) SKIP.add('params');
let rawKeys = Object.keys(resolved);
if (name === '$()') {
rawKeys = Reflect.ownKeys(resolved) as string[];
}
if (toResolve === 'Math') {
const descriptors = Object.getOwnPropertyDescriptors(Math);
rawKeys = Object.keys(descriptors).sort((a, b) => a.localeCompare(b));
}
const localKeys = rank(rawKeys)
.filter((key) => !SKIP.has(key) && isAllowedInDotNotation(key) && !isPseudoParam(key))
.map((key) => {
ensureKeyCanBeResolved(resolved, key);
const isFunction = typeof resolved[key] === 'function';
const option: Completion = {
label: isFunction ? key + '()' : key,
type: isFunction ? 'function' : 'keyword',
};
const infoKey = [name, key].join('.');
const info = i18n.proxyVars[infoKey];
if (info) option.info = info;
return option;
});
const skipObjectExtensions =
resolved.isProxy ||
resolved.json ||
/json('])?$/.test(toResolve) ||
toResolve === '$execution' ||
toResolve.endsWith('params') ||
toResolve === 'Math';
if (skipObjectExtensions) return [...localKeys, ...natives('object')];
return [...localKeys, ...natives('object'), ...extensions('object')];
};
function ensureKeyCanBeResolved(obj: IDataObject, key: string) {
try {
obj[key];
} catch (error) {
// e.g. attempt to access disconnected node with `$()`
throw new Error('Cannot generate options', { cause: error });
}
}
/**
* Methods and fields defined on a Luxon `DateTime` class instance.
*/
export const luxonInstanceOptions = () => {
const SKIP = new Set(['constructor', 'get', 'invalidExplanation', 'invalidReason']);
return Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype))
.filter(([key]) => !SKIP.has(key))
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, descriptor]) => {
const isFunction = typeof descriptor.value === 'function';
const option: Completion = {
label: isFunction ? key + '()' : key,
type: isFunction ? 'function' : 'keyword',
};
const info = i18n.luxonInstance[key];
if (info) option.info = info;
return option;
});
};
/**
* Methods defined on a Luxon `DateTime` class.
*/
export const luxonStaticOptions = () => {
const SKIP = new Set(['prototype', 'name', 'length', 'invalid']);
return Object.keys(Object.getOwnPropertyDescriptors(DateTime))
.filter((key) => !SKIP.has(key) && !key.includes('_'))
.sort((a, b) => a.localeCompare(b))
.map((key) => {
const option: Completion = {
label: key + '()',
type: 'function',
};
const info = i18n.luxonStatic[key];
if (info) option.info = info;
return option;
});
};
const regexes = {
generalRef: /\$[^$]+\.([^{\s])*/, // $input. or $json. or similar ones
selectorRef: /\$\(['"][\S\s]+['"]\)\.([^{\s])*/, // $('nodeName').
numberLiteral: /\((\d+)\.?(\d*)\)\.([^{\s])*/, // (123). or (123.4).
stringLiteral: /(".+"|('.+'))\.([^{\s])*/, // 'abc'. or "abc".
dateLiteral: /\(?new Date\(\(?.*?\)\)?\.([^{\s])*/, // new Date(). or (new Date()).
arrayLiteral: /(\[.+\])\.([^{\s])*/, // [1, 2, 3].
objectLiteral: /\(\{.*\}\)\.([^{\s])*/, // ({}).
mathGlobal: /Math\.([^{\s])*/, // Math.
datetimeGlobal: /DateTime\.[^.}]*/, // DateTime.
};
const DATATYPE_REGEX = new RegExp(
Object.values(regexes)
.map((regex) => regex.source)
.join('|'),
);

View File

@@ -0,0 +1,79 @@
import { i18n } from '@/plugins/i18n';
import {
autocompletableNodeNames,
receivesNoBinaryData,
longestCommonPrefix,
setRank,
prefixMatch,
stripExcessParens,
hasActiveNode,
} from './utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
/**
* Completions offered at the dollar position: `$|`
*/
export function dollarCompletions(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(/\$[^$]*/);
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
let options = dollarOptions().map(stripExcessParens(context));
const userInput = word.text;
if (userInput !== '$') {
options = options.filter((o) => prefixMatch(o.label, userInput));
}
if (options.length === 0) return null;
return {
from: word.to - userInput.length,
options,
filter: false,
getMatch(completion: Completion) {
const lcp = longestCommonPrefix(userInput, completion.label);
return [0, lcp.length];
},
};
}
export function dollarOptions() {
const rank = setRank(['$json', '$input']);
const SKIP = new Set();
const DOLLAR_FUNCTIONS = ['$jmespath'];
if (!hasActiveNode()) return []; // e.g. credential modal
if (receivesNoBinaryData()) SKIP.add('$binary');
const keys = Object.keys(i18n.rootVars).sort((a, b) => a.localeCompare(b));
return rank(keys)
.filter((key) => !SKIP.has(key))
.map((key) => {
const isFunction = DOLLAR_FUNCTIONS.includes(key);
const option: Completion = {
label: isFunction ? key + '()' : key,
type: isFunction ? 'function' : 'keyword',
};
const info = i18n.rootVars[key];
if (info) option.info = info;
return option;
})
.concat(
autocompletableNodeNames().map((nodeName) => ({
label: `$('${nodeName}')`,
type: 'keyword',
info: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }),
})),
);
}

View File

@@ -1,84 +0,0 @@
import { i18n } from '@/plugins/i18n';
import { longestCommonPrefix } from './utils';
import { DateTime } from 'luxon';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
export function luxonCompletions(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(/(DateTime|\$(now|today)*)\.(\w|\.|\(|\))*/); //
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
const toResolve = word.text.endsWith('.')
? word.text.slice(0, -1)
: word.text.split('.').slice(0, -1).join('.');
let options = generateOptions(toResolve);
const userInputTail = word.text.split('.').pop();
if (userInputTail === undefined) return null;
if (userInputTail !== '') {
options = options.filter((o) => o.label.startsWith(userInputTail) && userInputTail !== o.label);
}
return {
from: word.to - userInputTail.length,
options,
filter: false,
getMatch(completion: Completion) {
const lcp = longestCommonPrefix([userInputTail, completion.label]);
return [0, lcp.length];
},
};
}
function generateOptions(toResolve: string): Completion[] {
if (toResolve === '$now' || toResolve === '$today') return nowTodayOptions();
if (toResolve === 'DateTime') return dateTimeOptions();
return [];
}
export const nowTodayOptions = () => {
const SKIP_SET = new Set(['constructor', 'get']);
const entries = Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype))
.filter(([key]) => !SKIP_SET.has(key))
.sort(([a], [b]) => a.localeCompare(b));
return entries.map(([key, descriptor]) => {
const isFunction = typeof descriptor.value === 'function';
const option: Completion = {
label: isFunction ? `${key}()` : key,
type: isFunction ? 'function' : 'keyword',
};
const info = i18n.luxonInstance[key];
if (info) option.info = info;
return option;
});
};
export const dateTimeOptions = () => {
const SKIP_SET = new Set(['prototype', 'name', 'length']);
const keys = Object.keys(Object.getOwnPropertyDescriptors(DateTime))
.filter((key) => !SKIP_SET.has(key) && !key.includes('_'))
.sort((a, b) => a.localeCompare(b));
return keys.map((key) => {
const option: Completion = { label: `${key}()`, type: 'function' };
const info = i18n.luxonStatic[key];
if (info) option.info = info;
return option;
});
};

View File

@@ -0,0 +1,44 @@
import { i18n } from '@/plugins/i18n';
import type { CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { prefixMatch } from './utils';
/**
* Completions offered at the initial position for any char other than `$`.
*
* Currently only `D...` for `DateTime` and `M...` for `Math`
*/
export function nonDollarCompletions(context: CompletionContext): CompletionResult | null {
const dateTime = /(\s+)D[ateTim]*/;
const math = /(\s+)M[ath]*/;
const combinedRegex = new RegExp([dateTime.source, math.source].join('|'));
const word = context.matchBefore(combinedRegex);
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
const userInput = word.text.trim();
const nonDollarOptions = [
{
label: 'DateTime',
type: 'keyword',
info: i18n.rootVars.DateTime,
},
{
label: 'Math',
type: 'keyword',
info: i18n.rootVars.DateTime,
},
];
const options = nonDollarOptions.filter((o) => prefixMatch(o.label, userInput));
return {
from: word.to - userInput.length,
filter: false,
options,
};
}

View File

@@ -1,106 +0,0 @@
import { i18n } from '@/plugins/i18n';
import { resolveParameter } from '@/mixins/workflowHelpers';
import { isAllowedInDotNotation, longestCommonPrefix } from './utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { IDataObject } from 'n8n-workflow';
import type { Word } from '@/types/completions';
/**
* Completions from proxies to their content.
*/
export function proxyCompletions(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(
/\$(input|\(.+\)|prevNode|parameter|json|execution|workflow)*(\.|\[)(\w|\W)*/,
);
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
const toResolve = word.text.endsWith('.')
? word.text.slice(0, -1)
: word.text.split('.').slice(0, -1).join('.');
let options: Completion[] = [];
try {
const proxy = resolveParameter(`={{ ${toResolve} }}`);
if (!proxy || typeof proxy !== 'object' || Array.isArray(proxy)) return null;
options = generateOptions(toResolve, proxy, word);
} catch (_) {
return null;
}
let userInputTail = '';
const delimiter = word.text.includes('json[') ? 'json[' : '.';
userInputTail = word.text.split(delimiter).pop() as string;
if (userInputTail !== '') {
options = options.filter((o) => o.label.startsWith(userInputTail) && userInputTail !== o.label);
}
return {
from: word.to - userInputTail.length,
options,
filter: false,
getMatch(completion: Completion) {
const lcp = longestCommonPrefix([userInputTail, completion.label]);
return [0, lcp.length];
},
};
}
function generateOptions(toResolve: string, proxy: IDataObject, word: Word): Completion[] {
const SKIP_SET = new Set(['__ob__', 'pairedItem']);
if (word.text.includes('json[')) {
return Object.keys(proxy.json as object)
.filter((key) => !SKIP_SET.has(key))
.map((key) => {
return {
label: `'${key}']`,
type: 'keyword',
};
});
}
const proxyName = toResolve.startsWith('$(') ? '$()' : toResolve;
return (Reflect.ownKeys(proxy) as string[])
.filter((key) => {
if (word.text.endsWith('json.')) return !SKIP_SET.has(key) && isAllowedInDotNotation(key);
return !SKIP_SET.has(key);
})
.map((key) => {
ensureKeyCanBeResolved(proxy, key);
const isFunction = typeof proxy[key] === 'function';
const option: Completion = {
label: isFunction ? `${key}()` : key,
type: isFunction ? 'function' : 'keyword',
};
const infoKey = [proxyName, key].join('.');
const info = i18n.proxyVars[infoKey];
if (info) option.info = info;
return option;
});
}
function ensureKeyCanBeResolved(proxy: IDataObject, key: string) {
try {
proxy[key];
} catch (error) {
// e.g. attempting to access non-parent node with `$()`
throw new Error('Cannot generate options', { cause: error });
}
}

View File

@@ -1,60 +0,0 @@
import { i18n } from '@/plugins/i18n';
import { autocompletableNodeNames, longestCommonPrefix } from './utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
/**
* Completions from `$` to proxies.
*/
export function rootCompletions(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(/\$\w*[^.]*/);
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
let options = generateOptions();
const { text: userInput } = word;
if (userInput !== '' && userInput !== '$') {
options = options.filter((o) => o.label.startsWith(userInput) && userInput !== o.label);
}
return {
from: word.to - userInput.length,
options,
filter: false,
getMatch(completion: Completion) {
const lcp = longestCommonPrefix([userInput, completion.label]);
return [0, lcp.length];
},
};
}
function generateOptions() {
const rootKeys = [...Object.keys(i18n.rootVars), '$parameter'].sort((a, b) => a.localeCompare(b));
const options: Completion[] = rootKeys.map((key) => {
const option: Completion = {
label: key,
type: key.endsWith('()') ? 'function' : 'keyword',
};
const info = i18n.rootVars[key];
if (info) option.info = info;
return option;
});
options.push(
...autocompletableNodeNames().map((nodeName) => ({
label: `$('${nodeName}')`,
type: 'keyword',
info: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }),
})),
);
return options;
}

View File

@@ -0,0 +1,8 @@
import { resolveParameter } from '@/mixins/workflowHelpers';
import type { DocMetadata } from 'n8n-workflow';
export type Resolved = ReturnType<typeof resolveParameter>;
export type ExtensionTypeName = 'number' | 'string' | 'date' | 'array' | 'object';
export type FnToDoc = { [fnName: string]: { doc?: DocMetadata } };

View File

@@ -1,14 +1,24 @@
import { NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION } from '@/components/CodeNodeEditor/constants';
import { SPLIT_IN_BATCHES_NODE_TYPE } from '@/constants';
import { useWorkflowsStore } from '@/stores/workflows';
import { resolveParameter } from '@/mixins/workflowHelpers';
import { useNDVStore } from '@/stores/ndv';
import type { Completion, CompletionContext } from '@codemirror/autocomplete';
export function autocompletableNodeNames() {
return useWorkflowsStore()
.allNodes.filter((node) => !NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION.includes(node.type))
.map((node) => node.name);
/**
* Split user input into base (to resolve) and tail (to filter).
*/
export function splitBaseTail(userInput: string): [string, string] {
const parts = userInput.split('.');
const tail = parts.pop() ?? '';
return [parts.join('.'), tail];
}
export const longestCommonPrefix = (strings: string[]) => {
if (strings.length === 0) return '';
export function longestCommonPrefix(...strings: string[]) {
if (strings.length < 2) {
throw new Error('Expected at least two strings');
}
return strings.reduce((acc, next) => {
let i = 0;
@@ -19,13 +29,90 @@ export const longestCommonPrefix = (strings: string[]) => {
return acc.slice(0, i);
});
}
export const prefixMatch = (first: string, second: string) =>
first.startsWith(second) && first !== second;
/**
* Make a function to bring selected elements to the start of an array, in order.
*/
export const setRank = (selected: string[]) => (full: string[]) => {
const fullCopy = [...full];
[...selected].reverse().forEach((s) => {
const index = fullCopy.indexOf(s);
if (index !== -1) fullCopy.unshift(fullCopy.splice(index, 1)[0]);
});
return fullCopy;
};
export const isPseudoParam = (candidate: string) => {
const PSEUDO_PARAMS = ['notice']; // user input disallowed
return PSEUDO_PARAMS.includes(candidate);
};
/**
* Whether a string may be used as a key in object dot notation access.
* Whether a string may be used as a key in object dot access notation.
*/
export const isAllowedInDotNotation = (str: string) => {
const DOT_NOTATION_BANNED_CHARS = /^(\d)|[\\ `!@#$%^&*()_+\-=[\]{};':"\\|,.<>?~]/g;
const DOT_NOTATION_BANNED_CHARS = /^(\d)|[\\ `!@#$%^&*()+\-=[\]{};':"\\|,.<>?~]/g;
return !DOT_NOTATION_BANNED_CHARS.test(str);
};
// ----------------------------------
// resolution-based utils
// ----------------------------------
export function receivesNoBinaryData() {
return resolveParameter('={{ $binary }}')?.data === undefined;
}
export function hasNoParams(toResolve: string) {
const params = resolveParameter(`={{ ${toResolve}.params }}`);
if (!params) return true;
const paramKeys = Object.keys(params);
return paramKeys.length === 1 && isPseudoParam(paramKeys[0]);
}
// ----------------------------------
// state-based utils
// ----------------------------------
export const hasActiveNode = () => useNDVStore().activeNode?.name !== undefined;
export const isSplitInBatchesAbsent = () =>
!useWorkflowsStore().workflow.nodes.some((node) => node.type === SPLIT_IN_BATCHES_NODE_TYPE);
export function autocompletableNodeNames() {
return useWorkflowsStore()
.allNodes.filter((node) => {
const activeNodeName = useNDVStore().activeNode?.name;
return (
!NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION.includes(node.type) && node.name !== activeNodeName
);
})
.map((node) => node.name);
}
/**
* Remove excess parens from an option label when the cursor is already
* followed by parens, e.g. `$json.myStr.|()` -> `isNumeric`
*/
export const stripExcessParens = (context: CompletionContext) => (option: Completion) => {
const followedByParens = context.state.sliceDoc(context.pos, context.pos + 2) === '()';
if (option.label.endsWith('()') && followedByParens) {
option.label = option.label.slice(0, '()'.length * -1);
}
return option;
};

View File

@@ -1,4 +1,9 @@
import { closeBrackets, completionStatus, insertBracket } from '@codemirror/autocomplete';
import {
closeBrackets,
completionStatus,
insertBracket,
startCompletion,
} from '@codemirror/autocomplete';
import { codePointAt, codePointSize, Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
@@ -59,6 +64,8 @@ const handler = EditorView.inputHandler.of((view, from, to, insert) => {
selection: { anchor: cursor + 1 },
});
startCompletion(view);
return true;
}
@@ -68,7 +75,13 @@ const handler = EditorView.inputHandler.of((view, from, to, insert) => {
view.state.sliceDoc(cursor - 1, cursor) === '{' &&
view.state.sliceDoc(cursor, cursor + 1) === '}';
if (isBraceSetup) {
const { head } = view.state.selection.main;
const isInsideResolvable =
view.state.sliceDoc(0, head).includes('{{') &&
view.state.sliceDoc(head, view.state.doc.length).includes('}}');
if (isBraceSetup && !isInsideResolvable) {
view.dispatch({ changes: { from: cursor, insert: ' ' } });
return true;

View File

@@ -4,10 +4,11 @@ import { parseMixed } from '@lezer/common';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { ifIn } from '@codemirror/autocomplete';
import { proxyCompletions } from './completions/proxy.completions';
import { rootCompletions } from './completions/root.completions';
import { luxonCompletions } from './completions/luxon.completions';
import { alphaCompletions } from './completions/alpha.completions';
import { blankCompletions } from './completions/blank.completions';
import { bracketAccessCompletions } from './completions/bracketAccess.completions';
import { datatypeCompletions } from './completions/datatype.completions';
import { dollarCompletions } from './completions/dollar.completions';
import { nonDollarCompletions } from './completions/nonDollar.completions';
const n8nParserWithNestedJsParser = n8nParser.configure({
wrap: parseMixed((node) => {
@@ -22,12 +23,16 @@ const n8nParserWithNestedJsParser = n8nParser.configure({
const n8nLanguage = LRLanguage.define({ parser: n8nParserWithNestedJsParser });
export function n8nLang() {
const options = [alphaCompletions, rootCompletions, proxyCompletions, luxonCompletions].map(
(group) => n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], group) }),
);
const options = [
blankCompletions,
bracketAccessCompletions,
datatypeCompletions,
dollarCompletions,
nonDollarCompletions,
].map((group) => n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], group) }));
return new LanguageSupport(n8nLanguage, [
n8nLanguage.data.of({ closeBrackets: { brackets: ['{'] } }),
n8nLanguage.data.of({ closeBrackets: { brackets: ['{', '('] } }),
...options,
]);
}

View File

@@ -330,10 +330,11 @@ export class I18nClass {
$binary: this.baseText('codeNodeEditor.completer.binary'),
$execution: this.baseText('codeNodeEditor.completer.$execution'),
$input: this.baseText('codeNodeEditor.completer.$input'),
'$jmespath()': this.baseText('codeNodeEditor.completer.$jmespath'),
$jmespath: this.baseText('codeNodeEditor.completer.$jmespath'),
$json: this.baseText('codeNodeEditor.completer.json'),
$itemIndex: this.baseText('codeNodeEditor.completer.$itemIndex'),
$now: this.baseText('codeNodeEditor.completer.$now'),
$parameter: this.baseText('codeNodeEditor.completer.$parameter'),
$prevNode: this.baseText('codeNodeEditor.completer.$prevNode'),
$runIndex: this.baseText('codeNodeEditor.completer.$runIndex'),
$today: this.baseText('codeNodeEditor.completer.$today'),

View File

@@ -123,6 +123,7 @@
"codeNodeEditor.completer.$itemIndex": "The position of the current item in the list of items",
"codeNodeEditor.completer.$jmespath": "Evaluate a JMESPath expression",
"codeNodeEditor.completer.$now": "The current timestamp (as a Luxon object)",
"codeNodeEditor.completer.$parameter": "The parameters of the current node",
"codeNodeEditor.completer.$prevNode": "The node providing the input data for this run",
"codeNodeEditor.completer.$prevNode.name": "The name of the node providing the input data for this run",
"codeNodeEditor.completer.$prevNode.outputIndex": "The output connector of the node providing input data for this run",
@@ -499,6 +500,7 @@
"expressionEdit.expression": "Expression",
"expressionEdit.resultOfItem1": "Result of item 1",
"expressionEdit.variableSelector": "Variable Selector",
"expressionEditor.uncalledFunction": "[this is a function, please add ()]",
"expressionModalInput.empty": "[empty]",
"expressionModalInput.undefined": "[undefined]",
"expressionModalInput.null": "null",

View File

@@ -1,6 +1,6 @@
type Range = { from: number; to: number };
export type RawSegment = { text: string; type: string } & Range;
export type RawSegment = { text: string; token: string } & Range;
export type Segment = Plaintext | Resolvable;

View File

@@ -61,6 +61,7 @@
"lodash.set": "^4.3.2",
"luxon": "^3.1.0",
"recast": "^0.21.5",
"title-case": "^3.0.3",
"transliteration": "^2.3.5",
"xml2js": "^0.4.23"
}

View File

@@ -34,6 +34,10 @@ tmpl.tmpl.errorHandler = (error: Error) => {
if (error.context.failExecution) {
throw error;
}
if (typeof process === 'undefined' && error.clientOnly) {
throw error;
}
}
};
@@ -44,6 +48,10 @@ export class Expression {
this.workflow = workflow;
}
static resolveWithoutWorkflow(expression: string) {
return tmpl.tmpl(expression, {});
}
/**
* Converts an object to a string in a way to make it clear that
* the value comes from an object
@@ -51,7 +59,16 @@ export class Expression {
*/
convertObjectValueToString(value: object): string {
const typeName = Array.isArray(value) ? 'Array' : 'Object';
return `[${typeName}: ${JSON.stringify(value)}]`;
if (DateTime.isDateTime(value) && value.invalidReason !== null) {
throw new Error('invalid DateTime');
}
const result = JSON.stringify(value)
.replace(/,"/g, ', "') // spacing for
.replace(/":/g, '": '); // readability
return `[${typeName}: ${result}]`;
}
/**
@@ -268,7 +285,11 @@ export class Expression {
const returnValue = this.renderExpression(extendedExpression, data);
if (typeof returnValue === 'function') {
if (returnValue.name === '$') throw new Error('invalid syntax');
throw new Error('This is a function. Please add ()');
if (returnValue.name === 'DateTime')
throw new Error('this is a DateTime, please access its methods');
throw new Error('this is a function, please add ()');
} else if (typeof returnValue === 'string') {
return returnValue;
} else if (returnValue !== null && typeof returnValue === 'object') {
@@ -293,6 +314,10 @@ export class Expression {
if (error.context.failExecution) {
throw error;
}
if (typeof process === 'undefined' && error.clientOnly) {
throw error;
}
}
// Syntax errors resolve to `Error` on the frontend and `null` on the backend.
@@ -305,6 +330,19 @@ export class Expression {
) {
throw new Error('invalid syntax');
}
if (
typeof process === 'undefined' &&
error instanceof Error &&
error.name === 'TypeError' &&
error.message.endsWith('is not a function')
) {
const match = error.message.match(/(?<msg>[^.]+is not a function)/);
if (!match?.groups?.msg) return null;
throw new Error(match.groups.msg);
}
}
return null;
}
@@ -325,7 +363,7 @@ export class Expression {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (!output?.code) {
throw new ExpressionExtensionError('Failed to extend syntax');
throw new ExpressionExtensionError('invalid syntax');
}
let text = output.code;

View File

@@ -5,6 +5,8 @@ import { ExecutionBaseError } from './NodeErrors';
* Class for instantiating an expression error
*/
export class ExpressionError extends ExecutionBaseError {
clientOnly = false;
constructor(
message: string,
options?: {
@@ -13,6 +15,7 @@ export class ExpressionError extends ExecutionBaseError {
description?: string;
descriptionTemplate?: string;
failExecution?: boolean;
clientOnly?: boolean; // whether to throw error only on frontend
functionality?: 'pairedItem';
itemIndex?: number;
messageTemplate?: string;
@@ -28,6 +31,10 @@ export class ExpressionError extends ExecutionBaseError {
this.description = options.description;
}
if (options?.clientOnly) {
this.clientOnly = options.clientOnly;
}
this.context.failExecution = !!options?.failExecution;
const allowedKeys = [

View File

@@ -87,11 +87,11 @@ function first(value: unknown[]): unknown {
return value[0];
}
function isBlank(value: unknown[]): boolean {
function isEmpty(value: unknown[]): boolean {
return value.length === 0;
}
function isPresent(value: unknown[]): boolean {
function isNotEmpty(value: unknown[]): boolean {
return value.length > 0;
}
@@ -99,10 +99,6 @@ function last(value: unknown[]): unknown {
return value[value.length - 1];
}
function length(value: unknown[]): number {
return Array.isArray(value) ? value.length : 0;
}
function pluck(value: unknown[], extraArgs: unknown[]): unknown[] {
if (!Array.isArray(extraArgs)) {
throw new ExpressionError('arguments must be passed to pluck');
@@ -121,7 +117,7 @@ function pluck(value: unknown[], extraArgs: unknown[]): unknown[] {
}) as unknown[];
}
function random(value: unknown[]): unknown {
function randomItem(value: unknown[]): unknown {
const len = value === undefined ? 0 : value.length;
return len ? value[Math.floor(Math.random() * len)] : undefined;
}
@@ -149,7 +145,15 @@ function unique(value: unknown[], extraArgs: string[]): unknown[] {
}, []);
}
const ensureNumberArray = (arr: unknown[], { fnName }: { fnName: string }) => {
if (arr.some((i) => typeof i !== 'number')) {
throw new ExpressionExtensionError(`${fnName}(): all array elements must be numbers`);
}
};
function sum(value: unknown[]): number {
ensureNumberArray(value, { fnName: 'sum' });
return value.reduce((p: number, c: unknown) => {
if (typeof c === 'string') {
return p + parseFloat(c);
@@ -162,6 +166,8 @@ function sum(value: unknown[]): number {
}
function min(value: unknown[]): number {
ensureNumberArray(value, { fnName: 'min' });
return Math.min(
...value.map((v) => {
if (typeof v === 'string') {
@@ -176,6 +182,8 @@ function min(value: unknown[]): number {
}
function max(value: unknown[]): number {
ensureNumberArray(value, { fnName: 'max' });
return Math.max(
...value.map((v) => {
if (typeof v === 'string') {
@@ -190,6 +198,8 @@ function max(value: unknown[]): number {
}
export function average(value: unknown[]) {
ensureNumberArray(value, { fnName: 'average' });
// This would usually be NaN but I don't think users
// will expect that
if (value.length === 0) {
@@ -200,7 +210,11 @@ export function average(value: unknown[]) {
function compact(value: unknown[]): unknown[] {
return value
.filter((v) => v !== null && v !== undefined)
.filter((v) => {
if (v && typeof v === 'object' && Object.keys(v).length === 0) return false;
return v !== null && v !== undefined && v !== 'nil' && v !== '';
})
.map((v) => {
if (typeof v === 'object' && v !== null) {
return oCompact(v);
@@ -213,7 +227,7 @@ function smartJoin(value: unknown[], extraArgs: string[]): object {
const [keyField, valueField] = extraArgs;
if (!keyField || !valueField || typeof keyField !== 'string' || typeof valueField !== 'string') {
throw new ExpressionExtensionError(
'smartJoin requires 2 arguments: keyField and nameField. e.g. .smartJoin("name", "value")',
'smartJoin(): expected two string args, e.g. .smartJoin("name", "value")',
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
@@ -229,8 +243,8 @@ function smartJoin(value: unknown[], extraArgs: string[]): object {
function chunk(value: unknown[], extraArgs: number[]) {
const [chunkSize] = extraArgs;
if (typeof chunkSize !== 'number') {
throw new ExpressionExtensionError('chunk requires 1 parameter: chunkSize. e.g. .chunk(5)');
if (typeof chunkSize !== 'number' || chunkSize === 0) {
throw new ExpressionExtensionError('chunk(): expected non-zero numeric arg, e.g. .chunk(5)');
}
const chunks: unknown[][] = [];
for (let i = 0; i < value.length; i += chunkSize) {
@@ -241,30 +255,10 @@ function chunk(value: unknown[], extraArgs: number[]) {
return chunks;
}
function filter(value: unknown[], extraArgs: unknown[]): unknown[] {
const [field, term] = extraArgs as [string | (() => void), unknown | string];
if (typeof field !== 'string' && typeof field !== 'function') {
throw new ExpressionExtensionError(
'filter requires 1 or 2 arguments: (field and term), (term and [optional keepOrRemove "keep" or "remove" default "keep"] (for string arrays)), or function. e.g. .filter("type", "home") or .filter((i) => i.type === "home") or .filter("home", [optional keepOrRemove]) (for string arrays)',
);
}
if (value.every((i) => typeof i === 'string') && typeof field === 'string') {
return (value as string[]).filter((i) =>
term === 'remove' ? !i.includes(field) : i.includes(field),
);
} else if (typeof field === 'string') {
return value.filter(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
(v) => typeof v === 'object' && v !== null && field in v && (v as any)[field] === term,
);
}
return value.filter(field);
}
function renameKeys(value: unknown[], extraArgs: string[]): unknown[] {
if (extraArgs.length === 0 || extraArgs.length % 2 !== 0) {
throw new ExpressionExtensionError(
'renameKeys requires an even amount of arguments: from1, to1 [, from2, to2, ...]. e.g. .renameKeys("name", "title")',
'renameKeys(): expected an even amount of args: from1, to1 [, from2, to2, ...]. e.g. .renameKeys("name", "title")',
);
}
return value.map((v) => {
@@ -291,7 +285,7 @@ function merge(value: unknown[], extraArgs: unknown[][]): unknown[] {
const [others] = extraArgs;
if (!Array.isArray(others)) {
throw new ExpressionExtensionError(
'merge requires 1 argument that is an array. e.g. .merge([{ id: 1, otherValue: 3 }])',
'merge(): expected array arg, e.g. .merge([{ id: 1, otherValue: 3 }])',
);
}
const listLength = value.length > others.length ? value.length : others.length;
@@ -314,9 +308,7 @@ function merge(value: unknown[], extraArgs: unknown[][]): unknown[] {
function union(value: unknown[], extraArgs: unknown[][]): unknown[] {
const [others] = extraArgs;
if (!Array.isArray(others)) {
throw new ExpressionExtensionError(
'union requires 1 argument that is an array. e.g. .union([1, 2, 3, 4])',
);
throw new ExpressionExtensionError('union(): expected array arg, e.g. .union([1, 2, 3, 4])');
}
const newArr: unknown[] = Array.from(value);
for (const v of others) {
@@ -331,7 +323,7 @@ function difference(value: unknown[], extraArgs: unknown[][]): unknown[] {
const [others] = extraArgs;
if (!Array.isArray(others)) {
throw new ExpressionExtensionError(
'difference requires 1 argument that is an array. e.g. .difference([1, 2, 3, 4])',
'difference(): expected array arg, e.g. .difference([1, 2, 3, 4])',
);
}
const newArr: unknown[] = [];
@@ -347,7 +339,7 @@ function intersection(value: unknown[], extraArgs: unknown[][]): unknown[] {
const [others] = extraArgs;
if (!Array.isArray(others)) {
throw new ExpressionExtensionError(
'difference requires 1 argument that is an array. e.g. .difference([1, 2, 3, 4])',
'intersection(): expected array arg, e.g. .intersection([1, 2, 3, 4])',
);
}
const newArr: unknown[] = [];
@@ -364,27 +356,129 @@ function intersection(value: unknown[], extraArgs: unknown[][]): unknown[] {
return unique(newArr, []);
}
average.doc = {
name: 'average',
description: 'Returns the mean average of all values in the array',
returnType: 'number',
};
compact.doc = {
name: 'compact',
description: 'Removes all empty values from the array',
returnType: 'array',
};
isEmpty.doc = {
name: 'isEmpty',
description: 'Checks if the array doesnt have any elements',
returnType: 'boolean',
};
isNotEmpty.doc = {
name: 'isNotEmpty',
description: 'Checks if the array has elements',
returnType: 'boolean',
};
first.doc = {
name: 'first',
description: 'Returns the first element of the array',
returnType: 'array item',
};
last.doc = {
name: 'last',
description: 'Returns the last element of the array',
returnType: 'array item',
};
max.doc = {
name: 'max',
description: 'Gets the maximum value from a number-only array',
returnType: 'number',
};
min.doc = {
name: 'min',
description: 'Gets the minimum value from a number-only array',
returnType: 'number',
};
randomItem.doc = {
name: 'randomItem',
description: 'Returns a random element from an array',
returnType: 'number',
};
sum.doc = {
name: 'sum',
description: 'Returns the total sum all the values in an array of parsable numbers',
returnType: 'number',
};
// @TODO_NEXT_PHASE: Surface extensions below which take args
chunk.doc = {
name: 'chunk',
returnType: 'array',
};
difference.doc = {
name: 'difference',
returnType: 'array',
};
intersection.doc = {
name: 'intersection',
returnType: 'array',
};
merge.doc = {
name: 'merge',
returnType: 'array',
};
pluck.doc = {
name: 'pluck',
returnType: 'array',
};
renameKeys.doc = {
name: 'renameKeys',
returnType: 'array',
};
smartJoin.doc = {
name: 'smartJoin',
returnType: 'array',
};
union.doc = {
name: 'union',
returnType: 'array',
};
unique.doc = {
name: 'unique',
returnType: 'array item',
aliases: ['removeDuplicates'],
};
export const arrayExtensions: ExtensionMap = {
typeName: 'Array',
functions: {
count: length,
duplicates: unique,
filter,
removeDuplicates: unique,
first,
last,
length,
pluck,
unique,
random,
randomItem: random,
remove: unique,
size: length,
randomItem,
sum,
min,
max,
average,
isPresent,
isBlank,
isNotEmpty,
isEmpty,
compact,
smartJoin,
chunk,

View File

@@ -1,14 +1,7 @@
/* eslint-disable @typescript-eslint/unbound-method */
/* eslint-disable @typescript-eslint/explicit-member-accessibility */
import type {
DateTimeFormatOptions,
DateTimeUnit,
Duration,
DurationLike,
DurationObjectUnits,
LocaleOptions,
} from 'luxon';
import { DateTime } from 'luxon';
import type { DateTimeUnit, DurationLike, DurationObjectUnits, LocaleOptions } from 'luxon';
import type { ExtensionMap } from './Extensions';
type DurationUnit =
@@ -23,6 +16,7 @@ type DurationUnit =
| 'years';
type DatePart =
| 'day'
| 'week'
| 'month'
| 'year'
| 'hour'
@@ -103,7 +97,7 @@ function endOfMonth(date: Date | DateTime): Date {
}
function extract(inputDate: Date | DateTime, extraArgs: DatePart[]): number | Date {
const [part] = extraArgs;
let [part] = extraArgs;
let date = inputDate;
if (isDateTime(date)) {
date = date.toJSDate();
@@ -117,6 +111,10 @@ function extract(inputDate: Date | DateTime, extraArgs: DatePart[]): number | Da
return Math.floor(diff / (1000 * 60 * 60 * 24));
}
if (part === 'week') {
part = 'weekNumber';
}
return DateTime.fromJSDate(date).get((DATETIMEUNIT_MAP[part] as keyof DateTime) || part);
}
@@ -194,73 +192,60 @@ function plus(date: Date | DateTime, extraArgs: unknown[]): Date | DateTime {
return DateTime.fromJSDate(date).plus(generateDurationObject(durationValue, unit)).toJSDate();
}
function toLocaleString(date: Date | DateTime, extraArgs: unknown[]): string {
const [locale, dateFormat = { timeStyle: 'short', dateStyle: 'short' }] = extraArgs as [
string | undefined,
DateTimeFormatOptions,
];
endOfMonth.doc = {
name: 'endOfMonth',
returnType: 'Date',
description: 'Transforms a date to the last possible moment that lies within the month',
};
if (isDateTime(date)) {
return date.toLocaleString(dateFormat, { locale });
}
return DateTime.fromJSDate(date).toLocaleString(dateFormat, { locale });
}
isDst.doc = {
name: 'isDst',
returnType: 'boolean',
description: 'Checks if a Date is within Daylight Savings Time',
};
function toTimeFromNow(date: Date | DateTime): string {
let diffObj: Duration;
if (isDateTime(date)) {
diffObj = date.diffNow();
} else {
diffObj = DateTime.fromJSDate(date).diffNow();
}
isWeekend.doc = {
name: 'isWeekend',
returnType: 'boolean',
description: 'Checks if the Date falls on a Saturday or Sunday',
};
const as = (unit: DurationUnit) => {
return Math.round(Math.abs(diffObj.as(unit)));
};
// @TODO_NEXT_PHASE: Surface extensions below which take args
if (as('years')) {
return `${as('years')} years ago`;
}
if (as('months')) {
return `${as('months')} months ago`;
}
if (as('weeks')) {
return `${as('weeks')} weeks ago`;
}
if (as('days')) {
return `${as('days')} days ago`;
}
if (as('hours')) {
return `${as('hours')} hours ago`;
}
if (as('minutes')) {
return `${as('minutes')} minutes ago`;
}
if (as('seconds') && as('seconds') > 10) {
return `${as('seconds')} seconds ago`;
}
return 'just now';
}
beginningOf.doc = {
name: 'beginningOf',
returnType: 'Date',
};
function timeTo(date: Date | DateTime, extraArgs: unknown[]): Duration {
const [diff = new Date().toISOString(), unit = 'seconds'] = extraArgs as [string, DurationUnit];
const diffDate = new Date(diff);
if (isDateTime(date)) {
return date.diff(DateTime.fromJSDate(diffDate), DURATION_MAP[unit] || unit);
}
return DateTime.fromJSDate(date).diff(DateTime.fromJSDate(diffDate), DURATION_MAP[unit] || unit);
}
extract.doc = {
name: 'extract',
returnType: 'number',
};
function toDate(date: Date | DateTime) {
if (isDateTime(date)) {
return date.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).toJSDate();
}
let datetime = DateTime.fromJSDate(date);
if (date.getTimezoneOffset() === 0) {
datetime = datetime.setZone('UTC');
}
return datetime.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).toJSDate();
}
format.doc = {
name: 'format',
returnType: '(?)',
};
isBetween.doc = {
name: 'isBetween',
returnType: 'boolean',
};
isInLast.doc = {
name: 'isInLast',
returnType: 'boolean',
};
minus.doc = {
name: 'minus',
returnType: 'Date',
};
plus.doc = {
name: 'plus',
returnType: 'Date',
};
export const dateExtensions: ExtensionMap = {
typeName: 'Date',
@@ -274,10 +259,6 @@ export const dateExtensions: ExtensionMap = {
isWeekend,
minus,
plus,
toTimeFromNow,
timeTo,
format,
toLocaleString,
toDate,
},
};

View File

@@ -11,15 +11,15 @@ import { objectExtensions } from './ObjectExtensions';
const EXPRESSION_EXTENDER = 'extend';
function isBlank(value: unknown) {
function isEmpty(value: unknown) {
return value === null || value === undefined || !value;
}
function isPresent(value: unknown) {
return !isBlank(value);
function isNotEmpty(value: unknown) {
return !isEmpty(value);
}
const EXTENSION_OBJECTS = [
export const EXTENSION_OBJECTS = [
arrayExtensions,
dateExtensions,
numberExtensions,
@@ -29,8 +29,8 @@ const EXTENSION_OBJECTS = [
// eslint-disable-next-line @typescript-eslint/ban-types
const genericExtensions: Record<string, Function> = {
isBlank,
isPresent,
isEmpty,
isNotEmpty,
};
const EXPRESSION_EXTENSION_METHODS = Array.from(

View File

@@ -1,5 +1,13 @@
export interface ExtensionMap {
typeName: string;
// eslint-disable-next-line @typescript-eslint/ban-types
functions: Record<string, Function>;
functions: Record<string, Function & { doc?: DocMetadata }>;
}
export type DocMetadata = {
name: string;
returnType: string;
description?: string;
aliases?: string[];
args?: unknown[];
};

View File

@@ -13,26 +13,6 @@ function format(value: number, extraArgs: unknown[]): string {
return new Intl.NumberFormat(locales, config).format(value);
}
function isBlank(value: number): boolean {
return typeof value !== 'number';
}
function isPresent(value: number): boolean {
return !isBlank(value);
}
function random(value: number): number {
return Math.floor(Math.random() * value);
}
function isTrue(value: number) {
return value === 1;
}
function isFalse(value: number) {
return value === 0;
}
function isEven(value: number) {
return value % 2 === 0;
}
@@ -54,19 +34,49 @@ function round(value: number, extraArgs: number[]) {
return +value.toFixed(decimalPlaces);
}
ceil.doc = {
name: 'ceil',
description: 'Rounds up a number to a whole number',
returnType: 'number',
};
floor.doc = {
name: 'floor',
description: 'Rounds down a number to a whole number',
returnType: 'number',
};
isEven.doc = {
name: 'isEven',
description: 'Returns true if the number is even. Only works on whole numbers.',
returnType: 'boolean',
};
isOdd.doc = {
name: 'isOdd',
description: 'Returns true if the number is odd. Only works on whole numbers.',
returnType: 'boolean',
};
// @TODO_NEXT_PHASE: Surface extensions below which take args
format.doc = {
name: 'format',
returnType: 'string',
};
round.doc = {
name: 'round',
returnType: 'number',
};
export const numberExtensions: ExtensionMap = {
typeName: 'Number',
functions: {
ceil,
floor,
format,
random,
round,
isBlank,
isPresent,
isTrue,
isNotTrue: isFalse,
isFalse,
isEven,
isOdd,
},

View File

@@ -4,7 +4,7 @@ import type { ExtensionMap } from './Extensions';
export function merge(value: object, extraArgs: unknown[]): unknown {
const [other] = extraArgs;
if (typeof other !== 'object' || !other) {
throw new ExpressionExtensionError('argument of merge must be an object');
throw new ExpressionExtensionError('merge(): expected object arg');
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newObject: any = { ...value };
@@ -21,6 +21,10 @@ function isEmpty(value: object): boolean {
return Object.keys(value).length === 0;
}
function isNotEmpty(value: object): boolean {
return !isEmpty(value);
}
function hasField(value: object, extraArgs: string[]): boolean {
const [name] = extraArgs;
return name in value;
@@ -40,7 +44,7 @@ function removeField(value: object, extraArgs: string[]): object {
function removeFieldsContaining(value: object, extraArgs: string[]): object {
const [match] = extraArgs;
if (typeof match !== 'string') {
throw new ExpressionExtensionError('argument of removeFieldsContaining must be an string');
throw new ExpressionExtensionError('removeFieldsContaining(): expected string arg');
}
const newObject = { ...value };
for (const [key, val] of Object.entries(value)) {
@@ -55,7 +59,7 @@ function removeFieldsContaining(value: object, extraArgs: string[]): object {
function keepFieldsContaining(value: object, extraArgs: string[]): object {
const [match] = extraArgs;
if (typeof match !== 'string') {
throw new ExpressionExtensionError('argument of keepFieldsContaining must be an string');
throw new ExpressionExtensionError('argument of keepFieldsContaining must be a string');
}
const newObject = { ...value };
for (const [key, val] of Object.entries(value)) {
@@ -71,8 +75,9 @@ export function compact(value: object): object {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newObj: any = {};
for (const [key, val] of Object.entries(value)) {
if (val !== null && val !== undefined) {
if (val !== null && val !== undefined && val !== 'nil' && val !== '') {
if (typeof val === 'object') {
if (Object.keys(val as object).length === 0) continue;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument
newObj[key] = compact(val);
} else {
@@ -89,10 +94,62 @@ export function urlEncode(value: object) {
return new URLSearchParams(value as Record<string, string>).toString();
}
isEmpty.doc = {
name: 'isEmpty',
description: 'Checks if the Object has no key-value pairs',
returnType: 'boolean',
};
isNotEmpty.doc = {
name: 'isNotEmpty',
description: 'Checks if the Object has key-value pairs',
returnType: 'boolean',
};
compact.doc = {
name: 'compact',
description: 'Removes empty values from an Object',
returnType: 'boolean',
};
urlEncode.doc = {
name: 'urlEncode',
description: 'Transforms an Object into a URL parameter list. Only top-level keys are supported.',
returnType: 'string',
};
// @TODO_NEXT_PHASE: Surface extensions below which take args
merge.doc = {
name: 'merge',
returnType: 'object',
};
hasField.doc = {
name: 'hasField',
returnType: 'boolean',
};
removeField.doc = {
name: 'removeField',
returnType: 'object',
};
removeFieldsContaining.doc = {
name: 'removeFieldsContaining',
returnType: 'object',
};
keepFieldsContaining.doc = {
name: 'keepFieldsContaining',
returnType: 'object',
};
export const objectExtensions: ExtensionMap = {
typeName: 'Object',
functions: {
isEmpty,
isNotEmpty,
merge,
hasField,
removeField,

View File

@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/unbound-method */
// import { createHash } from 'crypto';
import { titleCase } from 'title-case';
import * as ExpressionError from '../ExpressionError';
import type { ExtensionMap } from './Extensions';
import CryptoJS from 'crypto-js';
@@ -34,20 +35,17 @@ const URL_REGEXP =
const CHAR_TEST_REGEXP = /\p{L}/u;
const PUNC_TEST_REGEXP = /[!?.]/;
const TRUE_VALUES = ['true', '1', 't', 'yes', 'y'];
const FALSE_VALUES = ['false', '0', 'f', 'no', 'n'];
function encrypt(value: string, extraArgs?: unknown): string {
const [format = 'MD5'] = extraArgs as string[];
if (format.toLowerCase() === 'base64') {
function hash(value: string, extraArgs?: unknown): string {
const [algorithm = 'MD5'] = extraArgs as string[];
if (algorithm.toLowerCase() === 'base64') {
// We're using a library instead of btoa because btoa only
// works on ASCII
return encode(value);
}
const hashFunction = hashFunctions[format.toLowerCase()];
const hashFunction = hashFunctions[algorithm.toLowerCase()];
if (!hashFunction) {
throw new ExpressionError.ExpressionExtensionError(
`Unknown encrypt type ${format}. Available types are: ${Object.keys(hashFunctions)
`Unknown algorithm ${algorithm}. Available algorithms are: ${Object.keys(hashFunctions)
.map((s) => s.toUpperCase())
.join(', ')}, and Base64.`,
);
@@ -56,24 +54,12 @@ function encrypt(value: string, extraArgs?: unknown): string {
// return createHash(format).update(value.toString()).digest('hex');
}
function getOnlyFirstCharacters(value: string, extraArgs: number[]): string {
const [end] = extraArgs;
if (typeof end !== 'number') {
throw new ExpressionError.ExpressionExtensionError(
'getOnlyFirstCharacters() requires a argument',
);
}
return value.slice(0, end);
}
function isBlank(value: string): boolean {
function isEmpty(value: string): boolean {
return value === '';
}
function isPresent(value: string): boolean {
return !isBlank(value);
function isNotEmpty(value: string): boolean {
return !isEmpty(value);
}
function length(value: string): number {
@@ -122,16 +108,18 @@ function removeMarkdown(value: string): string {
return output;
}
function sayHi(value: string) {
return `hi ${value}`;
}
function stripTags(value: string): string {
function removeTags(value: string): string {
return value.replace(/<[^>]*>?/gm, '');
}
function toDate(value: string): Date {
return new Date(value.toString());
const date = new Date(value.toString());
if (date.toString() === 'Invalid Date') {
throw new ExpressionError.ExpressionExtensionError('cannot convert to date');
}
return date;
}
function urlDecode(value: string, extraArgs: boolean[]): string {
@@ -152,11 +140,29 @@ function urlEncode(value: string, extraArgs: boolean[]): string {
function toInt(value: string, extraArgs: Array<number | undefined>) {
const [radix] = extraArgs;
return parseInt(value.replace(CURRENCY_REGEXP, ''), radix);
const int = parseInt(value.replace(CURRENCY_REGEXP, ''), radix);
if (isNaN(int)) {
throw new ExpressionError.ExpressionExtensionError('cannot convert to integer');
}
return int;
}
function toFloat(value: string) {
return parseFloat(value.replace(CURRENCY_REGEXP, ''));
if (value.includes(',')) {
throw new ExpressionError.ExpressionExtensionError(
'cannot convert to float, expected . as decimal separator',
);
}
const float = parseFloat(value.replace(CURRENCY_REGEXP, ''));
if (isNaN(float)) {
throw new ExpressionError.ExpressionExtensionError('cannot convert to float');
}
return float;
}
function quote(value: string, extraArgs: string[]) {
@@ -166,15 +172,9 @@ function quote(value: string, extraArgs: string[]) {
.replace(new RegExp(`\\${quoteChar}`, 'g'), `\\${quoteChar}`)}${quoteChar}`;
}
function isTrue(value: string) {
return TRUE_VALUES.includes(value.toLowerCase());
}
function isFalse(value: string) {
return FALSE_VALUES.includes(value.toLowerCase());
}
function isNumeric(value: string) {
if (value.includes(' ')) return false;
return !isNaN(value as unknown as number) && !isNaN(parseFloat(value));
}
@@ -185,7 +185,18 @@ function isUrl(value: string) {
} catch (_error) {
return false;
}
return url.protocol === 'http:' || url.protocol === 'https:';
// URL constructor tolerates missing `//` after protocol so check manually
for (const scheme of ['http:', 'https:']) {
if (
url.protocol === scheme &&
value.slice(scheme.length, scheme.length + '//'.length) === '//'
) {
return true;
}
}
return false;
}
function isDomain(value: string) {
@@ -193,15 +204,22 @@ function isDomain(value: string) {
}
function isEmail(value: string) {
return EMAIL_REGEXP.test(value);
}
const result = EMAIL_REGEXP.test(value);
function stripSpecialChars(value: string) {
return transliterate(value, { unknown: '?' });
// email regex is loose so check manually for now
if (result && value.includes(' ')) {
return false;
}
return result;
}
function toTitleCase(value: string) {
return value.replace(/\w\S*/g, (v) => v.charAt(0).toLocaleUpperCase() + v.slice(1));
return titleCase(value);
}
function replaceSpecialChars(value: string) {
return transliterate(value, { unknown: '?' });
}
function toSentenceCase(value: string) {
@@ -265,16 +283,153 @@ function extractUrl(value: string) {
return matched[0];
}
removeMarkdown.doc = {
name: 'removeMarkdown',
description: 'Removes Markdown formatting from a string',
returnType: 'string',
};
removeTags.doc = {
name: 'removeTags',
description: 'Removes tags, such as HTML or XML, from a string',
returnType: 'string',
};
toDate.doc = {
name: 'toDate',
description: 'Converts a string to a date',
returnType: 'Date',
};
toFloat.doc = {
name: 'toFloat',
description: 'Converts a string to a decimal number',
returnType: 'number',
aliases: ['toDecimalNumber'],
};
toInt.doc = {
name: 'toInt',
description: 'Converts a string to an integer',
returnType: 'number',
aliases: ['toWholeNumber'],
};
toSentenceCase.doc = {
name: 'toSentenceCase',
description: 'Formats a string to sentence case. Example: "This is a sentence"',
returnType: 'string',
};
toSnakeCase.doc = {
name: 'toSnakeCase',
description: 'Formats a string to snake case. Example: "this_is_snake_case"',
returnType: 'string',
};
toTitleCase.doc = {
name: 'toTitleCase',
description: 'Formats a string to title case. Example: "This Is a Title"',
returnType: 'string',
};
urlDecode.doc = {
name: 'urlDecode',
description:
'Decodes a URL-encoded string. It decodes any percent-encoded characters in the input string, and replaces them with their original characters.',
returnType: 'string',
};
replaceSpecialChars.doc = {
name: 'replaceSpecialChars',
description: 'Replaces non-ASCII characters in a string with an ASCII representation',
returnType: 'string',
};
length.doc = {
name: 'length',
description: 'Returns the character count of a string',
returnType: 'number',
};
isDomain.doc = {
name: 'isDomain',
description: 'Checks if a string is a domain',
returnType: 'boolean',
};
isEmail.doc = {
name: 'isEmail',
description: 'Checks if a string is an email',
returnType: 'boolean',
};
isNumeric.doc = {
name: 'isEmail',
description: 'Checks if a string only contains digits',
returnType: 'boolean',
};
isUrl.doc = {
name: 'isUrl',
description: 'Checks if a string is a valid URL',
returnType: 'boolean',
};
isEmpty.doc = {
name: 'isEmpty',
description: 'Checks if a string is empty',
returnType: 'boolean',
};
isNotEmpty.doc = {
name: 'isNotEmpty',
description: 'Checks if a string has content',
returnType: 'boolean',
};
extractEmail.doc = {
name: 'extractEmail',
description: 'Extracts an email from a string. Returns undefined if none is found.',
returnType: 'string',
};
extractDomain.doc = {
name: 'extractDomain',
description:
'Extracts a domain from a string containing a valid URL. Returns undefined if none is found.',
returnType: 'string',
};
extractUrl.doc = {
name: 'extractUrl',
description: 'Extracts a URL from a string. Returns undefined if none is found.',
returnType: 'string',
};
// @TODO_NEXT_PHASE: Surface extensions below which take args
hash.doc = {
name: 'hash',
returnType: 'string',
};
urlEncode.doc = {
name: 'urlEncode',
returnType: 'string',
};
quote.doc = {
name: 'quote',
returnType: 'string',
};
export const stringExtensions: ExtensionMap = {
typeName: 'String',
functions: {
encrypt,
hash: encrypt,
getOnlyFirstCharacters,
hash,
removeMarkdown,
sayHi,
stripTags,
toBoolean: isTrue,
removeTags,
toDate,
toDecimalNumber: toFloat,
toFloat,
@@ -286,18 +441,14 @@ export const stringExtensions: ExtensionMap = {
urlDecode,
urlEncode,
quote,
stripSpecialChars,
replaceSpecialChars,
length,
isDomain,
isEmail,
isTrue,
isFalse,
isNotTrue: isFalse,
isNumeric,
isUrl,
isURL: isUrl,
isBlank,
isPresent,
isEmpty,
isNotEmpty,
extractEmail,
extractDomain,
extractUrl,

View File

@@ -4,4 +4,7 @@ export {
hasExpressionExtension,
hasNativeMethod,
extendTransform,
EXTENSION_OBJECTS as ExpressionExtensions,
} from './ExpressionExtension';
export type { DocMetadata } from './Extensions';

View File

@@ -0,0 +1,13 @@
export const stringMethods = {
typeName: 'String',
functions: {
// @TODO_NEXT_PHASE: Populate below and cover other datatypes
// trim: {
// doc: {
// name: 'trim',
// description: 'Removes whitespace from both ends of a string and returns a new string',
// returnType: 'string',
// },
// },
},
};

View File

@@ -0,0 +1,5 @@
import { stringMethods } from './String.methods';
const NATIVE_METHODS = [stringMethods];
export { NATIVE_METHODS as NativeMethods };

View File

@@ -126,7 +126,7 @@ export class WorkflowDataProxy {
const that = this;
const node = this.workflow.nodes[nodeName];
if (!that.runExecutionData?.executionData && that.connectionInputData.length > 1) {
if (!that.runExecutionData?.executionData && that.connectionInputData.length > 0) {
return {}; // incoming connection has pinned data, so stub context object
}
@@ -158,6 +158,7 @@ export class WorkflowDataProxy {
};
},
get(target, name, receiver) {
if (name === 'isProxy') return true;
// eslint-disable-next-line no-param-reassign
name = name.toString();
const contextData = NodeHelpers.getContext(that.runExecutionData!, 'node', node);
@@ -179,6 +180,7 @@ export class WorkflowDataProxy {
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
get(target, name, receiver) {
if (name === 'isProxy') return true;
name = name.toString();
return that.selfData[name];
},
@@ -207,6 +209,7 @@ export class WorkflowDataProxy {
};
},
get(target, name, receiver) {
if (name === 'isProxy') return true;
name = name.toString();
let returnValue: NodeParameterValueType;
@@ -379,6 +382,7 @@ export class WorkflowDataProxy {
{ binary: undefined, data: undefined, json: undefined },
{
get(target, name, receiver) {
if (name === 'isProxy') return true;
name = name.toString();
if (!node) {
@@ -455,6 +459,8 @@ export class WorkflowDataProxy {
{},
{
get(target, name, receiver) {
if (name === 'isProxy') return true;
if (
typeof process === 'undefined' || // env vars are inaccessible to frontend
process.env.N8N_BLOCK_ENV_ACCESS_IN_NODE === 'true'
@@ -490,6 +496,8 @@ export class WorkflowDataProxy {
};
},
get(target, name, receiver) {
if (name === 'isProxy') return true;
if (!that.executeData?.source) {
// Means the previous node did not get executed yet
return undefined;
@@ -535,6 +543,8 @@ export class WorkflowDataProxy {
};
},
get(target, name, receiver) {
if (name === 'isProxy') return true;
if (allowedValues.includes(name.toString())) {
const value = that.workflow[name as keyof typeof target];
@@ -567,6 +577,8 @@ export class WorkflowDataProxy {
{},
{
get(target, name, receiver) {
if (name === 'isProxy') return true;
const nodeName = name.toString();
if (that.workflow.getNode(nodeName) === null) {
@@ -607,6 +619,14 @@ export class WorkflowDataProxy {
// replacing proxies with the actual data.
const jmespathWrapper = (data: IDataObject | IDataObject[], query: string) => {
if (typeof data !== 'object' || typeof query !== 'string') {
throw new ExpressionError('expected two arguments (Object, string) for this function', {
runIndex: that.runIndex,
itemIndex: that.itemIndex,
clientOnly: true,
});
}
if (!Array.isArray(data) && typeof data === 'object') {
return jmespath.search({ ...data }, query);
}
@@ -939,6 +959,8 @@ export class WorkflowDataProxy {
];
},
get(target, property, receiver) {
if (property === 'isProxy') return true;
if (['pairedItem', 'itemMatching', 'item'].includes(property as string)) {
const pairedItemMethod = (itemIndex?: number) => {
if (itemIndex === undefined) {
@@ -1059,6 +1081,8 @@ export class WorkflowDataProxy {
};
},
get(target, property, receiver) {
if (property === 'isProxy') return true;
if (property === 'item') {
return that.connectionInputData[that.itemIndex];
}
@@ -1214,6 +1238,8 @@ export class WorkflowDataProxy {
return new Proxy(base, {
get(target, name, receiver) {
if (name === 'isProxy') return true;
if (['$data', '$json'].includes(name as string)) {
return that.nodeDataGetter(that.activeNodeName, true)?.json;
}

View File

@@ -28,3 +28,8 @@ export {
isINodePropertyCollectionList,
isINodePropertyOptionsList,
} from './type-guards';
export { ExpressionExtensions } from './Extensions';
export { NativeMethods } from './NativeMethods';
export type { DocMetadata } from './Extensions';

View File

@@ -6,16 +6,12 @@ import { evaluate } from './Helpers';
describe('Data Transformation Functions', () => {
describe('Array Data Transformation Functions', () => {
test('.random() should work correctly on an array', () => {
expect(evaluate('={{ [1,2,3].random() }}')).not.toBeUndefined();
});
test('.randomItem() alias should work correctly on an array', () => {
test('.randomItem() should work correctly on an array', () => {
expect(evaluate('={{ [1,2,3].randomItem() }}')).not.toBeUndefined();
});
test('.isPresent() should work correctly on an array', () => {
expect(evaluate('={{ [1,2,3, "imhere"].isPresent() }}')).toEqual(true);
test('.isNotEmpty() should work correctly on an array', () => {
expect(evaluate('={{ [1,2,3, "imhere"].isNotEmpty() }}')).toEqual(true);
});
test('.pluck() should work correctly on an array', () => {
@@ -46,24 +42,12 @@ describe('Data Transformation Functions', () => {
);
});
test('.isBlank() should work correctly on an array', () => {
expect(evaluate('={{ [].isBlank() }}')).toEqual(true);
test('.isEmpty() should work correctly on an array', () => {
expect(evaluate('={{ [].isEmpty() }}')).toEqual(true);
});
test('.isBlank() should work correctly on an array', () => {
expect(evaluate('={{ [1].isBlank() }}')).toEqual(false);
});
test('.length() should work correctly on an array', () => {
expect(evaluate('={{ [].length() }}')).toEqual(0);
});
test('.count() should work correctly on an array', () => {
expect(evaluate('={{ [1].count() }}')).toEqual(1);
});
test('.size() should work correctly on an array', () => {
expect(evaluate('={{ [1,2].size() }}')).toEqual(2);
test('.isEmpty() should work correctly on an array', () => {
expect(evaluate('={{ [1].isEmpty() }}')).toEqual(false);
});
test('.last() should work correctly on an array', () => {
@@ -74,12 +58,6 @@ describe('Data Transformation Functions', () => {
expect(evaluate('={{ ["repeat","repeat","a","b","c"].first() }}')).toEqual('repeat');
});
test('.filter() should work correctly on an array', () => {
expect(evaluate('={{ ["repeat","repeat","a","b","c"].filter("repeat") }}')).toEqual(
expect.arrayContaining(['repeat', 'repeat']),
);
});
test('.merge() should work correctly on an array', () => {
expect(
evaluate(
@@ -115,26 +93,22 @@ describe('Data Transformation Functions', () => {
test('.sum() should work on an array of numbers', () => {
expect(evaluate('={{ [1, 2, 3, 4, 5, 6].sum() }}')).toEqual(21);
expect(evaluate('={{ ["1", 2, 3, 4, 5, 6].sum() }}')).toEqual(21);
expect(evaluate('={{ ["1", 2, 3, 4, 5, "bad"].sum() }}')).toBeNaN();
expect(() => evaluate('={{ ["1", 2, 3, 4, 5, "bad"].sum() }}')).toThrow();
});
test('.average() should work on an array of numbers', () => {
expect(evaluate('={{ [1, 2, 3, 4, 5, 6].average() }}')).toEqual(3.5);
expect(evaluate('={{ ["1", 2, 3, 4, 5, 6].average() }}')).toEqual(3.5);
expect(evaluate('={{ ["1", 2, 3, 4, 5, "bad"].average() }}')).toBeNaN();
expect(() => evaluate('={{ ["1", 2, 3, 4, 5, "bad"].average() }}')).toThrow();
});
test('.min() should work on an array of numbers', () => {
expect(evaluate('={{ [1, 2, 3, 4, 5, 6].min() }}')).toEqual(1);
expect(evaluate('={{ ["1", 2, 3, 4, 5, 6].min() }}')).toEqual(1);
expect(evaluate('={{ ["1", 2, 3, 4, 5, "bad"].min() }}')).toBeNaN();
expect(() => evaluate('={{ ["1", 2, 3, 4, 5, "bad"].min() }}')).toThrow();
});
test('.max() should work on an array of numbers', () => {
expect(evaluate('={{ [1, 2, 3, 4, 5, 6].max() }}')).toEqual(6);
expect(evaluate('={{ ["1", 2, 3, 4, 5, 6].max() }}')).toEqual(6);
expect(evaluate('={{ ["1", 2, 3, 4, 5, "bad"].max() }}')).toBeNaN();
expect(() => evaluate('={{ ["1", 2, 3, 4, 5, "bad"].max() }}')).toThrow();
});
test('.union() should work on an array of objects', () => {
@@ -181,18 +155,5 @@ describe('Data Transformation Functions', () => {
[16, 17, 18, 19, 20],
]);
});
test('.filter() should work on a list of strings', () => {
expect(
evaluate(
'={{ ["i am a test string", "i should be kept", "i should be removed test"].filter("test", "remove") }}',
),
).toEqual(['i should be kept']);
expect(
evaluate(
'={{ ["i am a test string", "i should be kept test", "i should be removed"].filter("test") }}',
),
).toEqual(['i am a test string', 'i should be kept test']);
});
});
});

View File

@@ -14,11 +14,6 @@ describe('Data Transformation Functions', () => {
expect(evaluate('={{ DateTime.local(2023, 1, 23).isWeekend() }}')).toBe(false);
});
test('.toTimeFromNow() should work correctly on a date', () => {
const JUST_NOW_STRING_RESULT = 'just now';
expect(evaluate('={{DateTime.now().toTimeFromNow()}}')).toEqual(JUST_NOW_STRING_RESULT);
});
test('.beginningOf("week") should work correctly on a date', () => {
expect(evaluate('={{ DateTime.local(2023, 1, 20).beginningOf("week") }}')).toEqual(
DateTime.local(2023, 1, 16, { zone: TEST_TIMEZONE }).toJSDate(),

View File

@@ -8,19 +8,19 @@ import { evaluate } from './Helpers';
describe('Expression Extension Transforms', () => {
describe('extend() transform', () => {
test('Basic transform with .isBlank', () => {
expect(extendTransform('"".isBlank()')!.code).toEqual('extend("", "isBlank", [])');
test('Basic transform with .isEmpty', () => {
expect(extendTransform('"".isEmpty()')!.code).toEqual('extend("", "isEmpty", [])');
});
test('Chained transform with .sayHi.getOnlyFirstCharacters', () => {
expect(extendTransform('"".sayHi().getOnlyFirstCharacters(2)')!.code).toEqual(
'extend(extend("", "sayHi", []), "getOnlyFirstCharacters", [2])',
test('Chained transform with .toSnakeCase.toSentenceCase', () => {
expect(extendTransform('"".toSnakeCase().toSentenceCase(2)')!.code).toEqual(
'extend(extend("", "toSnakeCase", []), "toSentenceCase", [2])',
);
});
test('Chained transform with native functions .sayHi.trim.getOnlyFirstCharacters', () => {
expect(extendTransform('"aaa ".sayHi().trim().getOnlyFirstCharacters(2)')!.code).toEqual(
'extend(extend("aaa ", "sayHi", []).trim(), "getOnlyFirstCharacters", [2])',
test('Chained transform with native functions .toSnakeCase.trim.toSentenceCase', () => {
expect(extendTransform('"aaa ".toSnakeCase().trim().toSentenceCase(2)')!.code).toEqual(
'extend(extend("aaa ", "toSnakeCase", []).trim(), "toSentenceCase", [2])',
);
});
});
@@ -36,19 +36,21 @@ describe('tmpl Expression Parser', () => {
});
test('Multiple expression', () => {
expect(splitExpression('{{ "test".sayHi() }} you have ${{ (100).format() }}.')).toEqual([
{ type: 'text', text: '' },
{ type: 'code', text: ' "test".sayHi() ', hasClosingBrackets: true },
{ type: 'text', text: ' you have $' },
{ type: 'code', text: ' (100).format() ', hasClosingBrackets: true },
{ type: 'text', text: '.' },
]);
expect(splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format() }}.')).toEqual(
[
{ type: 'text', text: '' },
{ type: 'code', text: ' "test".toSnakeCase() ', hasClosingBrackets: true },
{ type: 'text', text: ' you have $' },
{ type: 'code', text: ' (100).format() ', hasClosingBrackets: true },
{ type: 'text', text: '.' },
],
);
});
test('Unclosed expression', () => {
expect(splitExpression('{{ "test".sayHi() }} you have ${{ (100).format()')).toEqual([
expect(splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format()')).toEqual([
{ type: 'text', text: '' },
{ type: 'code', text: ' "test".sayHi() ', hasClosingBrackets: true },
{ type: 'code', text: ' "test".toSnakeCase() ', hasClosingBrackets: true },
{ type: 'text', text: ' you have $' },
{ type: 'code', text: ' (100).format()', hasClosingBrackets: false },
]);
@@ -75,14 +77,16 @@ describe('tmpl Expression Parser', () => {
test('Multiple expression', () => {
expect(
joinExpression(splitExpression('{{ "test".sayHi() }} you have ${{ (100).format() }}.')),
).toEqual('{{ "test".sayHi() }} you have ${{ (100).format() }}.');
joinExpression(
splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format() }}.'),
),
).toEqual('{{ "test".toSnakeCase() }} you have ${{ (100).format() }}.');
});
test('Unclosed expression', () => {
expect(
joinExpression(splitExpression('{{ "test".sayHi() }} you have ${{ (100).format()')),
).toEqual('{{ "test".sayHi() }} you have ${{ (100).format()');
joinExpression(splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format()')),
).toEqual('{{ "test".toSnakeCase() }} you have ${{ (100).format()');
});
test('Escaped opening bracket', () => {

View File

@@ -2,8 +2,8 @@ import { evaluate } from './Helpers';
describe('Data Transformation Functions', () => {
describe('Genric Data Transformation Functions', () => {
test('.isBlank() should work correctly on undefined', () => {
expect(evaluate('={{(undefined).isBlank()}}')).toEqual(true);
test('.isEmpty() should work correctly on undefined', () => {
expect(evaluate('={{(undefined).isEmpty()}}')).toEqual(true);
});
});
});

View File

@@ -7,20 +7,6 @@ import { evaluate } from './Helpers';
describe('Data Transformation Functions', () => {
describe('Number Data Transformation Functions', () => {
test('.random() should work correctly on a number', () => {
expect(evaluate('={{ Number(100).random() }}')).not.toBeUndefined();
});
test('.isBlank() should work correctly on a number', () => {
expect(evaluate('={{ Number(100).isBlank() }}')).toEqual(false);
});
test('.isPresent() should work correctly on a number', () => {
expect(evaluate('={{ Number(100).isPresent() }}')).toEqual(
numberExtensions.functions.isPresent(100),
);
});
test('.format() should work correctly on a number', () => {
expect(evaluate('={{ Number(100).format() }}')).toEqual(
numberExtensions.functions.format(100, []),
@@ -48,18 +34,6 @@ describe('Data Transformation Functions', () => {
expect(evaluate('={{ (NaN).round(3) }}')).toBeNaN();
});
test('.isTrue() should work on a number', () => {
expect(evaluate('={{ (1).isTrue() }}')).toEqual(true);
expect(evaluate('={{ (0).isTrue() }}')).toEqual(false);
expect(evaluate('={{ (NaN).isTrue() }}')).toEqual(false);
});
test('.isFalse() should work on a number', () => {
expect(evaluate('={{ (1).isFalse() }}')).toEqual(false);
expect(evaluate('={{ (0).isFalse() }}')).toEqual(true);
expect(evaluate('={{ (NaN).isFalse() }}')).toEqual(false);
});
test('.isOdd() should work on a number', () => {
expect(evaluate('={{ (9).isOdd() }}')).toEqual(true);
expect(evaluate('={{ (8).isOdd() }}')).toEqual(false);
@@ -77,8 +51,8 @@ describe('Data Transformation Functions', () => {
describe('Multiple expressions', () => {
test('Basic multiple expressions', () => {
expect(evaluate('={{ "Test".sayHi() }} you have ${{ (100).format() }}.')).toEqual(
'hi Test you have $100.',
expect(evaluate('={{ "test abc".toSnakeCase() }} you have ${{ (100).format() }}.')).toEqual(
'test_abc you have $100.',
);
});
});

View File

@@ -8,47 +8,25 @@ import { evaluate } from './Helpers';
describe('Data Transformation Functions', () => {
describe('String Data Transformation Functions', () => {
test('.isBlank() should work correctly on a string that is not empty', () => {
expect(evaluate('={{"NotBlank".isBlank()}}')).toEqual(false);
test('.isEmpty() should work correctly on a string that is not empty', () => {
expect(evaluate('={{"NotBlank".isEmpty()}}')).toEqual(false);
});
test('.isBlank() should work correctly on a string that is empty', () => {
expect(evaluate('={{"".isBlank()}}')).toEqual(true);
test('.isEmpty() should work correctly on a string that is empty', () => {
expect(evaluate('={{"".isEmpty()}}')).toEqual(true);
});
test('.getOnlyFirstCharacters() should work correctly on a string', () => {
expect(evaluate('={{"myNewField".getOnlyFirstCharacters(5)}}')).toEqual('myNew');
expect(evaluate('={{"myNewField".getOnlyFirstCharacters(10)}}')).toEqual('myNewField');
expect(
evaluate('={{"myNewField".getOnlyFirstCharacters(5).length >= "myNewField".length}}'),
).toEqual(false);
expect(evaluate('={{DateTime.now().toLocaleString().getOnlyFirstCharacters(2)}}')).toEqual(
stringExtensions.functions.getOnlyFirstCharacters(
// @ts-ignore
dateExtensions.functions.toLocaleString(new Date(), []),
[2],
),
);
});
test('.sayHi() should work correctly on a string', () => {
expect(evaluate('={{ "abc".sayHi() }}')).toEqual('hi abc');
});
test('.encrypt() should work correctly on a string', () => {
expect(evaluate('={{ "12345".encrypt("sha256") }}')).toEqual(
stringExtensions.functions.encrypt('12345', ['sha256']),
test('.hash() should work correctly on a string', () => {
expect(evaluate('={{ "12345".hash("sha256") }}')).toEqual(
stringExtensions.functions.hash('12345', ['sha256']),
);
expect(evaluate('={{ "12345".encrypt("sha256") }}')).not.toEqual(
stringExtensions.functions.encrypt('12345', ['MD5']),
expect(evaluate('={{ "12345".hash("sha256") }}')).not.toEqual(
stringExtensions.functions.hash('12345', ['MD5']),
);
expect(evaluate('={{ "12345".encrypt("MD5") }}')).toEqual(
stringExtensions.functions.encrypt('12345', ['MD5']),
expect(evaluate('={{ "12345".hash("MD5") }}')).toEqual(
stringExtensions.functions.hash('12345', ['MD5']),
);
expect(evaluate('={{ "12345".hash("sha256") }}')).toEqual(
@@ -74,8 +52,8 @@ describe('Data Transformation Functions', () => {
);
});
test('.stripTags should work correctly on a string', () => {
expect(evaluate('={{ "<html><head>test</head></html>".stripTags() }}')).toEqual('test');
test('.removeTags should work correctly on a string', () => {
expect(evaluate('={{ "<html><head>test</head></html>".removeTags() }}')).toEqual('test');
});
test('.removeMarkdown should work correctly on a string', () => {
@@ -92,48 +70,6 @@ describe('Data Transformation Functions', () => {
);
});
test('.toBoolean should work correctly on a string', () => {
const validTrue = ['y', 'yes', 't', 'true', '1', 'YES'];
for (const v of validTrue) {
expect(evaluate(`={{ "${v}".toBoolean() }}`)).toEqual(true);
}
const validFalse = ['n', 'no', 'f', 'false', '0', 'NO'];
for (const v of validFalse) {
expect(evaluate(`={{ "${v}".toBoolean() }}`)).toEqual(false);
}
expect(evaluate('={{ "maybe".toBoolean() }}')).toEqual(false);
});
test('.isTrue should work correctly on a string', () => {
const validTrue = ['y', 'yes', 't', 'true', '1', 'YES'];
for (const v of validTrue) {
expect(evaluate(`={{ "${v}".isTrue() }}`)).toEqual(true);
}
const validFalse = ['n', 'no', 'f', 'false', '0', 'NO'];
for (const v of validFalse) {
expect(evaluate(`={{ "${v}".isTrue() }}`)).toEqual(false);
}
expect(evaluate('={{ "maybe".isTrue() }}')).toEqual(false);
});
test('.isFalse should work correctly on a string', () => {
const validTrue = ['y', 'yes', 't', 'true', '1', 'YES'];
for (const v of validTrue) {
expect(evaluate(`={{ "${v}".isFalse() }}`)).toEqual(false);
}
const validFalse = ['n', 'no', 'f', 'false', '0', 'NO'];
for (const v of validFalse) {
expect(evaluate(`={{ "${v}".isFalse() }}`)).toEqual(true);
}
expect(evaluate('={{ "maybe".isFalse() }}')).toEqual(false);
});
test('.toFloat should work correctly on a string', () => {
expect(evaluate('={{ "1.1".toFloat() }}')).toEqual(1.1);
expect(evaluate('={{ "1.1".toDecimalNumber() }}')).toEqual(1.1);
@@ -185,16 +121,6 @@ describe('Data Transformation Functions', () => {
expect(evaluate('={{ "i am a test".toSentenceCase() }}')).toEqual('I am a test');
});
test('.toTitleCase should work on a string', () => {
expect(
evaluate(
'={{ "i am a test! i have multiple types of Punctuation. or do i?".toTitleCase() }}',
),
).toEqual('I Am A Test! I Have Multiple Types Of Punctuation. Or Do I?');
expect(evaluate('={{ "i am a test!".toTitleCase() }}')).toEqual('I Am A Test!');
expect(evaluate('={{ "i am a test".toTitleCase() }}')).toEqual('I Am A Test');
});
test('.extractUrl should work on a string', () => {
expect(
evaluate(

11
pnpm-lock.yaml generated
View File

@@ -558,7 +558,7 @@ importers:
axios: ^0.21.1
c8: ^7.12.0
codemirror-lang-html-n8n: ^1.0.0
codemirror-lang-n8n-expression: ^0.1.0
codemirror-lang-n8n-expression: ^0.2.0
dateformat: ^3.0.3
esprima-next: 5.8.4
fast-json-stable-stringify: ^2.1.0
@@ -627,7 +627,7 @@ importers:
'@jsplumb/util': 5.13.2
axios: 0.21.4
codemirror-lang-html-n8n: 1.0.0
codemirror-lang-n8n-expression: 0.1.0_zyklskjzaprvz25ee7sq7godcq
codemirror-lang-n8n-expression: 0.2.0_zyklskjzaprvz25ee7sq7godcq
dateformat: 3.0.3
esprima-next: 5.8.4
fast-json-stable-stringify: 2.1.0
@@ -937,6 +937,7 @@ importers:
lodash.set: ^4.3.2
luxon: ^3.1.0
recast: ^0.21.5
title-case: ^3.0.3
transliteration: ^2.3.5
xml2js: ^0.4.23
dependencies:
@@ -950,6 +951,7 @@ importers:
lodash.set: 4.3.2
luxon: 3.1.1
recast: 0.21.5
title-case: 3.0.3
transliteration: 2.3.5
xml2js: 0.4.23
devDependencies:
@@ -9441,8 +9443,8 @@ packages:
'@lezer/lr': 1.2.3
dev: false
/codemirror-lang-n8n-expression/0.1.0_zyklskjzaprvz25ee7sq7godcq:
resolution: {integrity: sha512-20ss5p0koTu5bfivr1sBHYs7cpjWT2JhVB5gn7TX9WWPt+v/9p9tEcYSOyL/sm+OFuWh698Cgnmrba4efQnMCQ==}
/codemirror-lang-n8n-expression/0.2.0_zyklskjzaprvz25ee7sq7godcq:
resolution: {integrity: sha512-kdlpzevdCpWcpbNcwES9YZy+rDFwWOdO6Z78SWxT6jMhCPmdHQmO+gJ39aXAXlUI7OGLfOBtg1/ONxPjRpEIYQ==}
dependencies:
'@codemirror/autocomplete': 6.4.0_wo7q3lvweq5evsu423o7qzum5i
'@codemirror/language': 6.2.1
@@ -20972,7 +20974,6 @@ packages:
resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==}
dependencies:
tslib: 2.4.0
dev: true
/tlds/1.231.0:
resolution: {integrity: sha512-L7UQwueHSkGxZHQBXHVmXW64oi+uqNtzFt2x6Ssk7NVnpIbw16CRs4eb/jmKOZ9t2JnqZ/b3Cfvo97lnXqKrhw==}