refactor(editor): Move editor-ui and design-system to frontend dir (no-changelog) (#13564)

This commit is contained in:
Alex Grozav
2025-02-28 14:28:30 +02:00
committed by GitHub
parent 684353436d
commit f5743176e5
1635 changed files with 805 additions and 1079 deletions

View File

@@ -0,0 +1,28 @@
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
BarElement,
LineElement,
PointElement,
CategoryScale,
LinearScale,
LineController,
} from 'chart.js';
export const ChartJSPlugin = {
install: () => {
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
Title,
Tooltip,
Legend,
LineController,
);
},
};

View File

@@ -0,0 +1,180 @@
import type { INode, IRunExecutionData, IExecuteData } from 'n8n-workflow';
import { WorkflowDataProxy } from 'n8n-workflow';
import { createTestWorkflowObject, mockNodes } from '@/__tests__/mocks';
import { mock } from 'vitest-mock-extended';
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 = createTestWorkflowObject({
id: '123',
name: 'test workflow',
nodes: mockNodes,
connections: mock(),
active: false,
});
const lastNodeName = mockNodes[mockNodes.length - 1].name;
const lastNodeConnectionInputData =
runExecutionData.resultData.runData[lastNodeName][0].data!.main[0];
const executeData: IExecuteData = {
data: runExecutionData.resultData.runData[lastNodeName][0].data!,
node: mockNodes.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',
{},
executeData,
);
export const mockProxy = dataProxy.getDataProxy();

View File

@@ -0,0 +1,18 @@
import { ifIn } from '@codemirror/autocomplete';
import { blankCompletions } from './blank.completions';
import { bracketAccessCompletions } from './bracketAccess.completions';
import { datatypeCompletions } from './datatype.completions';
import { dollarCompletions } from './dollar.completions';
import { nonDollarCompletions } from './nonDollar.completions';
export function n8nCompletionSources() {
return [
blankCompletions,
bracketAccessCompletions,
datatypeCompletions,
dollarCompletions,
nonDollarCompletions,
].map((source) => ({
autocomplete: ifIn(['Resolvable'], source),
}));
}

View File

