feat(editor): Add object keys that need bracket access to autocomplete (#9088)

This commit is contained in:
Elias Meire 2024-04-10 16:33:59 +02:00 committed by GitHub
parent feffc7ffd9
commit 98bcd50bab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 84 additions and 9 deletions

View file

@ -542,6 +542,43 @@ describe('Resolution-based completions', () => {
expect(found.map((c) => c.label).every((l) => l.endsWith(']')));
});
});
test('should give completions for keys that need bracket access', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue({
foo: 'bar',
'Key with spaces': 1,
'Key with spaces and \'quotes"': 1,
});
const found = completions('{{ $json.| }}');
if (!found) throw new Error('Expected to find completions');
expect(found).toContainEqual(
expect.objectContaining({
label: 'Key with spaces',
apply: utils.applyBracketAccessCompletion,
}),
);
expect(found).toContainEqual(
expect.objectContaining({
label: 'Key with spaces and \'quotes"',
apply: utils.applyBracketAccessCompletion,
}),
);
});
test('should escape keys with quotes', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue({
'Key with spaces and \'quotes"': 1,
});
const found = completions('{{ $json[| }}');
if (!found) throw new Error('Expected to find completions');
expect(found).toContainEqual(
expect.objectContaining({
label: "'Key with spaces and \\'quotes\"']",
}),
);
});
});
describe('recommended completions', () => {

View file

@ -3,6 +3,7 @@ import { prefixMatch, longestCommonPrefix } from './utils';
import type { IDataObject } from 'n8n-workflow';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { Resolved } from './types';
import { escapeMappingString } from '@/utils/mappingUtils';
/**
* Resolution-based completions offered at the start of bracket access notation.
@ -67,7 +68,7 @@ function bracketAccessOptions(resolved: IDataObject) {
const isNumber = !isNaN(parseInt(key)); // array or string index
return {
label: isNumber ? `${key}]` : `'${key}']`,
label: isNumber ? `${key}]` : `'${escapeMappingString(key)}']`,
type: 'keyword',
};
});

View file

@ -19,6 +19,8 @@ import {
hasRequiredArgs,
getDefaultArgs,
insertDefaultArgs,
applyBracketAccessCompletion,
applyBracketAccess,
} from './utils';
import type {
Completion,
@ -354,7 +356,11 @@ const createPropHeader = (typeName: string, property: { doc?: DocMetadata | unde
const header = document.createElement('div');
if (property.doc) {
const typeNameSpan = document.createElement('span');
typeNameSpan.innerHTML = typeName.slice(0, 1).toUpperCase() + typeName.slice(1) + '.';
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');
@ -371,7 +377,7 @@ const createPropHeader = (typeName: string, property: { doc?: DocMetadata | unde
};
const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
const { base, resolved, transformLabel } = input;
const { base, resolved, transformLabel = (label) => label } = input;
const rank = setRank(['item', 'all', 'first', 'last']);
const SKIP = new Set(['__ob__', 'pairedItem']);
@ -393,9 +399,10 @@ const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
}
const localKeys = rank(rawKeys)
.filter((key) => !SKIP.has(key) && isAllowedInDotNotation(key) && !isPseudoParam(key))
.filter((key) => !SKIP.has(key) && !isPseudoParam(key))
.map((key) => {
ensureKeyCanBeResolved(resolved, key);
const needsBracketAccess = !isAllowedInDotNotation(key);
const resolvedProp = resolved[key];
const isFunction = typeof resolvedProp === 'function';
@ -403,20 +410,25 @@ const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
const option: Completion = {
label: isFunction ? key + '()' : key,
type: isFunction ? 'function' : 'keyword',
section: getObjectPropertySection({ name, key, isFunction }),
apply: applyCompletion({ hasArgs, transformLabel }),
apply: needsBracketAccess
? applyBracketAccessCompletion
: applyCompletion({
hasArgs,
transformLabel,
}),
detail: getDetail(name, resolvedProp),
};
const infoKey = [name, key].join('.');
const infoName = needsBracketAccess ? applyBracketAccess(key) : key;
option.info = createCompletionOption(
'',
key,
infoName,
isFunction ? 'native-function' : 'keyword',
{
doc: {
name: key,
name: infoName,
returnType: getType(resolvedProp),
description: i18n.proxyVars[infoKey],
},

View file

@ -20,6 +20,7 @@ import type { SyntaxNode } from '@lezer/common';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { useRouter } from 'vue-router';
import type { DocMetadata } from 'n8n-workflow';
import { escapeMappingString } from '@/utils/mappingUtils';
/**
* Split user input into base (to resolve) and tail (to filter).
@ -194,7 +195,6 @@ export const applyCompletion =
(view: EditorView, completion: Completion, from: number, to: number): void => {
const isFunction = completion.label.endsWith('()');
const label = insertDefaultArgs(transformLabel(completion.label), defaultArgs);
const tx: TransactionSpec = {
...insertCompletionText(view.state, label, from, to),
annotations: pickedCompletion.of(completion),
@ -212,6 +212,31 @@ export const applyCompletion =
view.dispatch(tx);
};
export const applyBracketAccess = (key: string): string => {
return `['${escapeMappingString(key)}']`;
};
/**
* Apply a bracket-access completion
*
* @example `$json.` -> `$json['key with spaces']`
* @example `$json` -> `$json['key with spaces']`
*/
export const applyBracketAccessCompletion = (
view: EditorView,
completion: Completion,
from: number,
to: number,
): void => {
const label = applyBracketAccess(completion.label);
const completionAtDot = view.state.sliceDoc(from - 1, from) === '.';
view.dispatch({
...insertCompletionText(view.state, label, completionAtDot ? from - 1 : from, to),
annotations: pickedCompletion.of(completion),
});
};
export const hasRequiredArgs = (doc?: DocMetadata): boolean => {
if (!doc) return false;
const requiredArgs = doc?.args?.filter((arg) => !arg.name.endsWith('?')) ?? [];