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:
committed by
GitHub
parent
af703371fc
commit
6592d144d1
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user