@@ -0,0 +1,130 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { DateTime } from 'luxon';
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
import * as utils from '@/plugins/codemirror/completions/utils';
import {
extensions,
luxonInstanceOptions,
natives,
} from '@/plugins/codemirror/completions/datatype.completions';
import type { CompletionSource, CompletionResult } from '@codemirror/autocomplete';
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import { n8nLang } from '@/plugins/codemirror/n8nLang';
import { LUXON_RECOMMENDED_OPTIONS, STRING_RECOMMENDED_OPTIONS } from './constants';
import { uniqBy } from 'lodash-es';
beforeEach(async () => {
setActivePinia(createTestingPinia());
vi.spyOn(utils, 'receivesNoBinaryData').mockReturnValue(true); // hide $binary
vi.spyOn(utils, 'isSplitInBatchesAbsent').mockReturnValue(false); // show context
vi.spyOn(utils, 'hasActiveNode').mockReturnValue(true);
});
describe('Additional Tests', () => {
describe('Edge Case Completions', () => {
test('should return no completions for empty string: {{ ""| }}', () => {
expect(completions('{{ ""| }}')).toBeNull();
});
test('should return no completions for null value: {{ null.| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(null);
expect(completions('{{ null.| }}')).toBeNull();
});
test('should return no completions for undefined value: {{ undefined.| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(undefined);
expect(completions('{{ undefined.| }}')).toBeNull();
});
test('should return completions for deeply nested object: {{ $json.deep.nested.value.| }}', () => {
const nestedObject = { deep: { nested: { value: 'test' } } };
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
nestedObject.deep.nested.value,
);
expect(completions('{{ $json.deep.nested.value.| }}')).toHaveLength(
natives({ typeName: 'string' }).length +
extensions({ typeName: 'string' }).length +
STRING_RECOMMENDED_OPTIONS.length,
);
});
});
describe('Special Characters', () => {
test('should handle completions for strings with special characters: {{ "special@char!".| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('special@char!');
expect(completions('{{ "special@char!".| }}')).toHaveLength(
natives({ typeName: 'string' }).length +
extensions({ typeName: 'string' }).length +
STRING_RECOMMENDED_OPTIONS.length,
);
});
test('should handle completions for strings with escape sequences: {{ "escape\\nsequence".| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('escape\nsequence');
expect(completions('{{ "escape\\nsequence".| }}')).toHaveLength(
natives({ typeName: 'string' }).length +
extensions({ typeName: 'string' }).length +
STRING_RECOMMENDED_OPTIONS.length,
);
});
});
describe('Function Calls', () => {
test('should return completions for function call results: {{ Math.abs(-5).| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(5);
expect(completions('{{ Math.abs(-5).| }}')).toHaveLength(
natives({ typeName: 'number' }).length +
extensions({ typeName: 'number' }).length +
['isEven()', 'isOdd()'].length,
);
});
test('should return completions for chained function calls: {{ $now.plus({ days: 1 }).| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
DateTime.now().plus({ days: 1 }),
);
expect(completions('{{ $now.plus({ days: 1 }).| }}')).toHaveLength(
uniqBy(
luxonInstanceOptions().concat(extensions({ typeName: 'date' })),
(option) => option.label,
).length + LUXON_RECOMMENDED_OPTIONS.length,
);
});
});
});
export function completions(docWithCursor: string, explicit = false) {
const cursorPosition = docWithCursor.indexOf('|');
const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1);
const state = EditorState.create({
doc,
selection: { anchor: cursorPosition },
extensions: [n8nLang()],
});
const context = new CompletionContext(state, cursorPosition, explicit);
for (const completionSource of state.languageDataAt<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

@@ -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,73 @@
import { prefixMatch, longestCommonPrefix, resolveAutocompleteExpression } from './utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { Resolved } from './types';
import { escapeMappingString } from '@/utils/mappingUtils';
/**
* 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 = resolveAutocompleteExpression(`={{ ${base} }}`);
} catch {
return null;
}
if (resolved === null || resolved === undefined || typeof resolved !== 'object') 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: object) {
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}]` : `'${escapeMappingString(key)}']`,
type: 'keyword',
};
});
}

View File

@@ -0,0 +1,830 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { DateTime } from 'luxon';
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
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 { mockProxy } from './__tests__/mock';
import type { CompletionSource, CompletionResult } from '@codemirror/autocomplete';
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import { n8nLang } from '@/plugins/codemirror/n8nLang';
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
import { CREDENTIAL_EDIT_MODAL_KEY, EnterpriseEditionFeature } from '@/constants';
import {
ARRAY_NUMBER_ONLY_METHODS,
LUXON_RECOMMENDED_OPTIONS,
METADATA_SECTION,
METHODS_SECTION,
RECOMMENDED_SECTION,
STRING_RECOMMENDED_OPTIONS,
} from './constants';
import { set, uniqBy } from 'lodash-es';
import { mockNodes } from '@/__tests__/mocks';
let externalSecretsStore: ReturnType<typeof useExternalSecretsStore>;
let uiStore: ReturnType<typeof useUIStore>;
let settingsStore: ReturnType<typeof useSettingsStore>;
beforeEach(async () => {
setActivePinia(createTestingPinia());
externalSecretsStore = useExternalSecretsStore();
uiStore = useUIStore();
settingsStore = useSettingsStore();
vi.spyOn(utils, 'receivesNoBinaryData').mockReturnValue(true); // hide $binary
vi.spyOn(utils, 'isSplitInBatchesAbsent').mockReturnValue(false); // show context
vi.spyOn(utils, 'hasActiveNode').mockReturnValue(true);
});
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: {{ | }}', () => {
const result = completions('{{ | }}');
expect(result).toHaveLength(dollarOptions().length);
expect(result?.[0]).toEqual(
expect.objectContaining({
label: '$json',
section: RECOMMENDED_SECTION,
}),
);
expect(result?.[4]).toEqual(
expect.objectContaining({
label: '$execution',
section: METADATA_SECTION,
}),
);
expect(result?.[15]).toEqual(
expect.objectContaining({ label: '$max()', section: METHODS_SECTION }),
);
});
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 Object completion for: {{ O| }}', () => {
const found = completions('{{ O| }}');
if (!found) throw new Error('Expected to find completion');
expect(found).toHaveLength(1);
expect(found[0].label).toBe('Object');
});
test('should return dollar completions for: {{ $| }}', () => {
expect(completions('{{ $| }}')).toHaveLength(dollarOptions().length);
});
test('should return node selector completions for: {{ $(| }}', () => {
vi.spyOn(utils, 'autocompletableNodeNames').mockReturnValue(mockNodes.map((node) => node.name));
expect(completions('{{ $(| }}')).toHaveLength(mockNodes.length);
});
});
describe('Luxon method completions', () => {
test('should return class completions for: {{ DateTime.| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime);
expect(completions('{{ DateTime.| }}')).toHaveLength(luxonStaticOptions().length);
});
test('should return instance completions for: {{ $now.| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now());
expect(completions('{{ $now.| }}')).toHaveLength(
uniqBy(
luxonInstanceOptions().concat(extensions({ typeName: 'date' })),
(option) => option.label,
).length + LUXON_RECOMMENDED_OPTIONS.length,
);
});
test('should return instance completions for: {{ $today.| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now());
expect(completions('{{ $today.| }}')).toHaveLength(
uniqBy(
luxonInstanceOptions().concat(extensions({ typeName: 'date' })),
(option) => option.label,
).length + LUXON_RECOMMENDED_OPTIONS.length,
);
});
});
describe('Resolution-based completions', () => {
describe('literals', () => {
test('should return completions for string literal: {{ "abc".| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('abc');
expect(completions('{{ "abc".| }}')).toHaveLength(
natives({ typeName: 'string' }).length +
extensions({ typeName: 'string' }).length +
STRING_RECOMMENDED_OPTIONS.length,
);
});
test('should return completions for boolean literal: {{ true.| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(true);
expect(completions('{{ true.| }}')).toHaveLength(
natives({ typeName: 'boolean' }).length + extensions({ typeName: 'boolean' }).length,
);
});
test('should properly handle string that contain dollar signs', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce("You 'owe' me 200$ ");
const result = completions('{{ "You \'owe\' me 200$".| }}');
expect(result).toHaveLength(
natives({ typeName: 'string' }).length + extensions({ typeName: 'string' }).length + 1,
);
});
test('should return completions for number literal: {{ (123).| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(123);
expect(completions('{{ (123).| }}')).toHaveLength(
natives({ typeName: 'number' }).length +
extensions({ typeName: 'number' }).length +
['isEven()', 'isOdd()'].length,
);
});
test('should return completions for array literal: {{ [1, 2, 3].| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce([1, 2, 3]);
expect(completions('{{ [1, 2, 3].| }}')).toHaveLength(
natives({ typeName: 'array' }).length + extensions({ typeName: 'array' }).length,
);
});
test('should return completions for Object methods: {{ Object.values({ abc: 123 }).| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce([123]);
const found = completions('{{ Object.values({ abc: 123 }).| }}');
if (!found) throw new Error('Expected to find completion');
expect(found).toHaveLength(
natives({ typeName: 'array' }).length + extensions({ typeName: 'array' }).length,
);
});
test('should return completions for object literal', () => {
const object = { a: 1 };
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(object);
expect(completions('{{ ({ a: 1 }).| }}')).toHaveLength(
Object.keys(object).length + extensions({ typeName: 'object' }).length,
);
});
test('should return case-insensitive completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('abc');
const result = completions('{{ "abc".tolowerca| }}');
expect(result).toHaveLength(1);
expect(result?.at(0)).toEqual(expect.objectContaining({ label: 'toLowerCase()' }));
});
});
describe('indexed access completions', () => {
test('should return string completions for indexed access that resolves to string literal: {{ "abc"[0].| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('a');
expect(completions('{{ "abc"[0].| }}')).toHaveLength(
natives({ typeName: 'string' }).length +
extensions({ typeName: 'string' }).length +
STRING_RECOMMENDED_OPTIONS.length,
);
});
});
describe('complex expression completions', () => {
const { $input } = mockProxy;
test('should return completions when $input is used as a function parameter', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item?.json.num);
const found = completions('{{ Math.abs($input.item.json.num1).| }}');
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(
extensions({ typeName: 'number' }).length +
natives({ typeName: 'number' }).length +
['isEven()', 'isOdd()'].length,
);
});
test('should return completions when node reference is used as a function parameter', () => {
const initialState = { workflows: { workflow: { nodes: mockNodes } } };
setActivePinia(createTestingPinia({ initialState }));
expect(completions('{{ new Date($(|) }}')).toHaveLength(mockNodes.length);
});
test('should return completions for complex expression: {{ $now.diff($now.diff($now.|)) }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now());
expect(completions('{{ $now.diff($now.diff($now.|)) }}')).toHaveLength(
uniqBy(
luxonInstanceOptions().concat(extensions({ typeName: 'date' })),
(option) => option.label,
).length + LUXON_RECOMMENDED_OPTIONS.length,
);
});
test('should return completions for complex expression: {{ $execution.resumeUrl.includes($json.) }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce($input.item?.json);
const { $json } = mockProxy;
const found = completions('{{ $execution.resumeUrl.includes($json.|) }}');
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(
Object.keys($json).length + extensions({ typeName: 'object' }).length,
);
});
test('should return completions for operation expression: {{ $now.day + $json. }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce($input.item?.json);
const { $json } = mockProxy;
const found = completions('{{ $now.day + $json.| }}');
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(
Object.keys($json).length + extensions({ typeName: 'object' }).length,
);
});
test('should return completions for operation expression: {{ Math.abs($now.day) >= 10 ? $now : Math.abs($json.). }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item?.json);
const { $json } = mockProxy;
const found = completions('{{ Math.abs($now.day) >= 10 ? $now : Math.abs($json.|) }}');
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(
Object.keys($json).length + extensions({ typeName: 'object' }).length,
);
});
});
describe('bracket-aware completions', () => {
const { $input } = mockProxy;
test('should return bracket-aware completions for: {{ $input.item.json.str.|() }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').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({ typeName: 'string' }).length +
natives({ typeName: 'string' }).length +
STRING_RECOMMENDED_OPTIONS.length,
);
expect(found.map((c) => c.label).every((l) => !l.endsWith('()')));
});
test('should return bracket-aware completions for: {{ $input.item.json.num.|() }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').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({ typeName: 'number' }).length +
natives({ typeName: 'number' }).length +
['isEven()', 'isOdd()'].length,
);
expect(found.map((c) => c.label).every((l) => !l.endsWith('()')));
});
test('should return bracket-aware completions for: {{ $input.item.json.arr.| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').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({ typeName: 'array' }).length + natives({ typeName: 'array' }).length,
);
expect(found.map((c) => c.label).every((l) => !l.endsWith('()')));
});
});
describe('secrets', () => {
const { $input } = mockProxy;
beforeEach(() => {});
test('should return completions for: {{ $secrets.| }}', () => {
const provider = 'infisical';
const secrets = ['SECRET'];
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input);
uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY].open = true;
set(settingsStore.settings, ['enterprise', EnterpriseEditionFeature.ExternalSecrets], true);
externalSecretsStore.state.secrets = {
[provider]: secrets,
};
const result = completions('{{ $secrets.| }}');
expect(result).toEqual([
{
info: expect.any(Function),
label: provider,
apply: expect.any(Function),
},
]);
});
test('should return completions for: {{ $secrets.provider.| }}', () => {
const provider = 'infisical';
const secrets = ['SECRET1', 'SECRET2'];
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input);
uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY].open = true;
set(settingsStore.settings, ['enterprise', EnterpriseEditionFeature.ExternalSecrets], true);
externalSecretsStore.state.secrets = {
[provider]: secrets,
};
const result = completions(`{{ $secrets.${provider}.| }}`);
expect(result).toEqual([
{
info: expect.any(Function),
label: secrets[0],
apply: expect.any(Function),
},
{
info: expect.any(Function),
label: secrets[1],
apply: expect.any(Function),
},
]);
});
});
describe('references', () => {
const { $input, $ } = mockProxy;
test('should return completions for: {{ $input.| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input);
expect(completions('{{ $input.| }}')).toHaveLength(Reflect.ownKeys($input).length);
});
test('should return completions for: {{ "hello"+input.| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input);
expect(completions('{{ "hello"+$input.| }}')).toHaveLength(Reflect.ownKeys($input).length);
});
test("should return completions for: {{ $('nodeName').| }}", () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($('Rename'));
expect(completions('{{ $("Rename").| }}')).toHaveLength(
Reflect.ownKeys($('Rename')).length - ['pairedItem'].length,
);
});
test("should return completions for: {{ $('(Complex) \"No\\'de\" name').| }}", () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($('Rename'));
expect(completions("{{ $('(Complex) \"No\\'de\" name').| }}")).toHaveLength(
Reflect.ownKeys($('Rename')).length - ['pairedItem'].length,
);
});
test('should return completions for: {{ $input.item.| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').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().| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').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().| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').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 completions for: {{ $input.all().| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue([$input.item]);
expect(completions('{{ $input.all().| }}')).toHaveLength(
extensions({ typeName: 'array' }).length +
natives({ typeName: 'array' }).length -
ARRAY_NUMBER_ONLY_METHODS.length,
);
});
test("should return completions for: '{{ $input.item.| }}'", () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item?.json);
expect(completions('{{ $input.item.| }}')).toHaveLength(
Object.keys($input.item?.json ?? {}).length + extensions({ typeName: 'object' }).length,
);
});
test("should return completions for: '{{ $input.first().| }}'", () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.first()?.json);
expect(completions('{{ $input.first().| }}')).toHaveLength(
Object.keys($input.first()?.json ?? {}).length + extensions({ typeName: 'object' }).length,
);
});
test("should return completions for: '{{ $input.last().| }}'", () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.last()?.json);
expect(completions('{{ $input.last().| }}')).toHaveLength(
Object.keys($input.last()?.json ?? {}).length + extensions({ typeName: 'object' }).length,
);
});
test("should return completions for: '{{ $input.all()[0].| }}'", () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.all()[0].json);
expect(completions('{{ $input.all()[0].| }}')).toHaveLength(
Object.keys($input.all()[0].json).length + extensions({ typeName: 'object' }).length,
);
});
test('should return completions for: {{ $input.item.json.str.| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item?.json.str);
expect(completions('{{ $input.item.json.str.| }}')).toHaveLength(
extensions({ typeName: 'string' }).length +
natives({ typeName: 'string' }).length +
STRING_RECOMMENDED_OPTIONS.length,
);
});
test('should return completions for: {{ $input.item.json.num.| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item?.json.num);
expect(completions('{{ $input.item.json.num.| }}')).toHaveLength(
extensions({ typeName: 'number' }).length +
natives({ typeName: 'number' }).length +
['isEven()', 'isOdd()'].length,
);
});
test('should return completions for: {{ $input.item.json.arr.| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item?.json.arr);
expect(completions('{{ $input.item.json.arr.| }}')).toHaveLength(
extensions({ typeName: 'array' }).length + natives({ typeName: 'array' }).length,
);
});
test('should return completions for: {{ $input.item.json.obj.| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item?.json.obj);
expect(completions('{{ $input.item.json.obj.| }}')).toHaveLength(
Object.keys($input.item?.json.obj ?? {}).length + extensions({ typeName: 'object' }).length,
);
});
});
describe('bracket access', () => {
const { $input } = mockProxy;
['{{ $input.item.json[| }}', '{{ $json[| }}'].forEach((expression) => {
test(`should return completions for: ${expression}`, () => {
vi.spyOn(workflowHelpers, 'resolveParameter').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}`, () => {
vi.spyOn(workflowHelpers, 'resolveParameter').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(']')));
});
});
test('should give completions for keys that need bracket access', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue({
foo: 'bar',
'Key with spaces': 1,
'Key with spaces and \'quotes"': 1,
});
const found = completions('{{ $json.| }}');
if (!found) throw new Error('Expected to find completions');
expect(found).toContainEqual(
expect.objectContaining({
label: 'Key with spaces',
apply: utils.applyBracketAccessCompletion,
}),
);
expect(found).toContainEqual(
expect.objectContaining({
label: 'Key with spaces and \'quotes"',
apply: utils.applyBracketAccessCompletion,
}),
);
});
test('should escape keys with quotes', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue({
'Key with spaces and \'quotes"': 1,
});
const found = completions('{{ $json[| }}');
if (!found) throw new Error('Expected to find completions');
expect(found).toContainEqual(
expect.objectContaining({
label: "'Key with spaces and \\'quotes\"']",
}),
);
});
});
describe('recommended completions', () => {
test('should recommend toDateTime() for {{ "1-Feb-2024".| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('1-Feb-2024');
expect(completions('{{ "1-Feb-2024".| }}')?.[0]).toEqual(
expect.objectContaining({ label: 'toDateTime()', section: RECOMMENDED_SECTION }),
);
});
test('should recommend toNumber() for: {{ "5.3".| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('5.3');
const options = completions('{{ "5.3".| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'toNumber()', section: RECOMMENDED_SECTION }),
);
});
test('should recommend extractEmail() for: {{ "string with test@n8n.io in it".| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
'string with test@n8n.io in it',
);
const options = completions('{{ "string with test@n8n.io in it".| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'extractEmail()', section: RECOMMENDED_SECTION }),
);
});
test('should recommend extractDomain(), isEmail() for: {{ "test@n8n.io".| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('test@n8n.io');
const options = completions('{{ "test@n8n.io".| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'extractDomain()', section: RECOMMENDED_SECTION }),
);
expect(options?.[1]).toEqual(
expect.objectContaining({ label: 'isEmail()', section: RECOMMENDED_SECTION }),
);
});
test('should recommend extractDomain(), extractUrlPath() for: {{ "https://n8n.io/pricing".| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('https://n8n.io/pricing');
const options = completions('{{ "https://n8n.io/pricing".| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'extractDomain()', section: RECOMMENDED_SECTION }),
);
expect(options?.[1]).toEqual(
expect.objectContaining({ label: 'extractUrlPath()', section: RECOMMENDED_SECTION }),
);
});
test('should recommend round(),floor(),ceil() for: {{ (5.46).| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(5.46);
const options = completions('{{ (5.46).| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'round()', section: RECOMMENDED_SECTION }),
);
expect(options?.[1]).toEqual(
expect.objectContaining({ label: 'floor()', section: RECOMMENDED_SECTION }),
);
expect(options?.[2]).toEqual(
expect.objectContaining({ label: 'ceil()', section: RECOMMENDED_SECTION }),
);
});
test("should recommend toDateTime('s') for: {{ (1900062210).| }}", () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(1900062210);
const options = completions('{{ (1900062210).| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: "toDateTime('s')", section: RECOMMENDED_SECTION }),
);
});
test("should recommend toDateTime('ms') for: {{ (1900062210000).| }}", () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(1900062210000);
const options = completions('{{ (1900062210000).| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: "toDateTime('ms')", section: RECOMMENDED_SECTION }),
);
});
test('should recommend toBoolean() for: {{ (0).| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(0);
const options = completions('{{ (0).| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'toBoolean()', section: RECOMMENDED_SECTION }),
);
});
test('should recommend toBoolean() for: {{ "true".| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('true');
const options = completions('{{ "true".| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'toBoolean()', section: RECOMMENDED_SECTION }),
);
});
});
describe('explicit completions (opened by Ctrl+Space or programatically)', () => {
test('should return completions for: {{ $json.foo| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter')
.mockReturnValueOnce(undefined)
.mockReturnValueOnce('foo');
const result = completions('{{ $json.foo| }}', true);
expect(result).toHaveLength(
extensions({ typeName: 'string' }).length +
natives({ typeName: 'string' }).length +
STRING_RECOMMENDED_OPTIONS.length,
);
});
});
describe('type information', () => {
test('should display type information for: {{ $json.obj.| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce({
str: 'bar',
empty: null,
arr: [],
obj: {},
});
const result = completions('{{ $json.obj.| }}');
expect(result).toContainEqual(expect.objectContaining({ label: 'str', detail: 'string' }));
expect(result).toContainEqual(expect.objectContaining({ label: 'empty', detail: 'null' }));
expect(result).toContainEqual(expect.objectContaining({ label: 'arr', detail: 'Array' }));
expect(result).toContainEqual(expect.objectContaining({ label: 'obj', detail: 'Object' }));
});
test('should display type information for: {{ $input.item.json.| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce({
str: 'bar',
empty: null,
arr: [],
obj: {},
});
const result = completions('{{ $json.item.json.| }}');
expect(result).toContainEqual(expect.objectContaining({ label: 'str', detail: 'string' }));
expect(result).toContainEqual(expect.objectContaining({ label: 'empty', detail: 'null' }));
expect(result).toContainEqual(expect.objectContaining({ label: 'arr', detail: 'Array' }));
expect(result).toContainEqual(expect.objectContaining({ label: 'obj', detail: 'Object' }));
});
test('should display type information for: {{ $("My Node").item.json.| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce({
str: 'bar',
empty: null,
arr: [],
obj: {},
});
const result = completions('{{ $("My Node").item.json.| }}');
expect(result).toContainEqual(expect.objectContaining({ label: 'str', detail: 'string' }));
expect(result).toContainEqual(expect.objectContaining({ label: 'empty', detail: 'null' }));
expect(result).toContainEqual(expect.objectContaining({ label: 'arr', detail: 'Array' }));
expect(result).toContainEqual(expect.objectContaining({ label: 'obj', detail: 'Object' }));
});
test('should not display type information for other completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue({
str: 'bar',
id: '123',
isExecuted: false,
});
expect(completions('{{ $execution.| }}')).not.toContainEqual(
expect.objectContaining({ detail: expect.any(String) }),
);
expect(completions('{{ $input.params.| }}')).not.toContainEqual(
expect.objectContaining({ detail: expect.any(String) }),
);
expect(completions('{{ $("My Node").| }}')).not.toContainEqual(
expect.objectContaining({ detail: expect.any(String) }),
);
});
});
});
export function completions(docWithCursor: string, explicit = false) {
const cursorPosition = docWithCursor.indexOf('|');
const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1);
const state = EditorState.create({
doc,
selection: { anchor: cursorPosition },
extensions: [n8nLang()],
});
const context = new CompletionContext(state, cursorPosition, explicit);
for (const completionSource of state.languageDataAt<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

@@ -0,0 +1,438 @@
import type { Completion, CompletionSection } from '@codemirror/autocomplete';
import { i18n } from '@/plugins/i18n';
import { withSectionHeader } from './utils';
import { createInfoBoxRenderer } from './infoBoxRenderer';
export const FIELDS_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.fields'),
rank: -1,
});
export const RECOMMENDED_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.recommended'),
rank: 0,
});
export const RECOMMENDED_METHODS_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.recommendedMethods'),
rank: 0,
});
export const PREVIOUS_NODES_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.prevNodes'),
rank: 1,
});
export const PROPERTIES_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.properties'),
rank: 2,
});
export const METHODS_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.methods'),
rank: 3,
});
export const METADATA_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.metadata'),
rank: 4,
});
export const OTHER_METHODS_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.otherMethods'),
rank: 100,
});
export const OTHER_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.other'),
rank: 101,
});
export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [
{
label: '$json',
section: RECOMMENDED_SECTION,
info: createInfoBoxRenderer({
name: '$json',
returnType: 'Object',
description: i18n.baseText('codeNodeEditor.completer.json'),
docURL: 'https://docs.n8n.io/data/data-structure/',
}),
},
{
label: '$binary',
section: RECOMMENDED_SECTION,
info: createInfoBoxRenderer({
name: '$binary',
returnType: 'Object',
description: i18n.baseText('codeNodeEditor.completer.binary'),
}),
},
{
label: '$now',
section: RECOMMENDED_SECTION,
info: createInfoBoxRenderer({
name: '$now',
returnType: 'DateTime',
description: i18n.baseText('codeNodeEditor.completer.$now'),
}),
},
{
label: '$if()',
section: RECOMMENDED_SECTION,
info: createInfoBoxRenderer(
{
name: '$if',
returnType: 'any',
description: i18n.baseText('codeNodeEditor.completer.$if'),
args: [
{
name: 'condition',
description: i18n.baseText('codeNodeEditor.completer.$if.args.condition'),
type: 'boolean',
},
{
name: 'valueIfTrue',
description: i18n.baseText('codeNodeEditor.completer.$if.args.valueIfTrue'),
type: 'any',
},
{
name: 'valueIfFalse',
description: i18n.baseText('codeNodeEditor.completer.$if.args.valueIfFalse'),
type: 'any',
},
],
examples: [
{
example: '$if($now.hour < 17, "Good day", "Good evening")',
description: i18n.baseText('codeNodeEditor.completer.$if.examples.1'),
},
{
description: i18n.baseText('codeNodeEditor.completer.$if.examples.2'),
example:
'$if($now.hour < 10, "Good morning", $if($now.hour < 17, "Good day", "Good evening"))',
},
],
},
true,
),
},
{
label: '$ifEmpty()',
section: RECOMMENDED_SECTION,
info: createInfoBoxRenderer(
{
name: '$ifEmpty',
returnType: 'any',
description: i18n.baseText('codeNodeEditor.completer.$ifEmpty'),
args: [
{
name: 'value',
description: i18n.baseText('codeNodeEditor.completer.$ifEmpty.args.value'),
type: 'any',
},
{
name: 'valueIfEmpty',
description: i18n.baseText('codeNodeEditor.completer.$ifEmpty.args.valueIfEmpty'),
type: 'any',
},
],
examples: [
{
example: '"Hi " + $ifEmpty(name, "there")',
evaluated: 'e.g. "Hi Nathan" or "Hi there"',
},
],
},
true,
),
},
{
label: '$execution',
section: METADATA_SECTION,
info: createInfoBoxRenderer({
name: '$execution',
returnType: 'ExecData',
description: i18n.baseText('codeNodeEditor.completer.$execution'),
}),
},
{
label: '$itemIndex',
section: METADATA_SECTION,
info: createInfoBoxRenderer({
name: '$itemIndex',
returnType: 'number',
description: i18n.baseText('codeNodeEditor.completer.$itemIndex'),
}),
},
{
label: '$input',
section: METADATA_SECTION,
info: createInfoBoxRenderer({
name: '$input',
returnType: 'Object',
description: i18n.baseText('codeNodeEditor.completer.$input'),
}),
},
{
label: '$parameter',
section: METADATA_SECTION,
info: createInfoBoxRenderer({
name: '$parameter',
returnType: 'Object',
description: i18n.baseText('codeNodeEditor.completer.$parameter'),
}),
},
{
label: '$prevNode',
section: METADATA_SECTION,
info: createInfoBoxRenderer({
name: '$prevNode',
returnType: 'PrevNodeData',
description: i18n.baseText('codeNodeEditor.completer.$prevNode'),
}),
},
{
label: '$runIndex',
section: METADATA_SECTION,
info: createInfoBoxRenderer({
name: '$runIndex',
returnType: 'number',
description: i18n.baseText('codeNodeEditor.completer.$runIndex'),
}),
},
{
label: '$today',
section: METADATA_SECTION,
info: createInfoBoxRenderer({
name: '$today',
returnType: 'DateTime',
description: i18n.baseText('codeNodeEditor.completer.$today'),
}),
},
{
label: '$vars',
section: METADATA_SECTION,
info: createInfoBoxRenderer({
name: '$vars',
returnType: 'Object',
description: i18n.baseText('codeNodeEditor.completer.$vars'),
}),
},
{
label: '$workflow',
section: METADATA_SECTION,
info: createInfoBoxRenderer({
name: '$workflow',
returnType: 'WorkflowData',
description: i18n.baseText('codeNodeEditor.completer.$workflow'),
}),
},
{
label: '$jmespath()',
section: METHODS_SECTION,
info: createInfoBoxRenderer(
{
name: '$jmespath',
description: i18n.baseText('codeNodeEditor.completer.$jmespath'),
returnType: 'any',
args: [
{
name: 'obj',
description: i18n.baseText('codeNodeEditor.completer.$jmespath.args.obj'),
type: 'Object | Array',
},
{
name: 'expression',
description: i18n.baseText('codeNodeEditor.completer.$jmespath.args.expression'),
type: 'string',
},
],
examples: [
{
example:
'data = {\n "people": [\n {\n "name": "Bob",\n "age": 20,\n "other": "foo"\n },\n {\n "name": "Fred",\n "age": 25,\n "other": "bar"\n },\n {\n "name": "George",\n "age": 30,\n "other": "baz"\n }\n ]\n}\n\n$jmespath(data.people, \'[*].name\')',
evaluated: "['Bob', 'Fred', 'George']",
description: i18n.baseText('codeNodeEditor.completer.$jmespath.examples.1'),
},
{
example: "$jmespath(data.people, '[?age > `20`].[name, age]')",
evaluated: "[['Fred', 25], ['George', 30]]",
description: i18n.baseText('codeNodeEditor.completer.$jmespath.examples.2'),
},
{
example: "$jmespath(data.people, '[?age > `20`].name | [0]')",
evaluated: 'Fred',
description: i18n.baseText('codeNodeEditor.completer.$jmespath.examples.3'),
},
{
example:
'data = {\n "reservations": [\n {\n "id": 1,\n "guests": [\n {\n "name": "Nathan",\n "requirements": {\n "room": "double",\n "meal": "vegetarian"\n }\n },\n {\n "name": "Meg",\n "requirements": {\n "room": "single"\n }\n }\n ]\n },\n {\n "id": 2,\n "guests": [\n {\n "name": "Lex",\n "requirements": {\n "room": "double"\n }\n }\n ]\n }\n ]\n}\n\n$jmespath(data, "reservations[].guests[?requirements.room==\'double\'][].name")',
evaluated: "['Nathan', 'Lex']",
description: i18n.baseText('codeNodeEditor.completer.$jmespath.examples.4'),
},
],
},
true,
),
},
{
label: '$fromAI()',
section: METHODS_SECTION,
info: createInfoBoxRenderer(
{
name: '$fromAI',
returnType: 'any',
description: 'Populate this with the parameter passed from the large language model',
docURL: 'https://docs.n8n.io/advanced-ai/examples/using-the-fromai-function/',
args: [
{
name: 'key',
description:
'The key or name of the argument, must be between 1 and 64 characters long and only contain lowercase letters, uppercase letters, numbers, underscores, and hyphens',
type: 'string',
},
{
name: 'description',
description: 'Description of the argument',
type: 'string',
optional: true,
},
{
name: 'type',
description: 'Type of the argument',
type: 'string | number | boolean | json',
optional: true,
},
{
name: 'defaultValue',
description: 'Default value for the argument',
type: 'any',
optional: true,
},
],
examples: [
{
example: '$fromAI("name")',
description: 'Get the name of the person',
},
{
example: '$fromAI("age", "The age of the person", "number", 18)',
description: 'Get the age of the person as number with default value 18',
},
{
example: '$fromAI("isStudent", "Is the person a student", "boolean", false)',
description: 'Get the student status of the person as boolean with default value false',
},
],
},
true,
),
},
{
label: '$max()',
section: METHODS_SECTION,
info: createInfoBoxRenderer(
{
name: '$max',
returnType: 'number',
description: i18n.baseText('codeNodeEditor.completer.$max'),
args: [
{
name: 'numbers',
description: i18n.baseText('codeNodeEditor.completer.$max.args.numbers'),
type: 'number',
variadic: true,
},
],
examples: [{ example: '$max(1, 5, 42, 0.5)', evaluated: '42' }],
},
true,
),
},
{
label: '$min()',
section: METHODS_SECTION,
info: createInfoBoxRenderer(
{
name: '$min',
returnType: 'number',
description: i18n.baseText('codeNodeEditor.completer.$min'),
args: [
{
name: 'numbers',
description: i18n.baseText('codeNodeEditor.completer.$max.args.numbers'),
variadic: true,
type: 'number',
},
],
examples: [{ example: '$min(1, 5, 42, 0.5)', evaluated: '0.5' }],
},
true,
),
},
{
label: '$nodeVersion',
section: METADATA_SECTION,
info: createInfoBoxRenderer({
name: '$nodeVersion',
returnType: 'number',
description: i18n.baseText('codeNodeEditor.completer.$nodeVersion'),
}),
},
];
export const STRING_RECOMMENDED_OPTIONS = [
'includes()',
'split()',
'startsWith()',
'replaceAll()',
'length',
];
export const LUXON_RECOMMENDED_OPTIONS = ['format()', 'minus()', 'plus()', 'diffTo()', 'extract()'];
export const OBJECT_RECOMMENDED_OPTIONS = ['keys()', 'values()', 'isEmpty()', 'hasField()'];
export const ARRAY_RECOMMENDED_OPTIONS = ['length', 'last()', 'includes()', 'map()', 'filter()'];
export const ARRAY_NUMBER_ONLY_METHODS = ['max()', 'min()', 'sum()', 'average()'];
export const LUXON_SECTIONS: Record<string, CompletionSection> = {
edit: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.edit'),
rank: 1,
}),
compare: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.compare'),
rank: 2,
}),
format: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.format'),
rank: 3,
}),
query: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.component'),
rank: 4,
}),
};
export const STRING_SECTIONS: Record<string, CompletionSection> = {
edit: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.edit'),
rank: 1,
}),
query: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.query'),
rank: 2,
}),
validation: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.validation'),
rank: 3,
}),
case: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.case'),
rank: 4,
}),
cast: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.cast'),
rank: 5,
}),
};

View File

@@ -0,0 +1,144 @@
import { i18n } from '@/plugins/i18n';
import {
autocompletableNodeNames,
receivesNoBinaryData,
longestCommonPrefix,
prefixMatch,
stripExcessParens,
hasActiveNode,
isCredentialsModalOpen,
applyCompletion,
isInHttpNodePagination,
} from './utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import { escapeMappingString } from '@/utils/mappingUtils';
import {
METADATA_SECTION,
PREVIOUS_NODES_SECTION,
RECOMMENDED_SECTION,
ROOT_DOLLAR_COMPLETIONS,
} from './constants';
import { createInfoBoxRenderer } from './infoBoxRenderer';
/**
* 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(): Completion[] {
const SKIP = new Set();
let recommendedCompletions: Completion[] = [];
if (isInHttpNodePagination()) {
recommendedCompletions = [
{
label: '$pageCount',
section: RECOMMENDED_SECTION,
info: createInfoBoxRenderer({
name: '$pageCount',
returnType: 'number',
docURL: 'https://docs.n8n.io/code/builtin/http-node-variables/',
description: i18n.baseText('codeNodeEditor.completer.$pageCount'),
}),
},
{
label: '$response',
section: RECOMMENDED_SECTION,
info: createInfoBoxRenderer({
name: '$response',
returnType: 'HTTPResponse',
docURL: 'https://docs.n8n.io/code/builtin/http-node-variables/',
description: i18n.baseText('codeNodeEditor.completer.$response'),
}),
},
{
label: '$request',
section: RECOMMENDED_SECTION,
info: createInfoBoxRenderer({
name: '$request',
returnType: 'Object',
docURL: 'https://docs.n8n.io/code/builtin/http-node-variables/',
description: i18n.baseText('codeNodeEditor.completer.$request'),
}),
},
];
}
if (isCredentialsModalOpen()) {
return useExternalSecretsStore().isEnterpriseExternalSecretsEnabled
? [
{
label: '$vars',
section: METADATA_SECTION,
info: createInfoBoxRenderer({
name: '$vars',
returnType: 'Object',
description: i18n.baseText('codeNodeEditor.completer.$vars'),
}),
},
{
label: '$secrets',
section: METADATA_SECTION,
info: createInfoBoxRenderer({
name: '$secrets',
returnType: 'Object',
description: i18n.baseText('codeNodeEditor.completer.$secrets'),
}),
},
]
: [];
}
if (!hasActiveNode()) {
return [];
}
if (receivesNoBinaryData()) SKIP.add('$binary');
const previousNodesCompletions = autocompletableNodeNames().map((nodeName) => {
const label = `$('${escapeMappingString(nodeName)}')`;
return {
label,
info: createInfoBoxRenderer({
name: label,
returnType: 'Object',
description: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }),
}),
section: PREVIOUS_NODES_SECTION,
};
});
return recommendedCompletions
.concat(ROOT_DOLLAR_COMPLETIONS)
.filter(({ label }) => !SKIP.has(label))
.concat(previousNodesCompletions)
.map((completion) => ({ ...completion, apply: applyCompletion() }));
}

View File

@@ -0,0 +1,278 @@
import type { Completion } from '@codemirror/autocomplete';
import type { DocMetadata, DocMetadataArgument, DocMetadataExample } from 'n8n-workflow';
import { sanitizeHtml } from '@/utils/htmlUtils';
import { i18n } from '@/plugins/i18n';
const shouldHighlightArgument = (
arg: DocMetadataArgument,
index: number,
highlightArgIndex?: number,
) => {
if (arg.variadic) {
return (highlightArgIndex ?? 0) >= index;
}
return highlightArgIndex === index;
};
const renderFunctionHeader = (doc?: DocMetadata, highlightArgIndex?: number) => {
const header = document.createElement('div');
if (doc) {
const functionNameSpan = document.createElement('span');
functionNameSpan.classList.add('autocomplete-info-name');
functionNameSpan.textContent = doc.name;
header.appendChild(functionNameSpan);
const openBracketsSpan = document.createElement('span');
openBracketsSpan.textContent = '(';
header.appendChild(openBracketsSpan);
const argsSpan = document.createElement('span');
doc.args?.forEach((arg, index, array) => {
const optional = arg.optional && !arg.name.endsWith('?');
const argSpan = document.createElement(
shouldHighlightArgument(arg, index, highlightArgIndex) ? 'strong' : 'span',
);
argSpan.classList.add('autocomplete-info-arg');
argSpan.textContent = arg.name;
if (optional) {
argSpan.textContent += '?';
}
if (arg.variadic) {
argSpan.textContent = '...' + argSpan.textContent;
}
argsSpan.appendChild(argSpan);
if (index !== array.length - 1) {
const separatorSpan = document.createElement('span');
separatorSpan.textContent = ', ';
argsSpan.appendChild(separatorSpan);
}
});
header.appendChild(argsSpan);
const closingBracket = document.createElement('span');
closingBracket.textContent = ')';
header.appendChild(closingBracket);
}
return header;
};
const renderPropHeader = (doc?: DocMetadata) => {
const header = document.createElement('div');
if (doc) {
const propNameSpan = document.createElement('span');
propNameSpan.classList.add('autocomplete-info-name');
propNameSpan.textContent = doc.name;
header.appendChild(propNameSpan);
}
return header;
};
const renderDescription = ({
description,
docUrl,
example,
}: {
description: string;
docUrl?: string;
example?: DocMetadataExample;
}) => {
const descriptionBody = document.createElement('div');
descriptionBody.classList.add('autocomplete-info-description');
const descriptionText = document.createElement('p');
const separator = !description.endsWith('.') && docUrl ? '. ' : ' ';
descriptionText.innerHTML = sanitizeHtml(
description.replace(/`(.*?)`/g, '<code>$1</code>') + separator,
);
descriptionBody.appendChild(descriptionText);
if (docUrl) {
const descriptionLink = document.createElement('a');
descriptionLink.setAttribute('target', '_blank');
descriptionLink.setAttribute('href', docUrl);
descriptionLink.innerText =
i18n.autocompleteUIValues.docLinkLabel ?? i18n.baseText('generic.learnMore');
descriptionLink.addEventListener('mousedown', (event: MouseEvent) => {
// This will prevent documentation popup closing before click
// event gets to links
event.preventDefault();
});
descriptionLink.classList.add('autocomplete-info-doc-link');
descriptionText.appendChild(descriptionLink);
}
if (example) {
const renderedExample = renderExample(example);
descriptionBody.appendChild(renderedExample);
}
return descriptionBody;
};
const renderArg = (arg: DocMetadataArgument, highlight: boolean) => {
const argItem = document.createElement('li');
const argName = document.createElement(highlight ? 'strong' : 'span');
argName.classList.add('autocomplete-info-arg-name');
argName.textContent = arg.name.replaceAll('?', '');
const tags = [];
if (arg.type) {
tags.push(arg.type);
}
if (!!arg.optional || arg.name.endsWith('?')) {
tags.push(i18n.baseText('codeNodeEditor.optional'));
}
if (tags.length > 0) {
argName.textContent += ` (${tags.join(', ')})`;
}
if (arg.description) {
argName.textContent += ':';
}
argItem.appendChild(argName);
if (arg.description) {
const argDescription = document.createElement('span');
argDescription.classList.add('autocomplete-info-arg-description');
if (arg.default && arg.optional && !arg.description.toLowerCase().includes('default')) {
const separator = arg.description.endsWith('.') ? ' ' : '. ';
arg.description +=
separator +
i18n.baseText('codeNodeEditor.defaultsTo', {
interpolate: { default: arg.default },
});
}
argDescription.innerHTML = sanitizeHtml(arg.description.replace(/`(.*?)`/g, '<code>$1</code>'));
argItem.appendChild(argDescription);
}
if (Array.isArray(arg.args)) {
argItem.appendChild(renderArgList(arg.args));
}
return argItem;
};
const renderArgList = (args: DocMetadataArgument[], highlightArgIndex?: number) => {
const argsList = document.createElement('ul');
argsList.classList.add('autocomplete-info-args');
args.forEach((arg, index) => {
argsList.appendChild(renderArg(arg, shouldHighlightArgument(arg, index, highlightArgIndex)));
});
return argsList;
};
const renderArgs = (args: DocMetadataArgument[], highlightArgIndex?: number) => {
const argsContainer = document.createElement('div');
argsContainer.classList.add('autocomplete-info-args-container');
const argsTitle = document.createElement('div');
argsTitle.classList.add('autocomplete-info-section-title');
argsTitle.textContent = i18n.baseText('codeNodeEditor.parameters');
argsContainer.appendChild(argsTitle);
argsContainer.appendChild(renderArgList(args, highlightArgIndex));
return argsContainer;
};
const renderExample = (example: DocMetadataExample) => {
const examplePre = document.createElement('pre');
examplePre.classList.add('autocomplete-info-example');
const exampleCode = document.createElement('code');
examplePre.appendChild(exampleCode);
if (example.description) {
const exampleDescription = document.createElement('span');
exampleDescription.classList.add('autocomplete-info-example-comment');
exampleDescription.textContent = `// ${example.description}\n`;
exampleCode.appendChild(exampleDescription);
}
const exampleExpression = document.createElement('span');
exampleExpression.classList.add('autocomplete-info-example-expr');
exampleExpression.textContent = example.example + '\n';
exampleCode.appendChild(exampleExpression);
if (example.evaluated) {
const exampleEvaluated = document.createElement('span');
exampleEvaluated.classList.add('autocomplete-info-example-comment');
exampleEvaluated.textContent = `// => ${example.evaluated}\n`;
exampleCode.appendChild(exampleEvaluated);
}
return examplePre;
};
const renderExamples = (examples: DocMetadataExample[]) => {
const examplesContainer = document.createElement('div');
examplesContainer.classList.add('autocomplete-info-examples');
const examplesTitle = document.createElement('div');
examplesTitle.classList.add('autocomplete-info-section-title');
examplesTitle.textContent = i18n.baseText('codeNodeEditor.examples');
examplesContainer.appendChild(examplesTitle);
const examplesList = document.createElement('div');
examplesList.classList.add('autocomplete-info-examples-list');
for (const example of examples) {
const renderedExample = renderExample(example);
examplesList.appendChild(renderedExample);
}
examplesContainer.appendChild(examplesList);
return examplesContainer;
};
export const createInfoBoxRenderer =
(doc?: DocMetadata, isFunction = false) =>
(_completion: Completion, highlightArgIndex = -1) => {
const tooltipContainer = document.createElement('div');
tooltipContainer.setAttribute('tabindex', '-1');
tooltipContainer.setAttribute('title', '');
tooltipContainer.classList.add('autocomplete-info-container');
if (!doc) return null;
const { examples, args } = doc;
const hasArgs = args && args.length > 0;
const hasExamples = examples && examples.length > 0;
const header = isFunction
? renderFunctionHeader(doc, highlightArgIndex)
: renderPropHeader(doc);
header.classList.add('autocomplete-info-header');
tooltipContainer.appendChild(header);
if (doc.description) {
const descriptionBody = renderDescription({
description: doc.description,
docUrl: doc.docURL,
example: hasArgs && hasExamples ? examples[0] : undefined,
});
tooltipContainer.appendChild(descriptionBody);
}
if (hasArgs) {
const argsContainer = renderArgs(args, highlightArgIndex);
tooltipContainer.appendChild(argsContainer);
}
if (hasExamples && (examples.length > 1 || !hasArgs)) {
const examplesContainer = renderExamples(examples);
tooltipContainer.appendChild(examplesContainer);
}
return tooltipContainer;
};

View File

@@ -0,0 +1,105 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import { n8nLang } from '@/plugins/codemirror/n8nLang';
import { useItemFieldCompletions } from '@/components/CodeNodeEditor/completions/itemField.completions';
describe('itemFieldCompletions', () => {
let context: CompletionContext;
beforeEach(() => {
setActivePinia(createTestingPinia());
});
describe('matcherItemFieldCompletions', () => {
test('should return null if no match found', () => {
const doc = '{{ $input.noMatch.| }}';
const cursorPosition = doc.indexOf('|');
const state = EditorState.create({
doc: doc.replace('|', ''),
selection: { anchor: cursorPosition },
extensions: [n8nLang()],
});
context = new CompletionContext(state, cursorPosition, true);
const result = useItemFieldCompletions('javaScript').matcherItemFieldCompletions(
context,
'$input.noMatch',
{ '$input.item': 'item' },
);
expect(result).toBeNull();
});
});
describe('inputMethodCompletions', () => {
test('should return completions for $input.first().', () => {
const doc = '{{ $input.first().| }}';
const cursorPosition = doc.indexOf('|');
const state = EditorState.create({
doc: doc.replace('|', ''),
selection: { anchor: cursorPosition },
extensions: [n8nLang()],
});
context = new CompletionContext(state, cursorPosition, true);
const result = useItemFieldCompletions('javaScript').inputMethodCompletions(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(2);
expect(result?.options[0].label).toBe('$input.first().json');
expect(result?.options[1].label).toBe('$input.first().binary');
});
test('should return null if no match found', () => {
const doc = '{{ $input.noMatch().| }}';
const cursorPosition = doc.indexOf('|');
const state = EditorState.create({
doc: doc.replace('|', ''),
selection: { anchor: cursorPosition },
extensions: [n8nLang()],
});
context = new CompletionContext(state, cursorPosition, true);
const result = useItemFieldCompletions('javaScript').inputMethodCompletions(context);
expect(result).toBeNull();
});
});
describe('selectorMethodCompletions', () => {
test("should return completions for $('nodeName').first().", () => {
const doc = "{{ $('nodeName').first().| }}";
const cursorPosition = doc.indexOf('|');
const state = EditorState.create({
doc: doc.replace('|', ''),
selection: { anchor: cursorPosition },
extensions: [n8nLang()],
});
context = new CompletionContext(state, cursorPosition, true);
const result = useItemFieldCompletions('javaScript').selectorMethodCompletions(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(2);
expect(result?.options[0].label).toBe("$('nodeName').first().json");
expect(result?.options[1].label).toBe("$('nodeName').first().binary");
});
test('should return null if no match found', () => {
const doc = "{{ $('noMatch').noMatch().| }}";
const cursorPosition = doc.indexOf('|');
const state = EditorState.create({
doc: doc.replace('|', ''),
selection: { anchor: cursorPosition },
extensions: [n8nLang()],
});
context = new CompletionContext(state, cursorPosition, true);
const result = useItemFieldCompletions('javaScript').selectorMethodCompletions(context);
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,55 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { EditorState } from '@codemirror/state';
import { CompletionContext } from '@codemirror/autocomplete';
import { describe, test, expect, beforeEach } from 'vitest';
import { useItemIndexCompletions } from '@/components/CodeNodeEditor/completions/itemIndex.completions';
let mode: 'runOnceForEachItem' | 'runOnceForAllItems';
beforeEach(() => {
setActivePinia(createTestingPinia());
mode = 'runOnceForAllItems';
});
describe('itemIndexCompletions', () => {
test('should return completions for $input. in all-items mode', () => {
const state = EditorState.create({ doc: '$input.', selection: { anchor: 7 } });
const context = new CompletionContext(state, 7, true);
const result = useItemIndexCompletions(mode).inputCompletions.call({ mode }, context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(4);
expect(result?.options).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: '$input.first()' }),
expect.objectContaining({ label: '$input.last()' }),
expect.objectContaining({ label: '$input.all()' }),
expect.objectContaining({ label: '$input.itemMatching()' }),
]),
);
});
test('should return completions for $input. in single-item mode', () => {
mode = 'runOnceForEachItem';
const state = EditorState.create({ doc: '$input.', selection: { anchor: 7 } });
const context = new CompletionContext(state, 7, true);
const result = useItemIndexCompletions(mode).inputCompletions.call({ mode }, context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(1);
expect(result?.options).toEqual(
expect.arrayContaining([expect.objectContaining({ label: '$input.item' })]),
);
});
test('should return null for non-matching context', () => {
const state = EditorState.create({ doc: 'randomText', selection: { anchor: 10 } });
const context = new CompletionContext(state, 10, true);
expect(useItemIndexCompletions(mode).inputCompletions.call({ mode }, context)).toBeNull();
});
test('should return null for non-matching selector context', () => {
const state = EditorState.create({ doc: 'randomText', selection: { anchor: 10 } });
const context = new CompletionContext(state, 10, true);
expect(useItemIndexCompletions(mode).selectorCompletions.call({ mode }, context)).toBeNull();
});
});

View File

@@ -0,0 +1,96 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
import * as utils from '@/plugins/codemirror/completions/utils';
import { extensions, natives } from '@/plugins/codemirror/completions/datatype.completions';
import type { CompletionSource, CompletionResult } from '@codemirror/autocomplete';
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import { n8nLang } from '@/plugins/codemirror/n8nLang';
beforeEach(async () => {
setActivePinia(createTestingPinia());
vi.spyOn(utils, 'receivesNoBinaryData').mockReturnValue(true); // hide $binary
vi.spyOn(utils, 'isSplitInBatchesAbsent').mockReturnValue(false); // show context
vi.spyOn(utils, 'hasActiveNode').mockReturnValue(true);
});
export function completions(docWithCursor: string, explicit = false) {
const cursorPosition = docWithCursor.indexOf('|');
const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1);
const state = EditorState.create({
doc,
selection: { anchor: cursorPosition },
extensions: [n8nLang()],
});
const context = new CompletionContext(state, cursorPosition, explicit);
for (const completionSource of state.languageDataAt<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;
}
describe('jsonField.completions', () => {
test('should return null for invalid syntax: {{ $input.item.json..| }}', () => {
expect(completions('{{ $input.item.json..| }}')).toBeNull();
});
test('should return null for non-existent node: {{ $("NonExistentNode").item.json.| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(null);
expect(completions('{{ $("NonExistentNode").item.json.| }}')).toBeNull();
});
test('should return completions for complex expressions: {{ Math.max($input.item.json.num1, $input.item.json.num2).| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(123);
const result = completions('{{ Math.max($input.item.json.num1, $input.item.json.num2).| }}');
expect(result).toHaveLength(
extensions({ typeName: 'number' }).length +
natives({ typeName: 'number' }).length +
['isEven()', 'isOdd()'].length,
);
});
test('should return completions for boolean expressions: {{ $input.item.json.flag && $input.item.json.| }}', () => {
const json = { flag: true, key1: 'value1', key2: 'value2' };
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(json);
const result = completions('{{ $input.item.json.flag && $input.item.json.| }}');
expect(result).toHaveLength(
Object.keys(json).length + extensions({ typeName: 'object' }).length,
);
});
test('should return null for undefined values: {{ $input.item.json.undefinedValue.| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(undefined);
expect(completions('{{ $input.item.json.undefinedValue.| }}')).toBeNull();
});
test('should return completions for large JSON objects: {{ $input.item.json.largeObject.| }}', () => {
const largeObject: { [key: string]: string } = {};
for (let i = 0; i < 1000; i++) {
largeObject[`key${i}`] = `value${i}`;
}
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(largeObject);
const result = completions('{{ $input.item.json.largeObject.| }}');
expect(result).toHaveLength(
Object.keys(largeObject).length + extensions({ typeName: 'object' }).length,
);
});
});

View File

@@ -0,0 +1,452 @@
import type { NativeDoc } from 'n8n-workflow';
import { i18n } from '@/plugins/i18n';
// Autocomplete documentation definition for DateTime class static props and methods
export const luxonStaticDocs: Required<NativeDoc> = {
typeName: 'DateTime',
properties: {},
functions: {
now: {
doc: {
name: 'now',
description: i18n.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.now'),
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimenow',
returnType: 'DateTime',
},
},
local: {
doc: {
name: 'local',
description: i18n.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.local'),
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimelocal',
returnType: 'DateTime',
args: [
{
name: 'year',
optional: true,
type: 'number',
description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.year'),
},
{
name: 'month',
optional: true,
type: 'number',
description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.month'),
},
{
name: 'day',
optional: true,
type: 'number',
description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.day'),
},
{
name: 'hour',
optional: true,
type: 'number',
description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.hour'),
},
{
name: 'minute',
optional: true,
type: 'number',
description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.minute'),
},
{
name: 'second',
optional: true,
type: 'number',
description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.second'),
},
{
name: 'millisecond',
optional: true,
type: 'number',
description: i18n.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.millisecond',
),
},
],
examples: [
{
example: 'DateTime.local(1982, 12, 3)',
evaluated: '[DateTime: 1982-12-03T00:00:00.000-05:00]',
},
],
},
},
utc: {
doc: {
name: 'utc',
description: i18n.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.utc'),
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeutc',
returnType: 'DateTime',
args: [
{
name: 'year',
optional: true,
type: 'number',
description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.year'),
},
{
name: 'month',
optional: true,
type: 'number',
description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.month'),
},
{
name: 'day',
optional: true,
type: 'number',
description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.day'),
},
{
name: 'hour',
optional: true,
type: 'number',
description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.hour'),
},
{
name: 'minute',
optional: true,
type: 'number',
description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.minute'),
},
{
name: 'second',
optional: true,
type: 'number',
description: i18n.baseText('codeNodeEditor.completer.luxon.instanceMethods.second'),
},
{
name: 'millisecond',
optional: true,
type: 'number',
description: i18n.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.millisecond',
),
},
],
examples: [
{
example: 'DateTime.utc(1982, 12, 3)',
evaluated: '[DateTime: 1982-12-03T00:00:00.000Z]',
},
],
},
},
fromJSDate: {
doc: {
name: 'fromJSDate',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromjsdate',
returnType: 'DateTime',
args: [
{ name: 'date', type: 'Date' },
{ name: 'options', optional: true, type: 'Object' },
],
},
},
fromMillis: {
doc: {
name: 'fromMillis',
description: i18n.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromMillis',
),
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefrommillis',
returnType: 'DateTime',
args: [
{
name: 'milliseconds',
type: 'number',
description: i18n.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromMillis.args.milliseconds',
),
},
{
name: 'options',
optional: true,
type: 'Object',
description: i18n.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromMillis.args.opts',
),
},
],
examples: [
{
example: 'DateTime.fromMillis(1711838940000)',
evaluated: '[DateTime: 2024-03-30T18:49:00.000Z]',
},
],
},
},
fromSeconds: {
doc: {
name: 'fromSeconds',
description: i18n.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSeconds',
),
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromseconds',
returnType: 'DateTime',
args: [
{
name: 'seconds',
type: 'number',
description: i18n.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSeconds.args.seconds',
),
},
{
name: 'options',
optional: true,
type: 'Object',
description: i18n.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSeconds.args.opts',
),
},
],
examples: [
{
example: 'DateTime.fromSeconds(1711838940)',
evaluated: '[DateTime: 2024-03-30T18:49:00.000Z]',
},
],
},
},
fromObject: {
doc: {
name: 'fromObject',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromobject',
returnType: 'DateTime',
hidden: true,
args: [
{ name: 'obj', type: 'Object' },
{ name: 'options', optional: true, type: 'Object' },
],
},
},
fromISO: {
doc: {
name: 'fromISO',
description: i18n.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromISO'),
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromiso',
returnType: 'DateTime',
args: [
{
name: 'isoString',
type: 'string',
description: i18n.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromISO.args.isoString',
),
},
{
name: 'options',
optional: true,
type: 'Object',
description: i18n.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromISO.args.opts',
),
},
],
examples: [
{
example: "DateTime.fromISO('2024-05-10T14:15:59.493Z')",
evaluated: '[DateTime: 2024-05-10T14:15:59.493Z]',
},
],
},
},
fromRFC2822: {
doc: {
name: 'fromRFC2822',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromrfc2822',
returnType: 'DateTime',
args: [
{ name: 'text', type: 'string' },
{ name: 'options', optional: true, type: 'Object' },
],
},
},
fromHTTP: {
doc: {
name: 'fromHTTP',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromhttp',
returnType: 'DateTime',
args: [
{ name: 'text', type: 'string' },
{ name: 'options', optional: true, type: 'Object' },
],
},
},
fromFormat: {
doc: {
name: 'fromFormat',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromformat',
returnType: 'DateTime',
args: [
{ name: 'text', type: 'string' },
{ name: 'fmt', type: 'string' },
{ name: 'options', optional: true, type: 'Object' },
],
},
},
fromSQL: {
doc: {
name: 'fromSQL',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromsql',
returnType: 'DateTime',
args: [
{ name: 'text', type: 'string' },
{ name: 'options', optional: true, type: 'Object' },
],
},
},
invalid: {
doc: {
name: 'invalid',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeinvalid',
returnType: 'DateTime',
hidden: true,
args: [
{ name: 'reason', type: 'DateTime' },
{ name: 'explanation', optional: true, type: 'string' },
],
},
},
isDateTime: {
doc: {
name: 'isDateTime',
description: i18n.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.isDateTime',
),
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeisdatetime',
returnType: 'boolean',
args: [
{
name: 'maybeDateTime',
type: 'any',
description: i18n.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.isDateTime.args.maybeDateTime',
),
},
],
},
},
expandFormat: {
doc: {
name: 'expandFormat',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeexpandformat',
returnType: 'string',
hidden: true,
args: [
{ name: 'fmt', type: 'any' },
{ name: 'localeOpts', optional: true, type: 'any' },
],
},
},
fromFormatExplain: {
doc: {
name: 'fromFormatExplain',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromformatexplain',
returnType: 'Object',
hidden: true,
args: [
{ name: 'text', type: 'string' },
{ name: 'fmt', type: 'string' },
{ name: 'options', optional: true, type: 'Object' },
],
},
},
fromString: {
doc: {
name: 'fromString',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromstring',
returnType: 'DateTime',
hidden: true,
args: [
{ name: 'text', type: 'string' },
{ name: 'fmt', type: 'string' },
{ name: 'options', optional: true, type: 'Object' },
],
},
},
fromStringExplain: {
doc: {
name: 'fromStringExplain',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimefromstringexplain',
returnType: 'Object',
hidden: true,
args: [
{ name: 'text', type: 'string' },
{ name: 'fmt', type: 'string' },
{ name: 'options', optional: true, type: 'Object' },
],
},
},
max: {
doc: {
name: 'max',
description: i18n.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.max'),
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimemax',
returnType: 'DateTime',
args: [
{
name: 'dateTimes',
variadic: true,
type: 'DateTime',
description: i18n.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.max.args.dateTimes',
),
},
],
examples: [
{
example:
"DateTime.max('2024-03-30T18:49'.toDateTime(), '2025-03-30T18:49'.toDateTime())",
evaluated: '[DateTime: 2025-03-30T18:49:00.000Z]',
},
],
},
},
min: {
doc: {
name: 'min',
description: i18n.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.min'),
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimemin',
returnType: 'DateTime',
args: [
{
name: 'dateTimes',
variadic: true,
type: 'DateTime',
description: i18n.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.min.args.dateTimes',
),
},
],
examples: [
{
example:
"DateTime.min('2024-03-30T18:49'.toDateTime(), '2025-03-30T18:49'.toDateTime())",
evaluated: '[DateTime: 2024-03-30T18:49:00.000Z]',
},
],
},
},
parseFormatForOpts: {
doc: {
name: 'parseFormatForOpts',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeparseformatforopts',
returnType: 'string',
hidden: true,
args: [
{ name: 'fmt', type: 'any' },
{ name: 'localeOpts', optional: true, type: 'any' },
],
},
},
},
};

View File

@@ -0,0 +1,63 @@
import { i18n } from '@/plugins/i18n';
import type { CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { prefixMatch } from './utils';
import { createInfoBoxRenderer } from './infoBoxRenderer';
/**
* Completions offered at the initial position for any char other than `$`.
*/
export function nonDollarCompletions(context: CompletionContext): CompletionResult | null {
const dateTime = /(\s+)D[ateTim]*/;
const math = /(\s+)M[ath]*/;
const object = /(\s+)O[bject]*/;
const combinedRegex = new RegExp([dateTime.source, math.source, object.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',
info: createInfoBoxRenderer({
name: 'DateTime',
returnType: 'DateTimeGlobal',
description: i18n.baseText('codeNodeEditor.completer.dateTime'),
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetime',
}),
},
{
label: 'Math',
info: createInfoBoxRenderer({
name: 'Math',
returnType: 'MathGlobal',
description: i18n.baseText('codeNodeEditor.completer.math'),
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math',
}),
},
{
label: 'Object',
info: createInfoBoxRenderer({
name: 'Object',
returnType: 'ObjectGlobal',
description: i18n.baseText('codeNodeEditor.completer.globalObject'),
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object',
}),
},
];
const options = nonDollarOptions.filter((o) => prefixMatch(o.label, userInput));
return {
from: word.to - userInput.length,
filter: false,
options,
};
}

View File

@@ -0,0 +1,56 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import { usePrevNodeCompletions } from '@/components/CodeNodeEditor/completions/prevNode.completions';
describe('prevNodeCompletions', () => {
const { prevNodeCompletions } = usePrevNodeCompletions();
beforeEach(() => {
setActivePinia(createTestingPinia());
});
test('should return completions for explicit empty context', () => {
const state = EditorState.create({ doc: '$prevNode.', selection: { anchor: 10 } });
const context = new CompletionContext(state, 10, true);
const result = prevNodeCompletions(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(3);
expect(result?.options).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: '$prevNode.name' }),
expect.objectContaining({ label: '$prevNode.outputIndex' }),
expect.objectContaining({ label: '$prevNode.runIndex' }),
]),
);
});
test('should return null for non-matching context', () => {
const state = EditorState.create({ doc: 'randomText', selection: { anchor: 10 } });
const context = new CompletionContext(state, 10, true);
expect(prevNodeCompletions(context)).toBeNull();
});
test('should return completions for partial match', () => {
const state = EditorState.create({ doc: '$prevNode.n', selection: { anchor: 11 } });
const context = new CompletionContext(state, 11, true);
const result = prevNodeCompletions(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(3);
expect(result?.options).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: '$prevNode.name' }),
expect.objectContaining({ label: '$prevNode.outputIndex' }),
expect.objectContaining({ label: '$prevNode.runIndex' }),
]),
);
});
test('should return null for empty matcher', () => {
const state = EditorState.create({ doc: '.', selection: { anchor: 1 } });
const context = new CompletionContext(state, 1, true);
expect(prevNodeCompletions(context)).toBeNull();
});
});

