feat(editor): Add most important native props and methods to autocomplete (#5486)

*  Implemented support for documentation links in autocomplete tooltips
*  Added support for arguments and code stying in autocomplete documentation. Added build-in string functions docs.
*  Added support for args without types in autocomplete, Added array native functions.
*  Added native Number and Object methods to autocomplete
*  Added support for native properties in autocomplete
* 📚 Added comment for next phase
* ✔️ Updating tests to account for native autocomplete options. Fixing lint errros.
* 👌 Addressing design review comments
* 🎨 Using design-system tokens instead of colors for autocomplete
This commit is contained in:
Milorad FIlipović
2023-02-16 12:47:19 +01:00
committed by GitHub
parent af703371fc
commit 6592d144d1
11 changed files with 673 additions and 56 deletions

View File

@@ -172,7 +172,7 @@ describe('Resolution-based completions', () => {
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(extensions('string').length);
expect(found).toHaveLength(extensions('string').length + natives('string').length);
expect(found.map((c) => c.label).every((l) => !l.endsWith('()')));
});
@@ -183,7 +183,7 @@ describe('Resolution-based completions', () => {
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(extensions('number').length);
expect(found).toHaveLength(extensions('number').length + natives('number').length);
expect(found.map((c) => c.label).every((l) => !l.endsWith('()')));
});
@@ -194,7 +194,7 @@ describe('Resolution-based completions', () => {
if (!found) throw new Error('Expected to find completions');
expect(found).toHaveLength(extensions('array').length);
expect(found).toHaveLength(extensions('array').length + natives('array').length);
expect(found.map((c) => c.label).every((l) => !l.endsWith('()')));
});
});
@@ -206,14 +206,16 @@ describe('Resolution-based completions', () => {
test('should return completions for: {{ $input.| }}', () => {
resolveParameterSpy.mockReturnValue($input);
expect(completions('{{ $input.| }}')).toHaveLength(Reflect.ownKeys($input).length);
expect(completions('{{ $input.| }}')).toHaveLength(
Reflect.ownKeys($input).length + natives('object').length,
);
});
test("should return completions for: {{ $('nodeName').| }}", () => {
resolveParameterSpy.mockReturnValue($('Rename'));
expect(completions('{{ $("Rename").| }}')).toHaveLength(
Reflect.ownKeys($('Rename')).length - ['pairedItem'].length,
Reflect.ownKeys($('Rename')).length + natives('object').length - ['pairedItem'].length,
);
});
@@ -224,7 +226,7 @@ describe('Resolution-based completions', () => {
if (!found) throw new Error('Expected to find completion');
expect(found).toHaveLength(1);
expect(found).toHaveLength(3);
expect(found[0].label).toBe('json');
});
@@ -235,7 +237,7 @@ describe('Resolution-based completions', () => {
if (!found) throw new Error('Expected to find completion');
expect(found).toHaveLength(1);
expect(found).toHaveLength(3);
expect(found[0].label).toBe('json');
});
@@ -246,7 +248,7 @@ describe('Resolution-based completions', () => {
if (!found) throw new Error('Expected to find completion');
expect(found).toHaveLength(1);
expect(found).toHaveLength(3);
expect(found[0].label).toBe('json');
});
@@ -261,7 +263,8 @@ describe('Resolution-based completions', () => {
resolveParameterSpy.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('object').length + natives('object').length),
);
});
@@ -269,7 +272,8 @@ describe('Resolution-based completions', () => {
resolveParameterSpy.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('object').length + natives('object').length),
);
});
@@ -277,7 +281,8 @@ describe('Resolution-based completions', () => {
resolveParameterSpy.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('object').length + natives('object').length),
);
});
@@ -285,33 +290,41 @@ describe('Resolution-based completions', () => {
resolveParameterSpy.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('object').length + natives('object').length),
);
});
test('should return completions for: {{ $input.item.json.str.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.str);
expect(completions('{{ $input.item.json.str.| }}')).toHaveLength(extensions('string').length);
expect(completions('{{ $input.item.json.str.| }}')).toHaveLength(
extensions('string').length + natives('string').length,
);
});
test('should return completions for: {{ $input.item.json.num.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.num);
expect(completions('{{ $input.item.json.num.| }}')).toHaveLength(extensions('number').length);
expect(completions('{{ $input.item.json.num.| }}')).toHaveLength(
extensions('number').length + natives('number').length,
);
});
test('should return completions for: {{ $input.item.json.arr.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.arr);
expect(completions('{{ $input.item.json.arr.| }}')).toHaveLength(extensions('array').length);
expect(completions('{{ $input.item.json.arr.| }}')).toHaveLength(
extensions('array').length + natives('array').length,
);
});
test('should return completions for: {{ $input.item.json.obj.| }}', () => {
resolveParameterSpy.mockReturnValue($input.item.json.obj);
expect(completions('{{ $input.item.json.obj.| }}')).toHaveLength(
Object.keys($input.item.json.obj).length + extensions('object').length,
Object.keys($input.item.json.obj).length +
(extensions('object').length + natives('object').length),
);
});
});

View File

