feat(editor): Introduce proxy completions to expressions (#5075)
* ⚡ Introduce proxy completions to expressions * 🧪 Add tests * ⚡ Replace snippet with alphabetic char completions * ⚡ Tighten `DateTime` check * 🧹 Clean up `n8nLang` * 🔥 Remove duplicate * 👕 Remove non-null assertion * ⚡ Confirm that `overlay` is needed * 🔥 Remove comment * 🔥 Remove more unneeded code * 🔥 Remove unneded Pinia setup * ⚡ Simplify syntax
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
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', 'pairedItem', '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 === 'pairedItem') return {};
|
||||
if (property === 'itemMatching') return {};
|
||||
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const itemProxy = new Proxy(
|
||||
{ json: {}, pairedItem: {} },
|
||||
{
|
||||
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;
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,80 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
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__']);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION } from '@/components/CodeNodeEditor/constants';
|
||||
import { useWorkflowsStore } from '@/stores/workflows';
|
||||
|
||||
export function autocompletableNodeNames() {
|
||||
return useWorkflowsStore()
|
||||
.allNodes.filter((node) => !NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION.includes(node.type))
|
||||
.map((node) => node.name);
|
||||
}
|
||||
|
||||
export const longestCommonPrefix = (strings: string[]) => {
|
||||
if (strings.length === 0) return '';
|
||||
|
||||
return strings.reduce((acc, next) => {
|
||||
let i = 0;
|
||||
|
||||
while (acc[i] && next[i] && acc[i] === next[i]) {
|
||||
i++;
|
||||
}
|
||||
|
||||
return acc.slice(0, i);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a string may be used as a key in object dot notation access.
|
||||
*/
|
||||
export const isAllowedInDotNotation = (str: string) => {
|
||||
const DOT_NOTATION_BANNED_CHARS = /^(\d)|[\\ `!@#$%^&*()_+\-=[\]{};':"\\|,.<>?~]/g;
|
||||
|
||||
return !DOT_NOTATION_BANNED_CHARS.test(str);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { closeBrackets, completionStatus, insertBracket } from '@codemirror/autocomplete';
|
||||
import { codePointAt, codePointSize, Extension } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
const handler = EditorView.inputHandler.of((view, from, to, insert) => {
|
||||
if (view.composing || view.state.readOnly) return false;
|
||||
|
||||
// customization: do not autoclose tokens while autocompletion is active
|
||||
if (completionStatus(view.state) !== null) return false;
|
||||
|
||||
const selection = view.state.selection.main;
|
||||
|
||||
// customization: do not autoclose square brackets prior to `.json`
|
||||
if (
|
||||
insert === '[' &&
|
||||
view.state.doc.toString().slice(selection.from - '.json'.length, selection.to) === '.json'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
insert.length > 2 ||
|
||||
(insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) ||
|
||||
from !== selection.from ||
|
||||
to !== selection.to
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const transaction = insertBracket(view.state, insert);
|
||||
|
||||
if (!transaction) return false;
|
||||
|
||||
view.dispatch(transaction);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const [_, bracketState] = closeBrackets() as readonly Extension[];
|
||||
|
||||
/**
|
||||
* CodeMirror plugin for code node editor:
|
||||
*
|
||||
* - prevent token autoclosing during autocompletion
|
||||
* - prevent square bracket autoclosing prior to `.json`
|
||||
*
|
||||
* Other than segments marked `customization`, this is a copy of the [original](https://github.com/codemirror/closebrackets/blob/0a56edfaf2c6d97bc5e88f272de0985b4f41e37a/src/closebrackets.ts#L79).
|
||||
*/
|
||||
export const codeInputHandler = () => [handler, bracketState];
|
||||
@@ -1,12 +1,23 @@
|
||||
import { closeBrackets, insertBracket } from '@codemirror/autocomplete';
|
||||
import { closeBrackets, completionStatus, insertBracket } from '@codemirror/autocomplete';
|
||||
import { codePointAt, codePointSize, Extension } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
|
||||
const handler = EditorView.inputHandler.of((view, from, to, insert) => {
|
||||
if (view.composing || view.state.readOnly) return false;
|
||||
|
||||
// customization: do not autoclose tokens while autocompletion is active
|
||||
if (completionStatus(view.state) !== null) return false;
|
||||
|
||||
const selection = view.state.selection.main;
|
||||
|
||||
// customization: do not autoclose square brackets prior to `.json`
|
||||
if (
|
||||
insert === '[' &&
|
||||
view.state.doc.toString().slice(selection.from - '.json'.length, selection.to) === '.json'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
insert.length > 2 ||
|
||||
(insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) ||
|
||||
@@ -22,14 +33,10 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
|
||||
|
||||
view.dispatch(transaction);
|
||||
|
||||
/**
|
||||
* Customizations to inject whitespace and braces for setup and completion
|
||||
*/
|
||||
// customization: inject whitespace and second brace for brace completion: {| } -> {{ | }}
|
||||
|
||||
const cursor = view.state.selection.main.head;
|
||||
|
||||
// inject whitespace and second brace for brace completion: {| } -> {{ | }}
|
||||
|
||||
const isBraceCompletion =
|
||||
view.state.sliceDoc(cursor - 2, cursor) === '{{' &&
|
||||
view.state.sliceDoc(cursor, cursor + 1) === '}';
|
||||
@@ -43,7 +50,7 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
// inject whitespace for brace setup: empty -> {| }
|
||||
// customization: inject whitespace for brace setup: empty -> {| }
|
||||
|
||||
const isBraceSetup =
|
||||
view.state.sliceDoc(cursor - 1, cursor) === '{' &&
|
||||
@@ -55,7 +62,7 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
// inject whitespace for brace completion from selection: {{abc|}} -> {{ abc| }}
|
||||
// customization: inject whitespace for brace completion from selection: {{abc|}} -> {{ abc| }}
|
||||
|
||||
const [range] = view.state.selection.ranges;
|
||||
|
||||
@@ -78,6 +85,12 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
|
||||
const [_, bracketState] = closeBrackets() as readonly Extension[];
|
||||
|
||||
/**
|
||||
* CodeMirror plugin to handle double braces `{{ }}` for resolvables in n8n expressions.
|
||||
* CodeMirror plugin for (inline and modal) expression editor:
|
||||
*
|
||||
* - prevent token autoclosing during autocompletion (exception: `{`),
|
||||
* - prevent square bracket autoclosing prior to `.json`
|
||||
* - inject whitespace and braces for resolvables
|
||||
*
|
||||
* Other than segments marked `customization`, this is a copy of the [original](https://github.com/codemirror/closebrackets/blob/0a56edfaf2c6d97bc5e88f272de0985b4f41e37a/src/closebrackets.ts#L79).
|
||||
*/
|
||||
export const doubleBraceHandler = () => [inputHandler, bracketState];
|
||||
export const expressionInputHandler = () => [handler, bracketState];
|
||||
33
packages/editor-ui/src/plugins/codemirror/n8nLang.ts
Normal file
33
packages/editor-ui/src/plugins/codemirror/n8nLang.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { parserWithMetaData as n8nParser } from 'codemirror-lang-n8n-expression';
|
||||
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
||||
import { parseMixed } from '@lezer/common';
|
||||
import { javascriptLanguage } from '@codemirror/lang-javascript';
|
||||
import { ifIn } from '@codemirror/autocomplete';
|
||||
|
||||
import { proxyCompletions } from './completions/proxy.completions';
|
||||
import { rootCompletions } from './completions/root.completions';
|
||||
import { luxonCompletions } from './completions/luxon.completions';
|
||||
import { alphaCompletions } from './completions/alpha.completions';
|
||||
|
||||
const n8nParserWithNestedJsParser = n8nParser.configure({
|
||||
wrap: parseMixed((node) => {
|
||||
if (node.type.isTop) return null;
|
||||
|
||||
return node.name === 'Resolvable'
|
||||
? { parser: javascriptLanguage.parser, overlay: (node) => node.type.name === 'Resolvable' }
|
||||
: null;
|
||||
}),
|
||||
});
|
||||
|
||||
const n8nLanguage = LRLanguage.define({ parser: n8nParserWithNestedJsParser });
|
||||
|
||||
export function n8nLang() {
|
||||
const options = [alphaCompletions, rootCompletions, proxyCompletions, luxonCompletions].map(
|
||||
(group) => n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], group) }),
|
||||
);
|
||||
|
||||
return new LanguageSupport(n8nLanguage, [
|
||||
n8nLanguage.data.of({ closeBrackets: { brackets: ['{'] } }),
|
||||
...options,
|
||||
]);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { parserWithMetaData as n8nParser } from 'codemirror-lang-n8n-expression';
|
||||
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
||||
import { parseMixed } from '@lezer/common';
|
||||
import { parser as jsParser } from '@lezer/javascript';
|
||||
|
||||
const parserWithNestedJsParser = n8nParser.configure({
|
||||
wrap: parseMixed((node) => {
|
||||
if (node.type.isTop) return null;
|
||||
|
||||
return node.name === 'Resolvable'
|
||||
? { parser: jsParser, overlay: (node) => node.type.name === 'Resolvable' }
|
||||
: null;
|
||||
}),
|
||||
});
|
||||
|
||||
const n8nLanguage = LRLanguage.define({ parser: parserWithNestedJsParser });
|
||||
|
||||
export function n8nLanguageSupport() {
|
||||
return new LanguageSupport(n8nLanguage);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
|
||||
/**
|
||||
* Completions available inside the resolvable segment `{{ ... }}` of an n8n expression.
|
||||
*
|
||||
* Currently unused.
|
||||
*/
|
||||
export function resolvableCompletions(context: CompletionContext): CompletionResult | null {
|
||||
const nodeBefore = syntaxTree(context.state).resolveInner(context.pos, -1);
|
||||
|
||||
if (nodeBefore.name !== 'Resolvable') return null;
|
||||
|
||||
const pattern = /(?<quotedString>('|")\w*('|"))\./;
|
||||
|
||||
const preCursor = context.matchBefore(pattern);
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
|
||||
|
||||
const match = preCursor.text.match(pattern);
|
||||
|
||||
if (!match?.groups?.quotedString) return null;
|
||||
|
||||
const { quotedString } = match.groups;
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options: [
|
||||
{ label: `${quotedString}.replace()`, info: 'Replace part of a string with another' },
|
||||
{ label: `${quotedString}.slice()`, info: 'Copy part of a string' },
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user