feat(editor): Autocomplete info box: improve structure and add examples (#9019)

Co-authored-by: Giulio Andreini <andreini@netseven.it>
This commit is contained in:
Elias Meire
2024-05-10 14:39:06 +02:00
committed by GitHub
parent 4ed585040b
commit c92c870c73
30 changed files with 1596 additions and 457 deletions

View File

@@ -130,8 +130,10 @@ describe('Luxon method completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now());
expect(completions('{{ $now.| }}')).toHaveLength(
uniqBy(luxonInstanceOptions().concat(extensions('date')), (option) => option.label).length +
LUXON_RECOMMENDED_OPTIONS.length,
uniqBy(
luxonInstanceOptions().concat(extensions({ typeName: 'date' })),
(option) => option.label,
).length + LUXON_RECOMMENDED_OPTIONS.length,
);
});
@@ -140,8 +142,10 @@ describe('Luxon method completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now());
expect(completions('{{ $today.| }}')).toHaveLength(
uniqBy(luxonInstanceOptions().concat(extensions('date')), (option) => option.label).length +
LUXON_RECOMMENDED_OPTIONS.length,
uniqBy(
luxonInstanceOptions().concat(extensions({ typeName: 'date' })),
(option) => option.label,
).length + LUXON_RECOMMENDED_OPTIONS.length,
);
});
});
@@ -153,7 +157,9 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('abc');
expect(completions('{{ "abc".| }}')).toHaveLength(
natives('string').length + extensions('string').length + STRING_RECOMMENDED_OPTIONS.length,
natives({ typeName: 'string' }).length +
extensions({ typeName: 'string' }).length +
STRING_RECOMMENDED_OPTIONS.length,
);
});
@@ -163,7 +169,9 @@ describe('Resolution-based completions', () => {
const result = completions('{{ "You \'owe\' me 200$".| }}');
expect(result).toHaveLength(natives('string').length + extensions('string').length + 1);
expect(result).toHaveLength(
natives({ typeName: 'string' }).length + extensions({ typeName: 'string' }).length + 1,
);
});
test('should return completions for number literal: {{ (123).| }}', () => {
@@ -171,7 +179,9 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(123);
expect(completions('{{ (123).| }}')).toHaveLength(
natives('number').length + extensions('number').length + ['isEven()', 'isOdd()'].length,
natives({ typeName: 'number' }).length +
extensions({ typeName: 'number' }).length +
['isEven()', 'isOdd()'].length,
);
});
@@ -180,7 +190,7 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce([1, 2, 3]);
expect(completions('{{ [1, 2, 3].| }}')).toHaveLength(
natives('array').length + extensions('array').length,
natives({ typeName: 'array' }).length + extensions({ typeName: 'array' }).length,
);
});
@@ -192,7 +202,9 @@ describe('Resolution-based completions', () => {
if (!found) throw new Error('Expected to find completion');
expect(found).toHaveLength(natives('array').length + extensions('array').length);
expect(found).toHaveLength(
natives({ typeName: 'array' }).length + extensions({ typeName: 'array' }).length,
);
});
test('should return completions for object literal', () => {
@@ -201,7 +213,7 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(object);
expect(completions('{{ ({ a: 1 }).| }}')).toHaveLength(
Object.keys(object).length + extensions('object').length,
Object.keys(object).length + extensions({ typeName: 'object' }).length,
);
});
});
@@ -212,7 +224,9 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('a');
expect(completions('{{ "abc"[0].| }}')).toHaveLength(
natives('string').length + extensions('string').length + STRING_RECOMMENDED_OPTIONS.length,
natives({ typeName: 'string' }).length +
extensions({ typeName: 'string' }).length +
STRING_RECOMMENDED_OPTIONS.length,
);
});
});
@@ -225,7 +239,9 @@ describe('Resolution-based completions', () => {
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 + ['isEven()', 'isOdd()'].length,
extensions({ typeName: 'number' }).length +
natives({ typeName: 'number' }).length +
['isEven()', 'isOdd()'].length,
);
});
@@ -240,8 +256,10 @@ 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(
uniqBy(luxonInstanceOptions().concat(extensions('date')), (option) => option.label).length +
LUXON_RECOMMENDED_OPTIONS.length,
uniqBy(
luxonInstanceOptions().concat(extensions({ typeName: 'date' })),
(option) => option.label,
).length + LUXON_RECOMMENDED_OPTIONS.length,
);
});
@@ -251,7 +269,9 @@ describe('Resolution-based completions', () => {
const found = completions('{{ $execution.resumeUrl.includes($json.|) }}');
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(Object.keys($json).length + extensions('object').length);
expect(found).toHaveLength(
Object.keys($json).length + extensions({ typeName: 'object' }).length,
);
});
test('should return completions for operation expression: {{ $now.day + $json. }}', () => {
@@ -261,7 +281,9 @@ describe('Resolution-based completions', () => {
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(Object.keys($json).length + extensions('object').length);
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.). }}', () => {
@@ -271,7 +293,9 @@ describe('Resolution-based completions', () => {
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(Object.keys($json).length + extensions('object').length);
expect(found).toHaveLength(
Object.keys($json).length + extensions({ typeName: 'object' }).length,
);
});
});
@@ -286,7 +310,9 @@ describe('Resolution-based completions', () => {
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(
extensions('string').length + natives('string').length + STRING_RECOMMENDED_OPTIONS.length,
extensions({ typeName: 'string' }).length +
natives({ typeName: 'string' }).length +
STRING_RECOMMENDED_OPTIONS.length,
);
expect(found.map((c) => c.label).every((l) => !l.endsWith('()')));
});
@@ -299,7 +325,9 @@ describe('Resolution-based completions', () => {
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(
extensions('number').length + natives('number').length + ['isEven()', 'isOdd()'].length,
extensions({ typeName: 'number' }).length +
natives({ typeName: 'number' }).length +
['isEven()', 'isOdd()'].length,
);
expect(found.map((c) => c.label).every((l) => !l.endsWith('()')));
});
@@ -311,7 +339,9 @@ describe('Resolution-based completions', () => {
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(extensions('array').length + natives('array').length);
expect(found).toHaveLength(
extensions({ typeName: 'array' }).length + natives({ typeName: 'array' }).length,
);
expect(found.map((c) => c.label).every((l) => !l.endsWith('()')));
});
});
@@ -339,7 +369,6 @@ describe('Resolution-based completions', () => {
{
info: expect.any(Function),
label: provider,
type: 'keyword',
apply: expect.any(Function),
},
]);
@@ -363,13 +392,11 @@ 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),
},
]);
@@ -445,7 +472,9 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue([$input.item]);
expect(completions('{{ $input.all().| }}')).toHaveLength(
extensions('array').length + natives('array').length - ARRAY_NUMBER_ONLY_METHODS.length,
extensions({ typeName: 'array' }).length +
natives({ typeName: 'array' }).length -
ARRAY_NUMBER_ONLY_METHODS.length,
);
});
@@ -453,7 +482,7 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json);
expect(completions('{{ $input.item.| }}')).toHaveLength(
Object.keys($input.item.json).length + extensions('object').length,
Object.keys($input.item.json).length + extensions({ typeName: 'object' }).length,
);
});
@@ -461,7 +490,7 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.first().json);
expect(completions('{{ $input.first().| }}')).toHaveLength(
Object.keys($input.first().json).length + extensions('object').length,
Object.keys($input.first().json).length + extensions({ typeName: 'object' }).length,
);
});
@@ -469,7 +498,7 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.last().json);
expect(completions('{{ $input.last().| }}')).toHaveLength(
Object.keys($input.last().json).length + extensions('object').length,
Object.keys($input.last().json).length + extensions({ typeName: 'object' }).length,
);
});
@@ -477,7 +506,7 @@ describe('Resolution-based completions', () => {
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,
Object.keys($input.all()[0].json).length + extensions({ typeName: 'object' }).length,
);
});
@@ -485,7 +514,9 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.str);
expect(completions('{{ $input.item.json.str.| }}')).toHaveLength(
extensions('string').length + natives('string').length + STRING_RECOMMENDED_OPTIONS.length,
extensions({ typeName: 'string' }).length +
natives({ typeName: 'string' }).length +
STRING_RECOMMENDED_OPTIONS.length,
);
});
@@ -493,7 +524,9 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.num);
expect(completions('{{ $input.item.json.num.| }}')).toHaveLength(
extensions('number').length + natives('number').length + ['isEven()', 'isOdd()'].length,
extensions({ typeName: 'number' }).length +
natives({ typeName: 'number' }).length +
['isEven()', 'isOdd()'].length,
);
});
@@ -501,7 +534,7 @@ describe('Resolution-based completions', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.arr);
expect(completions('{{ $input.item.json.arr.| }}')).toHaveLength(
extensions('array').length + natives('array').length,
extensions({ typeName: 'array' }).length + natives({ typeName: 'array' }).length,
);
});
@@ -509,7 +542,7 @@ describe('Resolution-based completions', () => {
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,
Object.keys($input.item.json.obj).length + extensions({ typeName: 'object' }).length,
);
});
});
@@ -591,15 +624,12 @@ describe('Resolution-based completions', () => {
);
});
test('should recommend toInt(),toFloat() for: {{ "5.3".| }}', () => {
test('should recommend toNumber() 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 }),
expect.objectContaining({ label: 'toNumber()', section: RECOMMENDED_SECTION }),
);
});
@@ -659,25 +689,25 @@ describe('Resolution-based completions', () => {
);
});
test('should recommend toDateTime("s") for: {{ (1900062210).| }}', () => {
test("should recommend toDateTime('s') for: {{ (1900062210).| }}", () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
// @ts-expect-error Spied function is mistyped
1900062210,
);
const options = completions('{{ (1900062210).| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'toDateTime("s")', section: RECOMMENDED_SECTION }),
expect.objectContaining({ label: "toDateTime('s')", section: RECOMMENDED_SECTION }),
);
});
test('should recommend toDateTime("ms") for: {{ (1900062210000).| }}', () => {
test("should recommend toDateTime('ms') for: {{ (1900062210000).| }}", () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(
// @ts-expect-error Spied function is mistyped
1900062210000,
);
const options = completions('{{ (1900062210000).| }}');
expect(options?.[0]).toEqual(
expect.objectContaining({ label: 'toDateTime("ms")', section: RECOMMENDED_SECTION }),
expect.objectContaining({ label: "toDateTime('ms')", section: RECOMMENDED_SECTION }),
);
});
@@ -714,7 +744,9 @@ describe('Resolution-based completions', () => {
const result = completions('{{ $json.foo| }}', true);
expect(result).toHaveLength(
extensions('string').length + natives('string').length + STRING_RECOMMENDED_OPTIONS.length,
extensions({ typeName: 'string' }).length +
natives({ typeName: 'string' }).length +
STRING_RECOMMENDED_OPTIONS.length,
);
});
});