@@ -1,4 +1,4 @@
import { ExpressionExtensions, NativeMethods, IDataObject } from 'n8n-workflow';
import { ExpressionExtensions, NativeMethods, IDataObject, DocMetadata } from 'n8n-workflow';
import { DateTime } from 'luxon';
import { i18n } from '@/plugins/i18n';
import { resolveParameter } from '@/mixins/workflowHelpers';
@@ -15,6 +15,10 @@ import {
} from './utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { ExtensionTypeName, FnToDoc, Resolved } from './types';
import { sanitizeHtml } from '@/utils';
import { NativeDoc } from 'n8n-workflow/src/Extensions/Extensions';
type AutocompleteOptionType = 'function' | 'keyword';
/**
* Resolution-based completions offered according to datatype.
@@ -111,11 +115,14 @@ function datatypeOptions(resolved: Resolved, toResolve: string) {
}
export const natives = (typeName: ExtensionTypeName): Completion[] => {
const natives = NativeMethods.find((ee) => ee.typeName.toLowerCase() === typeName);
const natives: NativeDoc = NativeMethods.find((ee) => ee.typeName.toLowerCase() === typeName);
if (!natives) return [];
return toOptions(natives.functions, typeName);
const nativeProps = natives.properties ? toOptions(natives.properties, typeName, 'keyword') : [];
const nativeMethods = toOptions(natives.functions, typeName, 'function');
return [...nativeProps, ...nativeMethods];
};
export const extensions = (typeName: ExtensionTypeName) => {
@@ -132,44 +139,53 @@ export const extensions = (typeName: ExtensionTypeName) => {
return toOptions(fnToDoc, typeName);
};
export const toOptions = (fnToDoc: FnToDoc, typeName: ExtensionTypeName) => {
export const toOptions = (
fnToDoc: FnToDoc,
typeName: ExtensionTypeName,
optionType: AutocompleteOptionType = 'function',
) => {
return Object.entries(fnToDoc)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([fnName, fn]) => {
const option: Completion = {
label: fnName + '()',
type: 'function',
label: optionType === 'function' ? fnName + '()' : fnName,
type: optionType,
};
option.info = () => {
const tooltipContainer = document.createElement('div');
tooltipContainer.classList.add('autocomplete-info-container');
if (!fn.doc?.description) return null;
tooltipContainer.style.display = 'flex';
tooltipContainer.style.flexDirection = 'column';
tooltipContainer.style.paddingTop = 'var(--spacing-4xs)';
tooltipContainer.style.paddingBottom = 'var(--spacing-4xs)';
const header = document.createElement('div');
header.style.marginBottom = 'var(--spacing-2xs)';
const typeNameSpan = document.createElement('span');
typeNameSpan.innerHTML = typeName.slice(0, 1).toUpperCase() + typeName.slice(1) + '.';
const functionNameSpan = document.createElement('span');
functionNameSpan.innerHTML = fn.doc.name + '()';
functionNameSpan.style.fontWeight = 'var(--font-weight-bold)';
const returnTypeSpan = document.createElement('span');
returnTypeSpan.innerHTML = ': ' + fn.doc.returnType;
header.appendChild(typeNameSpan);
header.appendChild(functionNameSpan);
header.appendChild(returnTypeSpan);
const header =
optionType === 'function'
? createFunctionHeader(typeName, fn)
: createPropHeader(typeName, fn);
header.classList.add('autocomplete-info-header');
tooltipContainer.appendChild(header);
tooltipContainer.appendChild(document.createTextNode(fn.doc.description));
const descriptionBody = document.createElement('div');
descriptionBody.classList.add('autocomplete-info-description');
const descriptionText = document.createElement('p');
descriptionText.innerHTML = sanitizeHtml(
fn.doc.description.replace(/`(.*?)`/g, '<code>$1</code>'),
);
descriptionBody.appendChild(descriptionText);
if (fn.doc.docURL) {
const descriptionLink = document.createElement('a');
descriptionLink.setAttribute('target', '_blank');
descriptionLink.setAttribute('href', fn.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;
};
@@ -178,6 +194,63 @@ export const toOptions = (fnToDoc: FnToDoc, typeName: ExtensionTypeName) => {
});
};
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) + '.';
const functionNameSpan = document.createElement('span');
functionNameSpan.classList.add('autocomplete-info-name');
functionNameSpan.innerHTML = `${fn.doc.name}`;
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;
const returnTypeSpan = document.createElement('span');
returnTypeSpan.innerHTML = ': ' + fn.doc.returnType;
header.appendChild(typeNameSpan);
header.appendChild(functionNameSpan);
header.appendChild(argsSpan);
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.slice(0, 1).toUpperCase() + typeName.slice(1) + '.';
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 = (toResolve: string, resolved: IDataObject) => {
const rank = setRank(['item', 'all', 'first', 'last']);
const SKIP = new Set(['__ob__', 'pairedItem']);

View File

@@ -480,6 +480,10 @@ export class I18nClass {
invalid: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.invalid'),
isDateTime: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.isDateTime'),
};
autocompleteUIValues: Record<string, string | undefined> = {
docLinkLabel: this.baseText('expressionEdit.learnMore'),
};
}
export const i18nInstance = new VueI18n({