feat(editor): Help users discover expressions when using drag n drop (#8869)

This commit is contained in:
Elias Meire
2024-03-13 12:57:08 +01:00
committed by GitHub
parent 71f1b23771
commit e78cc2d8d2
28 changed files with 559 additions and 323 deletions

View File

@@ -599,9 +599,24 @@ describe('Resolution-based completions', () => {
);
});
});
describe('explicit completions (opened by Ctrl+Space or programatically)', () => {
test('should return completions for: {{ $json.foo| }}', () => {
vi.spyOn(workflowHelpers, 'resolveParameter')
// @ts-expect-error Spied function is mistyped
.mockReturnValueOnce(undefined)
// @ts-expect-error Spied function is mistyped
.mockReturnValueOnce('foo');
const result = completions('{{ $json.foo| }}', true);
expect(result).toHaveLength(
extensions('string').length + natives('string').length + STRING_RECOMMENDED_OPTIONS.length,
);
});
});
});
export function completions(docWithCursor: string) {
export function completions(docWithCursor: string, explicit = false) {
const cursorPosition = docWithCursor.indexOf('|');
const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1);
@@ -612,7 +627,7 @@ export function completions(docWithCursor: string) {
extensions: [n8nLang()],
});
const context = new CompletionContext(state, cursorPosition, false);
const context = new CompletionContext(state, cursorPosition, explicit);
for (const completionSource of state.languageDataAt<CompletionSource>(
'autocomplete',

View File

@@ -67,8 +67,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
if (word.from === word.to && !context.explicit) return null;
// eslint-disable-next-line prefer-const
let [base, tail] = splitBaseTail(word.text);
const [base, tail] = splitBaseTail(word.text);
let options: Completion[] = [];
@@ -102,14 +101,23 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
}
}
if (options.length === 0) return null;
if (tail !== '') {
options = options.filter((o) => prefixMatch(o.label, tail) && o.label !== tail);
}
let from = word.to - tail.length;
// When autocomplete is explicitely opened (by Ctrl+Space or programatically), add completions for the current word with '.' prefix
// example: {{ $json.str| }} -> ['length', 'includes()'...] (would usually need a '.' suffix)
if (context.explicit && !word.text.endsWith('.') && options.length === 0) {
options = explicitDataTypeOptions(word.text);
from = word.to;
}
if (options.length === 0) return null;
return {
from: word.to - tail.length,
from,
options,
filter: false,
getMatch(completion: Completion) {
@@ -120,6 +128,20 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
};
}
function explicitDataTypeOptions(expression: string): Completion[] {
try {
const resolved = resolveParameter(`={{ ${expression} }}`);
return datatypeOptions({
resolved,
base: expression,
tail: '',
transformLabel: (label) => '.' + label,
});
} catch {
return [];
}
}
function datatypeOptions(input: AutocompleteInput): Completion[] {
const { resolved } = input;
@@ -134,11 +156,11 @@ function datatypeOptions(input: AutocompleteInput): Completion[] {
}
if (resolved instanceof DateTime) {
return luxonOptions();
return luxonOptions(input as AutocompleteInput<DateTime>);
}
if (resolved instanceof Date) {
return dateOptions();
return dateOptions(input as AutocompleteInput<Date>);
}
if (Array.isArray(resolved)) {
@@ -152,18 +174,33 @@ function datatypeOptions(input: AutocompleteInput): Completion[] {
return [];
}
export const natives = (typeName: ExtensionTypeName): Completion[] => {
export const natives = (
typeName: ExtensionTypeName,
transformLabel: (label: string) => string = (label) => label,
): Completion[] => {
const natives: NativeDoc = NativeMethods.find((ee) => ee.typeName.toLowerCase() === typeName);
if (!natives) return [];
const nativeProps = natives.properties ? toOptions(natives.properties, typeName, 'keyword') : [];
const nativeMethods = toOptions(natives.functions, typeName, 'native-function');
const nativeProps = natives.properties
? toOptions(natives.properties, typeName, 'keyword', false, transformLabel)
: [];
const nativeMethods = toOptions(
natives.functions,
typeName,
'native-function',
false,
transformLabel,
);
return [...nativeProps, ...nativeMethods];
};
export const extensions = (typeName: ExtensionTypeName, includeHidden = false) => {
export const extensions = (
typeName: ExtensionTypeName,
includeHidden = false,
transformLabel: (label: string) => string = (label) => label,
) => {
const extensions = ExpressionExtensions.find((ee) => ee.typeName.toLowerCase() === typeName);
if (!extensions) return [];
@@ -172,7 +209,7 @@ export const extensions = (typeName: ExtensionTypeName, includeHidden = false) =
return { ...acc, [fnName]: { doc: fn.doc } };
}, {});
return toOptions(fnToDoc, typeName, 'extension-function', includeHidden);
return toOptions(fnToDoc, typeName, 'extension-function', includeHidden, transformLabel);
};
export const toOptions = (
@@ -180,12 +217,13 @@ export const toOptions = (
typeName: ExtensionTypeName,
optionType: AutocompleteOptionType = 'native-function',
includeHidden = false,
transformLabel: (label: string) => string = (label) => label,
) => {
return Object.entries(fnToDoc)
.sort((a, b) => a[0].localeCompare(b[0]))
.filter(([, docInfo]) => !docInfo.doc?.hidden || includeHidden)
.map(([fnName, docInfo]) => {
return createCompletionOption(typeName, fnName, optionType, docInfo);
return createCompletionOption(typeName, fnName, optionType, docInfo, transformLabel);
});
};
@@ -194,6 +232,7 @@ const createCompletionOption = (
name: string,
optionType: AutocompleteOptionType,
docInfo: { doc?: DocMetadata | undefined },
transformLabel: (label: string) => string = (label) => label,
): Completion => {
const isFunction = isFunctionOption(optionType);
const label = isFunction ? name + '()' : name;
@@ -201,7 +240,7 @@ const createCompletionOption = (
label,
type: optionType,
section: docInfo.doc?.section,
apply: applyCompletion(hasRequiredArgs(docInfo?.doc)),
apply: applyCompletion(hasRequiredArgs(docInfo?.doc), transformLabel),
};
option.info = () => {
@@ -304,7 +343,7 @@ const createPropHeader = (typeName: string, property: { doc?: DocMetadata | unde
};
const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
const { base, resolved } = input;
const { base, resolved, transformLabel } = input;
const rank = setRank(['item', 'all', 'first', 'last']);
const SKIP = new Set(['__ob__', 'pairedItem']);
@@ -338,17 +377,23 @@ const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
label: isFunction ? key + '()' : key,
type: isFunction ? 'function' : 'keyword',
section: getObjectPropertySection({ name, key, isFunction }),
apply: applyCompletion(hasArgs),
apply: applyCompletion(hasArgs, transformLabel),
};
const infoKey = [name, key].join('.');
option.info = createCompletionOption('', key, isFunction ? 'native-function' : 'keyword', {
doc: {
name: key,
returnType: typeof resolvedProp,
description: i18n.proxyVars[infoKey],
option.info = createCompletionOption(
'',
key,
isFunction ? 'native-function' : 'keyword',
{
doc: {
name: key,
returnType: typeof resolvedProp,
description: i18n.proxyVars[infoKey],
},
},
}).info;
transformLabel,
).info;
return option;
});
@@ -448,8 +493,11 @@ const isUrl = (url: string): boolean => {
};
const stringOptions = (input: AutocompleteInput<string>): Completion[] => {
const { resolved, tail } = input;
const options = sortCompletionsAlpha([...natives('string'), ...extensions('string')]);
const { resolved, transformLabel } = input;
const options = sortCompletionsAlpha([
...natives('string', transformLabel),
...extensions('string', false, transformLabel),
]);
if (validateFieldType('string', resolved, 'number').valid) {
return applySections({
@@ -491,8 +539,11 @@ const stringOptions = (input: AutocompleteInput<string>): Completion[] => {
};
const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
const { resolved } = input;
const options = sortCompletionsAlpha([...natives('number'), ...extensions('number')]);
const { resolved, transformLabel } = input;
const options = sortCompletionsAlpha([
...natives('number', transformLabel),
...extensions('number', false, transformLabel),
]);
const ONLY_INTEGER = ['isEven()', 'isOdd()'];
if (Number.isInteger(resolved)) {
@@ -509,17 +560,26 @@ const numberOptions = (input: AutocompleteInput<number>): Completion[] => {
}
};
const dateOptions = (): Completion[] => {
const dateOptions = (input: AutocompleteInput<Date>): Completion[] => {
return applySections({
options: sortCompletionsAlpha([...natives('date'), ...extensions('date', true)]),
options: sortCompletionsAlpha([
...natives('date', input.transformLabel),
...extensions('date', true, input.transformLabel),
]),
recommended: DATE_RECOMMENDED_OPTIONS,
});
};
const luxonOptions = (): Completion[] => {
const luxonOptions = (input: AutocompleteInput<DateTime>): Completion[] => {
return applySections({
options: sortCompletionsAlpha(
uniqBy([...extensions('date'), ...luxonInstanceOptions()], (option) => option.label),
uniqBy(
[
...extensions('date', false, input.transformLabel),
...luxonInstanceOptions(false, input.transformLabel),
],
(option) => option.label,
),
),
recommended: LUXON_RECOMMENDED_OPTIONS,
sections: LUXON_SECTIONS,
@@ -527,9 +587,12 @@ const luxonOptions = (): Completion[] => {
};
const arrayOptions = (input: AutocompleteInput<unknown[]>): Completion[] => {
const { resolved } = input;
const { resolved, transformLabel } = input;
const options = applySections({
options: sortCompletionsAlpha([...natives('array'), ...extensions('array')]),
options: sortCompletionsAlpha([
...natives('array', transformLabel),
...extensions('array', false, transformLabel),
]),
recommended: ARRAY_RECOMMENDED_OPTIONS,
methodsSection: OTHER_SECTION,
propSection: OTHER_SECTION,
@@ -620,7 +683,10 @@ export const secretProvidersOptions = () => {
/**
* Methods and fields defined on a Luxon `DateTime` class instance.
*/
export const luxonInstanceOptions = (includeHidden = false) => {
export const luxonInstanceOptions = (
includeHidden = false,
transformLabel: (label: string) => string = (label) => label,
) => {
const SKIP = new Set(['constructor', 'get', 'invalidExplanation', 'invalidReason']);
return Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype))
@@ -635,6 +701,7 @@ export const luxonInstanceOptions = (includeHidden = false) => {
luxonInstanceDocs,
i18n.luxonInstance,
includeHidden,
transformLabel,
) as Completion;
})
.filter(Boolean);
@@ -667,6 +734,7 @@ const createLuxonAutocompleteOption = (
docDefinition: NativeDoc,
translations: Record<string, string | undefined>,
includeHidden = false,
transformLabel: (label: string) => string = (label) => label,
): Completion | null => {
const isFunction = isFunctionOption(type);
const label = isFunction ? name + '()' : name;
@@ -696,12 +764,18 @@ const createLuxonAutocompleteOption = (
label,
type,
section: doc?.section,
apply: applyCompletion(hasRequiredArgs(doc)),
apply: applyCompletion(hasRequiredArgs(doc), transformLabel),
};
option.info = createCompletionOption('DateTime', name, type, {
// Add translated description
doc: { ...doc, description: translations[name] } as DocMetadata,
}).info;
option.info = createCompletionOption(
'DateTime',
name,
type,
{
// Add translated description
doc: { ...doc, description: translations[name] } as DocMetadata,
},
transformLabel,
).info;
return option;
};

View File

@@ -13,4 +13,5 @@ export type AutocompleteInput<R = Resolved> = {
resolved: R;
base: string;
tail: string;
transformLabel?: (label: string) => string;
};

View File

@@ -157,15 +157,16 @@ export const stripExcessParens = (context: CompletionContext) => (option: Comple
* @example `$max()` -> `$max()<cursor>`
*/
export const applyCompletion =
(hasArgs = true) =>
(hasArgs = true, transform: (label: string) => string = (label) => label) =>
(view: EditorView, completion: Completion, from: number, to: number): void => {
const label = transform(completion.label);
const tx: TransactionSpec = {
...insertCompletionText(view.state, completion.label, from, to),
...insertCompletionText(view.state, label, from, to),
annotations: pickedCompletion.of(completion),
};
if (completion.label.endsWith('()') && hasArgs) {
const cursorPosition = from + completion.label.length - 1;
if (label.endsWith('()') && hasArgs) {
const cursorPosition = from + label.length - 1;
tx.selection = { anchor: cursorPosition, head: cursorPosition };
}

View File

@@ -510,9 +510,6 @@
"dataMapping.dragFromPreviousHint": "Map data from previous nodes to <b>{name}</b> by first clicking this button",
"dataMapping.success.title": "You just mapped some data!",
"dataMapping.success.moreInfo": "Check out our <a href=\"https://docs.n8n.io/data/data-mapping\" target=\"_blank\">docs</a> for more details on mapping data in n8n",
"dataMapping.tableHint": "<img src='/static/data-mapping-gif.gif'/><br/> Drag a column onto <b>{name}</b> to map it",
"dataMapping.jsonHint": "<img src='/static/json-mapping-gif.gif'/><br/> Drag a JSON key onto <b>{name}</b> to map data",
"dataMapping.schemaHint": "<img src='/static/schema-mapping-gif.gif'/><br/> Drag a datapill onto <b>{name}</b> to map data",
"dataMapping.tableView.tableColumnsExceeded": "Some columns are hidden",
"dataMapping.tableView.tableColumnsExceeded.tooltip": "Your data has more than {columnLimit} columns so some are hidden. Switch to {link} to see all data.",
"dataMapping.tableView.tableColumnsExceeded.tooltip.link": "JSON view",
@@ -682,6 +679,7 @@
"expressionModalInput.empty": "[empty]",
"expressionModalInput.undefined": "[undefined]",
"expressionModalInput.null": "null",
"expressionTip.noExecutionData": "Execute previous nodes to use input data",
"expressionModalInput.noExecutionData": "Execute previous nodes for preview",
"expressionModalInput.noNodeExecutionData": "Execute node {node} for preview",
"expressionModalInput.noInputConnection": "No input connected",
@@ -1197,8 +1195,12 @@
"openWorkflow.workflowImportError": "Could not import workflow",
"openWorkflow.workflowNotFoundError": "Could not find workflow",
"parameterInput.expressionResult": "e.g. {result}",
"parameterInput.tip": "Tip",
"parameterInput.anythingInside": "Anything inside ",
"parameterInput.isJavaScript": " is JavaScript.",
"parameterInput.dragTipBeforePill": "Drag an",
"parameterInput.inputField": "input field",
"parameterInput.dragTipAfterPill": "from the left to use it here.",
"parameterInput.learnMore": "Learn more",
"parameterInput.resultForItem": "Result for Item",
"parameterInput.emptyString": "[empty]",