feat(editor): Add sections to autocomplete dropdown (#8720)

Co-authored-by: Giulio Andreini <andreini@netseven.it>
This commit is contained in:
Elias Meire
2024-03-07 17:01:05 +01:00
committed by GitHub
parent ed6dc86d60
commit 9b4618dd5e
35 changed files with 1308 additions and 468 deletions

View File

@@ -1,5 +1,5 @@
import { createTestingPinia } from '@pinia/testing';
import { createPinia, setActivePinia } from 'pinia';
import { setActivePinia } from 'pinia';
import { DateTime } from 'luxon';
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
@@ -21,20 +21,22 @@ 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 { setupServer } from '@/__tests__/server';
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';
let externalSecretsStore: ReturnType<typeof useExternalSecretsStore>;
let uiStore: ReturnType<typeof useUIStore>;
let settingsStore: ReturnType<typeof useSettingsStore>;
let server: ReturnType<typeof setupServer>;
beforeAll(() => {
server = setupServer();
});
beforeEach(async () => {
setActivePinia(createPinia());
setActivePinia(createTestingPinia());
externalSecretsStore = useExternalSecretsStore();
uiStore = useUIStore();
@@ -43,12 +45,6 @@ beforeEach(async () => {
vi.spyOn(utils, 'receivesNoBinaryData').mockReturnValue(true); // hide $binary
vi.spyOn(utils, 'isSplitInBatchesAbsent').mockReturnValue(false); // show context
vi.spyOn(utils, 'hasActiveNode').mockReturnValue(true);
await settingsStore.getSettings();
});
afterAll(() => {
server.shutdown();
});
describe('No completions', () => {
@@ -63,7 +59,18 @@ describe('No completions', () => {
describe('Top-level completions', () => {
test('should return dollar completions for blank position: {{ | }}', () => {
expect(completions('{{ | }}')).toHaveLength(dollarOptions().length);
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?.[14]).toEqual(
expect.objectContaining({ label: '$max()', section: METHODS_SECTION }),
);
});
test('should return DateTime completion for: {{ D| }}', () => {
@@ -98,77 +105,73 @@ describe('Top-level completions', () => {
});
test('should return node selector completions for: {{ $(| }}', () => {
const initialState = { workflows: { workflow: { nodes: mockNodes } } };
setActivePinia(createTestingPinia({ initialState }));
vi.spyOn(utils, 'autocompletableNodeNames').mockReturnValue(mockNodes.map((node) => node.name));
expect(completions('{{ $(| }}')).toHaveLength(mockNodes.length);
});
});
describe('Luxon method completions', () => {
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
test('should return class completions for: {{ DateTime.| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce(DateTime);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime);
expect(completions('{{ DateTime.| }}')).toHaveLength(luxonStaticOptions().length);
});
test('should return instance completions for: {{ $now.| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce(DateTime.now());
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now());
expect(completions('{{ $now.| }}')).toHaveLength(
luxonInstanceOptions().length + extensions('date').length,
uniqBy(luxonInstanceOptions().concat(extensions('date')), (option) => option.label).length +
LUXON_RECOMMENDED_OPTIONS.length,
);
});
test('should return instance completions for: {{ $today.| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce(DateTime.now());
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now());
expect(completions('{{ $today.| }}')).toHaveLength(
luxonInstanceOptions().length + extensions('date').length,
uniqBy(luxonInstanceOptions().concat(extensions('date')), (option) => option.label).length +
LUXON_RECOMMENDED_OPTIONS.length,
);
});
});
describe('Resolution-based completions', () => {
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
describe('literals', () => {
test('should return completions for string literal: {{ "abc".| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce('abc');
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('abc');
expect(completions('{{ "abc".| }}')).toHaveLength(
natives('string').length + extensions('string').length,
natives('string').length + extensions('string').length + STRING_RECOMMENDED_OPTIONS.length,
);
});
test('should properly handle string that contain dollar signs', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce('"You \'owe\' me 200$"');
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce("You 'owe' me 200$ ");
expect(completions('{{ "You \'owe\' me 200$".| }}')).toHaveLength(
natives('string').length + extensions('string').length,
);
const result = completions('{{ "You \'owe\' me 200$".| }}');
expect(result).toHaveLength(natives('string').length + extensions('string').length + 1);
});
test('should return completions for number literal: {{ (123).| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce(123);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(123);
expect(completions('{{ (123).| }}')).toHaveLength(
natives('number').length + extensions('number').length,
natives('number').length + extensions('number').length + ['isEven()', 'isOdd()'].length,
);
});
test('should return completions for array literal: {{ [1, 2, 3].| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce([1, 2, 3]);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce([1, 2, 3]);
expect(completions('{{ [1, 2, 3].| }}')).toHaveLength(
natives('array').length + extensions('array').length,
@@ -177,7 +180,7 @@ describe('Resolution-based completions', () => {
test('should return completions for Object methods: {{ Object.values({ abc: 123 }).| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce([123]);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce([123]);
const found = completions('{{ Object.values({ abc: 123 }).| }}');
@@ -189,10 +192,10 @@ describe('Resolution-based completions', () => {
test('should return completions for object literal', () => {
const object = { a: 1 };
resolveParameterSpy.mockReturnValueOnce(object);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(object);
expect(completions('{{ ({ a: 1 }).| }}')).toHaveLength(
Object.keys(object).length + natives('object').length + extensions('object').length,
Object.keys(object).length + extensions('object').length,
);
});
});
@@ -200,23 +203,24 @@ describe('Resolution-based completions', () => {
describe('indexed access completions', () => {
test('should return string completions for indexed access that resolves to string literal: {{ "abc"[0].| }}', () => {
// @ts-expect-error Spied function is mistyped
resolveParameterSpy.mockReturnValueOnce('a');
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('a');
expect(completions('{{ "abc"[0].| }}')).toHaveLength(
natives('string').length + extensions('string').length,
natives('string').length + extensions('string').length + STRING_RECOMMENDED_OPTIONS.length,
);
});
});
describe('complex expression completions', () => {
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
const { $input } = mockProxy;
test('should return completions when $input is used as a function parameter', () => {
resolveParameterSpy.mockReturnValue($input.item.json.num);
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('number').length + natives('number').length);
expect(found).toHaveLength(
extensions('number').length + natives('number').length + ['isEven()', 'isOdd()'].length,
);
});
test('should return completions when node reference is used as a function parameter', () => {
@@ -228,69 +232,74 @@ describe('Resolution-based completions', () => {
});
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(
natives('date').length + extensions('object').length,
uniqBy(luxonInstanceOptions().concat(extensions('date')), (option) => option.label).length +
LUXON_RECOMMENDED_OPTIONS.length,
);
});
test('should return completions for complex expression: {{ $execution.resumeUrl.includes($json.) }}', () => {
resolveParameterSpy.mockReturnValue($input.item.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 + natives('object').length);
expect(found).toHaveLength(Object.keys($json).length + extensions('object').length);
});
test('should return completions for operation expression: {{ $now.day + $json. }}', () => {
resolveParameterSpy.mockReturnValue($input.item.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 + natives('object').length);
expect(found).toHaveLength(Object.keys($json).length + extensions('object').length);
});
test('should return completions for operation expression: {{ Math.abs($now.day) >= 10 ? $now : Math.abs($json.). }}', () => {
resolveParameterSpy.mockReturnValue($input.item.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 + natives('object').length);
expect(found).toHaveLength(Object.keys($json).length + extensions('object').length);
});
});
describe('bracket-aware completions', () => {
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
const { $input } = mockProxy;
test('should return bracket-aware completions for: {{ $input.item.json.str.|() }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.str);
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('string').length + natives('string').length);
expect(found).toHaveLength(
extensions('string').length + natives('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.|() }}', () => {
resolveParameterSpy.mockReturnValue($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('number').length + natives('number').length);
expect(found).toHaveLength(
extensions('number').length + natives('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.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.arr);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.arr);
const found = completions('{{ $input.item.json.arr.|() }}');
@@ -302,17 +311,18 @@ describe('Resolution-based completions', () => {
});
describe('secrets', () => {
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
const { $input, $ } = mockProxy;
const { $input } = mockProxy;
beforeEach(() => {});
test('should return completions for: {{ $secrets.| }}', () => {
const provider = 'infisical';
const secrets = ['SECRET'];
resolveParameterSpy.mockReturnValue($input);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input);
uiStore.modals[CREDENTIAL_EDIT_MODAL_KEY].open = true;
settingsStore.settings.enterprise[EnterpriseEditionFeature.ExternalSecrets] = true;
set(settingsStore.settings, ['enterprise', EnterpriseEditionFeature.ExternalSecrets], true);
externalSecretsStore.state.secrets = {
[provider]: secrets,
};
@@ -324,6 +334,7 @@ describe('Resolution-based completions', () => {
info: expect.any(Function),
label: provider,
type: 'keyword',
apply: expect.any(Function),
},
]);
});
@@ -332,10 +343,10 @@ describe('Resolution-based completions', () => {
const provider = 'infisical';
const secrets = ['SECRET1', 'SECRET2'];
resolveParameterSpy.mockReturnValue($input);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input);
uiStore.modals[CREDENTIAL_EDIT_MODAL_KEY].open = true;
settingsStore.settings.enterprise[EnterpriseEditionFeature.ExternalSecrets] = true;
set(settingsStore.settings, ['enterprise', EnterpriseEditionFeature.ExternalSecrets], true);
externalSecretsStore.state.secrets = {
[provider]: secrets,
};
@@ -347,138 +358,141 @@ describe('Resolution-based completions', () => {
info: expect.any(Function),
label: secrets[0],
type: 'keyword',
apply: expect.any(Function),
},
{
info: expect.any(Function),
label: secrets[1],
type: 'keyword',
apply: expect.any(Function),
},
]);
});
});
describe('references', () => {
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
const { $input, $ } = mockProxy;
test('should return completions for: {{ $input.| }}', () => {
resolveParameterSpy.mockReturnValue($input);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input);
expect(completions('{{ $input.| }}')).toHaveLength(
Reflect.ownKeys($input).length + natives('object').length,
);
expect(completions('{{ $input.| }}')).toHaveLength(Reflect.ownKeys($input).length);
});
test('should return completions for: {{ "hello"+input.| }}', () => {
resolveParameterSpy.mockReturnValue($input);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input);
expect(completions('{{ "hello"+$input.| }}')).toHaveLength(
Reflect.ownKeys($input).length + natives('object').length,
);
expect(completions('{{ "hello"+$input.| }}')).toHaveLength(Reflect.ownKeys($input).length);
});
test("should return completions for: {{ $('nodeName').| }}", () => {
resolveParameterSpy.mockReturnValue($('Rename'));
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($('Rename'));
expect(completions('{{ $("Rename").| }}')).toHaveLength(
Reflect.ownKeys($('Rename')).length + natives('object').length - ['pairedItem'].length,
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.| }}', () => {
resolveParameterSpy.mockReturnValue($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(3);
expect(found).toHaveLength(1);
expect(found[0].label).toBe('json');
});
test('should return completions for: {{ $input.first().| }}', () => {
resolveParameterSpy.mockReturnValue($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(3);
expect(found).toHaveLength(1);
expect(found[0].label).toBe('json');
});
test('should return completions for: {{ $input.last().| }}', () => {
resolveParameterSpy.mockReturnValue($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(3);
expect(found).toHaveLength(1);
expect(found[0].label).toBe('json');
});
test('should return no completions for: {{ $input.all().| }}', () => {
test('should return completions for: {{ $input.all().| }}', () => {
// @ts-expect-error
resolveParameterSpy.mockReturnValue([$input.item]);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue([$input.item]);
expect(completions('{{ $input.all().| }}')).toBeNull();
expect(completions('{{ $input.all().| }}')).toHaveLength(
extensions('array').length + natives('array').length - ARRAY_NUMBER_ONLY_METHODS.length,
);
});
test("should return completions for: '{{ $input.item.| }}'", () => {
resolveParameterSpy.mockReturnValue($input.item.json);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json);
expect(completions('{{ $input.item.| }}')).toHaveLength(
Object.keys($input.item.json).length +
(extensions('object').length + natives('object').length),
Object.keys($input.item.json).length + extensions('object').length,
);
});
test("should return completions for: '{{ $input.first().| }}'", () => {
resolveParameterSpy.mockReturnValue($input.first().json);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.first().json);
expect(completions('{{ $input.first().| }}')).toHaveLength(
Object.keys($input.first().json).length +
(extensions('object').length + natives('object').length),
Object.keys($input.first().json).length + extensions('object').length,
);
});
test("should return completions for: '{{ $input.last().| }}'", () => {
resolveParameterSpy.mockReturnValue($input.last().json);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.last().json);
expect(completions('{{ $input.last().| }}')).toHaveLength(
Object.keys($input.last().json).length +
(extensions('object').length + natives('object').length),
Object.keys($input.last().json).length + extensions('object').length,
);
});
test("should return completions for: '{{ $input.all()[0].| }}'", () => {
resolveParameterSpy.mockReturnValue($input.all()[0].json);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.all()[0].json);
expect(completions('{{ $input.all()[0].| }}')).toHaveLength(
Object.keys($input.all()[0].json).length +
(extensions('object').length + natives('object').length),
Object.keys($input.all()[0].json).length + extensions('object').length,
);
});
test('should return completions for: {{ $input.item.json.str.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.str);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.str);
expect(completions('{{ $input.item.json.str.| }}')).toHaveLength(
extensions('string').length + natives('string').length,
extensions('string').length + natives('string').length + STRING_RECOMMENDED_OPTIONS.length,
);
});
test('should return completions for: {{ $input.item.json.num.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.num);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.num);
expect(completions('{{ $input.item.json.num.| }}')).toHaveLength(
extensions('number').length + natives('number').length,
extensions('number').length + natives('number').length + ['isEven()', 'isOdd()'].length,
);
});
test('should return completions for: {{ $input.item.json.arr.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.arr);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.arr);
expect(completions('{{ $input.item.json.arr.| }}')).toHaveLength(
extensions('array').length + natives('array').length,
@@ -486,22 +500,20 @@ describe('Resolution-based completions', () => {
});
test('should return completions for: {{ $input.item.json.obj.| }}', () => {
resolveParameterSpy.mockReturnValue($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('object').length + natives('object').length),
Object.keys($input.item.json.obj).length + extensions('object').length,
);
});
});
describe('bracket access', () => {
const resolveParameterSpy = vi.spyOn(workflowHelpers, 'resolveParameter');
const { $input } = mockProxy;
['{{ $input.item.json[| }}', '{{ $json[| }}'].forEach((expression) => {
test(`should return completions for: ${expression}`, () => {
resolveParameterSpy.mockReturnValue($input.item.json);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json);
const found = completions(expression);
@@ -514,7 +526,7 @@ describe('Resolution-based completions', () => {
["{{ $input.item.json['obj'][| }}", "{{ $json['obj'][| }}"].forEach((expression) => {
test(`should return completions for: ${expression}`, () => {
resolveParameterSpy.mockReturnValue($input.item.json.obj);
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.obj);
const found = completions(expression);
@@ -525,6 +537,68 @@ describe('Resolution-based completions', () => {
});
});
});
describe('recommended completions', () => {
test('should recommended toDate() for {{ "1-Feb-2024".| }}', () => {
// @ts-expect-error Spied function is mistyped
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('1-Feb-2024');
expect(completions('{{ "1-Feb-2024".| }}')?.[0]).toEqual(
expect.objectContaining({ label: 'toDate()', section: RECOMMENDED_SECTION }),
);
});
test('should recommended toInt(),toFloat() for: {{ "5.3".| }}', () => {
// @ts-expect-error Spied function is mistyped
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('5.3');
const options = completions('{{ "5.3".| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'toInt()', section: RECOMMENDED_SECTION }),
);
expect(options?.[1]).toEqual(
expect.objectContaining({ label: 'toFloat()', section: RECOMMENDED_SECTION }),
);
});
test('should recommended extractEmail() for: {{ "string with test@n8n.io in it".| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
// @ts-expect-error Spied function is mistyped
'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 recommended extractDomain() for: {{ "test@n8n.io".| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
// @ts-expect-error Spied function is mistyped
'test@n8n.io',
);
const options = completions('{{ "test@n8n.io".| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'extractDomain()', section: RECOMMENDED_SECTION }),
);
});
test('should recommended round(),floor(),ceil() for: {{ (5.46).| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
// @ts-expect-error Spied function is mistyped
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 }),
);
});
});
});
export function completions(docWithCursor: string) {

View File

@@ -0,0 +1,192 @@
import type { Completion, CompletionSection } from '@codemirror/autocomplete';
import { i18n } from '@/plugins/i18n';
import { withSectionHeader } from './utils';
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: i18n.rootVars.$json,
},
{
label: '$binary',
section: RECOMMENDED_SECTION,
info: i18n.rootVars.$binary,
},
{
label: '$now',
section: RECOMMENDED_SECTION,
info: i18n.rootVars.$now,
},
{
label: '$if()',
section: RECOMMENDED_SECTION,
info: i18n.rootVars.$if,
},
{
label: '$ifEmpty()',
section: RECOMMENDED_SECTION,
info: i18n.rootVars.$ifEmpty,
},
{
label: '$execution',
section: METADATA_SECTION,
info: i18n.rootVars.$execution,
},
{
label: '$itemIndex',
section: METADATA_SECTION,
info: i18n.rootVars.$itemIndex,
},
{
label: '$input',
section: METADATA_SECTION,
info: i18n.rootVars.$input,
},
{
label: '$parameter',
section: METADATA_SECTION,
info: i18n.rootVars.$parameter,
},
{
label: '$prevNode',
section: METADATA_SECTION,
info: i18n.rootVars.$prevNode,
},
{
label: '$runIndex',
section: METADATA_SECTION,
info: i18n.rootVars.$runIndex,
},
{
label: '$today',
section: METADATA_SECTION,
info: i18n.rootVars.$today,
},
{
label: '$vars',
section: METADATA_SECTION,
info: i18n.rootVars.$vars,
},
{
label: '$workflow',
section: METADATA_SECTION,
info: i18n.rootVars.$workflow,
},
{
label: '$jmespath()',
section: METHODS_SECTION,
info: i18n.rootVars.$jmespath,
},
{
label: '$max()',
section: METHODS_SECTION,
info: i18n.rootVars.$max,
},
{
label: '$min()',
section: METHODS_SECTION,
info: i18n.rootVars.$min,
},
];
export const STRING_RECOMMENDED_OPTIONS = [
'includes()',
'split()',
'startsWith()',
'replaceAll()',
'length',
];
export const DATE_RECOMMENDED_OPTIONS = ['format()', 'minus()', 'plus()', 'extract()'];
export const LUXON_RECOMMENDED_OPTIONS = ['format()', 'minus()', 'plus()', 'diff()', 'extract()'];
export const OBJECT_RECOMMENDED_OPTIONS = ['keys()', 'values()', 'isEmpty()'];
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

@@ -1,5 +1,5 @@
import type { IDataObject, DocMetadata, NativeDoc } from 'n8n-workflow';
import { Expression, ExpressionExtensions, NativeMethods } from 'n8n-workflow';
import { Expression, ExpressionExtensions, NativeMethods, validateFieldType } from 'n8n-workflow';
import { DateTime } from 'luxon';
import { i18n } from '@/plugins/i18n';
import { resolveParameter } from '@/composables/useWorkflowHelpers';
@@ -14,15 +14,48 @@ import {
isPseudoParam,
stripExcessParens,
isCredentialsModalOpen,
applyCompletion,
sortCompletionsAlpha,
hasRequiredArgs,
} from './utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { AutocompleteOptionType, ExtensionTypeName, FnToDoc, Resolved } from './types';
import type {
Completion,
CompletionContext,
CompletionResult,
CompletionSection,
} from '@codemirror/autocomplete';
import type {
AutocompleteInput,
AutocompleteOptionType,
ExtensionTypeName,
FnToDoc,
Resolved,
} from './types';
import { sanitizeHtml } from '@/utils/htmlUtils';
import { isFunctionOption } from './typeGuards';
import { luxonInstanceDocs } from './nativesAutocompleteDocs/luxon.instance.docs';
import { luxonStaticDocs } from './nativesAutocompleteDocs/luxon.static.docs';
import { useEnvironmentsStore } from '@/stores/environments.ee.store';
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import {
ARRAY_NUMBER_ONLY_METHODS,
ARRAY_RECOMMENDED_OPTIONS,
DATE_RECOMMENDED_OPTIONS,
FIELDS_SECTION,
LUXON_RECOMMENDED_OPTIONS,
LUXON_SECTIONS,
METHODS_SECTION,
OBJECT_RECOMMENDED_OPTIONS,
OTHER_METHODS_SECTION,
OTHER_SECTION,
PROPERTIES_SECTION,
RECOMMENDED_METHODS_SECTION,
RECOMMENDED_SECTION,
STRING_RECOMMENDED_OPTIONS,
STRING_SECTIONS,
} from './constants';
import { VALID_EMAIL_REGEX } from '@/constants';
import { uniqBy } from 'lodash-es';
/**
* Resolution-based completions offered according to datatype.
@@ -63,7 +96,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
if (resolved === null) return null;
try {
options = datatypeOptions(resolved, base).map(stripExcessParens(context));
options = datatypeOptions({ resolved, base, tail }).map(stripExcessParens(context));
} catch {
return null;
}
@@ -87,40 +120,33 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
};
}
function datatypeOptions(resolved: Resolved, toResolve: string) {
function datatypeOptions(input: AutocompleteInput): Completion[] {
const { resolved } = input;
if (resolved === null) return [];
if (typeof resolved === 'number') {
return [...natives('number'), ...extensions('number')];
return numberOptions(input as AutocompleteInput<number>);
}
if (typeof resolved === 'string') {
return [...natives('string'), ...extensions('string')];
return stringOptions(input as AutocompleteInput<string>);
}
if (['$now', '$today'].includes(toResolve)) {
return [...luxonInstanceOptions(), ...extensions('date')];
if (resolved instanceof DateTime) {
return luxonOptions();
}
if (resolved instanceof Date) {
return [...natives('date'), ...extensions('date')];
return dateOptions();
}
if (Array.isArray(resolved)) {
if (/all\(.*?\)/.test(toResolve)) return [];
const arrayMethods = [...natives('array'), ...extensions('array')];
if (resolved.length > 0 && resolved.some((i) => typeof i !== 'number')) {
const NUMBER_ONLY_ARRAY_EXTENSIONS = new Set(['max()', 'min()', 'sum()', 'average()']);
return arrayMethods.filter((m) => !NUMBER_ONLY_ARRAY_EXTENSIONS.has(m.label));
}
return arrayMethods;
return arrayOptions(input as AutocompleteInput<unknown[]>);
}
if (typeof resolved === 'object') {
return objectOptions(toResolve, resolved);
return objectOptions(input as AutocompleteInput<IDataObject>);
}
return [];
@@ -137,7 +163,7 @@ export const natives = (typeName: ExtensionTypeName): Completion[] => {
return [...nativeProps, ...nativeMethods];
};
export const extensions = (typeName: ExtensionTypeName) => {
export const extensions = (typeName: ExtensionTypeName, includeHidden = false) => {
const extensions = ExpressionExtensions.find((ee) => ee.typeName.toLowerCase() === typeName);
if (!extensions) return [];
@@ -146,18 +172,20 @@ export const extensions = (typeName: ExtensionTypeName) => {
return { ...acc, [fnName]: { doc: fn.doc } };
}, {});
return toOptions(fnToDoc, typeName, 'extension-function');
return toOptions(fnToDoc, typeName, 'extension-function', includeHidden);
};
export const toOptions = (
fnToDoc: FnToDoc,
typeName: ExtensionTypeName,
optionType: AutocompleteOptionType = 'native-function',
includeHidden = false,
) => {
return Object.entries(fnToDoc)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([fnName, fn]) => {
return createCompletionOption(typeName, fnName, optionType, fn);
.filter(([, docInfo]) => !docInfo.doc?.hidden || includeHidden)
.map(([fnName, docInfo]) => {
return createCompletionOption(typeName, fnName, optionType, docInfo);
});
};
@@ -167,9 +195,13 @@ const createCompletionOption = (
optionType: AutocompleteOptionType,
docInfo: { doc?: DocMetadata | undefined },
): Completion => {
const isFunction = isFunctionOption(optionType);
const label = isFunction ? name + '()' : name;
const option: Completion = {
label: isFunctionOption(optionType) ? name + '()' : name,
label,
type: optionType,
section: docInfo.doc?.section,
apply: applyCompletion(hasRequiredArgs(docInfo?.doc)),
};
option.info = () => {
@@ -271,15 +303,16 @@ const createPropHeader = (typeName: string, property: { doc?: DocMetadata | unde
return header;
};
const objectOptions = (toResolve: string, resolved: IDataObject) => {
const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
const { base, resolved } = input;
const rank = setRank(['item', 'all', 'first', 'last']);
const SKIP = new Set(['__ob__', 'pairedItem']);
if (isSplitInBatchesAbsent()) SKIP.add('context');
const name = toResolve.startsWith('$(') ? '$()' : toResolve;
const name = /^\$\(.*\)$/.test(base) ? '$()' : base;
if (['$input', '$()'].includes(name) && hasNoParams(toResolve)) SKIP.add('params');
if (['$input', '$()'].includes(name) && hasNoParams(base)) SKIP.add('params');
let rawKeys = Object.keys(resolved);
@@ -287,7 +320,7 @@ const objectOptions = (toResolve: string, resolved: IDataObject) => {
rawKeys = Reflect.ownKeys(resolved) as string[];
}
if (toResolve === 'Math') {
if (base === 'Math') {
const descriptors = Object.getOwnPropertyDescriptors(Math);
rawKeys = Object.keys(descriptors).sort((a, b) => a.localeCompare(b));
}
@@ -296,27 +329,26 @@ const objectOptions = (toResolve: string, resolved: IDataObject) => {
.filter((key) => !SKIP.has(key) && isAllowedInDotNotation(key) && !isPseudoParam(key))
.map((key) => {
ensureKeyCanBeResolved(resolved, key);
const resolvedProp = resolved[key];
const isFunction = typeof resolved[key] === 'function';
const isFunction = typeof resolvedProp === 'function';
const hasArgs = isFunction && resolvedProp.length > 0 && name !== '$()';
const option: Completion = {
label: isFunction ? key + '()' : key,
type: isFunction ? 'function' : 'keyword',
section: getObjectPropertySection({ name, key, isFunction }),
apply: applyCompletion(hasArgs),
};
const infoKey = [name, key].join('.');
option.info = createCompletionOption(
'Object',
key,
isFunction ? 'native-function' : 'keyword',
{
doc: {
name: key,
returnType: typeof resolved[key],
description: i18n.proxyVars[infoKey],
},
option.info = createCompletionOption('', key, isFunction ? 'native-function' : 'keyword', {
doc: {
name: key,
returnType: typeof resolvedProp,
description: i18n.proxyVars[infoKey],
},
).info;
}).info;
return option;
});
@@ -324,14 +356,192 @@ const objectOptions = (toResolve: string, resolved: IDataObject) => {
const skipObjectExtensions =
resolved.isProxy ||
resolved.json ||
/json('])?$/.test(toResolve) ||
toResolve === '$execution' ||
toResolve.endsWith('params') ||
toResolve === 'Math';
/json('])$/.test(base) ||
base === '$execution' ||
base.endsWith('params') ||
base === 'Math';
if (skipObjectExtensions) return [...localKeys, ...natives('object')];
if (skipObjectExtensions) {
return sortCompletionsAlpha([...localKeys, ...natives('object')]);
}
return [...localKeys, ...natives('object'), ...extensions('object')];
return applySections({
options: sortCompletionsAlpha([...localKeys, ...natives('object'), ...extensions('object')]),
recommended: OBJECT_RECOMMENDED_OPTIONS,
recommendedSection: RECOMMENDED_METHODS_SECTION,
methodsSection: OTHER_METHODS_SECTION,
propSection: FIELDS_SECTION,
excludeRecommended: true,
});
};
const getObjectPropertySection = ({
name,
key,
isFunction,
}: {
name: string;
key: string;
isFunction: boolean;
}): CompletionSection => {
if (name === '$input' || name === '$()') {
if (key === 'item') return RECOMMENDED_SECTION;
return OTHER_SECTION;
}
return isFunction ? METHODS_SECTION : FIELDS_SECTION;
};
const applySections = ({
options,
sections,
recommended = [],
excludeRecommended = false,
methodsSection = METHODS_SECTION,
propSection = PROPERTIES_SECTION,
recommendedSection = RECOMMENDED_SECTION,
}: {
options: Completion[];
recommended?: string[];
recommendedSection?: CompletionSection;
methodsSection?: CompletionSection;
propSection?: CompletionSection;
sections?: Record<string, CompletionSection>;
excludeRecommended?: boolean;
}) => {
const recommendedSet = new Set(recommended);
const optionByLabel = options.reduce(
(acc, option) => {
acc[option.label] = option;
return acc;
},
{} as Record<string, Completion>,
);
return recommended
.map(
(reco): Completion => ({
...optionByLabel[reco],
section: recommendedSection,
}),
)
.concat(
options
.filter((option) => !excludeRecommended || !recommendedSet.has(option.label))
.map((option) => {
if (sections) {
option.section = sections[option.section as string] ?? OTHER_SECTION;
} else {
option.section = option.label.endsWith('()') ? methodsSection : propSection;
}
return option;
}),
);
};
const isUrl = (url: string): boolean => {
try {
new URL(url);
return true;
} catch (error) {
return false;
}
};
const stringOptions = (input: AutocompleteInput<string>): Completion[] => {
const { resolved, tail } = input;
const options = sortCompletionsAlpha([...natives('string'), ...extensions('string')]);
if (validateFieldType('string', resolved, 'number').valid) {
return applySections({
options,
recommended: ['toInt()', 'toFloat()'],
sections: STRING_SECTIONS,
});
}
if (validateFieldType('string', resolved, 'dateTime').valid) {
return applySections({
options,
recommended: ['toDate()'],
sections: STRING_SECTIONS,
});
}
if (VALID_EMAIL_REGEX.test(resolved) || isUrl(resolved)) {
return applySections({
options,
recommended: ['extractDomain()', 'isEmail()', ...STRING_RECOMMENDED_OPTIONS],
sections: STRING_SECTIONS,
});
}
if (resolved.split(/\s/).find((token) => VALID_EMAIL_REGEX.test(token))) {
return applySections({
options,
recommended: ['extractEmail()', ...STRING_RECOMMENDED_OPTIONS],
sections: STRING_SECTIONS,
});
}
return applySections({
options,
recommended: STRING_RECOMMENDED_OPTIONS,
sections: STRING_SECTIONS,
});
};
const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
const { resolved } = input;
const options = sortCompletionsAlpha([...natives('number'), ...extensions('number')]);
const ONLY_INTEGER = ['isEven()', 'isOdd()'];
if (Number.isInteger(resolved)) {
return applySections({
options,
recommended: ONLY_INTEGER,
});
} else {
const exclude = new Set(ONLY_INTEGER);
return applySections({
options: options.filter((option) => !exclude.has(option.label)),
recommended: ['round()', 'floor()', 'ceil()', 'toFixed()'],
});
}
};
const dateOptions = (): Completion[] => {
return applySections({
options: sortCompletionsAlpha([...natives('date'), ...extensions('date', true)]),
recommended: DATE_RECOMMENDED_OPTIONS,
});
};
const luxonOptions = (): Completion[] => {
return applySections({
options: sortCompletionsAlpha(
uniqBy([...extensions('date'), ...luxonInstanceOptions()], (option) => option.label),
),
recommended: LUXON_RECOMMENDED_OPTIONS,
sections: LUXON_SECTIONS,
});
};
const arrayOptions = (input: AutocompleteInput<unknown[]>): Completion[] => {
const { resolved } = input;
const options = applySections({
options: sortCompletionsAlpha([...natives('array'), ...extensions('array')]),
recommended: ARRAY_RECOMMENDED_OPTIONS,
methodsSection: OTHER_SECTION,
propSection: OTHER_SECTION,
excludeRecommended: true,
});
if (resolved.length > 0 && resolved.some((i) => typeof i !== 'number')) {
const NUMBER_ONLY_ARRAY_EXTENSIONS = new Set(ARRAY_NUMBER_ONLY_METHODS);
return options.filter((m) => !NUMBER_ONLY_ARRAY_EXTENSIONS.has(m.label));
}
return options;
};
function ensureKeyCanBeResolved(obj: IDataObject, key: string) {
@@ -410,7 +620,7 @@ export const secretProvidersOptions = () => {
/**
* Methods and fields defined on a Luxon `DateTime` class instance.
*/
export const luxonInstanceOptions = () => {
export const luxonInstanceOptions = (includeHidden = false) => {
const SKIP = new Set(['constructor', 'get', 'invalidExplanation', 'invalidReason']);
return Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype))
@@ -419,8 +629,15 @@ export const luxonInstanceOptions = () => {
.map(([key, descriptor]) => {
const isFunction = typeof descriptor.value === 'function';
const optionType = isFunction ? 'native-function' : 'keyword';
return createLuxonAutocompleteOption(key, optionType, luxonInstanceDocs, i18n.luxonInstance);
});
return createLuxonAutocompleteOption(
key,
optionType,
luxonInstanceDocs,
i18n.luxonInstance,
includeHidden,
) as Completion;
})
.filter(Boolean);
};
/**
@@ -429,17 +646,19 @@ export const luxonInstanceOptions = () => {
export const luxonStaticOptions = () => {
const SKIP = new Set(['prototype', 'name', 'length', 'invalid']);
return Object.keys(Object.getOwnPropertyDescriptors(DateTime))
.filter((key) => !SKIP.has(key) && !key.includes('_'))
.sort((a, b) => a.localeCompare(b))
.map((key) => {
return createLuxonAutocompleteOption(
key,
'native-function',
luxonStaticDocs,
i18n.luxonStatic,
);
});
return sortCompletionsAlpha(
Object.keys(Object.getOwnPropertyDescriptors(DateTime))
.filter((key) => !SKIP.has(key) && !key.includes('_'))
.map((key) => {
return createLuxonAutocompleteOption(
key,
'native-function',
luxonStaticDocs,
i18n.luxonStatic,
) as Completion;
})
.filter(Boolean),
);
};
const createLuxonAutocompleteOption = (
@@ -447,11 +666,10 @@ const createLuxonAutocompleteOption = (
type: AutocompleteOptionType,
docDefinition: NativeDoc,
translations: Record<string, string | undefined>,
): Completion => {
const option: Completion = {
label: isFunctionOption(type) ? name + '()' : name,
type,
};
includeHidden = false,
): Completion | null => {
const isFunction = isFunctionOption(type);
const label = isFunction ? name + '()' : name;
let doc: DocMetadata | undefined;
if (docDefinition.properties && docDefinition.properties.hasOwnProperty(name)) {
@@ -469,6 +687,17 @@ const createLuxonAutocompleteOption = (
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetime',
};
}
if (doc?.hidden && !includeHidden) {
return null;
}
const option: Completion = {
label,
type,
section: doc?.section,
apply: applyCompletion(hasRequiredArgs(doc)),
};
option.info = createCompletionOption('DateTime', name, type, {
// Add translated description
doc: { ...doc, description: translations[name] } as DocMetadata,
@@ -495,20 +724,20 @@ export const objectGlobalOptions = () => {
};
const regexes = {
generalRef: /\$[^$'"]+\.([^{\s])*/, // $input. or $json. or similar ones
selectorRef: /\$\(['"][\S\s]+['"]\)\.([^{\s])*/, // $('nodeName').
generalRef: /\$[^$'"]+\.(.*)/, // $input. or $json. or similar ones
selectorRef: /\$\(['"][\S\s]+['"]\)\.(.*)/, // $('nodeName').
numberLiteral: /\((\d+)\.?(\d*)\)\.([^{\s])*/, // (123). or (123.4).
numberLiteral: /\((\d+)\.?(\d*)\)\.(.*)/, // (123). or (123.4).
singleQuoteStringLiteral: /('.+')\.([^'{\s])*/, // 'abc'.
doubleQuoteStringLiteral: /(".+")\.([^"{\s])*/, // "abc".
dateLiteral: /\(?new Date\(\(?.*?\)\)?\.([^{\s])*/, // new Date(). or (new Date()).
arrayLiteral: /(\[.+\])\.([^{\s])*/, // [1, 2, 3].
indexedAccess: /([^{\s]+\[.+\])\.([^{\s])*/, // 'abc'[0]. or 'abc'.split('')[0] or similar ones
objectLiteral: /\(\{.*\}\)\.([^{\s])*/, // ({}).
dateLiteral: /\(?new Date\(\(?.*?\)\)?\.(.*)/, // new Date(). or (new Date()).
arrayLiteral: /(\[.*\])\.(.*)/, // [1, 2, 3].
indexedAccess: /([^"{\s]+\[.+\])\.(.*)/, // 'abc'[0]. or 'abc'.split('')[0] or similar ones
objectLiteral: /\(\{.*\}\)\.(.*)/, // ({}).
mathGlobal: /Math\.([^{\s])*/, // Math.
datetimeGlobal: /DateTime\.[^.}]*/, // DateTime.
objectGlobal: /Object\.(\w+\(.*\)\.[^{\s]*)?/, // Object. or Object.method(arg).
mathGlobal: /Math\.(.*)/, // Math.
datetimeGlobal: /DateTime\.(.*)/, // DateTime.
objectGlobal: /Object\.(.*)/, // Object. or Object.method(arg).
};
const DATATYPE_REGEX = new RegExp(

View File

@@ -3,15 +3,16 @@ import {
autocompletableNodeNames,
receivesNoBinaryData,
longestCommonPrefix,
setRank,
prefixMatch,
stripExcessParens,
hasActiveNode,
isCredentialsModalOpen,
applyCompletion,
} from './utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import { escapeMappingString } from '@/utils/mappingUtils';
import { PREVIOUS_NODES_SECTION, ROOT_DOLLAR_COMPLETIONS } from './constants';
/**
* Completions offered at the dollar position: `$|`
@@ -45,10 +46,8 @@ export function dollarCompletions(context: CompletionContext): CompletionResult
};
}
export function dollarOptions() {
const rank = setRank(['$json', '$input']);
export function dollarOptions(): Completion[] {
const SKIP = new Set();
const DOLLAR_FUNCTIONS = ['$jmespath', '$ifEmpty'];
if (isCredentialsModalOpen()) {
return useExternalSecretsStore().isEnterpriseExternalSecretsEnabled
@@ -71,29 +70,14 @@ export function dollarOptions() {
if (receivesNoBinaryData()) SKIP.add('$binary');
const keys = Object.keys(i18n.rootVars).sort((a, b) => a.localeCompare(b));
const previousNodesCompletions = autocompletableNodeNames().map((nodeName) => ({
label: `$('${escapeMappingString(nodeName)}')`,
type: 'keyword',
info: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }),
section: PREVIOUS_NODES_SECTION,
}));
return rank(keys)
.filter((key) => !SKIP.has(key))
.map((key) => {
const isFunction = DOLLAR_FUNCTIONS.includes(key);
const option: Completion = {
label: isFunction ? key + '()' : key,
type: isFunction ? 'function' : 'keyword',
};
const info = i18n.rootVars[key];
if (info) option.info = info;
return option;
})
.concat(
autocompletableNodeNames().map((nodeName) => ({
label: `$('${escapeMappingString(nodeName)}')`,
type: 'keyword',
info: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }),
})),
);
return ROOT_DOLLAR_COMPLETIONS.filter(({ label }) => !SKIP.has(label))
.concat(previousNodesCompletions)
.map((completion) => ({ ...completion, apply: applyCompletion() }));
}

View File

@@ -8,6 +8,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
day: {
doc: {
name: 'day',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeday',
returnType: 'number',
},
@@ -15,6 +16,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
daysInMonth: {
doc: {
name: 'daysInMonth',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimedaysinmonth',
returnType: 'number',
},
@@ -22,6 +25,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
daysInYear: {
doc: {
name: 'daysInYear',
hidden: true,
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimedaysinyear',
returnType: 'number',
},
@@ -29,6 +34,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
hour: {
doc: {
name: 'hour',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimehour',
returnType: 'number',
},
@@ -36,6 +42,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
locale: {
doc: {
name: 'locale',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimelocale',
returnType: 'string',
},
@@ -43,6 +50,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
millisecond: {
doc: {
name: 'millisecond',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimemillisecond',
returnType: 'number',
},
@@ -50,6 +58,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
minute: {
doc: {
name: 'minute',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeminute',
returnType: 'number',
},
@@ -57,6 +66,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
month: {
doc: {
name: 'month',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimemonth',
returnType: 'number',
},
@@ -64,6 +74,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
monthLong: {
doc: {
name: 'monthLong',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimemonthlong',
returnType: 'string',
},
@@ -71,6 +82,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
monthShort: {
doc: {
name: 'monthShort',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimemonthshort',
returnType: 'string',
},
@@ -78,6 +90,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
numberingSystem: {
doc: {
name: 'numberingSystem',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimenumberingsystem',
returnType: 'string',
},
@@ -85,6 +99,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
offset: {
doc: {
name: 'offset',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeoffset',
returnType: 'number',
},
@@ -92,6 +108,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
offsetNameLong: {
doc: {
name: 'offsetNameLong',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeoffsetnamelong',
returnType: 'string',
},
@@ -99,6 +117,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
offsetNameShort: {
doc: {
name: 'offsetNameShort',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeoffsetnameshort',
returnType: 'string',
},
@@ -106,6 +126,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
ordinal: {
doc: {
name: 'ordinal',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeordinal',
returnType: 'string',
},
@@ -113,6 +135,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
outputCalendar: {
doc: {
name: 'outputCalendar',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeoutputcalendar',
returnType: 'string',
},
@@ -120,6 +144,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
quarter: {
doc: {
name: 'quarter',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimequarter',
returnType: 'number',
},
@@ -127,6 +152,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
second: {
doc: {
name: 'second',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimesecond',
returnType: 'number',
},
@@ -134,6 +160,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
weekday: {
doc: {
name: 'weekday',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweekday',
returnType: 'number',
},
@@ -141,6 +168,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
weekdayLong: {
doc: {
name: 'weekdayLong',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweekdaylong',
returnType: 'string',
},
@@ -148,6 +176,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
weekdayShort: {
doc: {
name: 'weekdayShort',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweekdayshort',
returnType: 'string',
},
@@ -155,6 +184,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
weekNumber: {
doc: {
name: 'weekNumber',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweeknumber',
returnType: 'number',
},
@@ -162,6 +192,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
weeksInWeekYear: {
doc: {
name: 'weeksInWeekYear',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweeksinweekyear',
returnType: 'number',
},
@@ -169,6 +201,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
weekYear: {
doc: {
name: 'weekYear',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweekyear',
returnType: 'number',
},
@@ -176,6 +209,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
year: {
doc: {
name: 'year',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeyear',
returnType: 'number',
},
@@ -183,6 +217,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
zone: {
doc: {
name: 'zone',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimezone',
returnType: 'Zone',
},
@@ -190,6 +225,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
zoneName: {
doc: {
name: 'zoneName',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimezonename',
returnType: 'string',
},
@@ -197,6 +233,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
isInDST: {
doc: {
name: 'isInDST',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeisindst',
returnType: 'boolean',
},
@@ -204,6 +241,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
isInLeapYear: {
doc: {
name: 'isInLeapYear',
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeisinleapyear',
returnType: 'boolean',
},
@@ -211,6 +249,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
isOffsetFixed: {
doc: {
name: 'isOffsetFixed',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeisoffsetfixed',
returnType: 'boolean',
},
@@ -218,6 +258,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
isValid: {
doc: {
name: 'isValid',
hidden: true,
section: 'query',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeisvalid',
returnType: 'boolean',
},
@@ -227,6 +269,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
diff: {
doc: {
name: 'diff',
section: 'compare',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimediff',
returnType: 'Duration',
args: [
@@ -239,6 +282,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
diffNow: {
doc: {
name: 'diffNow',
section: 'compare',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimediffnow',
returnType: 'Duration',
args: [
@@ -250,6 +294,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
endOf: {
doc: {
name: 'endOf',
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeendof',
returnType: 'DateTime',
args: [{ name: 'unit', type: 'string' }],
@@ -258,6 +303,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
equals: {
doc: {
name: 'equals',
section: 'compare',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeequals',
returnType: 'boolean',
args: [{ name: 'other', type: 'DateTime' }],
@@ -266,6 +312,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
hasSame: {
doc: {
name: 'hasSame',
section: 'compare',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimehassame',
returnType: 'boolean',
args: [
@@ -277,6 +324,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
minus: {
doc: {
name: 'minus',
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeminus',
returnType: 'DateTime',
args: [{ name: 'duration', type: 'Duration|object|number' }],
@@ -285,6 +333,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
plus: {
doc: {
name: 'plus',
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeplus',
returnType: 'DateTime',
args: [{ name: 'duration', type: 'Duration|object|number' }],
@@ -293,6 +342,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
reconfigure: {
doc: {
name: 'reconfigure',
section: 'other',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimereconfigure',
returnType: 'DateTime',
args: [{ name: 'properties', type: 'object' }],
@@ -301,6 +352,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
resolvedLocaleOptions: {
doc: {
name: 'resolvedLocaleOptions',
section: 'other',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeresolvedlocaleoptions',
returnType: 'object',
args: [{ name: 'opts', type: 'object' }],
@@ -309,6 +362,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
set: {
doc: {
name: 'set',
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeset',
returnType: 'DateTime',
args: [{ name: 'values', type: 'object' }],
@@ -317,6 +371,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
setLocale: {
doc: {
name: 'setLocale',
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimesetlocale',
returnType: 'DateTime',
args: [{ name: 'locale', type: 'any' }],
@@ -325,6 +380,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
setZone: {
doc: {
name: 'setZone',
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimesetzone',
returnType: 'DateTime',
args: [
@@ -336,6 +392,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
startOf: {
doc: {
name: 'startOf',
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimestartof',
returnType: 'DateTime',
args: [{ name: 'unit', type: 'string' }],
@@ -344,6 +401,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toBSON: {
doc: {
name: 'toBSON',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetobson',
returnType: 'Date',
},
@@ -351,6 +410,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toFormat: {
doc: {
name: 'toFormat',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetime',
returnType: 'string',
args: [
@@ -362,6 +423,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toHTTP: {
doc: {
name: 'toHTTP',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetohttp',
returnType: 'string',
},
@@ -369,6 +432,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toISO: {
doc: {
name: 'toISO',
section: 'format',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoiso',
returnType: 'string',
args: [{ name: 'opts', type: 'object' }],
@@ -377,6 +441,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toISODate: {
doc: {
name: 'toISODate',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoisodate',
returnType: 'string',
args: [{ name: 'opts', type: 'object' }],
@@ -385,6 +451,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toISOTime: {
doc: {
name: 'toISOTime',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoisotime',
returnType: 'string',
args: [{ name: 'opts', type: 'object' }],
@@ -393,6 +461,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toISOWeekDate: {
doc: {
name: 'toISOWeekDate',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoisoweekdate',
returnType: 'string',
},
@@ -400,6 +470,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toJSDate: {
doc: {
name: 'toJSDate',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetojsdate',
returnType: 'Date',
},
@@ -407,6 +479,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toJSON: {
doc: {
name: 'toJSON',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetojson',
returnType: 'string',
},
@@ -414,6 +488,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toLocal: {
doc: {
name: 'toLocal',
section: 'format',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetolocal',
returnType: 'DateTime',
},
@@ -421,6 +496,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toLocaleParts: {
doc: {
name: 'toLocaleParts',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetolocaleparts',
returnType: 'string',
args: [
@@ -432,6 +509,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toLocaleString: {
doc: {
name: 'toLocaleString',
section: 'format',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetolocalestring',
returnType: 'string',
args: [
@@ -443,6 +521,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toMillis: {
doc: {
name: 'toMillis',
section: 'format',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetomillis',
returnType: 'number',
},
@@ -450,6 +529,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toObject: {
doc: {
name: 'toObject',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoobject',
returnType: 'object',
args: [{ name: 'opts', type: 'any' }],
@@ -458,6 +539,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toRelative: {
doc: {
name: 'toRelative',
section: 'format',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetorelative',
returnType: 'string',
args: [{ name: 'options', type: 'object' }],
@@ -466,6 +548,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toRelativeCalendar: {
doc: {
name: 'toRelativeCalendar',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetorelativecalendar',
returnType: 'string',
args: [{ name: 'options', type: 'object' }],
@@ -474,6 +558,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toRFC2822: {
doc: {
name: 'toRFC2822',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetorfc2822',
returnType: 'string',
},
@@ -481,6 +567,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toSeconds: {
doc: {
name: 'toSeconds',
section: 'format',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoseconds',
returnType: 'number',
},
@@ -488,14 +575,18 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toSQL: {
doc: {
name: 'toSQL',
section: 'format',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetosql',
returnType: 'string',
hidden: true,
args: [{ name: 'options', type: 'object' }],
},
},
toSQLDate: {
doc: {
name: 'toSQLDate',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetosqldate',
returnType: 'string',
},
@@ -503,6 +594,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toSQLTime: {
doc: {
name: 'toSQLTime',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetosqltime',
returnType: 'string',
},
@@ -510,6 +603,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toString: {
doc: {
name: 'toString',
section: 'format',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetostring',
returnType: 'string',
},
@@ -517,6 +611,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toUnixInteger: {
doc: {
name: 'toUnixInteger',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetounixinteger',
returnType: 'number',
},
@@ -524,6 +620,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toUTC: {
doc: {
name: 'toUTC',
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetoutc',
returnType: 'DateTime',
args: [
@@ -535,6 +632,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
until: {
doc: {
name: 'until',
section: 'compare',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeuntil',
returnType: 'Interval',
args: [{ name: 'other', type: 'DateTime' }],
@@ -543,6 +641,8 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
valueOf: {
doc: {
name: 'valueOf',
section: 'format',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimevalueof',
returnType: 'number',
},

View File

@@ -1,7 +1,6 @@
import type { resolveParameter } from '@/composables/useWorkflowHelpers';
import type { DocMetadata } from 'n8n-workflow';
export type Resolved = ReturnType<typeof resolveParameter>;
export type Resolved = unknown;
export type ExtensionTypeName = 'number' | 'string' | 'date' | 'array' | 'object';
@@ -10,3 +9,8 @@ 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;
};

View File

@@ -1,24 +1,40 @@
import { NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION } from '@/components/CodeNodeEditor/constants';
import { CREDENTIAL_EDIT_MODAL_KEY, SPLIT_IN_BATCHES_NODE_TYPE } from '@/constants';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { resolveParameter } from '@/composables/useWorkflowHelpers';
import { resolveParameter, useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useNDVStore } from '@/stores/ndv.store';
import { useUIStore } from '@/stores/ui.store';
import type { Completion, CompletionContext } from '@codemirror/autocomplete';
import {
insertCompletionText,
type Completion,
type CompletionContext,
pickedCompletion,
type CompletionSection,
} from '@codemirror/autocomplete';
import type { EditorView } from '@codemirror/view';
import type { TransactionSpec } from '@codemirror/state';
import type { SyntaxNode } from '@lezer/common';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { useRouter } from 'vue-router';
import type { DocMetadata } from 'n8n-workflow';
// String literal expression is everything enclosed in single, double or tick quotes following a dot
const stringLiteralRegex = /^"[^"]+"|^'[^']+'|^`[^`]+`\./;
// JavaScript operands
const operandsRegex = /[+\-*/><<==>**!=?]/;
/**
* Split user input into base (to resolve) and tail (to filter).
*/
export function splitBaseTail(userInput: string): [string, string] {
const processedInput = extractSubExpression(userInput);
const parts = processedInput.split('.');
const tail = parts.pop() ?? '';
const read = (node: SyntaxNode | null) => (node ? userInput.slice(node.from, node.to) : '');
const lastNode = javascriptLanguage.parser.parse(userInput).resolveInner(userInput.length, -1);
return [parts.join('.'), tail];
switch (lastNode.type.name) {
case '.':
return [read(lastNode.parent).slice(0, -1), ''];
case 'MemberExpression':
return [read(lastNode.parent), read(lastNode)];
case 'PropertyName':
const tail = read(lastNode);
return [read(lastNode.parent).slice(0, -(tail.length + 1)), tail];
default:
return ['', ''];
}
}
export function longestCommonPrefix(...strings: string[]) {
@@ -37,29 +53,6 @@ export function longestCommonPrefix(...strings: string[]) {
}, '');
}
// Process user input if expressions are used as part of complex expression
// i.e. as a function parameter or an operation expression
// this function will extract expression that is currently typed so autocomplete
// suggestions can be matched based on it.
function extractSubExpression(userInput: string): string {
const dollarSignIndex = userInput.indexOf('$');
if (dollarSignIndex === -1) {
return userInput;
} else if (!stringLiteralRegex.test(userInput)) {
// If there is a dollar sign in the input and input is not a string literal,
// extract part of following the last $
const expressionParts = userInput.split('$');
userInput = `$${expressionParts[expressionParts.length - 1]}`;
// If input is part of a complex operation expression and extract last operand
const operationPart = userInput.split(operandsRegex).pop()?.trim() || '';
const lastOperand = operationPart.split(' ').pop();
if (lastOperand) {
userInput = lastOperand;
}
}
return userInput;
}
export const prefixMatch = (first: string, second: string) =>
first.startsWith(second) && first !== second;
@@ -133,15 +126,15 @@ export const isSplitInBatchesAbsent = () =>
!useWorkflowsStore().workflow.nodes.some((node) => node.type === SPLIT_IN_BATCHES_NODE_TYPE);
export function autocompletableNodeNames() {
return useWorkflowsStore()
.allNodes.filter((node) => {
const activeNodeName = useNDVStore().activeNode?.name;
const activeNodeName = useNDVStore().activeNode?.name;
return (
!NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION.includes(node.type) && node.name !== activeNodeName
);
})
.map((node) => node.name);
if (!activeNodeName) return [];
return useWorkflowHelpers({ router: useRouter() })
.getCurrentWorkflow()
.getParentNodesByDepth(activeNodeName)
.map((node) => node.name)
.filter((name) => name !== activeNodeName);
}
/**
@@ -157,3 +150,50 @@ export const stripExcessParens = (context: CompletionContext) => (option: Comple
return option;
};
/**
* When a function completion is selected, set the cursor correctly
* @example `.includes()` -> `.includes(<cursor>)`
* @example `$max()` -> `$max()<cursor>`
*/
export const applyCompletion =
(hasArgs = true) =>
(view: EditorView, completion: Completion, from: number, to: number): void => {
const tx: TransactionSpec = {
...insertCompletionText(view.state, completion.label, from, to),
annotations: pickedCompletion.of(completion),
};
if (completion.label.endsWith('()') && hasArgs) {
const cursorPosition = from + completion.label.length - 1;
tx.selection = { anchor: cursorPosition, head: cursorPosition };
}
view.dispatch(tx);
};
export const hasRequiredArgs = (doc?: DocMetadata): boolean => {
if (!doc) return false;
const requiredArgs = doc?.args?.filter((arg) => !arg.name.endsWith('?')) ?? [];
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;
};

View File

@@ -0,0 +1,76 @@
import {
acceptCompletion,
completionStatus,
moveCompletionSelection,
selectedCompletion,
} from '@codemirror/autocomplete';
import { indentLess, indentMore, insertNewlineAndIndent, redo, undo } from '@codemirror/commands';
import type { EditorView, KeyBinding } from '@codemirror/view';
export const tabKeyMap = (singleLine = false): KeyBinding[] => [
{
any(view, event) {
if (
event.key === 'Tab' ||
(event.key === 'Escape' && completionStatus(view.state) !== null)
) {
event.stopPropagation();
}
return false;
},
},
{
key: 'Tab',
run: (view) => {
if (selectedCompletion(view.state)) {
return acceptCompletion(view);
}
if (!singleLine) return indentMore(view);
return false;
},
},
{ key: 'Shift-Tab', run: indentLess },
];
export const enterKeyMap: KeyBinding[] = [
{
key: 'Enter',
run: (view) => {
if (selectedCompletion(view.state)) {
return acceptCompletion(view);
}
return insertNewlineAndIndent(view);
},
},
];
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;
};
export const autocompleteKeyMap: KeyBinding[] = [
{
key: 'ArrowDown',
run: onAutocompleteNavigate('down'),
},
{
key: 'ArrowUp',
run: onAutocompleteNavigate('up'),
},
];
export const historyKeyMap: KeyBinding[] = [
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
];

View File

@@ -4,6 +4,7 @@ import { parseMixed } from '@lezer/common';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { n8nCompletionSources } from './completions/addCompletions';
import { autocompletion } from '@codemirror/autocomplete';
const n8nParserWithNestedJsParser = n8nParser.configure({
wrap: parseMixed((node) => {
@@ -23,3 +24,5 @@ export function n8nLang() {
...n8nCompletionSources().map((source) => n8nLanguage.data.of(source)),
]);
}
export const n8nAutocompletion = () => autocompletion({ icons: false });

View File

@@ -63,28 +63,32 @@ const coloringStateField = StateField.define<DecorationSet>({
return Decoration.none;
},
update(colorings, transaction) {
colorings = colorings.map(transaction.changes); // recalculate positions for new doc
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)],
});
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) {
window?.Sentry?.captureException(error);
}
return colorings;

View File

@@ -357,7 +357,7 @@ export class I18nClass {
});
}
rootVars: Record<string, string | undefined> = {
rootVars = {
$binary: this.baseText('codeNodeEditor.completer.binary'),
$execution: this.baseText('codeNodeEditor.completer.$execution'),
$ifEmpty: this.baseText('codeNodeEditor.completer.$ifEmpty'),
@@ -375,7 +375,8 @@ export class I18nClass {
$today: this.baseText('codeNodeEditor.completer.$today'),
$vars: this.baseText('codeNodeEditor.completer.$vars'),
$workflow: this.baseText('codeNodeEditor.completer.$workflow'),
};
DateTime: this.baseText('codeNodeEditor.completer.dateTime'),
} as const satisfies Record<string, string | undefined>;
proxyVars: Record<string, string | undefined> = {
'$input.all': this.baseText('codeNodeEditor.completer.$input.all'),
@@ -390,6 +391,7 @@ export class I18nClass {
'$().itemMatching': this.baseText('codeNodeEditor.completer.selector.itemMatching'),
'$().last': this.baseText('codeNodeEditor.completer.selector.last'),
'$().params': this.baseText('codeNodeEditor.completer.selector.params'),
'$().isExecuted': this.baseText('codeNodeEditor.completer.selector.isExecuted'),
'$prevNode.name': this.baseText('codeNodeEditor.completer.$prevNode.name'),
'$prevNode.outputIndex': this.baseText('codeNodeEditor.completer.$prevNode.outputIndex'),

View File

@@ -205,6 +205,7 @@
"codeNodeEditor.completer.$workflow.active": "Whether the workflow is active or not (boolean)",
"codeNodeEditor.completer.$workflow.id": "The ID of the workflow",
"codeNodeEditor.completer.$workflow.name": "The name of the workflow",
"codeNodeEditor.completer.dateTime": "Luxon DateTime. Use this object to parse, format and manipulate dates and times",
"codeNodeEditor.completer.binary": "The item's binary (file) data",
"codeNodeEditor.completer.globalObject.assign": "Copy of the object containing all enumerable own properties",
"codeNodeEditor.completer.globalObject.entries": "The object's keys and values",
@@ -314,6 +315,25 @@
"codeNodeEditor.completer.selector.itemMatching": "@:_reusableBaseText.codeNodeEditor.completer.itemMatching",
"codeNodeEditor.completer.selector.last": "@:_reusableBaseText.codeNodeEditor.completer.last",
"codeNodeEditor.completer.selector.params": "The parameters of the node",
"codeNodeEditor.completer.selector.isExecuted": "Whether the node has executed",
"codeNodeEditor.completer.section.input": "Input",
"codeNodeEditor.completer.section.prevNodes": "Earlier nodes",
"codeNodeEditor.completer.section.metadata": "Metadata",
"codeNodeEditor.completer.section.fields": "Fields",
"codeNodeEditor.completer.section.properties": "Properties",
"codeNodeEditor.completer.section.methods": "Methods",
"codeNodeEditor.completer.section.otherMethods": "Other methods",
"codeNodeEditor.completer.section.recommended": "Suggested",
"codeNodeEditor.completer.section.recommendedMethods": "Suggested methods",
"codeNodeEditor.completer.section.other": "Other",
"codeNodeEditor.completer.section.edit": "Edit",
"codeNodeEditor.completer.section.query": "Query",
"codeNodeEditor.completer.section.format": "Format",
"codeNodeEditor.completer.section.component": "Component",
"codeNodeEditor.completer.section.case": "Case",
"codeNodeEditor.completer.section.cast": "Cast",
"codeNodeEditor.completer.section.compare": "Compare",
"codeNodeEditor.completer.section.validation": "Validate",
"codeNodeEditor.linter.allItems.firstOrLastCalledWithArg": "expects no argument.",
"codeNodeEditor.linter.allItems.emptyReturn": "Code doesn't return items properly. Please return an array of objects, one for each item you would like to output.",
"codeNodeEditor.linter.allItems.itemCall": "`item` is a property to access, not a method to call. Did you mean `.item` without brackets?",