mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-10 06:34:05 -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(']')));
|
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', () => {
|
describe('recommended completions', () => {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { prefixMatch, longestCommonPrefix } from './utils';
|
||||||
import type { IDataObject } from 'n8n-workflow';
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||||
import type { Resolved } from './types';
|
import type { Resolved } from './types';
|
||||||
|
import { escapeMappingString } from '@/utils/mappingUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolution-based completions offered at the start of bracket access notation.
|
* 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
|
const isNumber = !isNaN(parseInt(key)); // array or string index
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: isNumber ? `${key}]` : `'${key}']`,
|
label: isNumber ? `${key}]` : `'${escapeMappingString(key)}']`,
|
||||||
type: 'keyword',
|
type: 'keyword',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -19,6 +19,8 @@ import {
|
||||||
hasRequiredArgs,
|
hasRequiredArgs,
|
||||||
getDefaultArgs,
|
getDefaultArgs,
|
||||||
insertDefaultArgs,
|
insertDefaultArgs,
|
||||||
|
applyBracketAccessCompletion,
|
||||||
|
applyBracketAccess,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import type {
|
import type {
|
||||||
Completion,
|
Completion,
|
||||||
|
@ -354,7 +356,11 @@ const createPropHeader = (typeName: string, property: { doc?: DocMetadata | unde
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
if (property.doc) {
|
if (property.doc) {
|
||||||
const typeNameSpan = document.createElement('span');
|
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');
|
const propNameSpan = document.createElement('span');
|
||||||
propNameSpan.classList.add('autocomplete-info-name');
|
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 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 rank = setRank(['item', 'all', 'first', 'last']);
|
||||||
const SKIP = new Set(['__ob__', 'pairedItem']);
|
const SKIP = new Set(['__ob__', 'pairedItem']);
|
||||||
|
|
||||||
|
@ -393,9 +399,10 @@ const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const localKeys = rank(rawKeys)
|
const localKeys = rank(rawKeys)
|
||||||
.filter((key) => !SKIP.has(key) && isAllowedInDotNotation(key) && !isPseudoParam(key))
|
.filter((key) => !SKIP.has(key) && !isPseudoParam(key))
|
||||||
.map((key) => {
|
.map((key) => {
|
||||||
ensureKeyCanBeResolved(resolved, key);
|
ensureKeyCanBeResolved(resolved, key);
|
||||||
|
const needsBracketAccess = !isAllowedInDotNotation(key);
|
||||||
const resolvedProp = resolved[key];
|
const resolvedProp = resolved[key];
|
||||||
|
|
||||||
const isFunction = typeof resolvedProp === 'function';
|
const isFunction = typeof resolvedProp === 'function';
|
||||||
|
@ -403,20 +410,25 @@ const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
|
||||||
|
|
||||||
const option: Completion = {
|
const option: Completion = {
|
||||||
label: isFunction ? key + '()' : key,
|
label: isFunction ? key + '()' : key,
|
||||||
type: isFunction ? 'function' : 'keyword',
|
|
||||||
section: getObjectPropertySection({ name, key, isFunction }),
|
section: getObjectPropertySection({ name, key, isFunction }),
|
||||||
apply: applyCompletion({ hasArgs, transformLabel }),
|
apply: needsBracketAccess
|
||||||
|
? applyBracketAccessCompletion
|
||||||
|
: applyCompletion({
|
||||||
|
hasArgs,
|
||||||
|
transformLabel,
|
||||||
|
}),
|
||||||
detail: getDetail(name, resolvedProp),
|
detail: getDetail(name, resolvedProp),
|
||||||
};
|
};
|
||||||
|
|
||||||
const infoKey = [name, key].join('.');
|
const infoKey = [name, key].join('.');
|
||||||
|
const infoName = needsBracketAccess ? applyBracketAccess(key) : key;
|
||||||
option.info = createCompletionOption(
|
option.info = createCompletionOption(
|
||||||
'',
|
'',
|
||||||
key,
|
infoName,
|
||||||
isFunction ? 'native-function' : 'keyword',
|
isFunction ? 'native-function' : 'keyword',
|
||||||
{
|
{
|
||||||
doc: {
|
doc: {
|
||||||
name: key,
|
name: infoName,
|
||||||
returnType: getType(resolvedProp),
|
returnType: getType(resolvedProp),
|
||||||
description: i18n.proxyVars[infoKey],
|
description: i18n.proxyVars[infoKey],
|
||||||
},
|
},
|
||||||
|
|
|
@ -20,6 +20,7 @@ import type { SyntaxNode } from '@lezer/common';
|
||||||
import { javascriptLanguage } from '@codemirror/lang-javascript';
|
import { javascriptLanguage } from '@codemirror/lang-javascript';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import type { DocMetadata } from 'n8n-workflow';
|
import type { DocMetadata } from 'n8n-workflow';
|
||||||
|
import { escapeMappingString } from '@/utils/mappingUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split user input into base (to resolve) and tail (to filter).
|
* 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 => {
|
(view: EditorView, completion: Completion, from: number, to: number): void => {
|
||||||
const isFunction = completion.label.endsWith('()');
|
const isFunction = completion.label.endsWith('()');
|
||||||
const label = insertDefaultArgs(transformLabel(completion.label), defaultArgs);
|
const label = insertDefaultArgs(transformLabel(completion.label), defaultArgs);
|
||||||
|
|
||||||
const tx: TransactionSpec = {
|
const tx: TransactionSpec = {
|
||||||
...insertCompletionText(view.state, label, from, to),
|
...insertCompletionText(view.state, label, from, to),
|
||||||
annotations: pickedCompletion.of(completion),
|
annotations: pickedCompletion.of(completion),
|
||||||
|
@ -212,6 +212,31 @@ export const applyCompletion =
|
||||||
view.dispatch(tx);
|
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 => {
|
export const hasRequiredArgs = (doc?: DocMetadata): boolean => {
|
||||||
if (!doc) return false;
|
if (!doc) return false;
|
||||||
const requiredArgs = doc?.args?.filter((arg) => !arg.name.endsWith('?')) ?? [];
|
const requiredArgs = doc?.args?.filter((arg) => !arg.name.endsWith('?')) ?? [];
|
||||||
|
|
Loading…
Reference in a new issue