View File

@@ -0,0 +1,126 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import { useSettingsStore } from '@/stores/settings.store';
import { useRequireCompletions } from '@/components/CodeNodeEditor/completions/require.completions';
import { AUTOCOMPLETABLE_BUILT_IN_MODULES_JS } from '@/components/CodeNodeEditor/constants';
import * as utils from '@/plugins/codemirror/completions/utils';
let settingsStore: ReturnType<typeof useSettingsStore>;
describe('requireCompletions', () => {
beforeEach(() => {
setActivePinia(createTestingPinia());
settingsStore = useSettingsStore();
vi.spyOn(utils, 'receivesNoBinaryData').mockReturnValue(true); // hide $binary
vi.spyOn(utils, 'isSplitInBatchesAbsent').mockReturnValue(false); // show context
vi.spyOn(utils, 'hasActiveNode').mockReturnValue(true);
});
it('should return completions for explicit empty context', () => {
vi.spyOn(settingsStore, 'allowedModules', 'get').mockReturnValue({
builtIn: ['fs', 'path'],
external: ['lodash'],
});
const state = EditorState.create({ doc: 'req', selection: { anchor: 3 } });
const context = new CompletionContext(state, 3, true);
const result = useRequireCompletions().requireCompletions(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(3);
expect(result?.options).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: "require('fs');" }),
expect.objectContaining({ label: "require('path');" }),
expect.objectContaining({ label: "require('lodash');" }),
]),
);
});
it('should return completions for partial match', () => {
vi.spyOn(settingsStore, 'allowedModules', 'get').mockReturnValue({
builtIn: ['fs', 'path'],
external: ['lodash'],
});
const state = EditorState.create({ doc: 'req', selection: { anchor: 3 } });
const context = new CompletionContext(state, 3, true);
const result = useRequireCompletions().requireCompletions(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(3);
expect(result?.options).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: "require('fs');" }),
expect.objectContaining({ label: "require('path');" }),
expect.objectContaining({ label: "require('lodash');" }),
]),
);
});
it('should handle built-in wildcard', () => {
vi.spyOn(settingsStore, 'allowedModules', 'get').mockReturnValue({
builtIn: ['*'],
external: [],
});
const state = EditorState.create({ doc: 'req', selection: { anchor: 3 } });
const context = new CompletionContext(state, 3, true);
const result = useRequireCompletions().requireCompletions(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(AUTOCOMPLETABLE_BUILT_IN_MODULES_JS.length);
expect(result?.options).toEqual(
expect.arrayContaining(
AUTOCOMPLETABLE_BUILT_IN_MODULES_JS.map((module) =>
expect.objectContaining({ label: `require('${module}');` }),
),
),
);
});
it('should return null for non-matching context', () => {
const state = EditorState.create({ doc: 'randomText', selection: { anchor: 10 } });
const context = new CompletionContext(state, 10, true);
expect(useRequireCompletions().requireCompletions(context)).toBeNull();
});
it('should return completions for mixed built-in and external modules', () => {
vi.spyOn(settingsStore, 'allowedModules', 'get').mockReturnValue({
builtIn: ['fs'],
external: ['lodash', 'axios'],
});
const state = EditorState.create({ doc: 'req', selection: { anchor: 3 } });
const context = new CompletionContext(state, 3, true);
const result = useRequireCompletions().requireCompletions(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(3);
expect(result?.options).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: "require('fs');" }),
expect.objectContaining({ label: "require('lodash');" }),
expect.objectContaining({ label: "require('axios');" }),
]),
);
});
it('should handle empty allowedModules gracefully', () => {
vi.spyOn(settingsStore, 'allowedModules', 'get').mockReturnValue({
builtIn: [],
external: [],
});
const state = EditorState.create({ doc: 'req', selection: { anchor: 3 } });
const context = new CompletionContext(state, 3, true);
const result = useRequireCompletions().requireCompletions(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(0);
});
it('should handle missing allowedModules gracefully', () => {
vi.spyOn(settingsStore, 'allowedModules', 'get').mockReturnValue({
builtIn: undefined,
external: undefined,
});
const state = EditorState.create({ doc: 'req', selection: { anchor: 3 } });
const context = new CompletionContext(state, 3, true);
const result = useRequireCompletions().requireCompletions(context);
expect(result?.options).toHaveLength(0);
});
});

View File

@@ -0,0 +1,17 @@
import type { DocMetadata } from 'n8n-workflow';
export type Resolved = unknown;
export type ExtensionTypeName = 'number' | 'string' | 'date' | 'array' | 'object' | 'boolean';
export type FnToDoc = { [fnName: string]: { doc?: DocMetadata } };
export type FunctionOptionType = 'native-function' | 'extension-function';
export type KeywordOptionType = 'keyword';
export type AutocompleteOptionType = FunctionOptionType | KeywordOptionType;
export type AutocompleteInput<R = Resolved> = {
resolved: R;
base: string;
tail: string;
transformLabel?: (label: string) => string;
};

View File

@@ -0,0 +1,181 @@
import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks';
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
import * as ndvStore from '@/stores/ndv.store';
import { CompletionContext, insertCompletionText } from '@codemirror/autocomplete';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { NodeConnectionType, type IConnections } from 'n8n-workflow';
import type { MockInstance } from 'vitest';
import { autocompletableNodeNames, expressionWithFirstItem, stripExcessParens } from './utils';
vi.mock('@/composables/useWorkflowHelpers', () => ({
useWorkflowHelpers: vi.fn().mockReturnValue({
getCurrentWorkflow: vi.fn(),
}),
}));
const editorFromString = (docWithCursor: string) => {
const cursorPosition = docWithCursor.indexOf('|');
const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1);
const state = EditorState.create({
doc,
selection: { anchor: cursorPosition },
});
return {
context: new CompletionContext(state, cursorPosition, false),
view: new EditorView({ state, doc }),
};
};
describe('completion utils', () => {
describe('expressionWithFirstItem', () => {
it('should replace $input.item', () => {
const source = '$input.item.json.foo.bar';
const expected = '$input.first().json.foo.bar';
const tree = javascriptLanguage.parser.parse(source);
const result = expressionWithFirstItem(tree, source);
expect(result).toBe(expected);
});
it('should replace $input.itemMatching()', () => {
const source = '$input.itemMatching(4).json.foo.bar';
const expected = '$input.first().json.foo.bar';
const tree = javascriptLanguage.parser.parse(source);
const result = expressionWithFirstItem(tree, source);
expect(result).toBe(expected);
});
it('should replace $("Node Name").itemMatching()', () => {
const source = '$("Node Name").itemMatching(4).json.foo.bar';
const expected = '$("Node Name").first().json.foo.bar';
const tree = javascriptLanguage.parser.parse(source);
const result = expressionWithFirstItem(tree, source);
expect(result).toBe(expected);
});
it('should replace $("Node Name").item', () => {
const source = '$("Node Name").item.json.foo.bar';
const expected = '$("Node Name").first().json.foo.bar';
const tree = javascriptLanguage.parser.parse(source);
const result = expressionWithFirstItem(tree, source);
expect(result).toBe(expected);
});
it('should not replace anything in unrelated expressions', () => {
const source = '$input.first().foo.item.fn($json.item.foo)';
const expected = '$input.first().foo.item.fn($json.item.foo)';
const tree = javascriptLanguage.parser.parse(source);
const result = expressionWithFirstItem(tree, source);
expect(result).toBe(expected);
});
});
describe('autocompletableNodeNames', () => {
it('should work for normal nodes', () => {
const nodes = [
createTestNode({ name: 'Node 1' }),
createTestNode({ name: 'Node 2' }),
createTestNode({ name: 'Node 3' }),
];
const connections = {
[nodes[0].name]: {
[NodeConnectionType.Main]: [
[{ node: nodes[1].name, type: NodeConnectionType.Main, index: 0 }],
],
},
[nodes[1].name]: {
[NodeConnectionType.Main]: [
[{ node: nodes[2].name, type: NodeConnectionType.Main, index: 0 }],
],
},
};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
});
const workflowHelpersMock: MockInstance = vi.spyOn(workflowHelpers, 'useWorkflowHelpers');
workflowHelpersMock.mockReturnValue({
getCurrentWorkflow: vi.fn(() => workflowObject),
});
const ndvStoreMock: MockInstance = vi.spyOn(ndvStore, 'useNDVStore');
ndvStoreMock.mockReturnValue({ activeNode: nodes[2] });
expect(autocompletableNodeNames()).toEqual(['Node 2', 'Node 1']);
});
it('should work for AI tool nodes', () => {
const nodes = [
createTestNode({ name: 'Normal Node' }),
createTestNode({ name: 'Agent' }),
createTestNode({ name: 'Tool' }),
];
const connections: IConnections = {
[nodes[0].name]: {
[NodeConnectionType.Main]: [
[{ node: nodes[1].name, type: NodeConnectionType.Main, index: 0 }],
],
},
[nodes[2].name]: {
[NodeConnectionType.AiMemory]: [
[{ node: nodes[1].name, type: NodeConnectionType.AiMemory, index: 0 }],
],
},
};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
});
const workflowHelpersMock: MockInstance = vi.spyOn(workflowHelpers, 'useWorkflowHelpers');
workflowHelpersMock.mockReturnValue({
getCurrentWorkflow: vi.fn(() => workflowObject),
});
const ndvStoreMock: MockInstance = vi.spyOn(ndvStore, 'useNDVStore');
ndvStoreMock.mockReturnValue({ activeNode: nodes[2] });
expect(autocompletableNodeNames()).toEqual(['Normal Node']);
});
});
describe('stripExcessParens', () => {
test.each([
{
doc: '$(|',
completion: { label: "$('Node Name')" },
expected: "$('Node Name')",
},
{
doc: '$(|)',
completion: { label: "$('Node Name')" },
expected: "$('Node Name')",
},
{
doc: "$('|')",
completion: { label: "$('Node Name')" },
expected: "$('Node Name')",
},
{
doc: "$('No|')",
completion: { label: "$('Node Name')" },
expected: "$('Node Name')",
},
])('should complete $doc to $expected', ({ doc, completion, expected }) => {
const { context, view } = editorFromString(doc);
const result = stripExcessParens(context)(completion);
const from = 0;
const to = doc.indexOf('|');
if (typeof result.apply === 'function') {
result.apply(view, completion, from, to);
} else {
view.dispatch(insertCompletionText(view.state, completion.label, from, to));
}
expect(view.state.doc.toString()).toEqual(expected);
});
});
});

View File

