diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/completions.test.ts index 69e312b80..28ff31972 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/completions.test.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/completions.test.ts @@ -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', () => { diff --git a/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts index 755728073..a922b1354 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/bracketAccess.completions.ts @@ -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', }; }); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 5474d13fe..d3669da3c 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -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): 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): 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): 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], }, diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts index bc66596cc..479878875 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -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('?')) ?? [];