View File

@@ -1,6 +1,7 @@
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'),
@@ -51,87 +52,234 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [
{
label: '$json',
section: RECOMMENDED_SECTION,
info: i18n.rootVars.$json,
info: createInfoBoxRenderer({
name: '$json',
returnType: 'object',
description: i18n.rootVars.$json,
docURL: 'https://docs.n8n.io/data/data-structure/',
}),
},
{
label: '$binary',
section: RECOMMENDED_SECTION,
info: i18n.rootVars.$binary,
info: createInfoBoxRenderer({
name: '$binary',
returnType: 'object',
description: i18n.rootVars.$binary,
}),
},
{
label: '$now',
section: RECOMMENDED_SECTION,
info: i18n.rootVars.$now,
info: createInfoBoxRenderer({
name: '$now',
returnType: 'DateTime',
description: i18n.rootVars.$now,
}),
},
{
label: '$if()',
section: RECOMMENDED_SECTION,
info: i18n.rootVars.$if,
info: createInfoBoxRenderer(
{
name: '$if',
returnType: 'boolean',
description: i18n.rootVars.$if,
args: [
{
name: 'condition',
optional: false,
type: 'boolean',
},
{
name: 'valueIfTrue',
optional: false,
type: 'any',
},
{
name: 'valueIfFalse',
optional: false,
type: 'any',
},
],
},
true,
),
},
{
label: '$ifEmpty()',
section: RECOMMENDED_SECTION,
info: i18n.rootVars.$ifEmpty,
info: createInfoBoxRenderer(
{
name: '$ifEmpty',
returnType: 'boolean',
description: i18n.rootVars.$ifEmpty,
args: [
{
name: 'value',
optional: false,
type: 'any',
},
{
name: 'valueIfEmpty',
optional: false,
type: 'any',
},
],
},
true,
),
},
{
label: '$execution',
section: METADATA_SECTION,
info: i18n.rootVars.$execution,
info: createInfoBoxRenderer({
name: '$execution',
returnType: 'object',
description: i18n.rootVars.$execution,
}),
},
{
label: '$itemIndex',
section: METADATA_SECTION,
info: i18n.rootVars.$itemIndex,
info: createInfoBoxRenderer({
name: '$itemIndex',
returnType: 'number',
description: i18n.rootVars.$itemIndex,
}),
},
{
label: '$input',
section: METADATA_SECTION,
info: i18n.rootVars.$input,
info: createInfoBoxRenderer({
name: '$input',
returnType: 'object',
description: i18n.rootVars.$input,
}),
},
{
label: '$parameter',
section: METADATA_SECTION,
info: i18n.rootVars.$parameter,
info: createInfoBoxRenderer({
name: '$parameter',
returnType: 'object',
description: i18n.rootVars.$parameter,
}),
},
{
label: '$prevNode',
section: METADATA_SECTION,
info: i18n.rootVars.$prevNode,
info: createInfoBoxRenderer({
name: '$prevNode',
returnType: 'object',
description: i18n.rootVars.$prevNode,
}),
},
{
label: '$runIndex',
section: METADATA_SECTION,
info: i18n.rootVars.$runIndex,
info: createInfoBoxRenderer({
name: '$runIndex',
returnType: 'number',
description: i18n.rootVars.$runIndex,
}),
},
{
label: '$today',
section: METADATA_SECTION,
info: i18n.rootVars.$today,
info: createInfoBoxRenderer({
name: '$today',
returnType: 'DateTime',
description: i18n.rootVars.$today,
}),
},
{
label: '$vars',
section: METADATA_SECTION,
info: i18n.rootVars.$vars,
info: createInfoBoxRenderer({
name: '$vars',
returnType: 'object',
description: i18n.rootVars.$vars,
}),
},
{
label: '$workflow',
section: METADATA_SECTION,
info: i18n.rootVars.$workflow,
info: createInfoBoxRenderer({
name: '$workflow',
returnType: 'object',
description: i18n.rootVars.$workflow,
}),
},
{
label: '$jmespath()',
section: METHODS_SECTION,
info: i18n.rootVars.$jmespath,
info: createInfoBoxRenderer(
{
name: '$jmespath',
returnType: 'any',
description: i18n.rootVars.$jmespath,
},
true,
),
},
{
label: '$max()',
section: METHODS_SECTION,
info: i18n.rootVars.$max,
info: createInfoBoxRenderer(
{
name: '$max',
returnType: 'number',
description: i18n.rootVars.$max,
args: [
{
name: 'number1',
optional: false,
type: 'number',
},
{
name: 'number2',
optional: true,
type: 'number',
},
{
name: 'numberN',
optional: true,
type: 'number',
},
],
},
true,
),
},
{
label: '$min()',
section: METHODS_SECTION,
info: i18n.rootVars.$min,
info: createInfoBoxRenderer(
{
name: '$min',
returnType: 'number',
description: i18n.rootVars.$min,
args: [
{
name: 'number1',
optional: false,
type: 'number',
},
{
name: 'number2',
optional: true,
type: 'number',
},
{
name: 'numberN',
optional: true,
type: 'number',
},
],
},
true,
),
},
{
label: '$nodeVersion',
@@ -148,7 +296,6 @@ export const STRING_RECOMMENDED_OPTIONS = [
'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()', 'hasField()'];
export const ARRAY_RECOMMENDED_OPTIONS = ['length', 'last()', 'includes()', 'map()', 'filter()'];

View File

@@ -1,50 +1,22 @@
import type { IDataObject, DocMetadata, NativeDoc } 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';
import {
setRank,
hasNoParams,
prefixMatch,
isAllowedInDotNotation,
isSplitInBatchesAbsent,
longestCommonPrefix,
splitBaseTail,
isPseudoParam,
stripExcessParens,
isCredentialsModalOpen,
applyCompletion,
sortCompletionsAlpha,
hasRequiredArgs,
getDefaultArgs,
insertDefaultArgs,
applyBracketAccessCompletion,
applyBracketAccess,
} from './utils';
import { VALID_EMAIL_REGEX } from '@/constants';
import { i18n } from '@/plugins/i18n';
import { useEnvironmentsStore } from '@/stores/environments.ee.store';
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
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 { uniqBy } from 'lodash-es';
import { DateTime } from 'luxon';
import type { DocMetadata, IDataObject, NativeDoc } from 'n8n-workflow';
import { Expression, ExpressionExtensions, NativeMethods, validateFieldType } from 'n8n-workflow';
import {
ARRAY_NUMBER_ONLY_METHODS,
ARRAY_RECOMMENDED_OPTIONS,
DATE_RECOMMENDED_OPTIONS,
FIELDS_SECTION,
LUXON_RECOMMENDED_OPTIONS,
LUXON_SECTIONS,
@@ -58,8 +30,29 @@ import {
STRING_RECOMMENDED_OPTIONS,
STRING_SECTIONS,
} from './constants';
import { VALID_EMAIL_REGEX } from '@/constants';
import { uniqBy } from 'lodash-es';
import { createInfoBoxRenderer } from './infoBoxRenderer';
import { luxonInstanceDocs } from './nativesAutocompleteDocs/luxon.instance.docs';
import { luxonStaticDocs } from './nativesAutocompleteDocs/luxon.static.docs';
import type { AutocompleteInput, ExtensionTypeName, FnToDoc, Resolved } from './types';
import {
applyBracketAccess,
applyBracketAccessCompletion,
applyCompletion,
getDefaultArgs,
hasNoParams,
hasRequiredArgs,
insertDefaultArgs,
isAllowedInDotNotation,
isCredentialsModalOpen,
isPseudoParam,
isSplitInBatchesAbsent,
longestCommonPrefix,
prefixMatch,
setRank,
sortCompletionsAlpha,
splitBaseTail,
stripExcessParens,
} from './utils';
/**
* Resolution-based completions offered according to datatype.
@@ -92,7 +85,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
try {
resolved = resolveParameter(`={{ ${base} }}`);
} catch {
} catch (error) {
return null;
}
@@ -100,7 +93,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
try {
options = datatypeOptions({ resolved, base, tail }).map(stripExcessParens(context));
} catch {
} catch (error) {
return null;
}
}
@@ -163,7 +156,7 @@ function datatypeOptions(input: AutocompleteInput): Completion[] {
return booleanOptions();
}
if (resolved instanceof DateTime) {
if (DateTime.isDateTime(resolved)) {
return luxonOptions(input as AutocompleteInput<DateTime>);
}
@@ -182,42 +175,61 @@ function datatypeOptions(input: AutocompleteInput): Completion[] {
return [];
}
export const natives = (
typeName: ExtensionTypeName,
transformLabel: (label: string) => string = (label) => label,
): Completion[] => {
const natives: NativeDoc = NativeMethods.find((ee) => ee.typeName.toLowerCase() === typeName);
export const natives = ({
typeName,
transformLabel = (label) => label,
}: {
typeName: ExtensionTypeName;
transformLabel?: (label: string) => string;
}): Completion[] => {
const nativeDocs: NativeDoc = NativeMethods.find((ee) => ee.typeName.toLowerCase() === typeName);
if (!natives) return [];
if (!nativeDocs) return [];
const nativeProps = natives.properties
? toOptions(natives.properties, typeName, 'keyword', false, transformLabel)
const nativeProps = nativeDocs.properties
? toOptions({
fnToDoc: nativeDocs.properties,
includeHidden: false,
isFunction: false,
transformLabel,
})
: [];
const nativeMethods = toOptions(
natives.functions,
typeName,
'native-function',
false,
const nativeMethods = toOptions({
fnToDoc: nativeDocs.functions,
includeHidden: false,
isFunction: true,
transformLabel,
);
});
return [...nativeProps, ...nativeMethods];
};
export const extensions = (
typeName: ExtensionTypeName,
export const extensions = ({
typeName,
includeHidden = false,
transformLabel: (label: string) => string = (label) => label,
) => {
const extensions = ExpressionExtensions.find((ee) => ee.typeName.toLowerCase() === typeName);
transformLabel = (label) => label,
}: {
typeName: ExtensionTypeName;
includeHidden?: boolean;
transformLabel?: (label: string) => string;
}) => {
const expressionExtensions = ExpressionExtensions.find(
(ee) => ee.typeName.toLowerCase() === typeName,
);
if (!extensions) return [];
if (!expressionExtensions) return [];
const fnToDoc = Object.entries(extensions.functions).reduce<FnToDoc>((acc, [fnName, fn]) => {
return { ...acc, [fnName]: { doc: fn.doc } };
}, {});
const fnToDoc = Object.entries(expressionExtensions.functions).reduce<FnToDoc>(
(acc, [fnName, fn]) => {
// Extension method docs do not have more info than info box, do not show
delete fn.doc?.docURL;
return { ...acc, [fnName]: { doc: fn.doc } };
},
{},
);
return toOptions(fnToDoc, typeName, 'extension-function', includeHidden, transformLabel);
return toOptions({ fnToDoc, isFunction: true, includeHidden, transformLabel });
};
export const getType = (value: unknown): string => {
@@ -238,144 +250,56 @@ export const getDetail = (base: string, value: unknown): string | undefined => {
return type;
};
export const toOptions = (
fnToDoc: FnToDoc,
typeName: ExtensionTypeName,
optionType: AutocompleteOptionType = 'native-function',
export const toOptions = ({
fnToDoc,
isFunction = false,
includeHidden = false,
transformLabel: (label: string) => string = (label) => label,
) => {
transformLabel = (label) => label,
}: {
fnToDoc: FnToDoc;
isFunction?: boolean;
includeHidden?: boolean;
transformLabel?: (label: string) => string;
}) => {
return Object.entries(fnToDoc)
.sort((a, b) => a[0].localeCompare(b[0]))
.filter(([, docInfo]) => (docInfo.doc && !docInfo.doc?.hidden) || includeHidden)
.filter(([, docInfo]) => Boolean(docInfo.doc && !docInfo.doc?.hidden) || includeHidden)
.map(([fnName, docInfo]) => {
return createCompletionOption(typeName, fnName, optionType, docInfo, transformLabel);
return createCompletionOption({
name: fnName,
doc: docInfo.doc,
isFunction,
transformLabel,
});
});
};
const createCompletionOption = (
typeName: string,
name: string,
optionType: AutocompleteOptionType,
docInfo: { doc?: DocMetadata | undefined },
transformLabel: (label: string) => string = (label) => label,
): Completion => {
const isFunction = isFunctionOption(optionType);
const createCompletionOption = ({
name,
doc,
isFunction = false,
transformLabel = (label) => label,
}: {
name: string;
doc?: DocMetadata;
isFunction?: boolean;
transformLabel?: (label: string) => string;
}): Completion => {
const label = isFunction ? name + '()' : name;
const option: Completion = {
label,
type: optionType,
section: docInfo.doc?.section,
section: doc?.section,
apply: applyCompletion({
hasArgs: hasRequiredArgs(docInfo?.doc),
defaultArgs: getDefaultArgs(docInfo?.doc),
hasArgs: hasRequiredArgs(doc),
defaultArgs: getDefaultArgs(doc),
transformLabel,
}),
};
option.info = () => {
const tooltipContainer = document.createElement('div');
tooltipContainer.classList.add('autocomplete-info-container');
if (!docInfo.doc) return null;
const header = isFunctionOption(optionType)
? createFunctionHeader(typeName, docInfo)
: createPropHeader(typeName, docInfo);
header.classList.add('autocomplete-info-header');
tooltipContainer.appendChild(header);
if (docInfo.doc.description) {
const descriptionBody = document.createElement('div');
descriptionBody.classList.add('autocomplete-info-description');
const descriptionText = document.createElement('p');
descriptionText.innerHTML = sanitizeHtml(
docInfo.doc.description.replace(/`(.*?)`/g, '<code>$1</code>'),
);
descriptionBody.appendChild(descriptionText);
if (docInfo.doc.docURL) {
const descriptionLink = document.createElement('a');
descriptionLink.setAttribute('target', '_blank');
descriptionLink.setAttribute('href', docInfo.doc.docURL);
descriptionLink.innerText = i18n.autocompleteUIValues.docLinkLabel || 'Learn more';
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');
descriptionBody.appendChild(descriptionLink);
}
tooltipContainer.appendChild(descriptionBody);
}
return tooltipContainer;
};
option.info = createInfoBoxRenderer(doc, isFunction);
return option;
};
const createFunctionHeader = (typeName: string, fn: { doc?: DocMetadata | undefined }) => {
const header = document.createElement('div');
if (fn.doc) {
const typeNameSpan = document.createElement('span');
typeNameSpan.innerHTML = typeName.slice(0, 1).toUpperCase() + typeName.slice(1) + '.';
header.appendChild(typeNameSpan);
const functionNameSpan = document.createElement('span');
functionNameSpan.classList.add('autocomplete-info-name');
functionNameSpan.innerHTML = `${fn.doc.name}`;
header.appendChild(functionNameSpan);
let functionArgs = '(';
if (fn.doc.args) {
functionArgs += fn.doc.args
.map((arg) => {
let argString = `${arg.name}`;
if (arg.type) {
argString += `: ${arg.type}`;
}
return argString;
})
.join(', ');
}
functionArgs += ')';
const argsSpan = document.createElement('span');
argsSpan.classList.add('autocomplete-info-name-args');
argsSpan.innerText = functionArgs;
header.appendChild(argsSpan);
if (fn.doc.returnType) {
const returnTypeSpan = document.createElement('span');
returnTypeSpan.innerHTML = ': ' + fn.doc.returnType;
header.appendChild(returnTypeSpan);
}
}
return header;
};
const createPropHeader = (typeName: string, property: { doc?: DocMetadata | undefined }) => {
const header = document.createElement('div');
if (property.doc) {
const typeNameSpan = document.createElement('span');
typeNameSpan.innerHTML = typeName.charAt(0).toUpperCase() + typeName.slice(1);
if (!property.doc.name.startsWith("['")) {
typeNameSpan.innerHTML += '.';
}
const propNameSpan = document.createElement('span');
propNameSpan.classList.add('autocomplete-info-name');
propNameSpan.innerText = property.doc.name;
const returnTypeSpan = document.createElement('span');
returnTypeSpan.innerHTML = ': ' + property.doc.returnType;
header.appendChild(typeNameSpan);
header.appendChild(propNameSpan);
header.appendChild(returnTypeSpan);
}
return header;
};
const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
const { base, resolved, transformLabel = (label) => label } = input;
const rank = setRank(['item', 'all', 'first', 'last']);
@@ -422,19 +346,16 @@ const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
const infoKey = [name, key].join('.');
const infoName = needsBracketAccess ? applyBracketAccess(key) : key;
option.info = createCompletionOption(
'',
infoName,
isFunction ? 'native-function' : 'keyword',
{
doc: {
name: infoName,
returnType: getType(resolvedProp),
description: i18n.proxyVars[infoKey],
},
option.info = createCompletionOption({
name: infoName,
doc: {
name: infoName,
returnType: isFunction ? 'any' : getType(resolvedProp),
description: i18n.proxyVars[infoKey],
},
isFunction,
transformLabel,
).info;
}).info;
return option;
});
@@ -448,15 +369,19 @@ const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
base === 'Math';
if (skipObjectExtensions) {
return sortCompletionsAlpha([...localKeys, ...natives('object')]);
return sortCompletionsAlpha([...localKeys, ...natives({ typeName: 'object' })]);
}
return applySections({
options: sortCompletionsAlpha([...localKeys, ...natives('object'), ...extensions('object')]),
options: sortCompletionsAlpha([
...localKeys,
...natives({ typeName: 'object' }),
...extensions({ typeName: 'object' }),
]),
recommended: OBJECT_RECOMMENDED_OPTIONS,
recommendedSection: RECOMMENDED_METHODS_SECTION,
methodsSection: OTHER_METHODS_SECTION,
propSection: FIELDS_SECTION,
methodsSection: OTHER_METHODS_SECTION,
excludeRecommended: true,
});
};
@@ -536,14 +461,14 @@ const isUrl = (url: string): boolean => {
const stringOptions = (input: AutocompleteInput<string>): Completion[] => {
const { resolved, transformLabel } = input;
const options = sortCompletionsAlpha([
...natives('string', transformLabel),
...extensions('string', false, transformLabel),
...natives({ typeName: 'string', transformLabel }),
...extensions({ typeName: 'string', includeHidden: false, transformLabel }),
]);
if (validateFieldType('string', resolved, 'number').valid) {
if (resolved && validateFieldType('string', resolved, 'number').valid) {
return applySections({
options,
recommended: ['toInt()', 'toFloat()'],
recommended: ['toNumber()'],
sections: STRING_SECTIONS,
});
}
@@ -609,15 +534,18 @@ const stringOptions = (input: AutocompleteInput<string>): Completion[] => {
const booleanOptions = (): Completion[] => {
return applySections({
options: sortCompletionsAlpha([...natives('boolean'), ...extensions('boolean')]),
options: sortCompletionsAlpha([
...natives({ typeName: 'boolean' }),
...extensions({ typeName: 'boolean' }),
]),
});
};
const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
const { resolved, transformLabel } = input;
const options = sortCompletionsAlpha([
...natives('number', transformLabel),
...extensions('number', false, transformLabel),
...natives({ typeName: 'number', transformLabel }),
...extensions({ typeName: 'number', includeHidden: false, transformLabel }),
]);
const ONLY_INTEGER = ['isEven()', 'isOdd()'];
@@ -630,7 +558,7 @@ const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
if (isPlausableMillisDateTime) {
return applySections({
options,
recommended: [{ label: 'toDateTime()', args: ['ms'] }],
recommended: [{ label: 'toDateTime()', args: ["'ms'"] }],
});
}
@@ -641,7 +569,7 @@ const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
if (isPlausableSecondsDateTime) {
return applySections({
options,
recommended: [{ label: 'toDateTime()', args: ['s'] }],
recommended: [{ label: 'toDateTime()', args: ["'s'"] }],
});
}
@@ -666,22 +594,20 @@ const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
};
const dateOptions = (input: AutocompleteInput<Date>): Completion[] => {
return applySections({
options: sortCompletionsAlpha([
...natives('date', input.transformLabel),
...extensions('date', true, input.transformLabel),
]),
recommended: DATE_RECOMMENDED_OPTIONS,
});
const { transformLabel } = input;
return extensions({ typeName: 'date', includeHidden: true, transformLabel }).filter(
(ext) => ext.label === 'toDateTime()',
);
};
const luxonOptions = (input: AutocompleteInput<DateTime>): Completion[] => {
return applySections({
const { transformLabel } = input;
const result = applySections({
options: sortCompletionsAlpha(
uniqBy(
[
...extensions('date', false, input.transformLabel),
...luxonInstanceOptions(false, input.transformLabel),
...extensions({ typeName: 'date', includeHidden: false, transformLabel }),
...luxonInstanceOptions({ includeHidden: false, transformLabel }),
],
(option) => option.label,
),
@@ -689,14 +615,16 @@ const luxonOptions = (input: AutocompleteInput<DateTime>): Completion[] => {
recommended: LUXON_RECOMMENDED_OPTIONS,
sections: LUXON_SECTIONS,
});
return result;
};
const arrayOptions = (input: AutocompleteInput<unknown[]>): Completion[] => {
const { resolved, transformLabel } = input;
const options = applySections({
options: sortCompletionsAlpha([
...natives('array', transformLabel),
...extensions('array', false, transformLabel),
...natives({ typeName: 'array', transformLabel }),
...extensions({ typeName: 'array', includeHidden: false, transformLabel }),
]),
recommended: ARRAY_RECOMMENDED_OPTIONS,
methodsSection: OTHER_SECTION,
@@ -726,7 +654,8 @@ export const variablesOptions = () => {
const variables = environmentsStore.variables;
return variables.map((variable) =>
createCompletionOption('Object', variable.key, 'keyword', {
createCompletionOption({
name: variable.key,
doc: {
name: variable.key,
returnType: 'string',
@@ -756,7 +685,8 @@ export const secretOptions = (base: string) => {
return [];
}
return Object.entries(resolved).map(([secret, value]) =>
createCompletionOption('', secret, 'keyword', {
createCompletionOption({
name: secret,
doc: {
name: secret,
returnType: typeof value,
@@ -774,7 +704,8 @@ export const secretProvidersOptions = () => {
const externalSecretsStore = useExternalSecretsStore();
return Object.keys(externalSecretsStore.secretsAsObject).map((provider) =>
createCompletionOption('Object', provider, 'keyword', {
createCompletionOption({
name: provider,
doc: {
name: provider,
returnType: 'object',
@@ -788,10 +719,13 @@ export const secretProvidersOptions = () => {
/**
* Methods and fields defined on a Luxon `DateTime` class instance.
*/
export const luxonInstanceOptions = (
export const luxonInstanceOptions = ({
includeHidden = false,
transformLabel: (label: string) => string = (label) => label,
) => {
transformLabel = (label) => label,
}: {
includeHidden?: boolean;
transformLabel?: (label: string) => string;
} = {}) => {
const SKIP = new Set(['constructor', 'get', 'invalidExplanation', 'invalidReason']);
return Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype))
@@ -799,15 +733,14 @@ export const luxonInstanceOptions = (
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, descriptor]) => {
const isFunction = typeof descriptor.value === 'function';
const optionType = isFunction ? 'native-function' : 'keyword';
return createLuxonAutocompleteOption(
key,
optionType,
luxonInstanceDocs,
i18n.luxonInstance,
return createLuxonAutocompleteOption({
name: key,
isFunction,
docs: luxonInstanceDocs,
translations: i18n.luxonInstance,
includeHidden,
transformLabel,
) as Completion;
}) as Completion;
})
.filter(Boolean);
};
@@ -822,33 +755,39 @@ export const luxonStaticOptions = () => {
Object.keys(Object.getOwnPropertyDescriptors(DateTime))
.filter((key) => !SKIP.has(key) && !key.includes('_'))
.map((key) => {
return createLuxonAutocompleteOption(
key,
'native-function',
luxonStaticDocs,
i18n.luxonStatic,
) as Completion;
return createLuxonAutocompleteOption({
name: key,
isFunction: true,
docs: luxonStaticDocs,
translations: i18n.luxonStatic,
}) as Completion;
})
.filter(Boolean),
);
};
const createLuxonAutocompleteOption = (
name: string,
type: AutocompleteOptionType,
docDefinition: NativeDoc,
translations: Record<string, string | undefined>,
const createLuxonAutocompleteOption = ({
name,
docs,
translations,
isFunction = false,
includeHidden = false,
transformLabel: (label: string) => string = (label) => label,
): Completion | null => {
const isFunction = isFunctionOption(type);
transformLabel = (label) => label,
}: {
name: string;
docs: NativeDoc;
translations: Record<string, string | undefined>;
isFunction?: boolean;
includeHidden?: boolean;
transformLabel?: (label: string) => string;
}): Completion | null => {
const label = isFunction ? name + '()' : name;
let doc: DocMetadata | undefined;
if (docDefinition.properties && docDefinition.properties.hasOwnProperty(name)) {
doc = docDefinition.properties[name].doc;
} else if (docDefinition.functions.hasOwnProperty(name)) {
doc = docDefinition.functions[name].doc;
if (docs.properties && docs.properties.hasOwnProperty(name)) {
doc = docs.properties[name].doc;
} else if (docs.functions.hasOwnProperty(name)) {
doc = docs.functions[name].doc;
} else {
// Use inferred/default values if docs are still not updated
// This should happen when our doc specification becomes
@@ -867,7 +806,6 @@ const createLuxonAutocompleteOption = (
const option: Completion = {
label,
type,
section: doc?.section,
apply: applyCompletion({
hasArgs: hasRequiredArgs(doc),
@@ -875,16 +813,13 @@ const createLuxonAutocompleteOption = (
transformLabel,
}),
};
option.info = createCompletionOption(
'DateTime',
option.info = createCompletionOption({
name,
type,
{
// Add translated description
doc: { ...doc, description: translations[name] } as DocMetadata,
},
isFunction,
// Add translated description
doc: { ...doc, description: translations[name] } as DocMetadata,
transformLabel,
).info;
}).info;
return option;
};
@@ -911,8 +846,8 @@ const regexes = {
selectorRef: /\$\(['"][\S\s]+['"]\)\.(.*)/, // $('nodeName').
numberLiteral: /\((\d+)\.?(\d*)\)\.(.*)/, // (123). or (123.4).
singleQuoteStringLiteral: /('.+')\.([^'{\s])*/, // 'abc'.
doubleQuoteStringLiteral: /(".+")\.([^"{\s])*/, // "abc".
singleQuoteStringLiteral: /('.*')\.([^'{\s])*/, // 'abc'.
doubleQuoteStringLiteral: /(".*")\.([^"{\s])*/, // "abc".
dateLiteral: /\(?new Date\(\(?.*?\)\)?\.(.*)/, // new Date(). or (new Date()).
arrayLiteral: /\(?(\[.*\])\)?\.(.*)/, // [1, 2, 3].
indexedAccess: /([^"{\s]+\[.+\])\.(.*)/, // 'abc'[0]. or 'abc'.split('')[0] or similar ones

View File

@@ -14,6 +14,7 @@ import type { Completion, CompletionContext, CompletionResult } from '@codemirro
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import { escapeMappingString } from '@/utils/mappingUtils';
import { PREVIOUS_NODES_SECTION, RECOMMENDED_SECTION, ROOT_DOLLAR_COMPLETIONS } from './constants';
import { createInfoBoxRenderer } from './infoBoxRenderer';
/**
* Completions offered at the dollar position: `$|`
@@ -53,9 +54,33 @@ export function dollarOptions(): Completion[] {
if (isInHttpNodePagination()) {
recommendedCompletions = [
{ label: '$pageCount', section: RECOMMENDED_SECTION, info: i18n.rootVars.$pageCount },
{ label: '$response', section: RECOMMENDED_SECTION, info: i18n.rootVars.$response },
{ label: '$request', section: RECOMMENDED_SECTION, info: i18n.rootVars.$request },
{
label: '$pageCount',
section: RECOMMENDED_SECTION,
info: createInfoBoxRenderer({
name: '$pageCount',
returnType: 'number',
description: i18n.rootVars.$pageCount,
}),
},
{
label: '$response',
section: RECOMMENDED_SECTION,
info: createInfoBoxRenderer({
name: '$response',
returnType: 'object',
description: i18n.rootVars.$response,
}),
},
{
label: '$request',
section: RECOMMENDED_SECTION,
info: createInfoBoxRenderer({
name: '$request',
returnType: 'object',
description: i18n.rootVars.$request,
}),
},
];
}
@@ -80,12 +105,18 @@ export function dollarOptions(): Completion[] {
if (receivesNoBinaryData()) SKIP.add('$binary');
const previousNodesCompletions = autocompletableNodeNames().map((nodeName) => ({
label: `$('${escapeMappingString(nodeName)}')`,
type: 'keyword',
info: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }),
section: PREVIOUS_NODES_SECTION,
}));
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)

View File

@@ -0,0 +1,263 @@
import type { DocMetadata, DocMetadataArgument, DocMetadataExample } from 'n8n-workflow';
import { sanitizeHtml } from '@/utils/htmlUtils';
import { i18n } from '@/plugins/i18n';
const renderFunctionHeader = (doc?: DocMetadata) => {
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('span');
argSpan.textContent = arg.name;
if (optional) {
argSpan.textContent += '?';
}
if (arg.variadic) {
argSpan.textContent = '...' + argSpan.textContent;
}
argSpan.classList.add('autocomplete-info-arg');
argsSpan.appendChild(argSpan);
if (index !== array.length - 1) {
const separatorSpan = document.createElement('span');
separatorSpan.textContent = ', ';
argsSpan.appendChild(separatorSpan);
} else {
argSpan.textContent += ')';
}
});
header.appendChild(argsSpan);
const preTypeInfo = document.createElement('span');
preTypeInfo.textContent = !doc.args || doc.args.length === 0 ? '): ' : ': ';
header.appendChild(preTypeInfo);
const returnTypeSpan = document.createElement('span');
returnTypeSpan.textContent = doc.returnType;
returnTypeSpan.classList.add('autocomplete-info-return');
header.appendChild(returnTypeSpan);
}
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.innerText = doc.name;
const returnTypeSpan = document.createElement('span');
returnTypeSpan.textContent = ': ' + doc.returnType;
header.appendChild(propNameSpan);
header.appendChild(returnTypeSpan);
}
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 renderArgs = (args: DocMetadataArgument[]) => {
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);
const argsList = document.createElement('ul');
argsList.classList.add('autocomplete-info-args');
for (const arg of args.filter((a) => a.name !== '...')) {
const argItem = document.createElement('li');
const argName = document.createElement('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 (args.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.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);
}
argsList.appendChild(argItem);
}
argsContainer.appendChild(argsList);
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) =>
() => {
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) : 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);
tooltipContainer.appendChild(argsContainer);
}
if (hasExamples && (examples.length > 1 || !hasArgs)) {
const examplesContainer = renderExamples(examples);
tooltipContainer.appendChild(examplesContainer);
}
return tooltipContainer;
};

View File

@@ -202,6 +202,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
doc: {
name: 'weekYear',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweekyear',
returnType: 'number',
},
@@ -226,6 +227,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
doc: {
name: 'zoneName',
section: 'query',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimezonename',
returnType: 'string',
},
@@ -297,7 +299,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeendof',
returnType: 'DateTime',
args: [{ name: 'unit', type: 'string' }],
args: [{ name: 'unit', type: 'string', default: "'month'" }],
},
},
equals: {
@@ -395,7 +397,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimestartof',
returnType: 'DateTime',
args: [{ name: 'unit', type: 'string' }],
args: [{ name: 'unit', type: 'string', default: "'month'" }],
},
},
toBSON: {
@@ -488,7 +490,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
toLocal: {
doc: {
name: 'toLocal',
section: 'format',
section: 'edit',
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetolocal',
returnType: 'DateTime',
},
@@ -633,6 +635,7 @@ export const luxonInstanceDocs: Required<NativeDoc> = {
doc: {
name: 'until',
section: 'compare',
hidden: true,
docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeuntil',
returnType: 'Interval',
args: [{ name: 'other', type: 'DateTime' }],

View File

@@ -1,5 +0,0 @@
import type { AutocompleteOptionType, FunctionOptionType } from './types';
export const isFunctionOption = (value: AutocompleteOptionType): value is FunctionOptionType => {
return value === 'native-function' || value === 'extension-function';
};

View File

@@ -164,13 +164,18 @@ export const stripExcessParens = (context: CompletionContext) => (option: Comple
return option;
};
export const getDefaultArgs = (doc?: DocMetadata): unknown[] => {
return doc?.args?.map((arg) => arg.default).filter(Boolean) ?? [];
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.map((arg) => JSON.stringify(arg)).join(', ');
const argList = args.join(', ');
const fnName = label.replace('()', '');
return `${fnName}(${argList})`;
@@ -239,7 +244,7 @@ export const applyBracketAccessCompletion = (
export const hasRequiredArgs = (doc?: DocMetadata): boolean => {
if (!doc) return false;
const requiredArgs = doc?.args?.filter((arg) => !arg.name.endsWith('?')) ?? [];
const requiredArgs = doc?.args?.filter((arg) => !arg.name.endsWith('?') && !arg.optional) ?? [];
return requiredArgs.length > 0;
};

View File

@@ -25,4 +25,5 @@ export function n8nLang() {
]);
}
export const n8nAutocompletion = () => autocompletion({ icons: false });
export const n8nAutocompletion = () =>
autocompletion({ icons: false, aboveCursor: true, closeOnBlur: false });