@@ -0,0 +1,418 @@
import {
CREDENTIAL_EDIT_MODAL_KEY,
HTTP_REQUEST_NODE_TYPE,
SPLIT_IN_BATCHES_NODE_TYPE,
} from '@/constants';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { resolveParameter, useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useNDVStore } from '@/stores/ndv.store';
import { useUIStore } from '@/stores/ui.store';
import {
insertCompletionText,
type Completion,
type CompletionContext,
pickedCompletion,
type CompletionSection,
} from '@codemirror/autocomplete';
import type { EditorView } from '@codemirror/view';
import { EditorSelection, type TransactionSpec } from '@codemirror/state';
import type { SyntaxNode, Tree } from '@lezer/common';
import { useRouter } from 'vue-router';
import type { DocMetadata } from 'n8n-workflow';
import { escapeMappingString } from '@/utils/mappingUtils';
/**
* Split user input into base (to resolve) and tail (to filter).
*/
export function splitBaseTail(syntaxTree: Tree, userInput: string): [string, string] {
const lastNode = syntaxTree.resolveInner(userInput.length, -1);
switch (lastNode.type.name) {
case '.':
return [read(lastNode.parent, userInput).slice(0, -1), ''];
case 'MemberExpression':
return [read(lastNode.parent, userInput), read(lastNode, userInput)];
case 'PropertyName':
const tail = read(lastNode, userInput);
return [read(lastNode.parent, userInput).slice(0, -(tail.length + 1)), tail];
default:
return ['', ''];
}
}
function replaceSyntaxNode(source: string, node: SyntaxNode, replacement: string) {
return source.slice(0, node.from) + replacement + source.slice(node.to);
}
function isInputNodeCall(node: SyntaxNode, source: string): node is SyntaxNode {
return (
node.name === 'VariableName' &&
read(node, source) === '$' &&
node.parent?.name === 'CallExpression'
);
}
function isInputVariable(node: SyntaxNode | null | undefined, source: string): node is SyntaxNode {
return node?.name === 'VariableName' && read(node, source) === '$input';
}
function isItemProperty(node: SyntaxNode | null | undefined, source: string): node is SyntaxNode {
return (
node?.parent?.name === 'MemberExpression' &&
node.name === 'PropertyName' &&
read(node, source) === 'item'
);
}
function isItemMatchingCall(
node: SyntaxNode | null | undefined,
source: string,
): node is SyntaxNode {
return (
node?.name === 'CallExpression' &&
node.firstChild?.lastChild?.name === 'PropertyName' &&
read(node.firstChild.lastChild, source) === 'itemMatching'
);
}
function read(node: SyntaxNode | null, source: string) {
return node ? source.slice(node.from, node.to) : '';
}
/**
* Replace expressions that depend on pairedItem with the first item when possible
* $input.item.json.foo -> $input.first().json.foo
* $('Node').item.json.foo -> $('Node').item.json.foo
*/
export function expressionWithFirstItem(syntaxTree: Tree, expression: string): string {
let result = expression;
syntaxTree.cursor().iterate(({ node }) => {
if (isInputVariable(node, expression)) {
if (isItemProperty(node.parent?.lastChild, expression)) {
result = replaceSyntaxNode(expression, node.parent.lastChild, 'first()');
} else if (isItemMatchingCall(node.parent?.parent, expression)) {
result = replaceSyntaxNode(expression, node.parent.parent, '$input.first()');
}
}
if (isInputNodeCall(node, expression)) {
if (isItemProperty(node.parent?.parent?.lastChild, expression)) {
result = replaceSyntaxNode(expression, node.parent.parent.lastChild, 'first()');
} else if (isItemMatchingCall(node.parent?.parent?.parent, expression)) {
result = replaceSyntaxNode(
expression,
node.parent.parent.parent,
`${read(node.parent, expression)}.first()`,
);
}
}
});
return result;
}
export function longestCommonPrefix(...strings: string[]) {
if (strings.length < 2) return '';
return strings.reduce((prefix, str) => {
while (!str.startsWith(prefix)) {
prefix = prefix.slice(0, -1);
if (prefix === '') return '';
}
return prefix;
}, strings[0]);
}
export const prefixMatch = (first: string, second: string) =>
first.toLocaleLowerCase().startsWith(second.toLocaleLowerCase());
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 access notation.
*/
export const isAllowedInDotNotation = (str: string) => {
const DOT_NOTATION_BANNED_CHARS = /^(\d)|[\\ `!@#$%^&*()+\-=[\]{};':"\\|,.<>?~]/g;
return !DOT_NOTATION_BANNED_CHARS.test(str);
};
// ----------------------------------
// resolution-based utils
// ----------------------------------
export function receivesNoBinaryData() {
try {
return resolveAutocompleteExpression('={{ $binary }}')?.data === undefined;
} catch {
return true;
}
}
export function hasNoParams(toResolve: string) {
let params;
try {
params = resolveAutocompleteExpression(`={{ ${toResolve}.params }}`);
} catch {
return true;
}
if (!params) return true;
const paramKeys = Object.keys(params);
return paramKeys.length === 1 && isPseudoParam(paramKeys[0]);
}
export function resolveAutocompleteExpression(expression: string) {
const ndvStore = useNDVStore();
return resolveParameter(
expression,
ndvStore.isInputParentOfActiveNode
? {
targetItem: ndvStore.expressionTargetItem ?? undefined,
inputNodeName: ndvStore.ndvInputNodeName,
inputRunIndex: ndvStore.ndvInputRunIndex,
inputBranchIndex: ndvStore.ndvInputBranchIndex,
}
: {},
);
}
// ----------------------------------
// state-based utils
// ----------------------------------
export const isCredentialsModalOpen = () => useUIStore().modalsById[CREDENTIAL_EDIT_MODAL_KEY].open;
export const isInHttpNodePagination = () => {
const ndvStore = useNDVStore();
return (
ndvStore.activeNode?.type === HTTP_REQUEST_NODE_TYPE &&
ndvStore.focusedInputPath.startsWith('parameters.options.pagination')
);
};
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() {
const activeNode = useNDVStore().activeNode;
if (!activeNode) return [];
const activeNodeName = activeNode.name;
const workflow = useWorkflowHelpers({ router: useRouter() }).getCurrentWorkflow();
const nonMainChildren = workflow.getChildNodes(activeNodeName, 'ALL_NON_MAIN');
// This is a tool node, look for the nearest node with main connections
if (nonMainChildren.length > 0) {
return nonMainChildren.map(getPreviousNodes).flat();
}
return getPreviousNodes(activeNodeName);
}
export function getPreviousNodes(nodeName: string) {
const workflow = useWorkflowHelpers({ router: useRouter() }).getCurrentWorkflow();
return workflow
.getParentNodesByDepth(nodeName)
.map((node) => node.name)
.filter((name) => name !== nodeName);
}
/**
* Finds the amount of common chars at the end of the source and the start of the target.
* Example: "hello world", "world peace" => 5 ("world" is the overlap)
*/
function findCommonBoundary(source: string, target: string) {
return (
[...source]
.reverse()
.map((_, i) => source.slice(-i - 1))
.find((end) => target.startsWith(end))?.length ?? 0
);
}
function getClosingChars(input: string): string {
const match = input.match(/^['"\])]+/);
return match ? match[0] : '';
}
/**
* Remove excess parens from an option label when the cursor is already
* followed by parens, e.g. `$json.myStr.|()` -> `isNumeric` or `$(|)` -> `$("Node Name")|`
*/
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);
}
const closingChars = getClosingChars(context.state.sliceDoc(context.pos));
const commonClosingChars = findCommonBoundary(option.label, closingChars);
if (commonClosingChars > 0) {
option.apply = (view: EditorView, completion: Completion, from: number, to: number): void => {
const tx: TransactionSpec = {
...insertCompletionText(view.state, option.label.slice(0, -commonClosingChars), from, to),
annotations: pickedCompletion.of(completion),
};
tx.selection = EditorSelection.cursor(from + option.label.length);
view.dispatch(tx);
};
}
return option;
};
export const getDefaultArgs = (doc?: DocMetadata): string[] => {
return (
doc?.args
?.filter((arg) => !arg.optional)
.map((arg) => arg.default)
.filter((def): def is string => !!def) ?? []
);
};
export const insertDefaultArgs = (label: string, args: unknown[]): string => {
if (!label.endsWith('()')) return label;
const argList = args.join(', ');
const fnName = label.replace('()', '');
return `${fnName}(${argList})`;
};
/**
* When a function completion is selected, set the cursor correctly
*
* @example `.includes()` -> `.includes(<cursor>)`
* @example `$max()` -> `$max()<cursor>`
*/
export const applyCompletion =
({
hasArgs = true,
defaultArgs = [],
transformLabel = (label) => label,
}: {
hasArgs?: boolean;
defaultArgs?: unknown[];
transformLabel?: (label: string) => string;
} = {}) =>
(view: EditorView, completion: Completion, from: number, to: number): void => {
const isFunction = completion.label.endsWith('()');
const label = insertDefaultArgs(transformLabel(completion.label), defaultArgs);
const tx: TransactionSpec = {
...insertCompletionText(view.state, label, from, to),
annotations: pickedCompletion.of(completion),
};
if (isFunction) {
if (defaultArgs.length > 0) {
tx.selection = { anchor: from + label.indexOf('(') + 1, head: from + label.length - 1 };
} else if (hasArgs) {
const cursorPosition = from + label.length - 1;
tx.selection = { anchor: cursorPosition, head: cursorPosition };
}
}
view.dispatch(tx);
};
export const applyBracketAccess = (key: string): string => {
return `['${escapeMappingString(key)}']`;
};
/**
* Apply a bracket-access completion
*
* @example `$json.` -> `$json['key with spaces']`
* @example `$json` -> `$json['key with spaces']`
*/
export const applyBracketAccessCompletion = (
view: EditorView,
completion: Completion,
from: number,
to: number,
): void => {
const label = applyBracketAccess(completion.label);
const completionAtDot = view.state.sliceDoc(from - 1, from) === '.';
view.dispatch({
...insertCompletionText(view.state, label, completionAtDot ? from - 1 : from, to),
annotations: pickedCompletion.of(completion),
});
};
export const hasRequiredArgs = (doc?: DocMetadata): boolean => {
if (!doc) return false;
const requiredArgs = doc?.args?.filter((arg) => !arg.name.endsWith('?') && !arg.optional) ?? [];
return requiredArgs.length > 0;
};
export const sortCompletionsAlpha = (completions: Completion[]): Completion[] => {
return completions.sort((a, b) => a.label.localeCompare(b.label));
};
export const renderSectionHeader = (section: CompletionSection): HTMLElement => {
const container = document.createElement('li');
container.classList.add('cm-section-header');
const inner = document.createElement('div');
inner.classList.add('cm-section-title');
inner.textContent = section.name;
container.appendChild(inner);
return container;
};
export const withSectionHeader = (section: CompletionSection): CompletionSection => {
section.header = renderSectionHeader;
return section;
};
export const isCompletionSection = (
section: CompletionSection | string | undefined,
): section is CompletionSection => {
return typeof section === 'object';
};
export const getDisplayType = (value: unknown): string => {
if (Array.isArray(value)) {
if (value.length > 0) {
return `${getDisplayType(value[0])}[]`;
}
return 'Array';
}
if (value === null) return 'null';
if (typeof value === 'object') return 'Object';
return (typeof value).toLocaleLowerCase();
};
export function attempt<T, TDefault>(
fn: () => T,
onError: (error: unknown) => TDefault,
): T | TDefault;
export function attempt<T>(fn: () => T): T | null;
export function attempt<T, TDefault>(
fn: () => T,
onError?: (error: unknown) => TDefault,
): T | TDefault | null {
try {
return fn();
} catch (error) {
if (onError) {
return onError(error);
}
return null;
}
}

View File

@@ -0,0 +1,64 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { EditorState } from '@codemirror/state';
import { CompletionContext } from '@codemirror/autocomplete';
import { useEnvironmentsStore } from '@/stores/environments.ee.store';
import { useVariablesCompletions } from '@/components/CodeNodeEditor/completions/variables.completions';
let environmentsStore: ReturnType<typeof useEnvironmentsStore>;
beforeEach(() => {
setActivePinia(createTestingPinia());
environmentsStore = useEnvironmentsStore();
});
describe('variablesCompletions', () => {
test('should return completions for $vars prefix', () => {
environmentsStore.variables = [
{ key: 'VAR1', value: 'Value1', id: '1' },
{ key: 'VAR2', value: 'Value2', id: '2' },
];
const state = EditorState.create({ doc: '$vars.', selection: { anchor: 6 } });
const context = new CompletionContext(state, 6, true);
const result = useVariablesCompletions().variablesCompletions(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(2);
expect(result?.options).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: '$vars.VAR1', info: 'Value1' }),
expect.objectContaining({ label: '$vars.VAR2', info: 'Value2' }),
]),
);
});
test('should return null for non-matching context', () => {
const state = EditorState.create({ doc: 'randomText', selection: { anchor: 10 } });
const context = new CompletionContext(state, 10, true);
expect(useVariablesCompletions().variablesCompletions(context)).toBeNull();
});
test('should escape special characters in matcher', () => {
environmentsStore.variables = [{ key: 'VAR1', value: 'Value1', id: '1' }];
const state = EditorState.create({ doc: '$vars.', selection: { anchor: 6 } });
const context = new CompletionContext(state, 6, true);
const result = useVariablesCompletions().variablesCompletions(context, '$var$');
expect(result).toBeNull();
});
test('should return completions for custom matcher', () => {
environmentsStore.variables = [{ key: 'VAR1', value: 'Value1', id: '1' }];
const state = EditorState.create({ doc: '$custom.', selection: { anchor: 8 } });
const context = new CompletionContext(state, 8, true);
const result = useVariablesCompletions().variablesCompletions(context, '$custom');
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(1);
expect(result?.options).toEqual(
expect.arrayContaining([expect.objectContaining({ label: '$custom.VAR1', info: 'Value1' })]),
);
});
});

View File

@@ -0,0 +1,68 @@
import { useNDVStore } from '@/stores/ndv.store';
import { EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { createTestingPinia } from '@pinia/testing';
import { fireEvent } from '@testing-library/dom';
import { setActivePinia } from 'pinia';
import { mappingDropCursor } from './dragAndDrop';
import { n8nLang } from './n8nLang';
describe('CodeMirror drag and drop', () => {
beforeEach(() => {
const pinia = createTestingPinia({ stubActions: false });
setActivePinia(pinia);
});
describe('mappingDropCursor', () => {
const createEditor = () => {
const parent = document.createElement('div');
document.body.appendChild(parent);
const state = EditorState.create({
doc: 'test {{ $json.foo }} \n\nnewline',
extensions: [mappingDropCursor(), n8nLang()],
});
const editor = new EditorView({ parent, state });
return editor;
};
it('should render a drop cursor when dragging', async () => {
useNDVStore().draggableStartDragging({
type: 'mapping',
data: '{{ $json.bar }}',
dimensions: null,
});
const editor = createEditor();
const rect = editor.contentDOM.getBoundingClientRect();
fireEvent(
editor.contentDOM,
new MouseEvent('mousemove', {
clientX: rect.left,
clientY: rect.top,
bubbles: true,
}),
);
const cursor = editor.dom.querySelector('.cm-dropCursor');
expect(cursor).toBeInTheDocument();
});
it('should not render a drop cursor when not dragging', async () => {
const editor = createEditor();
const rect = editor.contentDOM.getBoundingClientRect();
fireEvent(
editor.contentDOM,
new MouseEvent('mousemove', {
clientX: rect.left,
clientY: rect.top,
bubbles: true,
}),
);
const cursor = editor.dom.querySelector('.cm-dropCursor');
expect(cursor).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,162 @@
import { useNDVStore } from '@/stores/ndv.store';
import { unwrapExpression } from '@/utils/expressions';
import { syntaxTree } from '@codemirror/language';
import { EditorSelection, StateEffect, StateField, type Extension } from '@codemirror/state';
import { ViewPlugin, type EditorView, type ViewUpdate } from '@codemirror/view';
const setDropCursorPos = StateEffect.define<number | null>({
map(pos, mapping) {
return pos === null ? null : mapping.mapPos(pos);
},
});
const dropCursorPos = StateField.define<number | null>({
create() {
return null;
},
update(pos, tr) {
if (pos !== null) pos = tr.changes.mapPos(pos);
return tr.effects.reduce((p, e) => (e.is(setDropCursorPos) ? e.value : p), pos);
},
});
interface MeasureRequest<T> {
read(view: EditorView): T;
write?(measure: T, view: EditorView): void;
key?: unknown;
}
// This is a modification of the CodeMirror dropCursor
// This version hooks into the state of the NDV drag-n-drop
//
// We can't use CodeMirror's dropCursor because it depends on HTML drag events while our drag-and-drop uses mouse events
// We could switch to drag events later but some features of the current drag-n-drop might not be possible with drag events
const drawDropCursor = ViewPlugin.fromClass(
class {
cursor: HTMLElement | null = null;
measureReq: MeasureRequest<{ left: number; top: number; height: number } | null>;
ndvStore: ReturnType<typeof useNDVStore>;
constructor(readonly view: EditorView) {
this.measureReq = { read: this.readPos.bind(this), write: this.drawCursor.bind(this) };
this.ndvStore = useNDVStore();
}
update(update: ViewUpdate) {
const cursorPos = update.state.field(dropCursorPos);
if (cursorPos === null) {
if (this.cursor !== null) {
this.cursor?.remove();
this.cursor = null;
}
} else {
if (!this.cursor) {
this.cursor = this.view.scrollDOM.appendChild(document.createElement('div'));
this.cursor.className = 'cm-dropCursor';
}
if (
update.startState.field(dropCursorPos) !== cursorPos ||
update.docChanged ||
update.geometryChanged
)
this.view.requestMeasure(this.measureReq);
}
}
readPos(): { left: number; top: number; height: number } | null {
const { view } = this;
const pos = view.state.field(dropCursorPos);
const rect = pos !== null && view.coordsAtPos(pos);
if (!rect) return null;
const outer = view.scrollDOM.getBoundingClientRect();
return {
left: rect.left - outer.left + view.scrollDOM.scrollLeft * view.scaleX,
top: rect.top - outer.top + view.scrollDOM.scrollTop * view.scaleY,
height: rect.bottom - rect.top,
};
}
drawCursor(pos: { left: number; top: number; height: number } | null) {
if (this.cursor) {
const { scaleX, scaleY } = this.view;
if (pos) {
this.cursor.style.left = pos.left / scaleX + 'px';
this.cursor.style.top = pos.top / scaleY + 'px';
this.cursor.style.height = pos.height / scaleY + 'px';
} else {
this.cursor.style.left = '-100000px';
}
}
}
destroy() {
if (this.cursor) this.cursor.remove();
}
setDropPos(pos: number | null) {
if (this.view.state.field(dropCursorPos) !== pos)
this.view.dispatch({ effects: setDropCursorPos.of(pos) });
}
},
{
eventObservers: {
mousemove(event) {
if (!this.ndvStore.isDraggableDragging || this.ndvStore.draggableType !== 'mapping') return;
const pos = this.view.posAtCoords(eventToCoord(event), false);
this.setDropPos(pos);
},
mouseleave() {
this.setDropPos(null);
},
mouseup() {
this.setDropPos(null);
},
},
},
);
function eventToCoord(event: MouseEvent): { x: number; y: number } {
return { x: event.clientX, y: event.clientY };
}
function dropValueInEditor(view: EditorView, pos: number, value: string) {
const changes = view.state.changes({ from: pos, insert: value });
const anchor = changes.mapPos(pos, -1);
const head = changes.mapPos(pos, 1);
const selection = EditorSelection.single(anchor, head);
view.dispatch({
changes,
selection,
userEvent: 'input.drop',
});
setTimeout(() => view.focus());
return selection;
}
export async function dropInExpressionEditor(view: EditorView, event: MouseEvent, value: string) {
const dropPos = view.posAtCoords(eventToCoord(event), false);
const node = syntaxTree(view.state).resolve(dropPos);
let valueToInsert = value;
// We are already in an expression, do not insert brackets
if (node.name === 'Resolvable') {
valueToInsert = unwrapExpression(value);
}
return dropValueInEditor(view, dropPos, valueToInsert);
}
export async function dropInCodeEditor(view: EditorView, event: MouseEvent, value: string) {
const dropPos = view.posAtCoords(eventToCoord(event), false);
const valueToInsert = unwrapExpression(value);
return dropValueInEditor(view, dropPos, valueToInsert);
}
export function mappingDropCursor(): Extension {
return [dropCursorPos, drawDropCursor];
}

View File

@@ -0,0 +1,52 @@
import { EditorView } from '@codemirror/view';
import userEvent from '@testing-library/user-event';
import { expressionCloseBrackets } from './expressionCloseBrackets';
import { n8nAutocompletion, n8nLang } from './n8nLang';
import { completionStatus } from '@codemirror/autocomplete';
import { EditorSelection } from '@codemirror/state';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
describe('expressionCloseBrackets', () => {
const createEditor = () => {
const parent = document.createElement('div');
document.body.appendChild(parent);
const editor = new EditorView({
parent,
extensions: [expressionCloseBrackets(), n8nLang(), n8nAutocompletion()],
});
return editor;
};
beforeEach(() => {
setActivePinia(createTestingPinia());
});
it('should complete {{| to {{ | }} and open autocomplete', async () => {
const editor = createEditor();
// '{' is an escape character: '{{' === '{'
await userEvent.type(editor.contentDOM, '{{{{');
expect(editor.state.doc.toString()).toEqual('{{ }}');
expect(editor.state.selection).toEqual(EditorSelection.single(3));
expect(completionStatus(editor.state)).not.toBeNull();
});
it('should type over auto-closed brackets', async () => {
const editor = createEditor();
await userEvent.type(editor.contentDOM, 'foo()');
// no extra closing bracket foo())
expect(editor.state.doc.toString()).toEqual('foo()');
});
it.each([
{ char: '"', expected: '""' },
{ char: "'", expected: "''" },
{ char: '(', expected: '()' },
{ char: '{{}', expected: '{}' },
{ char: '{[}', expected: '[]' },
])('should auto-close $expected', async ({ expected, char }) => {
const editor = createEditor();
await userEvent.type(editor.contentDOM, char);
expect(editor.state.doc.toString()).toEqual(expected);
});
});

View File

@@ -0,0 +1,40 @@
import {
closeBrackets,
closeBracketsKeymap,
startCompletion,
type CloseBracketConfig,
} from '@codemirror/autocomplete';
import { EditorSelection, Text } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
const expressionBracketSpacing = EditorView.updateListener.of((update) => {
if (!update.changes || update.changes.empty) return;
// {{|}} --> {{ | }}
update.changes.iterChanges((_fromA, _toA, fromB, toB, inserted) => {
const doc = update.state.doc;
if (
inserted.eq(Text.of(['{}'])) &&
doc.sliceString(fromB - 1, fromB) === '{' &&
doc.sliceString(toB, toB + 1) === '}'
) {
update.view.dispatch({
changes: [{ from: fromB + 1, insert: ' ' }],
selection: EditorSelection.cursor(toB),
});
startCompletion(update.view);
}
});
});
export const expressionCloseBracketsConfig: CloseBracketConfig = {
brackets: ['{', '(', '"', "'", '['],
// <> so bracket completion works in HTML tags
before: ')]}:;<>\'"',
};
export const expressionCloseBrackets = () => [
expressionBracketSpacing,
closeBrackets(),
keymap.of(closeBracketsKeymap),
];

View File

@@ -0,0 +1,47 @@
import { EditorSelection, Facet } from '@codemirror/state';
import type { EditorView } from '@codemirror/view';
import { formatWithCursor, type BuiltInParserName } from 'prettier';
import babelPlugin from 'prettier/plugins/babel';
import estreePlugin from 'prettier/plugins/estree';
export type CodeEditorLanguage = 'json' | 'html' | 'javaScript' | 'python';
export const languageFacet = Facet.define<CodeEditorLanguage, CodeEditorLanguage>({
combine: (values) => values[0] ?? 'javaScript',
});
export function formatDocument(view: EditorView) {
function format(parser: BuiltInParserName) {
void formatWithCursor(view.state.doc.toString(), {
cursorOffset: view.state.selection.main.anchor,
parser,
plugins: [babelPlugin, estreePlugin],
}).then(({ formatted, cursorOffset }) => {
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: formatted,
},
selection: EditorSelection.single(cursorOffset),
});
});
}
const langauge = view.state.facet(languageFacet);
switch (langauge) {
case 'javaScript':
format('babel');
break;
case 'html':
format('html');
break;
case 'json':
format('json');
break;
default:
return false;
}
return true;
}

View File

@@ -0,0 +1,297 @@
import {
acceptCompletion,
closeCompletion,
completionStatus,
deleteBracketPair,
moveCompletionSelection,
startCompletion,
} from '@codemirror/autocomplete';
import type { EditorView, KeyBinding } from '@codemirror/view';
import {
insertNewlineAndIndent,
cursorCharLeft,
selectCharLeft,
deleteLine,
moveLineDown,
moveLineUp,
copyLineDown,
copyLineUp,
selectLine,
cursorMatchingBracket,
indentMore,
indentLess,
cursorLineBoundaryBackward,
selectLineBoundaryBackward,
cursorDocStart,
selectDocStart,
cursorLineBoundaryForward,
selectLineBoundaryForward,
cursorDocEnd,
selectDocEnd,
cursorGroupLeft,
selectGroupLeft,
cursorPageDown,
cursorPageUp,
deleteCharBackward,
deleteCharForward,
deleteGroupBackward,
deleteGroupForward,
deleteToLineEnd,
deleteToLineStart,
selectAll,
selectPageDown,
selectPageUp,
cursorCharRight,
cursorGroupRight,
selectCharRight,
selectGroupRight,
cursorLineUp,
selectLineUp,
cursorLineDown,
selectLineDown,
cursorLineEnd,
cursorLineStart,
selectLineEnd,
selectLineStart,
splitLine,
transposeChars,
redo,
undo,
undoSelection,
toggleComment,
lineComment,
lineUncomment,
toggleBlockComment,
} from '@codemirror/commands';
import {
closeSearchPanel,
gotoLine,
openSearchPanel,
replaceAll,
selectMatches,
selectNextOccurrence,
selectSelectionMatches,
} from '@codemirror/search';
import { addCursorAtEachSelectionLine, addCursorDown, addCursorUp } from './multiCursor';
import { foldAll, foldCode, unfoldAll, unfoldCode } from '@codemirror/language';
import { nextDiagnostic, previousDiagnostic, openLintPanel } from '@codemirror/lint';
import { EditorSelection } from '@codemirror/state';
import { formatDocument } from './format';
const SELECTED_AUTOCOMPLETE_OPTION_SELECTOR = '.cm-tooltip-autocomplete li[aria-selected]';
const onAutocompleteNavigate = (dir: 'up' | 'down') => (view: EditorView) => {
if (completionStatus(view.state) !== null) {
moveCompletionSelection(dir === 'down')(view);
document
.querySelector(SELECTED_AUTOCOMPLETE_OPTION_SELECTOR)
?.scrollIntoView({ block: 'nearest' });
return true;
}
return false;
};
// Keymap based on VSCode
export const editorKeymap: KeyBinding[] = [
{ key: 'Ctrl-Space', run: startCompletion },
{ key: 'Escape', run: closeCompletion },
{
key: 'Escape',
run: (view) => {
if (view.state.selection.ranges.length > 1) {
view.dispatch({ selection: EditorSelection.single(view.state.selection.main.head) });
return true;
}
return false;
},
},
{
key: 'ArrowDown',
run: onAutocompleteNavigate('down'),
},
{
key: 'ArrowUp',
run: onAutocompleteNavigate('up'),
},
{ key: 'PageDown', run: moveCompletionSelection(true, 'page') },
{ key: 'PageUp', run: moveCompletionSelection(false, 'page') },
{ key: 'Enter', run: acceptCompletion },
{ key: 'Tab', run: acceptCompletion },
{ key: 'Mod-f', run: openSearchPanel, scope: 'editor search-panel' },
{ key: 'Escape', run: closeSearchPanel, scope: 'editor search-panel' },
{ key: 'Alt-Enter', run: selectMatches, scope: 'editor search-panel' },
{ key: 'Mod-Alt-Enter', run: replaceAll, scope: 'editor search-panel' },
{ key: 'Ctrl-g', run: gotoLine },
{ key: 'Mod-d', run: selectNextOccurrence, preventDefault: true },
{ key: 'Shift-Mod-l', run: selectSelectionMatches },
{ key: 'Enter', run: insertNewlineAndIndent, shift: insertNewlineAndIndent },
{
key: 'ArrowLeft',
run: cursorCharLeft,
shift: selectCharLeft,
preventDefault: true,
},
{
key: 'Mod-ArrowLeft',
mac: 'Alt-ArrowLeft',
run: cursorGroupLeft,
shift: selectGroupLeft,
},
{
key: 'ArrowRight',
run: cursorCharRight,
shift: selectCharRight,
preventDefault: true,
},
{
key: 'Mod-ArrowRight',
mac: 'Alt-ArrowRight',
run: cursorGroupRight,
shift: selectGroupRight,
},
{
key: 'ArrowUp',
run: cursorLineUp,
shift: selectLineUp,
preventDefault: true,
},
{
key: 'ArrowDown',
run: cursorLineDown,
shift: selectLineDown,
preventDefault: true,
},
{
key: 'Home',
run: cursorLineBoundaryBackward,
shift: selectLineBoundaryBackward,
},
{
mac: 'Cmd-ArrowLeft',
run: cursorLineBoundaryBackward,
shift: selectLineBoundaryBackward,
},
{ key: 'Mod-Home', run: cursorDocStart, shift: selectDocStart },
{ mac: 'Cmd-ArrowUp', run: cursorDocStart, shift: selectDocStart },
{ key: 'PageUp', run: cursorPageUp, shift: selectPageUp },
{ mac: 'Ctrl-ArrowUp', run: cursorPageUp, shift: selectPageUp },
{ key: 'PageDown', run: cursorPageDown, shift: selectPageDown },
{ mac: 'Ctrl-ArrowDown', run: cursorPageDown, shift: selectPageDown },
{
key: 'End',
run: cursorLineBoundaryForward,
shift: selectLineBoundaryForward,
},
{
mac: 'Cmd-ArrowRight',
run: cursorLineBoundaryForward,
shift: selectLineBoundaryForward,
},
{
key: 'Mod-Alt-ArrowUp',
linux: 'Shift-Alt-ArrowUp',
run: addCursorUp,
preventDefault: true,
},
{
key: 'Mod-Alt-ArrowDown',
linux: 'Shift-Alt-ArrowDown',
run: addCursorDown,
preventDefault: true,
},
{
key: 'Shift-Alt-i',
run: addCursorAtEachSelectionLine,
},
{ key: 'Mod-End', run: cursorDocEnd, shift: selectDocEnd },
{ mac: 'Cmd-ArrowDown', run: cursorDocEnd, shift: selectDocEnd },
{ key: 'Mod-a', run: selectAll },
{ key: 'Backspace', run: deleteBracketPair },
{ key: 'Backspace', run: deleteCharBackward, shift: deleteCharBackward },
{ key: 'Delete', run: deleteCharForward },
{ key: 'Mod-Backspace', mac: 'Alt-Backspace', run: deleteGroupBackward },
{ key: 'Mod-Delete', mac: 'Alt-Delete', run: deleteGroupForward },
{ mac: 'Mod-Backspace', run: deleteToLineStart },
{ mac: 'Mod-Delete', run: deleteToLineEnd },
{
mac: 'Ctrl-b',
run: cursorCharLeft,
shift: selectCharLeft,
preventDefault: true,
},
{ mac: 'Ctrl-f', run: cursorCharRight, shift: selectCharRight },
{ mac: 'Ctrl-p', run: cursorLineUp, shift: selectLineUp },
{ mac: 'Ctrl-n', run: cursorLineDown, shift: selectLineDown },
{ mac: 'Ctrl-a', run: cursorLineStart, shift: selectLineStart },
{ mac: 'Ctrl-e', run: cursorLineEnd, shift: selectLineEnd },
{ mac: 'Ctrl-d', run: deleteCharForward },
{ mac: 'Ctrl-h', run: deleteCharBackward },
{ mac: 'Ctrl-k', run: deleteToLineEnd },
{ mac: 'Ctrl-Alt-h', run: deleteGroupBackward },
{ mac: 'Ctrl-o', run: splitLine },
{ mac: 'Ctrl-t', run: transposeChars },
{ mac: 'Ctrl-v', run: cursorPageDown },
{ mac: 'Alt-v', run: cursorPageUp },
{ key: 'Shift-Mod-k', run: deleteLine },
{ key: 'Alt-ArrowDown', run: moveLineDown },
{ key: 'Alt-ArrowUp', run: moveLineUp },
{ win: 'Shift-Alt-ArrowDown', mac: 'Shift-Alt-ArrowDown', run: copyLineDown },
{ win: 'Shift-Alt-ArrowUp', mac: 'Shift-Alt-ArrowUp', run: copyLineUp },
{ key: 'Mod-l', run: selectLine, preventDefault: true },
{ key: 'Shift-Mod-\\', run: cursorMatchingBracket },
{
any(view, event) {
if (
event.key === 'Tab' ||
(event.key === 'Escape' && completionStatus(view.state) !== null)
) {
event.stopPropagation();
}
return false;
},
},
{ key: 'Tab', run: indentMore, shift: indentLess, preventDefault: true },
{ key: 'Mod-[', run: indentLess },
{ key: 'Mod-]', run: indentMore },
{ key: 'Ctrl-Shift-[', mac: 'Cmd-Alt-[', run: foldCode },
{ key: 'Ctrl-Shift-]', mac: 'Cmd-Alt-]', run: unfoldCode },
{ key: 'Mod-k Mod-0', run: foldAll },
{ key: 'Mod-k Mod-j', run: unfoldAll },
{ key: 'Mod-k Mod-c', run: lineComment },
{ key: 'Mod-k Mod-u', run: lineUncomment },
{ key: 'Mod-/', run: toggleComment },
{ key: 'Shift-Alt-a', run: toggleBlockComment },
{ key: 'Mod-z', run: undo, preventDefault: true },
{ key: 'Mod-y', run: redo, preventDefault: true },
{ key: 'Mod-Shift-z', run: redo, preventDefault: true },
{ key: 'Mod-u', run: undoSelection, preventDefault: true },
{ key: 'Mod-Shift-m', run: openLintPanel },
{ key: 'F8', run: nextDiagnostic },
{ key: 'Shift-F8', run: previousDiagnostic },
{ key: 'Shift-Alt-f', linux: 'Ctrl-Shift-i', run: formatDocument },
];

View File

@@ -0,0 +1,52 @@
import { EditorSelection } from '@codemirror/state';
import type { Command } from '@codemirror/view';
const createAddCursor =
(direction: 'up' | 'down'): Command =>
(view) => {
const forward = direction === 'down';
let selection = view.state.selection;
for (const r of selection.ranges) {
selection = selection.addRange(view.moveVertically(r, forward));
}
view.dispatch({ selection });
return true;
};
export const addCursorUp = createAddCursor('up');
export const addCursorDown = createAddCursor('down');
export const addCursorAtEachSelectionLine: Command = (view) => {
let selection: EditorSelection | null = null;
for (const r of view.state.selection.ranges) {
if (r.empty) {
continue;
}
for (let pos = r.from; pos <= r.to; ) {
const line = view.state.doc.lineAt(pos);
const anchor = Math.min(line.to, r.to);
if (selection) {
selection = selection.addRange(EditorSelection.range(anchor, anchor));
} else {
selection = EditorSelection.single(anchor);
}
pos = line.to + 1;
}
}
if (!selection) {
return false;
}
view.dispatch({ selection });
return true;
};

View File

@@ -0,0 +1,32 @@
import { parserWithMetaData as n8nParser } from '@n8n/codemirror-lang';
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parseMixed, type SyntaxNodeRef } from '@lezer/common';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { n8nCompletionSources } from './completions/addCompletions';
import { autocompletion } from '@codemirror/autocomplete';
import { expressionCloseBracketsConfig } from './expressionCloseBrackets';
const isResolvable = (node: SyntaxNodeRef) => node.type.name === 'Resolvable';
const n8nParserWithNestedJsParser = n8nParser.configure({
wrap: parseMixed((node) => {
if (node.type.isTop) return null;
return node.name === 'Resolvable'
? { parser: javascriptLanguage.parser, overlay: isResolvable }
: null;
}),
});
const n8nLanguage = LRLanguage.define({ parser: n8nParserWithNestedJsParser });
export function n8nLang() {
return new LanguageSupport(n8nLanguage, [
n8nLanguage.data.of(expressionCloseBracketsConfig),
...n8nCompletionSources().map((source) => n8nLanguage.data.of(source)),
]);
}
export const n8nAutocompletion = () =>
autocompletion({ icons: false, aboveCursor: true, closeOnBlur: false });

View File

@@ -0,0 +1,143 @@
import type { DecorationSet } from '@codemirror/view';
import { EditorView, Decoration } from '@codemirror/view';
import { StateField, StateEffect } from '@codemirror/state';
import { tags } from '@lezer/highlight';
import { syntaxHighlighting, HighlightStyle } from '@codemirror/language';
import { captureException } from '@sentry/vue';
import type {
ColoringStateEffect,
Plaintext,
Resolvable,
ResolvableState,
} from '@/types/expressions';
const cssClasses = {
validResolvable: 'cm-valid-resolvable',
invalidResolvable: 'cm-invalid-resolvable',
pendingResolvable: 'cm-pending-resolvable',
plaintext: 'cm-plaintext',
};
const resolvablesTheme = EditorView.theme({
['.' + cssClasses.validResolvable]: {
color: 'var(--color-valid-resolvable-foreground)',
backgroundColor: 'var(--color-valid-resolvable-background)',
},
['.' + cssClasses.invalidResolvable]: {
color: 'var(--color-invalid-resolvable-foreground)',
backgroundColor: 'var(--color-invalid-resolvable-background)',
},
['.' + cssClasses.pendingResolvable]: {
color: 'var(--color-pending-resolvable-foreground)',
backgroundColor: 'var(--color-pending-resolvable-background)',
},
});
const resolvableStateToDecoration: Record<ResolvableState, Decoration> = {
valid: Decoration.mark({ class: cssClasses.validResolvable }),
invalid: Decoration.mark({ class: cssClasses.invalidResolvable }),
pending: Decoration.mark({ class: cssClasses.pendingResolvable }),
};
const coloringStateEffects = {
addColorEffect: StateEffect.define<ColoringStateEffect.Value>({
map: ({ from, to, kind, state }, change) => ({
from: change.mapPos(from),
to: change.mapPos(to),
kind,
state,
}),
}),
removeColorEffect: StateEffect.define<ColoringStateEffect.Value>({
map: ({ from, to }, change) => ({
from: change.mapPos(from),
to: change.mapPos(to),
}),
}),
};
const coloringStateField = StateField.define<DecorationSet>({
provide: (stateField) => EditorView.decorations.from(stateField),
create() {
return Decoration.none;
},
update(colorings, transaction) {
try {
colorings = colorings.map(transaction.changes); // recalculate positions for new doc
for (const txEffect of transaction.effects) {
if (txEffect.is(coloringStateEffects.removeColorEffect)) {
colorings = colorings.update({
filter: (from, to) => txEffect.value.from !== from && txEffect.value.to !== to,
});
}
if (txEffect.is(coloringStateEffects.addColorEffect)) {
colorings = colorings.update({
filter: (from, to) => txEffect.value.from !== from && txEffect.value.to !== to,
});
const decoration = resolvableStateToDecoration[txEffect.value.state ?? 'pending'];
if (txEffect.value.from === 0 && txEffect.value.to === 0) continue;
colorings = colorings.update({
add: [decoration.range(txEffect.value.from, txEffect.value.to)],
});
}
}
} catch (error) {
captureException(error);
}
return colorings;
},
});
function addColor(view: EditorView, segments: Resolvable[]) {
const effects: Array<StateEffect<unknown>> = segments.map(({ from, to, kind, state }) =>
coloringStateEffects.addColorEffect.of({ from, to, kind, state }),
);
if (effects.length === 0) return;
if (!view.state.field(coloringStateField, false)) {
effects.push(StateEffect.appendConfig.of([coloringStateField, resolvablesTheme]));
}
view.dispatch({ effects });
}
function removeColor(view: EditorView, segments: Plaintext[]) {
const effects: Array<StateEffect<unknown>> = segments.map(({ from, to }) =>
coloringStateEffects.removeColorEffect.of({ from, to }),
);
if (effects.length === 0) return;
if (!view.state.field(coloringStateField, false)) {
effects.push(StateEffect.appendConfig.of([coloringStateField, resolvablesTheme]));
}
view.dispatch({ effects });
}
const resolvableStyle = syntaxHighlighting(
HighlightStyle.define([
{
tag: tags.content,
class: cssClasses.plaintext,
},
/**
* CSS classes for valid and invalid resolvables
* dynamically applied based on state fields
*/
]),
);
export const highlighter = {
addColor,
removeColor,
resolvableStyle,
};

View File

@@ -0,0 +1,320 @@
import {
CompletionContext,
completionStatus,
type Completion,
type CompletionInfo,
type CompletionResult,
} from '@codemirror/autocomplete';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { syntaxTree } from '@codemirror/language';
import { StateEffect, StateField, type EditorState, type Extension } from '@codemirror/state';
import {
hoverTooltip,
keymap,
showTooltip,
type Command,
type EditorView,
type Tooltip,
} from '@codemirror/view';
import type { SyntaxNode } from '@lezer/common';
import type { createInfoBoxRenderer } from '../completions/infoBoxRenderer';
const findNearestParentOfType =
(type: string) =>
(node: SyntaxNode): SyntaxNode | null => {
if (node.name === type) {
return node;
}
if (node.parent) {
return findNearestParentOfType(type)(node.parent);
}
return null;
};
const findNearestArgList = findNearestParentOfType('ArgList');
const findNearestCallExpression = findNearestParentOfType('CallExpression');
function completionToTooltip(
completion: Completion | null,
pos: number,
options: { argIndex?: number; end?: number } = {},
): Tooltip | null {
if (!completion) return null;
return {
pos,
end: options.end,
above: true,
create: () => {
const element = document.createElement('div');
element.classList.add('cm-cursorInfo');
const info = completion.info;
if (typeof info === 'string') {
element.textContent = info;
} else if (isInfoBoxRenderer(info)) {
const infoResult = info(completion, options.argIndex ?? -1);
if (infoResult) {
element.appendChild(infoResult);
}
}
return { dom: element };
},
};
}
function findActiveArgIndex(node: SyntaxNode, index: number) {
let currentIndex = 1;
let argIndex = 0;
let child: SyntaxNode | null = null;
do {
child = node.childAfter(currentIndex);
if (child) {
currentIndex = child.to;
if (index >= child.from && index <= child.to) {
return argIndex;
}
if (child.name !== ',' && child.name !== '(') argIndex++;
}
} while (child);
return -1;
}
const createStateReader = (state: EditorState) => (node?: SyntaxNode | null) => {
return node ? state.sliceDoc(node.from, node.to) : '';
};
const createStringReader = (str: string) => (node?: SyntaxNode | null) => {
return node ? str.slice(node.from, node.to) : '';
};
function getJsNodeAtPosition(state: EditorState, pos: number, anchor?: number) {
// Syntax node in the n8n language (Resolvable | Plaintext)
const rootNode = syntaxTree(state).resolveInner(pos, -1);
if (rootNode.name !== 'Resolvable') {
return null;
}
const read = createStateReader(state);
const resolvable = read(rootNode);
const jsCode = resolvable.replace(/^{{\s*(.*)\s*}}$/, '$1');
const prefixLength = resolvable.indexOf(jsCode);
const jsOffset = rootNode.from + prefixLength;
const jsPos = pos - jsOffset;
const jsAnchor = anchor ? anchor - jsOffset : jsPos;
const getGlobalPosition = (jsPosition: number) => jsPosition + jsOffset;
const isSelectionWithinNode = (n: SyntaxNode) => {
return jsPos >= n.from && jsPos <= n.to && jsAnchor >= n.from && jsAnchor <= n.to;
};
// Cursor or selection is outside of JS code
if (jsPos >= jsCode.length || jsAnchor >= jsCode.length) {
return null;
}
// Syntax node in JavaScript
const jsNode = javascriptLanguage.parser
.parse(jsCode)
.resolveInner(jsPos, typeof anchor === 'number' ? 0 : -1);
return {
node: jsNode,
pos: jsPos,
readNode: createStringReader(jsCode),
isSelectionWithinNode,
getGlobalPosition,
};
}
function getCompletion(
state: EditorState,
pos: number,
filter: (completion: Completion) => boolean,
): Completion | null {
const context = new CompletionContext(state, pos, true);
const sources = state.languageDataAt<(context: CompletionContext) => CompletionResult>(
'autocomplete',
pos,
);
for (const source of sources) {
const result = source(context);
const options = result?.options.filter(filter);
if (options && options.length > 0) {
return options[0];
}
}
return null;
}
const isInfoBoxRenderer = (
info: string | ((completion: Completion) => CompletionInfo | Promise<CompletionInfo>) | undefined,
): info is ReturnType<typeof createInfoBoxRenderer> => {
return typeof info === 'function';
};
function getInfoBoxTooltip(state: EditorState): Tooltip | null {
const { head, anchor } = state.selection.ranges[0];
const jsNodeResult = getJsNodeAtPosition(state, head, anchor);
if (!jsNodeResult) {
return null;
}
const { node, pos, isSelectionWithinNode, getGlobalPosition, readNode } = jsNodeResult;
const argList = findNearestArgList(node);
if (!argList || !isSelectionWithinNode(argList)) {
return null;
}
const callExpression = findNearestCallExpression(argList);
if (!callExpression) {
return null;
}
const argIndex = findActiveArgIndex(argList, pos);
const subject = callExpression?.firstChild;
switch (subject?.name) {
case 'MemberExpression': {
const methodName = readNode(subject.lastChild);
const completion = getCompletion(
state,
getGlobalPosition(subject.to - 1),
(c) => c.label === methodName + '()',
);
return completionToTooltip(completion, head, { argIndex });
}
case 'VariableName': {
const methodName = readNode(subject);
const completion = getCompletion(
state,
getGlobalPosition(subject.to - 1),
(c) => c.label === methodName + '()',
);
return completionToTooltip(completion, head, { argIndex });
}
default:
return null;
}
}
const cursorInfoBoxTooltip = StateField.define<{ tooltip: Tooltip | null }>({
create(state) {
return { tooltip: getInfoBoxTooltip(state) };
},
update(value, tr) {
if (
tr.state.selection.ranges.length !== 1 ||
tr.state.selection.ranges[0].head === 0 ||
completionStatus(tr.state) === 'active'
) {
return { tooltip: null };
}
if (tr.effects.find((effect) => effect.is(closeInfoBoxEffect))) {
return { tooltip: null };
}
if (!tr.docChanged && !tr.selection) return { tooltip: value.tooltip };
return { ...value, tooltip: getInfoBoxTooltip(tr.state) };
},
provide: (f) => showTooltip.compute([f], (state) => state.field(f).tooltip),
});
export const hoverTooltipSource = (view: EditorView, pos: number) => {
const state = view.state.field(cursorInfoBoxTooltip, false);
const cursorTooltipOpen = !!state?.tooltip;
// Don't show hover tooltips when autocomplete is active
if (completionStatus(view.state) === 'active') return null;
const jsNodeResult = getJsNodeAtPosition(view.state, pos);
if (!jsNodeResult) {
return null;
}
const { node, getGlobalPosition, readNode } = jsNodeResult;
const tooltipForNode = (subject: SyntaxNode) => {
const completion = getCompletion(
view.state,
getGlobalPosition(subject.to - 1),
(c) => c.label === readNode(subject) || c.label === readNode(subject) + '()',
);
const newHoverTooltip = completionToTooltip(completion, getGlobalPosition(subject.from), {
end: getGlobalPosition(subject.to),
});
if (newHoverTooltip && cursorTooltipOpen) {
closeCursorInfoBox(view);
}
return newHoverTooltip;
};
switch (node.name) {
case 'VariableName':
case 'PropertyName': {
return tooltipForNode(node);
}
case 'String':
case 'Number':
case 'Boolean':
case 'CallExpression': {
const callExpression = findNearestCallExpression(node);
if (!callExpression) return null;
return tooltipForNode(callExpression);
}
default:
return null;
}
};
const hoverInfoBoxTooltip = hoverTooltip(hoverTooltipSource, {
hideOnChange: true,
hoverTime: 500,
});
const closeInfoBoxEffect = StateEffect.define<null>();
export const closeCursorInfoBox: Command = (view) => {
const state = view.state.field(cursorInfoBoxTooltip, false);
if (!state?.tooltip) return false;
view.dispatch({ effects: closeInfoBoxEffect.of(null) });
return true;
};
export const infoBoxTooltips = (): Extension[] => {
return [
cursorInfoBoxTooltip,
hoverInfoBoxTooltip,
keymap.of([
{
key: 'Escape',
run: closeCursorInfoBox,
},
]),
];
};

View File

@@ -0,0 +1,169 @@
import { EditorState } from '@codemirror/state';
import { EditorView, getTooltip, showTooltip, type Tooltip } from '@codemirror/view';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { n8nLang } from '@/plugins/codemirror/n8nLang';
import { hoverTooltipSource, infoBoxTooltips } from './InfoBoxTooltip';
import * as utils from '@/plugins/codemirror/completions/utils';
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
import { completionStatus } from '@codemirror/autocomplete';
vi.mock('@codemirror/autocomplete', async (importOriginal) => {
const actual = await importOriginal<{}>();
return {
...actual,
completionStatus: vi.fn(() => null),
};
});
describe('Infobox tooltips', () => {
beforeEach(() => {
setActivePinia(createTestingPinia());
vi.spyOn(utils, 'hasActiveNode').mockReturnValue(true);
});
describe('Cursor tooltips', () => {
test('should NOT show a tooltip for: {{ $max(1,2) }} foo|', () => {
const tooltips = cursorTooltips('{{ $max(1,2) }} foo|');
expect(tooltips.length).toBe(0);
});
test('should NOT show a tooltip for: {{ $ma|x() }}', () => {
const tooltips = cursorTooltips('{{ $ma|x() }}');
expect(tooltips.length).toBe(0);
});
test('should show a tooltip for: {{ $max(|) }}', () => {
const tooltips = cursorTooltips('{{ $max(|) }}');
expect(tooltips.length).toBe(1);
expect(infoBoxHeader(tooltips[0].view)).toHaveTextContent('$max(...numbers)');
expect(highlightedArgIndex(tooltips[0].view)).toBe(0);
});
test('should show a tooltip for: {{ $max(1,2,3,|) }}', () => {
const tooltips = cursorTooltips('{{ $max(1, 2|) }}');
expect(tooltips.length).toBe(1);
expect(infoBoxHeader(tooltips[0].view)).toHaveTextContent('$max(...numbers)');
expect(highlightedArgIndex(tooltips[0].view)).toBe(0);
});
test('should NOT show a tooltip for: {{ $json.str|.includes("test") }}', () => {
const tooltips = cursorTooltips('{{ $json.str|.includes("test") }}');
expect(tooltips.length).toBe(0);
});
test('should show a tooltip for: {{ $json.str.includes(|) }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue('a string');
const tooltips = cursorTooltips('{{ $json.str.includes(|) }}');
expect(tooltips.length).toBe(1);
expect(infoBoxHeader(tooltips[0].view)).toHaveTextContent('includes(searchString, start?)');
expect(highlightedArgIndex(tooltips[0].view)).toBe(0);
});
test('should show a tooltip for: {{ $json.str.includes("tes|t") }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue('a string');
const tooltips = cursorTooltips('{{ $json.str.includes("tes|t") }}');
expect(tooltips.length).toBe(1);
expect(infoBoxHeader(tooltips[0].view)).toHaveTextContent('includes(searchString, start?)');
expect(highlightedArgIndex(tooltips[0].view)).toBe(0);
});
test('should show a tooltip for: {{ $json.str.includes("test",|) }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue('a string');
const tooltips = cursorTooltips('{{ $json.str.includes("test",|) }}');
expect(tooltips.length).toBe(1);
expect(infoBoxHeader(tooltips[0].view)).toHaveTextContent('includes(searchString, start?)');
expect(highlightedArgIndex(tooltips[0].view)).toBe(1);
});
});
describe('Hover tooltips', () => {
test('should NOT show a tooltip for: {{ $max(1,2) }} foo|', () => {
const tooltip = hoverTooltip('{{ $max(1,2) }} foo|');
expect(tooltip).toBeNull();
});
test('should show a tooltip for: {{ $jso|n }}', () => {
const tooltip = hoverTooltip('{{ $jso|n }}');
expect(tooltip).not.toBeNull();
expect(infoBoxHeader(tooltip?.view)).toHaveTextContent('$json');
});
test('should show a tooltip for: {{ $execution.mo|de }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue({ mode: 'foo' });
const tooltip = hoverTooltip('{{ $execution.mo|de }}');
expect(tooltip).not.toBeNull();
expect(infoBoxHeader(tooltip?.view)).toHaveTextContent('mode');
});
test('should show a tooltip for: {{ $jmespa|th() }}', () => {
const tooltip = hoverTooltip('{{ $jmespa|th() }}');
expect(tooltip).not.toBeNull();
expect(infoBoxHeader(tooltip?.view)).toHaveTextContent('$jmespath(obj, expression)');
});
test('should show a tooltip for: {{ $json.str.includ|es() }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue('foo');
const tooltip = hoverTooltip('{{ $json.str.includ|es() }}');
expect(tooltip).not.toBeNull();
expect(infoBoxHeader(tooltip?.view)).toHaveTextContent('includes(searchString, start?)');
});
test('should not show a tooltip when autocomplete is open', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue('foo');
vi.mocked(completionStatus).mockReturnValue('active');
const tooltip = hoverTooltip('{{ $json.str.includ|es() }}');
expect(tooltip).toBeNull();
});
});
});
function highlightedArgIndex(infoBox: HTMLElement | undefined) {
return Array.from(infoBox?.querySelectorAll('.autocomplete-info-arg-name') ?? []).findIndex(
(arg) => arg.localName === 'strong',
);
}
function infoBoxHeader(infoBox: HTMLElement | undefined) {
return infoBox?.querySelector('.autocomplete-info-header');
}
function cursorTooltips(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(), infoBoxTooltips()],
});
const view = new EditorView({ parent: document.createElement('div'), state });
return state
.facet(showTooltip)
.filter((t): t is Tooltip => !!t)
.map((tooltip) => ({ tooltip, view: getTooltip(view, tooltip)?.dom }));
}
function hoverTooltip(docWithCursor: string) {
const hoverPosition = docWithCursor.indexOf('|');
const doc = docWithCursor.slice(0, hoverPosition) + docWithCursor.slice(hoverPosition + 1);
const state = EditorState.create({
doc,
extensions: [n8nLang(), infoBoxTooltips()],
});
const view = new EditorView({ state, parent: document.createElement('div') });
const tooltip = hoverTooltipSource(view, hoverPosition);
if (!tooltip) {
return null;
}
return { tooltip, view: tooltip.create(view).dom };
}

View File

@@ -0,0 +1,94 @@
import { escapeMappingString } from '@/utils/mappingUtils';
import {
insertCompletionText,
pickedCompletion,
type Completion,
type CompletionSource,
} from '@codemirror/autocomplete';
import {
autocompletableNodeNames,
longestCommonPrefix,
prefixMatch,
} from '../../completions/utils';
import { typescriptWorkerFacet } from './facet';
import { blockCommentSnippet, snippets } from './snippets';
const START_CHARACTERS = ['"', "'", '(', '.', '@'];
const START_CHARACTERS_REGEX = /[\.\(\'\"\@]/;
export const typescriptCompletionSource: CompletionSource = async (context) => {
const { worker } = context.state.facet(typescriptWorkerFacet);
let word = context.matchBefore(START_CHARACTERS_REGEX);
if (!word?.text) {
word = context.matchBefore(/[\"\'].*/);
}
if (!word?.text) {
word = context.matchBefore(/[\$\w]+/);
}
const blockComment = context.matchBefore(/\/\*?\*?/);
if (blockComment) {
// Autocomplete a block comment snippet
return { from: blockComment?.from, options: [blockCommentSnippet] };
}
if (!word) return null;
const completionResult = await worker.getCompletionsAtPos(context.pos);
if (!completionResult || context.aborted) return null;
const { result, isGlobal } = completionResult;
let options = [...result.options];
if (isGlobal) {
options = options
.flatMap((opt) => {
if (opt.label === '$') {
return [
opt,
...autocompletableNodeNames().map((name) => ({
...opt,
label: `$('${escapeMappingString(name)}')`,
})),
];
}
return opt;
})
.concat(snippets);
}
return {
from: word ? (START_CHARACTERS.includes(word.text) ? word.to : word.from) : context.pos,
filter: false,
getMatch(completion: Completion) {
const lcp = longestCommonPrefix(completion.label, word.text);
return [0, lcp.length];
},
options: options
.filter(
(option) =>
word.text === '' ||
START_CHARACTERS.includes(word.text) ||
prefixMatch(
option.label.replace(START_CHARACTERS_REGEX, ''),
word.text.replace(START_CHARACTERS_REGEX, ''),
),
)
.map((completion) => {
if (completion.label.endsWith('()')) {
completion.apply = (view, _, from, to) => {
const cursorPosition = from + completion.label.length - 1;
view.dispatch({
...insertCompletionText(view.state, completion.label, from, to),
annotations: pickedCompletion.of(completion),
selection: { anchor: cursorPosition, head: cursorPosition },
});
};
}
return completion;
}),
};
};

View File

@@ -0,0 +1,12 @@
import { Facet, combineConfig } from '@codemirror/state';
import type { LanguageServiceWorker } from '../types';
import type * as Comlink from 'comlink';
export const typescriptWorkerFacet = Facet.define<
{ worker: Comlink.Remote<LanguageServiceWorker> },
{ worker: Comlink.Remote<LanguageServiceWorker> }
>({
combine(configs) {
return combineConfig(configs, {});
},
});

View File

@@ -0,0 +1,54 @@
import type { hoverTooltip } from '@codemirror/view';
import { typescriptWorkerFacet } from './facet';
type HoverSource = Parameters<typeof hoverTooltip>[0];
export const typescriptHoverTooltips: HoverSource = async (view, pos) => {
const { worker } = view.state.facet(typescriptWorkerFacet);
const info = await worker.getHoverTooltip(pos);
if (!info) return null;
return {
pos: info.start,
end: info.end,
above: true,
create: () => {
const div = document.createElement('div');
div.classList.add('cm-tooltip-lint');
const wrapper = document.createElement('div');
wrapper.classList.add('cm-diagnostic');
div.appendChild(wrapper);
const text = document.createElement('div');
text.classList.add('cm-diagnosticText');
wrapper.appendChild(text);
if (info.quickInfo?.displayParts) {
for (const part of info.quickInfo.displayParts) {
const span = text.appendChild(document.createElement('span'));
if (
part.kind === 'keyword' &&
['string', 'number', 'boolean', 'object'].includes(part.text)
) {
span.className = 'ts-primitive';
} else if (part.kind === 'punctuation' && ['(', ')'].includes(part.text)) {
span.className = 'ts-text';
} else {
span.className = `ts-${part.kind}`;
}
span.innerText = part.text;
}
}
const documentation = info.quickInfo?.documentation?.find((doc) => doc.kind === 'text')?.text;
if (documentation) {
const docElement = document.createElement('div');
docElement.classList.add('cm-diagnosticDocs');
docElement.textContent = documentation;
wrapper.appendChild(docElement);
}
return { dom: div };
},
};
};

View File

@@ -0,0 +1,11 @@
import type { LintSource } from '@codemirror/lint';
import { typescriptWorkerFacet } from './facet';
export const typescriptLintSource: LintSource = async (view) => {
const { worker } = view.state.facet(typescriptWorkerFacet);
const docLength = view.state.doc.length;
return (await worker.getDiagnostics()).filter((diag) => {
return diag.from < docLength && diag.to <= docLength && diag.from >= 0;
});
};

View File

@@ -0,0 +1,60 @@
import { snippetCompletion } from '@codemirror/autocomplete';
export const blockCommentSnippet = snippetCompletion('/**\n * #{}\n */', {
label: '/**',
detail: 'Block Comment',
});
export const snippets = [
snippetCompletion('console.log(#{})', { label: 'log', detail: 'Log to console' }),
snippetCompletion('for (const #{1:element} of #{2:array}) {\n\t#{}\n}', {
label: 'forof',
detail: 'For-of Loop',
}),
snippetCompletion(
'for (const #{1:key} in #{2:object}) {\n\tif (Object.prototype.hasOwnProperty.call(#{2:object}, #{1:key})) {\n\t\tconst #{3:element} = #{2:object}[#{1:key}];\n\t\t#{}\n\t}\n}',
{
label: 'forin',
detail: 'For-in Loop',
},
),
snippetCompletion(
'for (let #{1:index} = 0; #{1:index} < #{2:array}.length; #{1:index}++) {\n\tconst #{3:element} = #{2:array}[#{1:index}];\n\t#{}\n}',
{
label: 'for',
detail: 'For Loop',
},
),
snippetCompletion('if (#{1:condition}) {\n\t#{}\n}', {
label: 'if',
detail: 'If Statement',
}),
snippetCompletion('if (#{1:condition}) {\n\t#{}\n} else {\n\t\n}', {
label: 'ifelse',
detail: 'If-Else Statement',
}),
snippetCompletion('function #{1:name}(#{2:params}) {\n\t#{}\n}', {
label: 'function',
detail: 'Function Statement',
}),
snippetCompletion('function #{1:name}(#{2:params}) {\n\t#{}\n}', {
label: 'fn',
detail: 'Function Statement',
}),
snippetCompletion(
'switch (#{1:key}) {\n\tcase #{2:value}:\n\t\t#{}\n\t\tbreak;\n\tdefault:\n\t\tbreak;\n}',
{
label: 'switch',
detail: 'Switch Statement',
},
),
snippetCompletion('try {\n\t#{}\n} catch (#{1:error}) {\n\t\n}', {
label: 'trycatch',
detail: 'Try-Catch Statement',
}),
snippetCompletion('while (#{1:condition}) {\n\t#{}\n}', {
label: 'while',
detail: 'While Statement',
}),
blockCommentSnippet,
];

View File

@@ -0,0 +1,131 @@
import { useDataSchema } from '@/composables/useDataSchema';
import { useDebounce } from '@/composables/useDebounce';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { autocompletableNodeNames } from '@/plugins/codemirror/completions/utils';
import useEnvironmentsStore from '@/stores/environments.ee.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { forceParse } from '@/utils/forceParse';
import { executionDataToJson } from '@/utils/nodeTypesUtils';
import { autocompletion } from '@codemirror/autocomplete';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { LanguageSupport } from '@codemirror/language';
import { Text, type Extension } from '@codemirror/state';
import { EditorView, hoverTooltip } from '@codemirror/view';
import * as Comlink from 'comlink';
import { NodeConnectionType, type CodeExecutionMode, type INodeExecutionData } from 'n8n-workflow';
import { ref, toRef, toValue, watch, type MaybeRefOrGetter } from 'vue';
import type { LanguageServiceWorker, RemoteLanguageServiceWorkerInit } from '../types';
import { typescriptCompletionSource } from './completions';
import { typescriptWorkerFacet } from './facet';
import { typescriptHoverTooltips } from './hoverTooltip';
import { linter } from '@codemirror/lint';
import { typescriptLintSource } from './linter';
export function useTypescript(
view: MaybeRefOrGetter<EditorView | undefined>,
mode: MaybeRefOrGetter<CodeExecutionMode>,
id: MaybeRefOrGetter<string>,
) {
const { getInputDataWithPinned, getSchemaForExecutionData } = useDataSchema();
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const { debounce } = useDebounce();
const activeNodeName = ndvStore.activeNodeName;
const worker = ref<Comlink.Remote<LanguageServiceWorker>>();
async function createWorker(): Promise<Extension> {
const { init } = Comlink.wrap<RemoteLanguageServiceWorkerInit>(
new Worker(new URL('../worker/typescript.worker.ts', import.meta.url), { type: 'module' }),
);
worker.value = await init(
{
id: toValue(id),
content: Comlink.proxy((toValue(view)?.state.doc ?? Text.empty).toJSON()),
allNodeNames: autocompletableNodeNames(),
variables: useEnvironmentsStore().variables.map((v) => v.key),
inputNodeNames: activeNodeName
? workflowsStore
.getCurrentWorkflow()
.getParentNodes(activeNodeName, NodeConnectionType.Main, 1)
: [],
mode: toValue(mode),
},
Comlink.proxy(async (nodeName) => {
const node = workflowsStore.getNodeByName(nodeName);
if (node) {
const inputData: INodeExecutionData[] = getInputDataWithPinned(node);
const schema = getSchemaForExecutionData(executionDataToJson(inputData), true);
const execution = workflowsStore.getWorkflowExecution;
const binaryData = useNodeHelpers()
.getBinaryData(
execution?.data?.resultData?.runData ?? null,
node.name,
ndvStore.ndvInputRunIndex ?? 0,
0,
)
.filter((data) => Boolean(data && Object.keys(data).length));
return {
json: schema,
binary: Object.keys(binaryData.reduce((acc, obj) => ({ ...acc, ...obj }), {})),
params: getSchemaForExecutionData([node.parameters]),
};
}
return undefined;
}),
);
const editor = toValue(view);
if (editor) {
forceParse(editor);
}
return [
typescriptWorkerFacet.of({ worker: worker.value }),
new LanguageSupport(javascriptLanguage, [
javascriptLanguage.data.of({ autocomplete: typescriptCompletionSource }),
]),
autocompletion({ icons: false, aboveCursor: true }),
linter(typescriptLintSource),
hoverTooltip(typescriptHoverTooltips, {
hideOnChange: true,
hoverTime: 500,
}),
EditorView.updateListener.of(async (update) => {
if (update.docChanged) {
void worker.value?.updateFile(update.changes.toJSON());
}
}),
];
}
async function onWorkflowDataChange() {
const editor = toValue(view);
if (!editor || !worker.value) return;
await worker.value.updateNodeTypes();
forceParse(editor);
}
watch(
[() => workflowsStore.getWorkflowExecution, () => workflowsStore.getWorkflowRunData],
debounce(onWorkflowDataChange, { debounceTime: 200, trailing: true }),
);
watch(toRef(mode), async (newMode) => {
const editor = toValue(view);
if (!editor || !worker.value) return;
await worker.value.updateMode(newMode);
forceParse(editor);
});
return {
createWorker,
};
}

View File

@@ -0,0 +1,49 @@
import type { Schema } from '@/Interface';
import type { CompletionResult } from '@codemirror/autocomplete';
import type { Diagnostic } from '@codemirror/lint';
import type { CodeExecutionMode } from 'n8n-workflow';
import type ts from 'typescript';
import type * as Comlink from 'comlink';
import type { ChangeSet } from '@codemirror/state';
export interface HoverInfo {
start: number;
end: number;
typeDef?: readonly ts.DefinitionInfo[];
quickInfo: ts.QuickInfo | undefined;
}
export type WorkerInitOptions = {
id: string;
content: string[];
allNodeNames: string[];
inputNodeNames: string[];
variables: string[];
mode: CodeExecutionMode;
};
export type NodeData = { json: Schema | undefined; binary: string[]; params: Schema };
export type NodeDataFetcher = (nodeName: string) => Promise<NodeData | undefined>;
export type LanguageServiceWorker = {
updateFile(changes: ChangeSet): void;
updateMode(mode: CodeExecutionMode): void;
updateNodeTypes(): void;
getCompletionsAtPos(pos: number): Promise<{ result: CompletionResult; isGlobal: boolean } | null>;
getDiagnostics(): Diagnostic[];
getHoverTooltip(pos: number): HoverInfo | null;
};
export type LanguageServiceWorkerInit = {
init(
options: WorkerInitOptions,
nodeDataFetcher: NodeDataFetcher,
): Promise<LanguageServiceWorker>;
};
export type RemoteLanguageServiceWorkerInit = {
init(
options: WorkerInitOptions,
nodeDataFetcher: NodeDataFetcher,
): Comlink.Remote<LanguageServiceWorker>;
};

View File

@@ -0,0 +1,130 @@
export type IndexedDbCache = Awaited<ReturnType<typeof indexedDbCache>>;
export async function indexedDbCache(dbName: string, storeName: string) {
let cache: Record<string, string> = {};
void (await loadCache());
async function loadCache() {
await transaction('readonly', async (store, db) => {
return await new Promise<void>((resolve, reject) => {
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
if (cursor) {
cache[cursor.key as string] = cursor.value.value;
cursor.continue();
} else {
db.close();
resolve();
}
};
request.onerror = (event) => {
db.close();
reject(event);
};
});
});
}
async function openDb(): Promise<IDBDatabase> {
return await new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore(storeName, { keyPath: 'key' });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function setItem(key: string, value: string): void {
cache[key] = value;
void persistToIndexedDB(key, value);
}
function getItem(key: string): string | null {
return cache[key] ?? null;
}
function removeItem(key: string): void {
delete cache[key];
void deleteFromIndexedDB(key);
}
function clear(): void {
cache = {};
void clearIndexedDB();
}
async function getAllWithPrefix(prefix: string) {
const keyRange = IDBKeyRange.bound(prefix, prefix + '\uffff', false, false);
const results: Record<string, string> = {};
return await transaction('readonly', async (store) => {
return await new Promise<Record<string, string>>((resolve, reject) => {
const request = store.openCursor(keyRange);
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
if (cursor) {
results[cursor.key as string] = cursor.value.value;
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => {
reject(request.error);
};
});
});
}
async function transaction<T>(
mode: 'readonly' | 'readwrite',
action: (store: IDBObjectStore, db: IDBDatabase) => Promise<T> | T,
): Promise<T> {
const db = await openDb();
const tx = db.transaction(storeName, mode);
const store = tx.objectStore(storeName);
const result = await action(store, db);
return await new Promise<T>((resolve, reject) => {
tx.oncomplete = () => {
db.close();
resolve(result);
};
tx.onerror = () => {
db.close();
reject(tx.error);
};
});
}
async function persistToIndexedDB(key: string, value: string) {
await transaction('readwrite', (store) => {
store.put({ key, value });
});
}
async function deleteFromIndexedDB(key: string) {
await transaction('readwrite', (store) => {
store.delete(key);
});
}
async function clearIndexedDB() {
await transaction('readwrite', (store) => {
store.clear();
});
}
return { getItem, removeItem, setItem, clear, getAllWithPrefix };
}

View File

@@ -0,0 +1,60 @@
import type * as tsvfs from '@typescript/vfs';
import type ts from 'typescript';
import { type Completion } from '@codemirror/autocomplete';
import { TS_COMPLETE_BLOCKLIST, TYPESCRIPT_AUTOCOMPLETE_THRESHOLD } from './constants';
function convertTsKindtoEditorCompletionType(kind: ts.ScriptElementKind) {
if (!kind) return undefined;
const type = String(kind);
if (type === 'member') return 'property';
return type;
}
function typescriptCompletionToEditor(
completionInfo: ts.WithMetadata<ts.CompletionInfo>,
entry: ts.CompletionEntry,
): Completion {
const boost = -Number(entry.sortText) || 0;
const type = convertTsKindtoEditorCompletionType(entry.kind);
return {
label: type && ['method', 'function'].includes(type) ? entry.name + '()' : entry.name,
type: convertTsKindtoEditorCompletionType(entry.kind),
commitCharacters: entry.commitCharacters ?? completionInfo.defaultCommitCharacters,
detail: entry.labelDetails?.detail,
boost,
};
}
function filterTypescriptCompletions(
completionInfo: ts.WithMetadata<ts.CompletionInfo>,
entry: ts.CompletionEntry,
) {
return (
!TS_COMPLETE_BLOCKLIST.includes(entry.kind) &&
(entry.sortText < TYPESCRIPT_AUTOCOMPLETE_THRESHOLD ||
completionInfo.optionalReplacementSpan?.length)
);
}
export async function getCompletionsAtPos({
pos,
fileName,
env,
}: { pos: number; fileName: string; env: tsvfs.VirtualTypeScriptEnvironment }) {
const completionInfo = env.languageService.getCompletionsAtPosition(fileName, pos, {}, {});
if (!completionInfo) return null;
const options = completionInfo.entries
.filter((entry) => filterTypescriptCompletions(completionInfo, entry))
.map((entry) => typescriptCompletionToEditor(completionInfo, entry));
return {
result: { from: pos, options },
isGlobal: completionInfo.isGlobalCompletion,
};
}

View File

@@ -0,0 +1,26 @@
import ts from 'typescript';
export const TS_COMPLETE_BLOCKLIST: ts.ScriptElementKind[] = [ts.ScriptElementKind.warning];
export const COMPILER_OPTIONS: ts.CompilerOptions = {
allowJs: true,
checkJs: true,
target: ts.ScriptTarget.ESNext,
lib: ['es2023'],
module: ts.ModuleKind.ESNext,
strict: true,
noUnusedLocals: true,
noUnusedParameters: true,
importHelpers: false,
skipDefaultLibCheck: true,
noEmit: true,
};
export const TYPESCRIPT_AUTOCOMPLETE_THRESHOLD = '15';
export const TYPESCRIPT_FILES = {
DYNAMIC_TYPES: 'n8n-dynamic.d.ts',
DYNAMIC_INPUT_TYPES: 'n8n-dynamic-input.d.ts',
DYNAMIC_VARIABLES_TYPES: 'n8n-variables.d.ts',
MODE_TYPES: 'n8n-mode-specific.d.ts',
N8N_TYPES: 'n8n.d.ts',
GLOBAL_TYPES: 'globals.d.ts',
};
export const LUXON_VERSION = '3.2.0';

View File

@@ -0,0 +1,66 @@
import { schemaToTypescriptTypes } from './dynamicTypes';
describe('typescript worker dynamicTypes', () => {
describe('schemaToTypescriptTypes', () => {
it('should convert a schema to a typescript type', () => {
expect(
schemaToTypescriptTypes(
{
type: 'object',
value: [
{
key: 'test',
type: 'string',
value: '',
path: '.test',
},
{
type: 'object',
key: 'nested',
path: '.nested',
value: [
{
key: 'amount',
type: 'number',
value: '',
path: '.amount',
},
],
},
{
type: 'array',
key: 'nestedArray',
path: '.nestedArray',
value: [
{
type: 'object',
key: 'nested',
path: '.nestedArray.nested',
value: [
{
key: 'amount',
type: 'number',
value: '',
path: '.amount',
},
],
},
],
},
],
path: '',
},
'NodeName_1',
),
).toEqual(`interface NodeName_1 {
test: string;
nested: {
amount: number;
};
nestedArray: Array<{
amount: number;
}>;
}`);
});
});
});

View File

@@ -0,0 +1,91 @@
import type { Schema } from '@/Interface';
import { pascalCase } from 'change-case';
import { globalTypeDefinition } from './utils';
function processSchema(schema: Schema): string {
switch (schema.type) {
case 'string':
case 'number':
case 'boolean':
case 'bigint':
case 'symbol':
case 'null':
case 'undefined':
return schema.type;
case 'function':
return 'Function';
case 'array':
if (Array.isArray(schema.value)) {
if (schema.value.length > 0) {
return `Array<${processSchema(schema.value[0])}>`;
}
return 'any[]';
}
return `${schema.value}[]`;
case 'object':
if (!Array.isArray(schema.value)) {
return '{}';
}
const properties = schema.value
.map((prop) => {
const key = prop.key ?? 'unknown';
const type = processSchema(prop);
return ` ${key}: ${type};`;
})
.join('\n');
return `{\n${properties}\n}`;
default:
return 'any';
}
}
export function schemaToTypescriptTypes(schema: Schema, interfaceName: string): string {
return `interface ${interfaceName} ${processSchema(schema)}`;
}
export async function getDynamicNodeTypes({
nodeNames,
loadedNodes,
}: { nodeNames: string[]; loadedNodes: Map<string, { type: string; typeName: string }> }) {
return globalTypeDefinition(`
type NodeName = ${nodeNames.map((name) => `'${name}'`).join(' | ')};
${Array.from(loadedNodes.values())
.map(({ type }) => type)
.join(';\n')}
interface NodeDataMap {
${Array.from(loadedNodes.entries())
.map(
([nodeName, { typeName }]) =>
`'${nodeName}': NodeData<${typeName}Context, ${typeName}Json, ${typeName}BinaryKeys, ${typeName}Params>`,
)
.join(';\n')}
}
`);
}
export async function getDynamicInputNodeTypes(inputNodeNames: string[]) {
const typeNames = inputNodeNames.map((nodeName) => pascalCase(nodeName));
return globalTypeDefinition(`
type N8nInputJson = ${typeNames.map((typeName) => `${typeName}Json`).join(' | ')};
type N8nInputBinaryKeys = ${typeNames.map((typeName) => `${typeName}BinaryKeys`).join(' | ')};
type N8nInputContext = ${typeNames.map((typeName) => `${typeName}Context`).join(' | ')};
type N8nInputParams = ${typeNames.map((typeName) => `${typeName}Params`).join(' | ')};
`);
}
export async function getDynamicVariableTypes(variables: string[]) {
return globalTypeDefinition(`
interface N8nVars {
${variables.map((key) => `${key}: string;`).join('\n')}
}`);
}

View File

@@ -0,0 +1,60 @@
import * as tsvfs from '@typescript/vfs';
import { COMPILER_OPTIONS, TYPESCRIPT_FILES } from './constants';
import ts from 'typescript';
import type { IndexedDbCache } from './cache';
import globalTypes from './type-declarations/globals.d.ts?raw';
import n8nTypes from './type-declarations/n8n.d.ts?raw';
import type { CodeExecutionMode } from 'n8n-workflow';
import { wrapInFunction } from './utils';
type EnvOptions = {
code: {
content: string;
fileName: string;
};
mode: CodeExecutionMode;
cache: IndexedDbCache;
};
export function removeUnusedLibs(fsMap: Map<string, string>) {
for (const [name] of fsMap.entries()) {
if (
name === 'lib.d.ts' ||
name.startsWith('/lib.dom') ||
name.startsWith('/lib.webworker') ||
name.startsWith('/lib.scripthost') ||
name.endsWith('.full.d.ts')
) {
fsMap.delete(name);
}
}
}
export async function setupTypescriptEnv({ cache, code, mode }: EnvOptions) {
const fsMap = await tsvfs.createDefaultMapFromCDN(
COMPILER_OPTIONS,
ts.version,
true,
ts,
undefined,
undefined,
cache,
);
removeUnusedLibs(fsMap);
fsMap.set(TYPESCRIPT_FILES.N8N_TYPES, n8nTypes);
fsMap.set(TYPESCRIPT_FILES.GLOBAL_TYPES, globalTypes);
fsMap.set(code.fileName, wrapInFunction(code.content, mode));
const system = tsvfs.createSystem(fsMap);
return tsvfs.createVirtualTypeScriptEnvironment(
system,
Array.from(fsMap.keys()),
ts,
COMPILER_OPTIONS,
);
}

View File

@@ -0,0 +1,24 @@
import type * as tsvfs from '@typescript/vfs';
export function getHoverTooltip({
pos,
fileName,
env,
}: { pos: number; fileName: string; env: tsvfs.VirtualTypeScriptEnvironment }) {
const quickInfo = env.languageService.getQuickInfoAtPosition(fileName, pos);
if (!quickInfo) return null;
const start = quickInfo.textSpan.start;
const typeDef =
env.languageService.getTypeDefinitionAtPosition(fileName, pos) ??
env.languageService.getDefinitionAtPosition(fileName, pos);
return {
start,
end: start + quickInfo.textSpan.length,
typeDef,
quickInfo,
};
}

View File

@@ -0,0 +1,111 @@
import type { Diagnostic } from '@codemirror/lint';
import type * as tsvfs from '@typescript/vfs';
import ts from 'typescript';
import type { DiagnosticWithLocation } from 'typescript';
/**
* TypeScript has a set of diagnostic categories,
* which maps roughly onto CodeMirror's categories.
* Here, we do the mapping.
*/
export function tsCategoryToSeverity(
diagnostic: Pick<ts.DiagnosticWithLocation, 'category' | 'code'>,
): Diagnostic['severity'] {
switch (diagnostic.code) {
case 6133:
// No unused variables
return 'warning';
case 7027:
// Unreachable code detected
return 'warning';
default: {
switch (diagnostic.category) {
case ts.DiagnosticCategory.Error:
return 'error';
case ts.DiagnosticCategory.Message:
return 'info';
case ts.DiagnosticCategory.Warning:
return 'warning';
case ts.DiagnosticCategory.Suggestion:
return 'info';
}
}
}
}
/**
* Not all TypeScript diagnostic relate to specific
* ranges in source code: here we filter for those that
* do.
*/
function isDiagnosticWithLocation(
diagnostic: ts.Diagnostic,
): diagnostic is ts.DiagnosticWithLocation {
return !!(
diagnostic.file &&
typeof diagnostic.start === 'number' &&
typeof diagnostic.length === 'number'
);
}
function isIgnoredDiagnostic(diagnostic: ts.Diagnostic) {
// No implicit any
return diagnostic.code === 7006;
}
/**
* Get the message for a diagnostic. TypeScript
* is kind of weird: messageText might have the message,
* or a pointer to the message. This follows the chain
* to get a string, regardless of which case we're in.
*/
function tsDiagnosticMessage(diagnostic: Pick<ts.Diagnostic, 'messageText'>): string {
if (typeof diagnostic.messageText === 'string') {
return diagnostic.messageText;
}
// TODO: go through linked list
return diagnostic.messageText.messageText;
}
function tsDiagnosticClassName(diagnostic: ts.Diagnostic) {
switch (diagnostic.code) {
case 6133:
// No unused variables
return 'cm-faded';
default:
return undefined;
}
}
function convertTSDiagnosticToCM(d: ts.DiagnosticWithLocation): Diagnostic {
const start = d.start;
const message = tsDiagnosticMessage(d);
return {
from: start,
to: start + d.length,
message,
markClass: tsDiagnosticClassName(d),
severity: tsCategoryToSeverity(d),
};
}
export function getDiagnostics({
env,
fileName,
}: { env: tsvfs.VirtualTypeScriptEnvironment; fileName: string }) {
const exists = env.getSourceFile(fileName);
if (!exists) return [];
const tsDiagnostics = [
...env.languageService.getSemanticDiagnostics(fileName),
...env.languageService.getSyntacticDiagnostics(fileName),
];
const diagnostics = tsDiagnostics.filter(
(diagnostic): diagnostic is DiagnosticWithLocation =>
isDiagnosticWithLocation(diagnostic) && !isIgnoredDiagnostic(diagnostic),
);
return diagnostics.map((d) => convertTSDiagnosticToCM(d));
}

View File

@@ -0,0 +1,60 @@
type NPMTreeMeta = {
default: string;
files: Array<{ name: string }>;
moduleName: string;
version: string;
};
const jsDelivrApi = {
async getFileTree(packageName: string, version = 'latest'): Promise<NPMTreeMeta> {
const url = `https://data.jsdelivr.com/v1/package/npm/${packageName}@${version}/flat`;
const res = await fetch(url);
return await res.json();
},
async getFileContent(packageName: string, fileName: string, version = 'latest'): Promise<string> {
const url = `https://cdn.jsdelivr.net/npm/${packageName}@${version}${fileName}`;
const res = await fetch(url);
return await res.text();
},
};
function isRequiredTypePackageFile(fileName: string) {
return fileName.endsWith('.d.ts') || fileName === '/package.json';
}
function toLocalFilePath(packageName: string, fileName: string) {
return `/node_modules/@types/${packageName}${fileName}`;
}
export const loadTypes = async (
packageName: string,
version: string,
onFileReceived: (path: string, content: string) => void,
): Promise<void> => {
const { files } = await loadTypesFileTree(packageName, version);
await Promise.all(
files
.filter((file) => isRequiredTypePackageFile(file.name))
.map(
async (file) =>
await loadFileContent(packageName, file.name, version).then((content) =>
onFileReceived(toLocalFilePath(packageName, file.name), content),
),
),
);
};
export const loadTypesFileTree = async (
packageName: string,
version: string,
): Promise<NPMTreeMeta> => {
return await jsDelivrApi.getFileTree(`@types/${packageName}`, version);
};
export const loadFileContent = async (
packageName: string,
fileName: string,
version = 'latest',
) => {
return await jsDelivrApi.getFileContent(`@types/${packageName}`, fileName, version);
};

