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

@@ -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,
]);
}