mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 20:54:07 -08:00
feat(editor): Add object keys that need bracket access to autocomplete (#9088)
This commit is contained in:
parent
feffc7ffd9
commit
98bcd50bab
|
@ -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', () => {
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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],
|
||||
},
|
||||
|
|
|
@ -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('?')) ?? [];
|
||||
|
|
Loading…
Reference in a new issue