View File

@@ -0,0 +1,16 @@
export {};
import luxon from 'luxon';
declare global {
const DateTime: typeof luxon.DateTime;
type DateTime = luxon.DateTime;
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console) */
interface Console {
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static) */
log(...data: any[]): void;
}
var console: Console;
}

View File

@@ -0,0 +1,15 @@
export {};
declare global {
interface NodeData<C = any, J extends N8nJson = any, B extends string = string, P = any> {
context: C;
params: P;
all(branchIndex?: number, runIndex?: number): Array<N8nItem<J, B>>;
first(branchIndex?: number, runIndex?: number): N8nItem<J, B>;
last(branchIndex?: number, runIndex?: number): N8nItem<J, B>;
itemMatching(itemIndex: number): N8nItem<J, B>;
}
// @ts-expect-error N8nInputJson is populated dynamically
type N8nInput = NodeData<N8nInputContext, N8nInputJson, N8nInputBinaryKeys, N8nInputParams>;
}

View File

@@ -0,0 +1,16 @@
export {};
declare global {
interface NodeData<C, J extends N8nJson, B extends string, P> {
context: C;
item: N8nItem<J, B>;
params: P;
}
// @ts-expect-error N8nInputJson is populated dynamically
type N8nInput = NodeData<{}, N8nInputJson, {}, {}>;
const $itemIndex: number;
const $json: N8nInput['item']['json'];
const $binary: N8nInput['item']['binary'];
}

View File

@@ -0,0 +1,103 @@
import type { DateTime } from 'luxon';
export {};
declare global {
type OutputItemWithoutJsonKey = {
[key: string]: unknown;
} & { json?: never };
type OutputItemWithJsonKey = {
json: {
[key: string]: unknown;
};
};
type MaybePromise<T> = Promise<T> | T;
type OneOutputItem = OutputItemWithJsonKey | OutputItemWithoutJsonKey;
type AllOutputItems = OneOutputItem | Array<OneOutputItem>;
type N8nOutputItem = MaybePromise<OneOutputItem>;
type N8nOutputItems = MaybePromise<AllOutputItems>;
interface N8nJson {
[key: string]: any;
}
interface N8nBinary {
id: string;
fileName: string;
fileExtension: string;
fileType: string;
fileSize: string;
mimeType: string;
}
interface N8nVars {}
// TODO: populate dynamically
interface N8nParameter {}
interface N8nItem<J extends N8nJson = N8nJson, B extends string = string> {
json: J & N8nJson;
binary: Record<B, N8nBinary>;
}
interface N8nCustomData {
set(key: string, value: string): void;
get(key: string): string;
getAll(): Record<string, string>;
setAll(values: Record<string, string>): void;
}
type N8nExecutionMode = 'test' | 'production';
interface N8nExecution {
id: string;
mode: N8nExecutionMode;
resumeUrl?: string;
resumeFormUrl?: string;
customData: N8nCustomData;
}
interface N8nWorkflow {
id: string;
active: boolean;
name: string;
}
interface N8nPrevNode {
name: string;
outputIndex: number;
runIndex: number;
}
const $input: N8nInput;
const $execution: N8nExecution;
const $workflow: N8nWorkflow;
const $prevNode: N8nPrevNode;
const $runIndex: number;
const $now: DateTime;
const $today: DateTime;
const $parameter: N8nInput['params'];
const $vars: N8nVars;
const $nodeVersion: number;
function $jmespath(object: Object | Array<any>, expression: string): any;
function $if<B extends boolean, T, F>(
condition: B,
valueIfTrue: T,
valueIfFalse: F,
): B extends true ? T : T extends false ? F : T | F;
function $ifEmpty<V, E>(value: V, valueIfEmpty: E): V | E;
function $min(...numbers: number[]): number;
function $max(...numbers: number[]): number;
type SomeOtherString = string & NonNullable<unknown>;
// @ts-expect-error NodeName is created dynamically
function $<K extends NodeName>(
nodeName: K | SomeOtherString,
// @ts-expect-error NodeDataMap is created dynamically
): K extends keyof NodeDataMap ? NodeDataMap[K] : NodeData;
}

View File

@@ -0,0 +1,224 @@
import * as Comlink from 'comlink';
import type { LanguageServiceWorker, LanguageServiceWorkerInit } from '../types';
import { indexedDbCache } from './cache';
import { bufferChangeSets, fnPrefix } from './utils';
import type { CodeExecutionMode } from 'n8n-workflow';
import { pascalCase } from 'change-case';
import { computed, reactive, ref, watch } from 'vue';
import { getCompletionsAtPos } from './completions';
import { LUXON_VERSION, TYPESCRIPT_FILES } from './constants';
import {
getDynamicInputNodeTypes,
getDynamicNodeTypes,
getDynamicVariableTypes,
schemaToTypescriptTypes,
} from './dynamicTypes';
import { setupTypescriptEnv } from './env';
import { getHoverTooltip } from './hoverTooltip';
import { getDiagnostics } from './linter';
import { getUsedNodeNames } from './typescriptAst';
import runOnceForAllItemsTypes from './type-declarations/n8n-once-for-all-items.d.ts?raw';
import runOnceForEachItemTypes from './type-declarations/n8n-once-for-each-item.d.ts?raw';
import { loadTypes } from './npmTypesLoader';
import { ChangeSet, Text } from '@codemirror/state';
import { until } from '@vueuse/core';
self.process = { env: {} } as NodeJS.Process;
const worker: LanguageServiceWorkerInit = {
async init(options, nodeDataFetcher) {
const loadedNodeTypesMap: Map<string, { type: string; typeName: string }> = reactive(new Map());
const inputNodeNames = options.inputNodeNames;
const allNodeNames = options.allNodeNames;
const codeFileName = `${options.id}.js`;
const mode = ref<CodeExecutionMode>(options.mode);
const busyApplyingChangesToCode = ref(false);
const cache = await indexedDbCache('typescript-cache', 'fs-map');
const env = await setupTypescriptEnv({
cache,
mode: mode.value,
code: { content: Text.of(options.content).toString(), fileName: codeFileName },
});
const prefix = computed(() => fnPrefix(mode.value));
function editorPositionToTypescript(pos: number) {
return pos + prefix.value.length;
}
function typescriptPositionToEditor(pos: number) {
return pos - prefix.value.length;
}
async function loadNodeTypes(nodeName: string) {
const data = await nodeDataFetcher(nodeName);
const typeName = pascalCase(nodeName);
const jsonType = data?.json
? schemaToTypescriptTypes(data.json, `${typeName}Json`)
: `type ${typeName}Json = N8nJson`;
const paramsType = data?.params
? schemaToTypescriptTypes(data.params, `${typeName}Params`)
: `type ${typeName}Params = {}`;
// Using || on purpose to handle empty string
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const binaryType = `type ${typeName}BinaryKeys = ${data?.binary.map((key) => `'${key}'`).join(' | ') || 'string'}`;
const contextType = `type ${typeName}Context = {}`;
const type = [jsonType, binaryType, paramsType, contextType].join('\n');
loadedNodeTypesMap.set(nodeName, { type, typeName });
}
async function loadTypesIfNeeded() {
const file = env.getSourceFile(codeFileName);
if (!file) return;
const nodeNames = await getUsedNodeNames(file);
for (const nodeName of nodeNames) {
if (!loadedNodeTypesMap.has(nodeName)) {
await loadNodeTypes(nodeName);
}
}
}
async function loadLuxonTypes() {
if (cache.getItem('/node_modules/@types/luxon/package.json')) {
const fileMap = await cache.getAllWithPrefix('/node_modules/@types/luxon');
for (const [path, content] of Object.entries(fileMap)) {
updateFile(path, content);
}
} else {
await loadTypes('luxon', LUXON_VERSION, (path, types) => {
cache.setItem(path, types);
updateFile(path, types);
});
}
}
async function setVariableTypes() {
updateFile(
TYPESCRIPT_FILES.DYNAMIC_VARIABLES_TYPES,
await getDynamicVariableTypes(options.variables),
);
}
function updateFile(fileName: string, content: string) {
const exists = env.getSourceFile(fileName);
if (exists) {
env.updateFile(fileName, content);
} else {
env.createFile(fileName, content);
}
}
const loadInputNodes = options.inputNodeNames.map(
async (nodeName) => await loadNodeTypes(nodeName),
);
await Promise.all(
loadInputNodes.concat(loadTypesIfNeeded(), loadLuxonTypes(), setVariableTypes()),
);
watch(
loadedNodeTypesMap,
async (loadedNodes) => {
updateFile(
TYPESCRIPT_FILES.DYNAMIC_INPUT_TYPES,
await getDynamicInputNodeTypes(inputNodeNames),
);
updateFile(
TYPESCRIPT_FILES.DYNAMIC_TYPES,
await getDynamicNodeTypes({ nodeNames: allNodeNames, loadedNodes }),
);
},
{ immediate: true },
);
watch(
mode,
(newMode) => {
updateFile(
TYPESCRIPT_FILES.MODE_TYPES,
newMode === 'runOnceForAllItems' ? runOnceForAllItemsTypes : runOnceForEachItemTypes,
);
},
{ immediate: true },
);
watch(prefix, (newPrefix, oldPrefix) => {
env.updateFile(codeFileName, newPrefix, { start: 0, length: oldPrefix.length });
});
const applyChangesToCode = bufferChangeSets((bufferedChanges) => {
bufferedChanges.iterChanges((start, end, _fromNew, _toNew, text) => {
const length = end - start;
env.updateFile(codeFileName, text.toString(), {
start: editorPositionToTypescript(start),
length,
});
});
void loadTypesIfNeeded();
});
const waitForChangesAppliedToCode = async () => {
await until(busyApplyingChangesToCode).toBe(false, { timeout: 500 });
};
return Comlink.proxy<LanguageServiceWorker>({
updateFile: async (changes) => {
busyApplyingChangesToCode.value = true;
void applyChangesToCode(ChangeSet.fromJSON(changes)).then(() => {
busyApplyingChangesToCode.value = false;
});
},
async getCompletionsAtPos(pos) {
await waitForChangesAppliedToCode();
return await getCompletionsAtPos({
pos: editorPositionToTypescript(pos),
fileName: codeFileName,
env,
});
},
getDiagnostics() {
return getDiagnostics({ env, fileName: codeFileName }).map((diagnostic) => ({
...diagnostic,
from: typescriptPositionToEditor(diagnostic.from),
to: typescriptPositionToEditor(diagnostic.to),
}));
},
getHoverTooltip(pos) {
const tooltip = getHoverTooltip({
pos: editorPositionToTypescript(pos),
fileName: codeFileName,
env,
});
if (!tooltip) return null;
tooltip.start = typescriptPositionToEditor(tooltip.start);
tooltip.end = typescriptPositionToEditor(tooltip.end);
return tooltip;
},
async updateMode(newMode) {
mode.value = newMode;
},
async updateNodeTypes() {
const loadedNodeNames = Array.from(loadedNodeTypesMap.keys());
await Promise.all(loadedNodeNames.map(async (nodeName) => await loadNodeTypes(nodeName)));
},
});
},
};
Comlink.expose(worker);

View File

@@ -0,0 +1,38 @@
import ts from 'typescript';
function findNodes(node: ts.Node, check: (node: ts.Node) => boolean): ts.Node[] {
const result: ts.Node[] = [];
// If the current node matches the condition, add it to the result
if (check(node)) {
result.push(node);
}
// Recursively check all child nodes
node.forEachChild((child) => {
result.push(...findNodes(child, check));
});
return result;
}
/**
* Get nodes mentioned in the code
* Check if code includes calls to $('Node A')
*/
export async function getUsedNodeNames(file: ts.SourceFile) {
const callExpressions = findNodes(
file,
(n) =>
n.kind === ts.SyntaxKind.CallExpression &&
(n as ts.CallExpression).expression.getText() === '$',
);
if (callExpressions.length === 0) return [];
const nodeNames = (callExpressions as ts.CallExpression[])
.map((e) => (e.arguments.at(0) as ts.StringLiteral)?.text)
.filter(Boolean);
return nodeNames;
}

View File

@@ -0,0 +1,64 @@
import { ChangeSet } from '@codemirror/state';
import { type CodeExecutionMode } from 'n8n-workflow';
export const fnPrefix = (mode: CodeExecutionMode) => `(
/**
* @returns {${returnTypeForMode(mode)}}
*/
() => {\n`;
export function wrapInFunction(script: string, mode: CodeExecutionMode): string {
return `${fnPrefix(mode)}${script}\n})()`;
}
export function globalTypeDefinition(types: string) {
return `export {};
declare global {
${types}
}`;
}
export function returnTypeForMode(mode: CodeExecutionMode): string {
return mode === 'runOnceForAllItems' ? 'N8nOutputItems' : 'N8nOutputItem';
}
const MAX_CHANGE_BUFFER_CHAR_SIZE = 10_000_000;
const MIN_CHANGE_BUFFER_WINDOW_MS = 50;
const MAX_CHANGE_BUFFER_WINDOW_MS = 500;
// Longer buffer window for large code
function calculateBufferWindowMs(docSize: number, minDelay: number, maxDelay: number): number {
const clampedSize = Math.min(docSize, MAX_CHANGE_BUFFER_CHAR_SIZE);
const normalizedSize = clampedSize / MAX_CHANGE_BUFFER_CHAR_SIZE;
return Math.ceil(minDelay + (maxDelay - minDelay) * normalizedSize);
}
// Create a buffer function to accumulate and compose changesets
export function bufferChangeSets(fn: (changeset: ChangeSet) => void) {
let changeSet = ChangeSet.empty(0);
let timeoutId: NodeJS.Timeout | null = null;
return async (changes: ChangeSet) => {
changeSet = changeSet.compose(changes);
if (timeoutId) {
clearTimeout(timeoutId);
}
return await new Promise<void>((resolve) => {
timeoutId = setTimeout(
() => {
fn(changeSet);
resolve();
changeSet = ChangeSet.empty(0);
},
calculateBufferWindowMs(
changeSet.length,
MIN_CHANGE_BUFFER_WINDOW_MS,
MAX_CHANGE_BUFFER_WINDOW_MS,
),
);
});
};
}

View File

@@ -0,0 +1,31 @@
import type { Plugin } from 'vue';
import 'regenerator-runtime/runtime';
import ElementPlus, { ElLoading, ElMessageBox } from 'element-plus';
import { N8nPlugin } from '@n8n/design-system';
import { useMessage } from '@/composables/useMessage';
import EnterpriseEdition from '@/components/EnterpriseEdition.ee.vue';
import ParameterInputList from '@/components/ParameterInputList.vue';
export const GlobalComponentsPlugin: Plugin = {
install(app) {
const messageService = useMessage();
app.component('EnterpriseEdition', EnterpriseEdition);
app.component('ParameterInputList', ParameterInputList);
app.use(ElementPlus);
app.use(N8nPlugin, {});
// app.use(ElLoading);
// app.use(ElNotification);
app.config.globalProperties.$loading = ElLoading.service;
app.config.globalProperties.$msgbox = ElMessageBox;
app.config.globalProperties.$alert = messageService.alert;
app.config.globalProperties.$confirm = messageService.confirm;
app.config.globalProperties.$prompt = messageService.prompt;
app.config.globalProperties.$message = messageService.message;
},
};

View File

@@ -0,0 +1,8 @@
import type { Plugin } from 'vue';
import VueTouchEvents from 'vue3-touch-events';
export const GlobalDirectivesPlugin: Plugin = {
install(app) {
app.use(VueTouchEvents);
},
};

View File

@@ -0,0 +1,118 @@
# Addendum for i18n in n8n
## Base text
### Pluralization
Certain base text strings accept [singular and plural versions](https://kazupon.github.io/vue-i18n/guide/pluralization.html) separated by a `|` character:
```json
{
"tagsView.inUse": "{count} workflow | {count} workflows"
}
```
### Interpolation
Certain base text strings use [interpolation](https://kazupon.github.io/vue-i18n/guide/formatting.html#named-formatting) to allow for a variable between curly braces:
```json
{
"stopExecution.message": "The execution with the ID {activeExecutionId} got stopped!",
"stopExecution.title": "Execution stopped"
}
```
When translating a string containing an interpolated variable, leave the variable untranslated:
```json
{
"stopExecution.message": "Die Ausführung mit der ID {activeExecutionId} wurde gestoppt",
"stopExecution.title": "Execution stopped"
}
```
### Reusable base text
As a convenience, the base text file may contain the special key `_reusableBaseText`, which defines strings that can be shared among other strings with the syntax `@:_reusableBaseText.key`, as follows:
```json
{
"_reusableBaseText.save": "🇩🇪 Save",
"duplicateWorkflowDialog.enterWorkflowName": "🇩🇪 Enter workflow name",
"duplicateWorkflowDialog.save": "@:_reusableBaseText.save",
"saveButton.save": "@:_reusableBaseText.save",
"saveButton.saving": "🇩🇪 Saving",
"saveButton.saved": "🇩🇪 Saved"
}
```
For more information, refer to Vue i18n's [linked locale messages](https://kazupon.github.io/vue-i18n/guide/messages.html#linked-locale-messages).
### Nodes in versioned dirs
For nodes in versioned dirs, place the `/translations` dir for the node translation file alongside the versioned `*.node.ts` file:
```
Mattermost
└── Mattermost.node.ts
└── v1
├── MattermostV1.node.ts
├── actions
├── methods
├── transport
└── translations
└── de
└── mattermost.json
```
### Nodes in grouping dirs
For nodes in grouping dirs, e.g. Google nodes, place the `/translations` dir for the node translation file alongside the `*.node.ts` file:
```
Google
├── Books
├── Calendar
└── Drive
├── GoogleDrive.node.ts
└── translations
└── de
├── googleDrive.json
└── googleDriveTrigger.json
```
## Dynamic text
### Reusable dynamic text
The base text file may contain the special key `reusableDynamicText`, allowing for a node parameter to be translated once and reused in all other node parameter translations.
Currently only the keys `oauth.clientId` and `oauth.clientSecret` are supported as a PoC - these two translations will be reused in all node credential parameters.
```json
{
"_reusableDynamicText.oauth2.clientId": "🇩🇪 Client ID",
"_reusableDynamicText.oauth2.clientSecret": "🇩🇪 Client Secret"
}
```
### Special cases
`eventTriggerDescription` and `activationMessage` are dynamic node properties that are not part of node parameters. To translate them, set the key at the root level of the `nodeView` property in the node translation file.
Webhook node:
```json
{
"nodeView.eventTriggerDescription": "🇩🇪 Waiting for you to call the Test URL"
}
```
Cron node:
```json
{
"nodeView.activationMessage": "🇩🇪 'Your cron trigger will now trigger executions on the schedule you have defined."
}
```

View File

@@ -0,0 +1,511 @@
# i18n in n8n
## Scope
n8n allows for internalization of the majority of UI text:
- base text, e.g. menu display items in the left-hand sidebar menu,
- node text, e.g. parameter display names and placeholders in the node view,
- credential text, e.g. parameter display names and placeholders in the credential modal,
- header text, e.g. node display names and descriptions at various spots.
Currently, n8n does _not_ allow for internalization of:
- messages from outside the `editor-ui` package, e.g. `No active database connection`,
- strings in certain Vue components, e.g. date time picker
- node subtitles, e.g. `create: user` or `getAll: post` below the node name on the canvas,
- new version notification contents in the updates panel, e.g. `Includes node enhancements`, and
- options that rely on `loadOptionsMethod`.
Pending functionality:
- Search in nodes panel by translated node name
- UI responsiveness to differently sized strings
- Locale-aware number formatting
## Locale identifiers
A **locale identifier** is a language code compatible with the [`Accept-Language` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language), e.g. `de` (German), `es` (Spanish), `ja` (Japanese). Regional variants of locale identifiers, such as `-AT` in `de-AT`, are _not_ supported. For a list of all locale identifiers, see [column 639-1 in this table](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes).
By default, n8n runs in the `en` (English) locale. To have run it in a different locale, set the `N8N_DEFAULT_LOCALE` environment variable to a locale identifier. When running in a non-`en` locale, n8n will display UI strings for the selected locale and fall back to `en` for any untranslated strings.
```
export N8N_DEFAULT_LOCALE=de
pnpm start
```
Output:
```
Initializing n8n process
n8n ready on 0.0.0.0, port 5678
Version: 0.156.0
Locale: de
Editor is now accessible via:
http://localhost:5678/
Press "o" to open in Browser.
```
## Base text
Base text is rendered with no dependencies, i.e. base text is fixed and does not change in any circumstances. Base text is supplied by the user in one file per locale in the `/frontend/editor-ui` package.
### Locating base text
The base text file for each locale is located at `/packages/frontend/editor-ui/src/plugins/i18n/locales/` and is named `{localeIdentifier}.json`. Keys in the base text file can be Vue component dirs, Vue component names, and references to symbols in those Vue components. These keys are added by the team as the UI is modified or expanded.
```json
{
"nodeCreator.categoryNames.analytics": "🇩🇪 Analytics",
"nodeCreator.categoryNames.communication": "🇩🇪 Communication",
"nodeCreator.categoryNames.coreNodes": "🇩🇪 Core Nodes"
}
```
### Translating base text
1. Select a new locale identifier, e.g. `de`, copy the `en` JSON base text file with a new name:
```
cp ./packages/frontend/editor-ui/src/plugins/i18n/locales/en.json ./packages/frontend/editor-ui/src/plugins/i18n/locales/de.json
```
2. Find in the UI a string to translate, and search for it in the newly created base text file. Alternatively, find in `/frontend/editor-ui` a call to `i18n.baseText(key)`, e.g. `i18n.baseText('workflowActivator.deactivateWorkflow')`, and take note of the key and find it in the newly created base text file.
> **Note**: If you cannot find a string in the new base text file, either it does not belong to base text (i.e., the string might be part of header text, credential text, or node text), or the string might belong to the backend, where i18n is currently unsupported.
3. Translate the string value - do not change the key. In the examples below, a string starting with 🇩🇪 stands for a string translated from English into German.
As an optional final step, remove any untranslated strings from the new base text file. Untranslated strings in the new base text file will trigger a fallback to the `en` base text file.
> For information about **interpolation** and **reusable base text**, refer to the [Addendum](./ADDENDUM.md).
## Dynamic text
Dynamic text relies on data specific to each node and credential:
- `headerText` and `nodeText` in the **node translation file**
- `credText` in the **credential translation file**
### Locating dynamic text
#### Locating the credential translation file
A credential translation file is placed at `/nodes-base/credentials/translations/{localeIdentifier}`
```
credentials
└── translations
└── de
├── githubApi.json
└── githubOAuth2Api.json
```
Every credential must have its own credential translation file.
The name of the credential translation file must be sourced from the credential's `description.name` property:
```ts
export class GithubApi implements ICredentialType {
name = 'githubApi'; // to use for credential translation file
displayName = 'Github API';
documentationUrl = 'github';
properties: INodeProperties[] = [
```
#### Locating the node translation file
A node translation file is placed at `/nodes-base/nodes/{node}/translations/{localeIdentifier}`
```
GitHub
├── GitHub.node.ts
├── GitHubTrigger.node.ts
└── translations
└── de
├── github.json
└── githubTrigger.json
```
Every node must have its own node translation file.
> For information about nodes in **versioned dirs** and **grouping dirs**, refer to the [Addendum](./ADDENDUM.md).
The name of the node translation file must be sourced from the node's `description.name` property:
```ts
export class Github implements INodeType {
description: INodeTypeDescription = {
displayName: 'GitHub',
name: 'github', // to use for node translation file name
icon: 'file:github.svg',
group: ['input'],
```
### Translating dynamic text
#### Translating the credential translation file
> **Note**: All translation keys are optional. Missing translation values trigger a fallback to the `en` locale strings.
A credential translation file, e.g. `githubApi.json` is an object containing keys that match the credential parameter names:
```ts
export class GithubApi implements ICredentialType {
name = 'githubApi';
displayName = 'Github API';
documentationUrl = 'github';
properties: INodeProperties[] = [
{
displayName: 'Github Server',
name: 'server', // key to use in translation
type: 'string',
default: 'https://api.github.com',
description: 'The server to connect to. Only has to be set if Github Enterprise is used.',
},
{
displayName: 'User',
name: 'user', // key to use in translation
type: 'string',
default: '',
},
{
displayName: 'Access Token',
name: 'accessToken', // key to use in translation
type: 'string',
default: '',
},
];
}
```
The object for each node credential parameter allows for the keys `displayName`, `description`, and `placeholder`.
```json
{
"server.displayName": "🇩🇪 Github Server",
"server.description": "🇩🇪 The server to connect to. Only has to be set if Github Enterprise is used.",
"user.placeholder": "🇩🇪 Hans",
"accessToken.placeholder": "🇩🇪 123"
}
```
<p align="center">
<img src="img/cred.png">
</p>
Only existing parameters are translatable. If a credential parameter does not have a description in the English original, adding a translation for that non-existing parameter will not result in the translation being displayed - the parameter will need to be added in the English original first.
#### Translating the node translation file
> **Note**: All keys are optional. Missing translations trigger a fallback to the `en` locale strings.
Each node translation file is an object that allows for two keys, `header` and `nodeView`, which are the _sections_ of each node translation.
The `header` section points to an object that may contain only two keys, `displayName` and `description`, matching the node's `description.displayName` and `description.description`.
```ts
export class Github implements INodeType {
description: INodeTypeDescription = {
displayName: 'GitHub', // key to use in translation
description: 'Consume GitHub API', // key to use in translation
name: 'github',
icon: 'file:github.svg',
group: ['input'],
version: 1,
```
```json
{
"header": {
"displayName": "🇩🇪 GitHub",
"description": "🇩🇪 Consume GitHub API"
}
}
```
Header text is used wherever the node's display name and description are needed:
<p align="center">
<img src="img/header1.png" width="400">
<img src="img/header2.png" width="200">
<img src="img/header3.png" width="400">
</p>
<p align="center">
<img src="img/header4.png" width="400">
<img src="img/header5.png" width="500">
</p>
In turn, the `nodeView` section points to an object containing translation keys that match the node's operational parameters, found in the `*.node.ts` and also found in `*Description.ts` files in the same dir.
```ts
export class Github implements INodeType {
description: INodeTypeDescription = {
displayName: 'GitHub',
name: 'github',
properties: [
{
displayName: 'Resource',
name: 'resource', // key to use in translation
type: 'options',
options: [],
default: 'issue',
description: 'The resource to operate on.',
},
```
```json
{
"nodeView.resource.displayName": "🇩🇪 Resource"
}
```
A node parameter allows for different translation keys depending on parameter type.
#### `string`, `number` and `boolean` parameters
Allowed keys: `displayName`, `description`, `placeholder`
```ts
{
displayName: 'Repository Owner',
name: 'owner', // key to use in translation
type: 'string',
required: true,
placeholder: 'n8n-io',
description: 'Owner of the repository.',
},
```
```json
{
"nodeView.owner.displayName": "🇩🇪 Repository Owner",
"nodeView.owner.placeholder": "🇩🇪 n8n-io",
"nodeView.owner.description": "🇩🇪 Owner of the repository"
}
```
<p align="center">
<img src="img/node1.png" width="400">
</p>
#### `options` parameter
Allowed keys: `displayName`, `description`, `placeholder`
Allowed subkeys: `options.{optionName}.displayName` and `options.{optionName}.description`.
```js
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{
name: 'File',
value: 'file', // key to use in translation
},
{
name: 'Issue',
value: 'issue', // key to use in translation
},
],
default: 'issue',
description: 'Resource to operate on',
},
```
```json
{
"nodeView.resource.displayName": "🇩🇪 Resource",
"nodeView.resource.description": "🇩🇪 Resource to operate on",
"nodeView.resource.options.file.name": "🇩🇪 File",
"nodeView.resource.options.issue.name": "🇩🇪 Issue"
}
```
<p align="center">
<img src="img/node2.png" width="400">
</p>
For nodes whose credentials may be used in the HTTP Request node, an additional option `Custom API Call` is injected into the `Resource` and `Operation` parameters. Use the `__CUSTOM_API_CALL__` key to translate this additional option.
```json
{
"nodeView.resource.options.file.name": "🇩🇪 File",
"nodeView.resource.options.issue.name": "🇩🇪 Issue",
"nodeView.resource.options.__CUSTOM_API_CALL__.name": "🇩🇪 Custom API Call"
}
```
#### `collection` and `fixedCollection` parameters
Allowed keys: `displayName`, `description`, `placeholder`, `multipleValueButtonText`
Example of `collection` parameter:
```js
{
displayName: 'Labels',
name: 'labels', // key to use in translation
type: 'collection',
typeOptions: {
multipleValues: true,
multipleValueButtonText: 'Add Label',
},
displayOptions: {
show: {
operation: [
'create',
],
resource: [
'issue',
],
},
},
default: { 'label': '' },
options: [
{
displayName: 'Label',
name: 'label', // key to use in translation
type: 'string',
default: '',
description: 'Label to add to issue',
},
],
},
```
```json
{
"nodeView.labels.displayName": "🇩🇪 Labels",
"nodeView.labels.multipleValueButtonText": "🇩🇪 Add Label",
"nodeView.labels.options.label.displayName": "🇩🇪 Label",
"nodeView.labels.options.label.description": "🇩🇪 Label to add to issue",
"nodeView.labels.options.label.placeholder": "🇩🇪 Some placeholder"
}
```
Example of `fixedCollection` parameter:
```js
{
displayName: 'Additional Parameters',
name: 'additionalParameters',
placeholder: 'Add Parameter',
description: 'Additional fields to add.',
type: 'fixedCollection',
default: {},
displayOptions: {
show: {
operation: [
'create',
'delete',
'edit',
],
resource: [
'file',
],
},
},
options: [
{
name: 'author',
displayName: 'Author',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
description: 'Name of the author of the commit',
placeholder: 'John',
},
{
displayName: 'Email',
name: 'email',
type: 'string',
default: '',
description: 'Email of the author of the commit',
placeholder: 'john@email.com',
},
],
},
],
}
```
```json
{
"nodeView.additionalParameters.displayName": "🇩🇪 Additional Parameters",
"nodeView.additionalParameters.placeholder": "🇩🇪 Add Field",
"nodeView.additionalParameters.options.author.displayName": "🇩🇪 Author",
"nodeView.additionalParameters.options.author.values.name.displayName": "🇩🇪 Name",
"nodeView.additionalParameters.options.author.values.name.description": "🇩🇪 Name of the author of the commit",
"nodeView.additionalParameters.options.author.values.name.placeholder": "🇩🇪 Jan",
"nodeView.additionalParameters.options.author.values.email.displayName": "🇩🇪 Email",
"nodeView.additionalParameters.options.author.values.email.description": "🇩🇪 Email of the author of the commit",
"nodeView.additionalParameters.options.author.values.email.placeholder": "🇩🇪 jan@n8n.io"
}
```
<p align="center">
<img src="img/node4.png" width="400">
</p>
> For information on **reusable dynamic text**, refer to the [Addendum](./ADDENDUM.md).
# Building translations
## Base text
When translating a base text file at `/packages/frontend/editor-ui/src/plugins/i18n/locales/{localeIdentifier}.json`:
1. Open a terminal:
```sh
export N8N_DEFAULT_LOCALE=de
pnpm start
```
2. Open another terminal:
```sh
export N8N_DEFAULT_LOCALE=de
cd packages/frontend/editor-ui
pnpm dev
```
Changing the base text file will trigger a rebuild of the client at `http://localhost:8080`.
## Dynamic text
When translating a dynamic text file at `/packages/nodes-base/nodes/{node}/translations/{localeIdentifier}/{node}.json`,
1. Open a terminal:
```sh
export N8N_DEFAULT_LOCALE=de
pnpm start
```
2. Open another terminal:
```sh
export N8N_DEFAULT_LOCALE=de
cd packages/nodes-base
pnpm n8n-generate-translations
pnpm watch
```
After changing the dynamic text file:
1. Stop and restart the first terminal.
2. Refresh the browser at `http://localhost:5678`
If a `headerText` section was changed, re-run `pnpm n8n-generate-translations` in `/nodes-base`.
> **Note**: To translate base and dynamic text simultaneously, run three terminals following the steps from both sections (first terminal running only once) and browse `http://localhost:8080`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 857 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -0,0 +1,457 @@
import axios from 'axios';
import { createI18n } from 'vue-i18n';
import { locale } from '@n8n/design-system';
import type { INodeProperties, INodePropertyCollection, INodePropertyOptions } from 'n8n-workflow';
import type { INodeTranslationHeaders } from '@/Interface';
import { useUIStore } from '@/stores/ui.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useRootStore } from '@/stores/root.store';
import englishBaseText from './locales/en.json';
import {
deriveMiddleKey,
isNestedInCollectionLike,
normalize,
insertOptionsAndValues,
} from './utils';
export const i18nInstance = createI18n({
locale: 'en',
fallbackLocale: 'en',
messages: { en: englishBaseText },
warnHtmlInMessage: 'off',
});
type BaseTextOptions = {
adjustToNumber?: number;
interpolate?: Record<string, string | number>;
};
export class I18nClass {
private baseTextCache = new Map<string, string>();
private get i18n() {
return i18nInstance.global;
}
// ----------------------------------
// helper methods
// ----------------------------------
exists(key: string) {
return this.i18n.te(key);
}
shortNodeType(longNodeType: string) {
return longNodeType.replace('n8n-nodes-base.', '');
}
get locale() {
return i18nInstance.global.locale;
}
// ----------------------------------
// render methods
// ----------------------------------
/**
* Render a string of base text, i.e. a string with a fixed path to the localized value. Optionally allows for [interpolation](https://kazupon.github.io/vue-i18n/guide/formatting.html#named-formatting) when the localized value contains a string between curly braces.
*/
baseText(key: BaseTextKey, options?: BaseTextOptions): string {
// Create a unique cache key
const cacheKey = `${key}-${JSON.stringify(options)}`;
// Check if the result is already cached
if (this.baseTextCache.has(cacheKey)) {
return this.baseTextCache.get(cacheKey) ?? key;
}
let result: string;
if (options?.adjustToNumber !== undefined) {
result = this.i18n.tc(key, options.adjustToNumber, options?.interpolate ?? {}).toString();
} else {
result = this.i18n.t(key, options?.interpolate ?? {}).toString();
}
// Store the result in the cache
this.baseTextCache.set(cacheKey, result);
return result;
}
/**
* Render a string of dynamic text, i.e. a string with a constructed path to the localized value.
*/
private dynamicRender({ key, fallback }: { key: string; fallback?: string }) {
return this.i18n.te(key) ? this.i18n.t(key).toString() : (fallback ?? '');
}
displayTimer(msPassed: number, showMs = false): string {
if (msPassed < 60000) {
if (!showMs) {
return `${Math.floor(msPassed / 1000)}${this.baseText('genericHelpers.secShort')}`;
}
return `${msPassed / 1000}${this.baseText('genericHelpers.secShort')}`;
}
const secondsPassed = Math.floor(msPassed / 1000);
const minutesPassed = Math.floor(secondsPassed / 60);
const secondsLeft = (secondsPassed - minutesPassed * 60).toString().padStart(2, '0');
return `${minutesPassed}:${secondsLeft}${this.baseText('genericHelpers.minShort')}`;
}
/**
* Render a string of header text (a node's name and description),
* used variously in the nodes panel, under the node icon, etc.
*/
headerText(arg: { key: string; fallback: string }) {
return this.dynamicRender(arg);
}
/**
* Namespace for methods to render text in the credentials details modal.
*/
credText() {
const uiStore = useUIStore();
const credentialType = uiStore.activeCredentialType;
const credentialPrefix = `n8n-nodes-base.credentials.${credentialType}`;
const context = this;
return {
/**
* Display name for a top-level param.
*/
inputLabelDisplayName({ name: parameterName, displayName }: INodeProperties) {
if (['clientId', 'clientSecret'].includes(parameterName)) {
return context.dynamicRender({
key: `_reusableDynamicText.oauth2.${parameterName}`,
fallback: displayName,
});
}
return context.dynamicRender({
key: `${credentialPrefix}.${parameterName}.displayName`,
fallback: displayName,
});
},
/**
* Hint for a top-level param.
*/
hint({ name: parameterName, hint }: INodeProperties) {
return context.dynamicRender({
key: `${credentialPrefix}.${parameterName}.hint`,
fallback: hint,
});
},
/**
* Description (tooltip text) for an input label param.
*/
inputLabelDescription({ name: parameterName, description }: INodeProperties) {
return context.dynamicRender({
key: `${credentialPrefix}.${parameterName}.description`,
fallback: description,
});
},
/**
* Display name for an option inside an `options` or `multiOptions` param.
*/
optionsOptionDisplayName(
{ name: parameterName }: INodeProperties,
{ value: optionName, name: displayName }: INodePropertyOptions,
) {
return context.dynamicRender({
key: `${credentialPrefix}.${parameterName}.options.${optionName}.displayName`,
fallback: displayName,
});
},
/**
* Description for an option inside an `options` or `multiOptions` param.
*/
optionsOptionDescription(
{ name: parameterName }: INodeProperties,
{ value: optionName, description }: INodePropertyOptions,
) {
return context.dynamicRender({
key: `${credentialPrefix}.${parameterName}.options.${optionName}.description`,
fallback: description,
});
},
/**
* Placeholder for a `string` param.
*/
placeholder({ name: parameterName, placeholder }: INodeProperties) {
return context.dynamicRender({
key: `${credentialPrefix}.${parameterName}.placeholder`,
fallback: placeholder,
});
},
};
}
/**
* Namespace for methods to render text in the node details view,
* except for `eventTriggerDescription`.
*/
nodeText() {
const ndvStore = useNDVStore();
const activeNode = ndvStore.activeNode;
const nodeType = activeNode ? this.shortNodeType(activeNode.type) : ''; // unused in eventTriggerDescription
const initialKey = `n8n-nodes-base.nodes.${nodeType}.nodeView`;
const context = this;
return {
/**
* Display name for an input label, whether top-level or nested.
*/
inputLabelDisplayName(parameter: INodeProperties | INodePropertyCollection, path: string) {
const middleKey = deriveMiddleKey(path, parameter);
return context.dynamicRender({
key: `${initialKey}.${middleKey}.displayName`,
fallback: parameter.displayName,
});
},
/**
* Description (tooltip text) for an input label, whether top-level or nested.
*/
inputLabelDescription(parameter: INodeProperties, path: string) {
const middleKey = deriveMiddleKey(path, parameter);
return context.dynamicRender({
key: `${initialKey}.${middleKey}.description`,
fallback: parameter.description,
});
},
/**
* Hint for an input, whether top-level or nested.
*/
hint(parameter: INodeProperties, path: string) {
const middleKey = deriveMiddleKey(path, parameter);
return context.dynamicRender({
key: `${initialKey}.${middleKey}.hint`,
fallback: parameter.hint,
});
},
/**
* Placeholder for an input label or `collection` or `fixedCollection` param,
* whether top-level or nested.
* - For an input label, the placeholder is unselectable greyed-out sample text.
* - For a `collection` or `fixedCollection`, the placeholder is the button text.
*/
placeholder(parameter: INodeProperties, path: string) {
let middleKey = parameter.name;
if (isNestedInCollectionLike(path)) {
const pathSegments = normalize(path).split('.');
middleKey = insertOptionsAndValues(pathSegments).join('.');
}
return context.dynamicRender({
key: `${initialKey}.${middleKey}.placeholder`,
fallback: parameter.placeholder,
});
},
/**
* Display name for an option inside an `options` or `multiOptions` param,
* whether top-level or nested.
*/
optionsOptionDisplayName(
parameter: INodeProperties,
{ value: optionName, name: displayName }: INodePropertyOptions,
path: string,
) {
let middleKey = parameter.name;
if (isNestedInCollectionLike(path)) {
const pathSegments = normalize(path).split('.');
middleKey = insertOptionsAndValues(pathSegments).join('.');
}
return context.dynamicRender({
key: `${initialKey}.${middleKey}.options.${optionName}.displayName`,
fallback: displayName,
});
},
/**
* Description for an option inside an `options` or `multiOptions` param,
* whether top-level or nested.
*/
optionsOptionDescription(
parameter: INodeProperties,
{ value: optionName, description }: INodePropertyOptions,
path: string,
) {
let middleKey = parameter.name;
if (isNestedInCollectionLike(path)) {
const pathSegments = normalize(path).split('.');
middleKey = insertOptionsAndValues(pathSegments).join('.');
}
return context.dynamicRender({
key: `${initialKey}.${middleKey}.options.${optionName}.description`,
fallback: description,
});
},
/**
* Display name for an option in the dropdown menu of a `collection` or
* fixedCollection` param. No nesting support since `collection` cannot
* be nested in a `collection` or in a `fixedCollection`.
*/
collectionOptionDisplayName(
parameter: INodeProperties,
{ name: optionName, displayName }: INodePropertyCollection,
path: string,
) {
let middleKey = parameter.name;
if (isNestedInCollectionLike(path)) {
const pathSegments = normalize(path).split('.');
middleKey = insertOptionsAndValues(pathSegments).join('.');
}
return context.dynamicRender({
key: `${initialKey}.${middleKey}.options.${optionName}.displayName`,
fallback: displayName,
});
},
/**
* Text for a button to add another option inside a `collection` or
* `fixedCollection` param having `multipleValues: true`.
*/
multipleValueButtonText({ name: parameterName, typeOptions }: INodeProperties) {
return context.dynamicRender({
key: `${initialKey}.${parameterName}.multipleValueButtonText`,
fallback: typeOptions?.multipleValueButtonText,
});
},
eventTriggerDescription(nodeType: string, eventTriggerDescription: string) {
return context.dynamicRender({
key: `n8n-nodes-base.nodes.${nodeType}.nodeView.eventTriggerDescription`,
fallback: eventTriggerDescription,
});
},
};
}
localizeNodeName(nodeName: string, type: string) {
const isEnglishLocale = useRootStore().defaultLocale === 'en';
if (isEnglishLocale) return nodeName;
const nodeTypeName = this.shortNodeType(type);
return this.headerText({
key: `headers.${nodeTypeName}.displayName`,
fallback: nodeName,
});
}
autocompleteUIValues: Record<string, string | undefined> = {
docLinkLabel: this.baseText('expressionEdit.learnMore'),
};
}
const loadedLanguages = ['en'];
async function setLanguage(language: string) {
i18nInstance.global.locale = language as 'en';
axios.defaults.headers.common['Accept-Language'] = language;
document!.querySelector('html')!.setAttribute('lang', language);
// update n8n design system and element ui
await locale.use(language);
return language;
}
export async function loadLanguage(language: string) {
if (i18nInstance.global.locale === language) {
return await setLanguage(language);
}
if (loadedLanguages.includes(language)) {
return await setLanguage(language);
}
const { numberFormats, ...rest } = (await import(`./locales/${language}.json`)).default;
i18nInstance.global.setLocaleMessage(language, rest);
if (numberFormats) {
i18nInstance.global.setNumberFormat(language, numberFormats);
}
loadedLanguages.push(language);
return await setLanguage(language);
}
/**
* Add a node translation to the i18n instance's `messages` object.
*/
export function addNodeTranslation(
nodeTranslation: { [nodeType: string]: object },
language: string,
) {
const newMessages = {
'n8n-nodes-base': {
nodes: nodeTranslation,
},
};
i18nInstance.global.mergeLocaleMessage(language, newMessages);
}
/**
* Add a credential translation to the i18n instance's `messages` object.
*/
export function addCredentialTranslation(
nodeCredentialTranslation: { [credentialType: string]: object },
language: string,
) {
const newMessages = {
'n8n-nodes-base': {
credentials: nodeCredentialTranslation,
},
};
i18nInstance.global.mergeLocaleMessage(language, newMessages);
}
/**
* Add a node's header strings to the i18n instance's `messages` object.
*/
export function addHeaders(headers: INodeTranslationHeaders, language: string) {
i18nInstance.global.mergeLocaleMessage(language, { headers });
}
export const i18n: I18nClass = new I18nClass();
// ----------------------------------
// typings
// ----------------------------------
type GetBaseTextKey<T> = T extends `_${string}` ? never : T;
export type BaseTextKey = GetBaseTextKey<keyof typeof englishBaseText>;
type GetCategoryName<T> = T extends `nodeCreator.categoryNames.${infer C}` ? C : never;
export type CategoryName = GetCategoryName<keyof typeof englishBaseText>;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
/**
* Derive the middle key, i.e. the segment of the render key located between
* the initial key (path to parameters root) and the property to render.
*
* Used by `nodeText()` to handle nested params.
*
* Location: `n8n-nodes-base.nodes.github.nodeView.<middleKey>.placeholder`
*/
export function deriveMiddleKey(path: string, parameter: { name: string; type?: string }) {
let middleKey = parameter.name;
if (isTopLevelCollection(path, parameter) || isNestedInCollectionLike(path)) {
const pathSegments = normalize(path).split('.');
middleKey = insertOptionsAndValues(pathSegments).join('.');
}
if (isNestedCollection(path, parameter) || isFixedCollection(path, parameter)) {
const pathSegments = [...normalize(path).split('.'), parameter.name];
middleKey = insertOptionsAndValues(pathSegments).join('.');
}
return middleKey;
}
/**
* Check if a param path is for a param nested inside a `collection` or
* `fixedCollection` param.
*/
export const isNestedInCollectionLike = (path: string) => path.split('.').length >= 3;
const isTopLevelCollection = (path: string, parameter: { type?: string }) =>
path.split('.').length === 2 && parameter.type === 'collection';
const isNestedCollection = (path: string, parameter: { type?: string }) =>
path.split('.').length > 2 && parameter.type === 'collection';
/**
* Check if the param is a normal `fixedCollection`, i.e. a FC other than the wrapper
* that sits at the root of a node's top-level param and contains all of them.
*/
const isFixedCollection = (path: string, parameter: { type?: string }) =>
parameter.type === 'fixedCollection' && path !== 'parameters';
/**
* Remove all indices and the `parameters.` prefix from a parameter path.
*
* Example: `parameters.a[0].b` → `a.b`
*/
export const normalize = (path: string) => path.replace(/\[.*?\]/g, '').replace('parameters.', '');
/**
* Insert `'options'` and `'values'` on an alternating basis in a string array of
* indefinite length. Helper to create a valid render key for a collection-like param.
*
* Example: `['a', 'b', 'c']` → `['a', 'options', 'b', 'values', 'c']`
*/
export const insertOptionsAndValues = (pathSegments: string[]) => {
return pathSegments.reduce<string[]>((acc, cur, i) => {
acc.push(cur);
if (i === pathSegments.length - 1) return acc;
acc.push(i % 2 === 0 ? 'options' : 'values');
return acc;
}, []);
};

View File

@@ -0,0 +1,62 @@
import type { IconDefinition, IconName, IconPrefix } from '@fortawesome/fontawesome-svg-core';
export const faVariable: IconDefinition = {
prefix: 'fas' as IconPrefix,
iconName: 'variable' as IconName,
icon: [
52,
52,
[],
'e001',
'M42.6,17.8c2.4,0,7.2-2,7.2-8.4c0-6.4-4.6-6.8-6.1-6.8c-2.8,0-5.6,2-8.1,6.3c-2.5,4.4-5.3,9.1-5.3,9.1 l-0.1,0c-0.6-3.1-1.1-5.6-1.3-6.7c-0.5-2.7-3.6-8.4-9.9-8.4c-6.4,0-12.2,3.7-12.2,3.7l0,0C5.8,7.3,5.1,8.5,5.1,9.9 c0,2.1,1.7,3.9,3.9,3.9c0.6,0,1.2-0.2,1.7-0.4l0,0c0,0,4.8-2.7,5.9,0c0.3,0.8,0.6,1.7,0.9,2.7c1.2,4.2,2.4,9.1,3.3,13.5l-4.2,6 c0,0-4.7-1.7-7.1-1.7s-7.2,2-7.2,8.4s4.6,6.8,6.1,6.8c2.8,0,5.6-2,8.1-6.3c2.5-4.4,5.3-9.1,5.3-9.1c0.8,4,1.5,7.1,1.9,8.5 c1.6,4.5,5.3,7.2,10.1,7.2c0,0,5,0,10.9-3.3c1.4-0.6,2.4-2,2.4-3.6c0-2.1-1.7-3.9-3.9-3.9c-0.6,0-1.2,0.2-1.7,0.4l0,0 c0,0-4.2,2.4-5.6,0.5c-1-2-1.9-4.6-2.6-7.8c-0.6-2.8-1.3-6.2-2-9.5l4.3-6.2C35.5,16.1,40.2,17.8,42.6,17.8z',
],
};
export const faVault: IconDefinition = {
prefix: 'fas' as IconPrefix,
iconName: 'vault' as IconName,
icon: [
576,
512,
[],
'e006',
'M64 0C28.7 0 0 28.7 0 64v352c0 35.3 28.7 64 64 64h16l16 32h64l16-32h224l16 32h64l16-32h16c35.3 0 64-28.7 64-64V64c0-35.3-28.7-64-64-64H64zm160 320a80 80 0 1 0 0-160a80 80 0 1 0 0 160zm0-240a160 160 0 1 1 0 320a160 160 0 1 1 0-320zm256 141.3V336c0 8.8-7.2 16-16 16s-16-7.2-16-16V221.3c-18.6-6.6-32-24.4-32-45.3c0-26.5 21.5-48 48-48s48 21.5 48 48c0 20.9-13.4 38.7-32 45.3z',
],
};
export const faXmark: IconDefinition = {
prefix: 'fas' as IconPrefix,
iconName: 'xmark' as IconName,
icon: [
400,
400,
[],
'',
'M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z',
],
};
export const faRefresh: IconDefinition = {
prefix: 'fas' as IconPrefix,
iconName: 'refresh' as IconName,
icon: [
12,
13,
[],
'',
'M8.67188 3.64062C7.94531 2.96094 6.98438 2.5625 5.97656 2.5625C4.17188 2.58594 2.60156 3.82812 2.17969 5.53906C2.13281 5.67969 2.01562 5.75 1.89844 5.75H0.5625C0.375 5.75 0.234375 5.60938 0.28125 5.42188C0.773438 2.72656 3.14062 0.6875 6 0.6875C7.54688 0.6875 8.95312 1.32031 10.0078 2.30469L10.8516 1.46094C11.2031 1.10938 11.8125 1.36719 11.8125 1.85938V5C11.8125 5.32812 11.5547 5.5625 11.25 5.5625H8.08594C7.59375 5.5625 7.33594 4.97656 7.6875 4.625L8.67188 3.64062ZM0.75 7.4375H3.89062C4.38281 7.4375 4.64062 8.04688 4.28906 8.39844L3.30469 9.38281C4.03125 10.0625 4.99219 10.4609 6 10.4609C7.80469 10.4375 9.375 9.19531 9.79688 7.48438C9.84375 7.34375 9.96094 7.27344 10.0781 7.27344H11.4141C11.6016 7.27344 11.7422 7.41406 11.6953 7.60156C11.2031 10.2969 8.83594 12.3125 6 12.3125C4.42969 12.3125 3.02344 11.7031 1.96875 10.7188L1.125 11.5625C0.773438 11.9141 0.1875 11.6562 0.1875 11.1641V8C0.1875 7.69531 0.421875 7.4375 0.75 7.4375Z',
],
};
export const faTriangle: IconDefinition = {
prefix: 'fas',
iconName: 'triangle',
icon: [
512,
512,
[],
'',
'M214.433 56C232.908 23.9999 279.096 24.0001 297.571 56L477.704 368C496.18 400 473.085 440 436.135 440H75.8685C38.918 440 15.8241 400 34.2993 368L214.433 56ZM256.002 144L131.294 360H380.709L256.002 144Z',
],
};

View File

@@ -0,0 +1,371 @@
import type { Plugin } from 'vue';
import { library } from '@fortawesome/fontawesome-svg-core';
import type { IconDefinition, Library } from '@fortawesome/fontawesome-svg-core';
import {
faAngleDoubleLeft,
faAngleDown,
faAngleLeft,
faAngleRight,
faAngleUp,
faArchive,
faArrowLeft,
faArrowRight,
faArrowUp,
faArrowDown,
faAt,
faBan,
faBalanceScaleLeft,
faBars,
faBolt,
faBook,
faBoxOpen,
faBug,
faBrain,
faCalculator,
faCalendar,
faChartBar,
faCheck,
faCheckCircle,
faCheckSquare,
faChevronDown,
faChevronUp,
faCircle,
faChevronLeft,
faChevronRight,
faCode,
faCodeBranch,
faCog,
faCogs,
faComment,
faComments,
faClipboardList,
faClock,
faClone,
faCloud,
faCloudDownloadAlt,
faCopy,
faCube,
faCut,
faDatabase,
faDotCircle,
faEdit,
faEllipsisH,
faEllipsisV,
faEnvelope,
faEquals,
faEye,
faEyeSlash,
faExclamationTriangle,
faExpand,
faExpandAlt,
faExternalLinkAlt,
faExchangeAlt,
faFile,
faFileAlt,
faFileArchive,
faFileCode,
faFileDownload,
faFileExport,
faFileImport,
faFilePdf,
faFilter,
faFingerprint,
faFlask,
faFolder,
faFolderOpen,
faFont,
faGlobeAmericas,
faGift,
faGlobe,
faGraduationCap,
faGripLinesVertical,
faGripVertical,
faHandHoldingUsd,
faHandScissors,
faHandPointLeft,
faHandshake,
faUserCheck,
faHashtag,
faHdd,
faHistory,
faHome,
faHourglass,
faImage,
faInbox,
faInfo,
faInfoCircle,
faKey,
faLanguage,
faLayerGroup,
faLink,
faList,
faLightbulb,
faLock,
faMapSigns,
faMousePointer,
faNetworkWired,
faPalette,
faPause,
faPauseCircle,
faPen,
faPencilAlt,
faPlay,
faPlayCircle,
faPlug,
faPlus,
faPlusCircle,
faPlusSquare,
faQuestion,
faQuestionCircle,
faRedo,
faRobot,
faRss,
faSave,
faSatelliteDish,
faSearch,
faSearchMinus,
faSearchPlus,
faServer,
faScrewdriver,
faSmile,
faSignInAlt,
faSignOutAlt,
faSlidersH,
faSpinner,
faStop,
faSun,
faSync,
faSyncAlt,
faTable,
faTags,
faTasks,
faTerminal,
faThLarge,
faThumbtack,
faThumbsDown,
faThumbsUp,
faTimes,
faTimesCircle,
faToolbox,
faTrash,
faUndo,
faUnlink,
faUser,
faUserCircle,
faUserFriends,
faUsers,
faVectorSquare,
faVideo,
faTree,
faStickyNote as faSolidStickyNote,
faUserLock,
faGem,
faDownload,
faRemoveFormat,
faTools,
faProjectDiagram,
faStream,
faPowerOff,
faPaperPlane,
faExclamationCircle,
faMinusCircle,
faAdjust,
} from '@fortawesome/free-solid-svg-icons';
import { faVariable, faXmark, faVault, faRefresh, faTriangle } from './custom';
import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
function addIcon(icon: IconDefinition) {
library.add(icon);
}
export const FontAwesomePlugin: Plugin = {
install: (app) => {
addIcon(faAngleDoubleLeft);
addIcon(faAngleDown);
addIcon(faAngleLeft);
addIcon(faAngleRight);
addIcon(faAngleUp);
addIcon(faArchive);
addIcon(faArrowLeft);
addIcon(faArrowRight);
addIcon(faArrowUp);
addIcon(faArrowDown);
addIcon(faAt);
addIcon(faBan);
addIcon(faBalanceScaleLeft);
addIcon(faBars);
addIcon(faBolt);
addIcon(faBook);
addIcon(faBoxOpen);
addIcon(faBug);
addIcon(faBrain);
addIcon(faCalculator);
addIcon(faCalendar);
addIcon(faChartBar);
addIcon(faCheck);
addIcon(faCheckCircle);
addIcon(faCheckSquare);
addIcon(faChevronLeft);
addIcon(faChevronRight);
addIcon(faChevronDown);
addIcon(faChevronUp);
addIcon(faCircle);
addIcon(faCode);
addIcon(faCodeBranch);
addIcon(faCog);
addIcon(faCogs);
addIcon(faComment);
addIcon(faComments);
addIcon(faClipboardList);
addIcon(faClock);
addIcon(faClone);
addIcon(faCloud);
addIcon(faCloudDownloadAlt);
addIcon(faCopy);
addIcon(faCube);
addIcon(faCut);
addIcon(faDatabase);
addIcon(faDotCircle);
addIcon(faGripLinesVertical);
addIcon(faGripVertical);
addIcon(faEdit);
addIcon(faEllipsisH);
addIcon(faEllipsisV);
addIcon(faEnvelope);
addIcon(faEquals);
addIcon(faEye);
addIcon(faEyeSlash);
addIcon(faExclamationTriangle);
addIcon(faExclamationCircle);
addIcon(faExpand);
addIcon(faExpandAlt);
addIcon(faExternalLinkAlt);
addIcon(faExchangeAlt);
addIcon(faFile);
addIcon(faFileAlt);
addIcon(faFileArchive);
addIcon(faFileCode);
addIcon(faFileDownload);
addIcon(faFileExport);
addIcon(faFileImport);
addIcon(faFilePdf);
addIcon(faFilter);
addIcon(faFingerprint);
addIcon(faFlask);
addIcon(faFolder);
addIcon(faFolderOpen);
addIcon(faFont);
addIcon(faGift);
addIcon(faGlobe);
addIcon(faGlobeAmericas);
addIcon(faGraduationCap);
addIcon(faHandHoldingUsd);
addIcon(faHandScissors);
addIcon(faHandshake);
addIcon(faHandPointLeft);
addIcon(faHashtag);
addIcon(faUserCheck);
addIcon(faHdd);
addIcon(faHistory);
addIcon(faHome);
addIcon(faHourglass);
addIcon(faImage);
addIcon(faInbox);
addIcon(faInfo);
addIcon(faInfoCircle);
addIcon(faKey);
addIcon(faLanguage);
addIcon(faLayerGroup);
addIcon(faLink);
addIcon(faList);
addIcon(faLightbulb);
addIcon(faLock);
addIcon(faMapSigns);
addIcon(faMousePointer);
addIcon(faNetworkWired);
addIcon(faPalette);
addIcon(faPause);
addIcon(faPauseCircle);
addIcon(faPen);
addIcon(faPencilAlt);
addIcon(faPlay);
addIcon(faPlayCircle);
addIcon(faPlug);
addIcon(faPlus);
addIcon(faPlusCircle);
addIcon(faPlusSquare);
addIcon(faProjectDiagram);
addIcon(faQuestion);
addIcon(faQuestionCircle);
addIcon(faRedo);
addIcon(faRemoveFormat);
addIcon(faRobot);
addIcon(faRss);
addIcon(faSave);
addIcon(faSatelliteDish);
addIcon(faSearch);
addIcon(faSearchMinus);
addIcon(faSearchPlus);
addIcon(faServer);
addIcon(faScrewdriver);
addIcon(faSmile);
addIcon(faSignInAlt);
addIcon(faSignOutAlt);
addIcon(faSlidersH);
addIcon(faSpinner);
addIcon(faSolidStickyNote);
addIcon(faStickyNote as IconDefinition);
addIcon(faStop);
addIcon(faStream);
addIcon(faSun);
addIcon(faSync);
addIcon(faSyncAlt);
addIcon(faTable);
addIcon(faTags);
addIcon(faTasks);
addIcon(faTerminal);
addIcon(faThLarge);
addIcon(faThumbtack);
addIcon(faThumbsDown);
addIcon(faThumbsUp);
addIcon(faTimes);
addIcon(faTimesCircle);
addIcon(faToolbox);
addIcon(faTools);
addIcon(faTrash);
addIcon(faTriangle);
addIcon(faUndo);
addIcon(faUnlink);
addIcon(faUser);
addIcon(faUserCircle);
addIcon(faUserFriends);
addIcon(faUsers);
addIcon(faVariable);
addIcon(faVault);
addIcon(faVectorSquare);
addIcon(faVideo);
addIcon(faTree);
addIcon(faUserLock);
addIcon(faGem);
addIcon(faXmark);
addIcon(faDownload);
addIcon(faPowerOff);
addIcon(faPaperPlane);
addIcon(faRefresh);
addIcon(faMinusCircle);
addIcon(faAdjust);
app.component('FontAwesomeIcon', FontAwesomeIcon);
},
};
type LibraryWithDefinitions = Library & {
definitions: Record<string, Record<string, IconDefinition>>;
};
export const iconLibrary = library as LibraryWithDefinitions;
export const getAllIconNames = () => {
return Object.keys(iconLibrary.definitions.fas);
};

View File

@@ -0,0 +1,4 @@
import './icons';
import './directives';
import './components';
import './chartjs';

View File

@@ -0,0 +1,46 @@
import type * as Sentry from '@sentry/vue';
import { beforeSend } from '@/plugins/sentry';
import { AxiosError } from 'axios';
import { ResponseError } from '@/utils/apiUtils';
function createErrorEvent(): Sentry.ErrorEvent {
return {} as Sentry.ErrorEvent;
}
describe('beforeSend', () => {
it('should return null when originalException is undefined', () => {
const event = createErrorEvent();
const hint = { originalException: undefined };
expect(beforeSend(event, hint)).toBeNull();
});
it('should return null when originalException matches ignoredErrors by instance and message', () => {
const event = createErrorEvent();
const hint = { originalException: new ResponseError("Can't connect to n8n.") };
expect(beforeSend(event, hint)).toBeNull();
});
it('should return null when originalException matches ignoredErrors by instance and message regex', () => {
const event = createErrorEvent();
const hint = { originalException: new ResponseError('ECONNREFUSED') };
expect(beforeSend(event, hint)).toBeNull();
});
it('should return null when originalException matches ignoredErrors by instance only', () => {
const event = createErrorEvent();
const hint = { originalException: new AxiosError() };
expect(beforeSend(event, hint)).toBeNull();
});
it('should return null when originalException matches ignoredErrors by instance and message regex (ResizeObserver)', () => {
const event = createErrorEvent();
const hint = { originalException: new Error('ResizeObserver loop limit exceeded') };
expect(beforeSend(event, hint)).toBeNull();
});
it('should return event when originalException does not match any ignoredErrors', () => {
const event = createErrorEvent();
const hint = { originalException: new Error('Some other error') };
expect(beforeSend(event, hint)).toEqual(event);
});
});

View File

@@ -0,0 +1,63 @@
import type { Plugin } from 'vue';
import { AxiosError } from 'axios';
import { ResponseError } from '@/utils/apiUtils';
import * as Sentry from '@sentry/vue';
const ignoredErrors = [
{ instanceof: AxiosError },
{ instanceof: ResponseError, message: /ECONNREFUSED/ },
{ instanceof: ResponseError, message: "Can't connect to n8n." },
{ instanceof: ResponseError, message: 'Unauthorized' },
{ instanceof: RangeError, message: /Position \d+ is out of range for changeset of length \d+/ },
{ instanceof: RangeError, message: /Invalid change range \d+ to \d+/ },
{ instanceof: RangeError, message: /Selection points outside of document$/ },
{ instanceof: Error, message: /ResizeObserver/ },
] as const;
export function beforeSend(event: Sentry.ErrorEvent, { originalException }: Sentry.EventHint) {
if (
!originalException ||
ignoredErrors.some((entry) => {
const typeMatch = originalException instanceof entry.instanceof;
if (!typeMatch) {
return false;
}
if ('message' in entry) {
if (entry.message instanceof RegExp) {
return entry.message.test(originalException.message ?? '');
} else {
return originalException.message === entry.message;
}
}
return true;
})
) {
return null;
}
return event;
}
export const SentryPlugin: Plugin = {
install: (app) => {
if (!window.sentry?.dsn) {
return;
}
const { dsn, release, environment, serverName } = window.sentry;
Sentry.init({
app,
dsn,
release,
environment,
beforeSend,
});
if (serverName) {
Sentry.setTag('server_name', serverName);
}
},
};

View File

@@ -0,0 +1,208 @@
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import { Telemetry } from '@/plugins/telemetry';
import { useSettingsStore } from '@/stores/settings.store';
import merge from 'lodash-es/merge';
import { createPinia, setActivePinia } from 'pinia';
let telemetry: Telemetry;
let settingsStore: ReturnType<typeof useSettingsStore>;
const MOCK_VERSION_CLI = '0.0.0';
describe('telemetry', () => {
beforeAll(() => {
telemetry = new Telemetry();
setActivePinia(createPinia());
settingsStore = useSettingsStore();
telemetry.init(
{ enabled: true, config: { url: '', key: '' } },
{ versionCli: '1', instanceId: '1' },
);
});
beforeEach(() => vitest.clearAllMocks());
describe('identify', () => {
it('Rudderstack identify method should be called when proving userId ', () => {
const identifyFunction = vi.spyOn(window.rudderanalytics, 'identify');
const userId = '1';
const instanceId = '1';
settingsStore.setSettings(
merge({}, SETTINGS_STORE_DEFAULT_STATE.settings, {
deployment: {
type: '',
},
}),
);
telemetry.identify(userId, instanceId);
expect(identifyFunction).toHaveBeenCalledTimes(1);
expect(identifyFunction).toHaveBeenCalledWith(
`${instanceId}#${userId}`,
{ instance_id: instanceId },
{ context: { ip: '0.0.0.0' } },
);
});
it('Rudderstack identify method should be called when proving userId and versionCli ', () => {
const identifyFunction = vi.spyOn(window.rudderanalytics, 'identify');
const userId = '1';
const instanceId = '1';
const versionCli = '1';
settingsStore.setSettings(
merge({}, SETTINGS_STORE_DEFAULT_STATE.settings, {
deployment: {
type: '',
},
}),
);
telemetry.identify(userId, instanceId, versionCli);
expect(identifyFunction).toHaveBeenCalledTimes(1);
expect(identifyFunction).toHaveBeenCalledWith(
`${instanceId}#${userId}`,
{
instance_id: instanceId,
version_cli: versionCli,
},
{ context: { ip: '0.0.0.0' } },
);
});
it('Rudderstack identify method should be called when proving userId and versionCli and projectId', () => {
const identifyFunction = vi.spyOn(window.rudderanalytics, 'identify');
const userId = '1';
const instanceId = '1';
const versionCli = '1';
const projectId = '1';
settingsStore.setSettings(
merge({}, SETTINGS_STORE_DEFAULT_STATE.settings, {
deployment: {
type: '',
},
}),
);
telemetry.identify(userId, instanceId, versionCli, projectId);
expect(identifyFunction).toHaveBeenCalledTimes(1);
expect(identifyFunction).toHaveBeenCalledWith(
`${instanceId}#${userId}#${projectId}`,
{
instance_id: instanceId,
version_cli: versionCli,
},
{ context: { ip: '0.0.0.0' } },
);
});
it('Rudderstack identify method should be called when proving userId and deployment type is cloud ', () => {
const identifyFunction = vi.spyOn(window.rudderanalytics, 'identify');
const userId = '1';
const instanceId = '1';
const versionCli = '1';
const userCloudId = '1';
settingsStore.setSettings(
merge({}, SETTINGS_STORE_DEFAULT_STATE.settings, {
n8nMetadata: {
userId: userCloudId,
},
deployment: {
type: 'cloud',
},
}),
);
telemetry.identify(userId, instanceId, versionCli);
expect(identifyFunction).toHaveBeenCalledTimes(1);
expect(identifyFunction).toHaveBeenCalledWith(
`${instanceId}#${userId}`,
{
instance_id: instanceId,
version_cli: versionCli,
user_cloud_id: userCloudId,
},
{ context: { ip: '0.0.0.0' } },
);
});
it('Rudderstack identify method should be called when proving userId and deployment type is cloud', () => {
const identifyFunction = vi.spyOn(window.rudderanalytics, 'identify');
const userId = '1';
const instanceId = '1';
const versionCli = '1';
const userCloudId = '1';
settingsStore.setSettings(
merge({}, SETTINGS_STORE_DEFAULT_STATE.settings, {
n8nMetadata: {
userId: userCloudId,
},
deployment: {
type: 'cloud',
},
}),
);
telemetry.identify(userId, instanceId, versionCli);
expect(identifyFunction).toHaveBeenCalledTimes(1);
expect(identifyFunction).toHaveBeenCalledWith(
`${instanceId}#${userId}`,
{
instance_id: instanceId,
version_cli: versionCli,
user_cloud_id: userCloudId,
},
{ context: { ip: '0.0.0.0' } },
);
});
it('Rudderstack reset method should be called when proving userId and deployment type is cloud', () => {
const resetFunction = vi.spyOn(window.rudderanalytics, 'reset');
const instanceId = '1';
settingsStore.setSettings(
merge({}, SETTINGS_STORE_DEFAULT_STATE.settings, {
deployment: {
type: '',
},
}),
);
telemetry.identify(instanceId);
expect(resetFunction).toHaveBeenCalledTimes(1);
});
});
describe('track function', () => {
it('should call Rudderstack track method with correct parameters', () => {
const trackFunction = vi.spyOn(window.rudderanalytics, 'track');
const event = 'testEvent';
const properties = { test: '1' };
const options = { withPostHog: false };
telemetry.track(event, properties, options);
expect(trackFunction).toHaveBeenCalledTimes(1);
expect(trackFunction).toHaveBeenCalledWith(
event,
{
...properties,
version_cli: MOCK_VERSION_CLI,
},
{ context: { ip: '0.0.0.0' } },
);
});
});
});

View File

@@ -0,0 +1,278 @@
import type { Plugin } from 'vue';
import type { ITelemetrySettings } from '@n8n/api-types';
import type { ITelemetryTrackProperties, IDataObject } from 'n8n-workflow';
import type { RouteLocation } from 'vue-router';
import type { IUpdateInformation } from '@/Interface';
import type { RudderStack } from './telemetry.types';
import {
APPEND_ATTRIBUTION_DEFAULT_PATH,
MICROSOFT_TEAMS_NODE_TYPE,
SLACK_NODE_TYPE,
TELEGRAM_NODE_TYPE,
} from '@/constants';
import { useRootStore } from '@/stores/root.store';
import { useNDVStore } from '@/stores/ndv.store';
import { usePostHog } from '@/stores/posthog.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
export class Telemetry {
private pageEventQueue: Array<{ route: RouteLocation }>;
private previousPath: string;
private get rudderStack(): RudderStack | undefined {
return window.rudderanalytics;
}
constructor() {
this.pageEventQueue = [];
this.previousPath = '';
}
init(
telemetrySettings: ITelemetrySettings,
{
instanceId,
userId,
projectId,
versionCli,
}: {
instanceId: string;
userId?: string;
projectId?: string;
versionCli: string;
},
) {
if (!telemetrySettings.enabled || !telemetrySettings.config || this.rudderStack) return;
const {
config: { key, url },
} = telemetrySettings;
const settingsStore = useSettingsStore();
const rootStore = useRootStore();
const logLevel = settingsStore.logLevel;
const logging = logLevel === 'debug' ? { logLevel: 'DEBUG' } : {};
this.initRudderStack(key, url, {
integrations: { All: false },
loadIntegration: false,
configUrl: 'https://api-rs.n8n.io',
...logging,
});
this.identify(instanceId, userId, versionCli, projectId);
this.flushPageEvents();
this.track('Session started', { session_id: rootStore.pushRef });
}
identify(instanceId: string, userId?: string, versionCli?: string, projectId?: string) {
const settingsStore = useSettingsStore();
const traits: { instance_id: string; version_cli?: string; user_cloud_id?: string } = {
instance_id: instanceId,
version_cli: versionCli,
};
if (settingsStore.isCloudDeployment) {
traits.user_cloud_id = settingsStore.settings?.n8nMetadata?.userId ?? '';
}
if (userId) {
this.rudderStack?.identify(
`${instanceId}#${userId}${projectId ? '#' + projectId : ''}`,
traits,
{
context: {
// provide a fake IP address to instruct RudderStack to not use the user's IP address
ip: '0.0.0.0',
},
},
);
} else {
this.rudderStack?.reset();
}
}
track(
event: string,
properties?: ITelemetryTrackProperties,
options: { withPostHog?: boolean } = {},
) {
if (!this.rudderStack) return;
const updatedProperties = {
...properties,
version_cli: useRootStore().versionCli,
};
this.rudderStack.track(event, updatedProperties, {
context: {
// provide a fake IP address to instruct RudderStack to not use the user's IP address
ip: '0.0.0.0',
},
});
if (options.withPostHog) {
usePostHog().capture(event, updatedProperties);
}
}
page(route: RouteLocation) {
if (this.rudderStack) {
if (route.path === this.previousPath) {
// avoid duplicate requests query is changed for example on search page
return;
}
this.previousPath = route.path;
const pageName = String(route.name);
let properties: Record<string, unknown> = {};
if (route.meta?.telemetry && typeof route.meta.telemetry.getProperties === 'function') {
properties = route.meta.telemetry.getProperties(route);
}
properties.theme = useUIStore().appliedTheme;
const category = route.meta?.telemetry?.pageCategory || 'Editor';
this.rudderStack.page(category, pageName, properties, {
context: {
// provide a fake IP address to instruct RudderStack to not use the user's IP address
ip: '0.0.0.0',
},
});
} else {
this.pageEventQueue.push({
route,
});
}
}
reset() {
this.rudderStack?.reset();
}
flushPageEvents() {
const queue = this.pageEventQueue;
this.pageEventQueue = [];
queue.forEach(({ route }) => {
this.page(route);
});
}
trackAskAI(event: string, properties: IDataObject = {}) {
if (this.rudderStack) {
properties.session_id = useRootStore().pushRef;
properties.ndv_session_id = useNDVStore().pushRef;
switch (event) {
case 'askAi.generationFinished':
this.track('Ai code generation finished', properties, { withPostHog: true });
default:
break;
}
}
}
trackAiTransform(event: string, properties: IDataObject = {}) {
if (this.rudderStack) {
properties.session_id = useRootStore().pushRef;
properties.ndv_session_id = useNDVStore().pushRef;
switch (event) {
case 'generationFinished':
this.track('Ai Transform code generation finished', properties, { withPostHog: true });
default:
break;
}
}
}
// We currently do not support tracking directly from within node implementation
// so we are using this method as centralized way to track node parameters changes
trackNodeParametersValuesChange(nodeType: string, change: IUpdateInformation) {
if (this.rudderStack) {
const changeNameMap: { [key: string]: string } = {
[SLACK_NODE_TYPE]: 'parameters.otherOptions.includeLinkToWorkflow',
[MICROSOFT_TEAMS_NODE_TYPE]: 'parameters.options.includeLinkToWorkflow',
[TELEGRAM_NODE_TYPE]: 'parameters.additionalFields.appendAttribution',
};
const changeName = changeNameMap[nodeType] || APPEND_ATTRIBUTION_DEFAULT_PATH;
if (change.name === changeName) {
this.track(
'User toggled n8n reference option',
{
node: nodeType,
toValue: change.value,
},
{ withPostHog: true },
);
}
}
}
private initRudderStack(key: string, url: string, options: IDataObject) {
window.rudderanalytics = window.rudderanalytics || [];
if (!this.rudderStack) {
return;
}
this.rudderStack.methods = [
'load',
'page',
'track',
'identify',
'alias',
'group',
'ready',
'reset',
'getAnonymousId',
'setAnonymousId',
];
this.rudderStack.factory = (method: string) => {
return (...args: unknown[]) => {
if (!this.rudderStack) {
throw new Error('RudderStack not initialized');
}
const argsCopy = [method, ...args];
this.rudderStack.push(argsCopy);
return this.rudderStack;
};
};
for (const method of this.rudderStack.methods) {
this.rudderStack[method] = this.rudderStack.factory(method);
}
this.rudderStack.loadJS = () => {
const script = document.createElement('script');
script.type = 'text/javascript';
script.async = !0;
script.src = 'https://cdn-rs.n8n.io/v1/ra.min.js';
const element: Element = document.getElementsByTagName('script')[0];
if (element && element.parentNode) {
element.parentNode.insertBefore(script, element);
}
};
this.rudderStack.loadJS();
this.rudderStack.load(key, url, options);
}
}
export const telemetry = new Telemetry();
export const TelemetryPlugin: Plugin = {
install(app) {
app.config.globalProperties.$telemetry = telemetry;
},
};

View File

@@ -0,0 +1,56 @@
declare global {
interface Window {
rudderanalytics: RudderStack;
}
}
export interface IUserNodesPanelSession {
pushRef: string;
data: IUserNodesPanelSessionData;
}
interface IUserNodesPanelSessionData {
nodeFilter: string;
resultsNodes: string[];
filterMode: string;
}
/**
* Simplified version of:
* https://github.com/rudderlabs/rudder-sdk-js/blob/master/dist/rudder-sdk-js/index.d.ts
*/
export interface RudderStack extends Array<unknown> {
[key: string]: unknown;
methods: string[];
factory: (method: string) => (...args: unknown[]) => RudderStack;
loadJS(): void;
/**
* Native methods
*/
load(writeKey: string, dataPlaneUrl: string, options?: object): void;
ready(): void;
page(category?: string, name?: string, properties?: object, options?: object): void;
track(event: string, properties?: object, options?: object): void;
identify(id?: string, traits?: object, options?: object): void;
alias(to: string, from?: string, options?: object): void;
group(group: string, traits?: object, options?: object): void;
getAnonymousId(): void;
setAnonymousId(id?: string): void;
reset(): void;
}
export {};