From c381d8928fb4669eb49da9a9f994306d22ce07e2 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Tue, 2 Apr 2024 12:32:02 +0200 Subject: [PATCH 01/30] Restyle info box + add args, examples --- .../completions/datatype.completions.ts | 540 +++++++++++------- .../codemirror/completions/typeGuards.ts | 5 - .../src/plugins/i18n/locales/en.json | 1 + .../src/styles/plugins/_codemirror.scss | 103 +++- .../src/Extensions/ExpressionExtension.ts | 3 +- .../workflow/src/Extensions/Extensions.ts | 11 +- 6 files changed, 442 insertions(+), 221 deletions(-) delete mode 100644 packages/editor-ui/src/plugins/codemirror/completions/typeGuards.ts 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 5474d13fe37ec..bb6f3caaa937b 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -1,44 +1,19 @@ -import type { IDataObject, DocMetadata, NativeDoc } from 'n8n-workflow'; -import { Expression, ExpressionExtensions, NativeMethods, validateFieldType } from 'n8n-workflow'; -import { DateTime } from 'luxon'; -import { i18n } from '@/plugins/i18n'; import { resolveParameter } from '@/composables/useWorkflowHelpers'; -import { - setRank, - hasNoParams, - prefixMatch, - isAllowedInDotNotation, - isSplitInBatchesAbsent, - longestCommonPrefix, - splitBaseTail, - isPseudoParam, - stripExcessParens, - isCredentialsModalOpen, - applyCompletion, - sortCompletionsAlpha, - hasRequiredArgs, - getDefaultArgs, - insertDefaultArgs, -} from './utils'; +import { VALID_EMAIL_REGEX } from '@/constants'; +import { i18n } from '@/plugins/i18n'; +import { useEnvironmentsStore } from '@/stores/environments.ee.store'; +import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store'; +import { sanitizeHtml } from '@/utils/htmlUtils'; import type { Completion, CompletionContext, CompletionResult, CompletionSection, } from '@codemirror/autocomplete'; -import type { - AutocompleteInput, - AutocompleteOptionType, - ExtensionTypeName, - FnToDoc, - Resolved, -} from './types'; -import { sanitizeHtml } from '@/utils/htmlUtils'; -import { isFunctionOption } from './typeGuards'; -import { luxonInstanceDocs } from './nativesAutocompleteDocs/luxon.instance.docs'; -import { luxonStaticDocs } from './nativesAutocompleteDocs/luxon.static.docs'; -import { useEnvironmentsStore } from '@/stores/environments.ee.store'; -import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store'; +import { uniqBy } from 'lodash-es'; +import { DateTime } from 'luxon'; +import type { DocMetadata, IDataObject, NativeDoc } from 'n8n-workflow'; +import { Expression, ExpressionExtensions, NativeMethods, validateFieldType } from 'n8n-workflow'; import { ARRAY_NUMBER_ONLY_METHODS, ARRAY_RECOMMENDED_OPTIONS, @@ -56,8 +31,26 @@ import { STRING_RECOMMENDED_OPTIONS, STRING_SECTIONS, } from './constants'; -import { VALID_EMAIL_REGEX } from '@/constants'; -import { uniqBy } from 'lodash-es'; +import { luxonInstanceDocs } from './nativesAutocompleteDocs/luxon.instance.docs'; +import { luxonStaticDocs } from './nativesAutocompleteDocs/luxon.static.docs'; +import type { AutocompleteInput, ExtensionTypeName, FnToDoc, Resolved } from './types'; +import { + applyCompletion, + getDefaultArgs, + hasNoParams, + hasRequiredArgs, + insertDefaultArgs, + isAllowedInDotNotation, + isCredentialsModalOpen, + isPseudoParam, + isSplitInBatchesAbsent, + longestCommonPrefix, + prefixMatch, + setRank, + sortCompletionsAlpha, + splitBaseTail, + stripExcessParens, +} from './utils'; /** * Resolution-based completions offered according to datatype. @@ -76,15 +69,15 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul const isCredential = isCredentialsModalOpen(); if (base === 'DateTime') { - options = luxonStaticOptions().map(stripExcessParens(context)); + options = luxonStaticOptions(base).map(stripExcessParens(context)); } else if (base === 'Object') { options = objectGlobalOptions().map(stripExcessParens(context)); } else if (base === '$vars') { - options = variablesOptions(); + options = variablesOptions(base); } else if (/\$secrets\./.test(base) && isCredential) { options = secretOptions(base).map(stripExcessParens(context)); } else if (base === '$secrets' && isCredential) { - options = secretProvidersOptions(); + options = secretProvidersOptions(base); } else { let resolved: Resolved; @@ -158,7 +151,7 @@ function datatypeOptions(input: AutocompleteInput): Completion[] { } if (typeof resolved === 'boolean') { - return booleanOptions(); + return booleanOptions(input as AutocompleteInput); } if (resolved instanceof DateTime) { @@ -180,42 +173,65 @@ function datatypeOptions(input: AutocompleteInput): Completion[] { return []; } -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', false, transformLabel) +export const natives = ({ + typeName, + base, + transformLabel = (label) => label, +}: { + typeName: ExtensionTypeName; + base: string; + transformLabel?: (label: string) => string; +}): Completion[] => { + const nativeDocs: NativeDoc = NativeMethods.find((ee) => ee.typeName.toLowerCase() === typeName); + + if (!nativeDocs) return []; + + const nativeProps = nativeDocs.properties + ? toOptions({ + fnToDoc: nativeDocs.properties, + base, + includeHidden: false, + isFunction: false, + transformLabel, + }) : []; - const nativeMethods = toOptions( - natives.functions, - typeName, - 'native-function', - false, + + const nativeMethods = toOptions({ + fnToDoc: nativeDocs.functions, + base, + includeHidden: false, + isFunction: true, transformLabel, - ); + }); return [...nativeProps, ...nativeMethods]; }; -export const extensions = ( - typeName: ExtensionTypeName, +export const extensions = ({ + typeName, + base, includeHidden = false, - transformLabel: (label: string) => string = (label) => label, -) => { - const extensions = ExpressionExtensions.find((ee) => ee.typeName.toLowerCase() === typeName); + transformLabel = (label) => label, +}: { + typeName: ExtensionTypeName; + base: string; + includeHidden?: boolean; + transformLabel?: (label: string) => string; +}) => { + const expressionExtensions: Extension = ExpressionExtensions.find( + (ee) => ee.typeName.toLowerCase() === typeName, + ); - if (!extensions) return []; + if (!expressionExtensions) return []; - const fnToDoc = Object.entries(extensions.functions).reduce((acc, [fnName, fn]) => { - return { ...acc, [fnName]: { doc: fn.doc } }; - }, {}); + const fnToDoc = Object.entries(expressionExtensions.functions).reduce( + (acc, [fnName, fn]) => { + return { ...acc, [fnName]: { doc: fn.doc } }; + }, + {}, + ); - return toOptions(fnToDoc, typeName, 'extension-function', includeHidden, transformLabel); + return toOptions({ fnToDoc, base, isFunction: true, includeHidden, transformLabel }); }; export const getType = (value: unknown): string => { @@ -236,75 +252,174 @@ export const getDetail = (base: string, value: unknown): string | undefined => { return type; }; -export const toOptions = ( - fnToDoc: FnToDoc, - typeName: ExtensionTypeName, - optionType: AutocompleteOptionType = 'native-function', +export const toOptions = ({ + fnToDoc, + base, + isFunction = false, includeHidden = false, - transformLabel: (label: string) => string = (label) => label, -) => { + transformLabel = (label) => label, +}: { + fnToDoc: FnToDoc; + base: string; + isFunction?: boolean; + includeHidden?: boolean; + transformLabel?: (label: string) => string; +}) => { return Object.entries(fnToDoc) .sort((a, b) => a[0].localeCompare(b[0])) - .filter(([, docInfo]) => (docInfo.doc && !docInfo.doc?.hidden) || includeHidden) + .filter(([, docInfo]) => Boolean(docInfo.doc && !docInfo.doc?.hidden) || includeHidden) .map(([fnName, docInfo]) => { - return createCompletionOption(typeName, fnName, optionType, docInfo, transformLabel); + return createCompletionOption({ + base, + name: fnName, + doc: docInfo.doc, + isFunction, + transformLabel, + }); }); }; -const createCompletionOption = ( - typeName: string, - name: string, - optionType: AutocompleteOptionType, - docInfo: { doc?: DocMetadata | undefined }, - transformLabel: (label: string) => string = (label) => label, -): Completion => { - const isFunction = isFunctionOption(optionType); +const createCompletionOption = ({ + base, + name, + doc, + isFunction = false, + transformLabel = (label) => label, +}: { + base: string; + name: string; + doc?: DocMetadata; + isFunction?: boolean; + transformLabel?: (label: string) => string; +}): Completion => { const label = isFunction ? name + '()' : name; const option: Completion = { label, - type: optionType, - section: docInfo.doc?.section, + section: doc?.section, apply: applyCompletion({ - hasArgs: hasRequiredArgs(docInfo?.doc), - defaultArgs: getDefaultArgs(docInfo?.doc), + hasArgs: hasRequiredArgs(doc), + defaultArgs: getDefaultArgs(doc), transformLabel, }), }; - option.info = () => { const tooltipContainer = document.createElement('div'); tooltipContainer.classList.add('autocomplete-info-container'); + const mainContent = document.createElement('div'); + mainContent.classList.add('autocomplete-info-main'); + tooltipContainer.appendChild(mainContent); - if (!docInfo.doc) return null; + if (!doc) return null; - const header = isFunctionOption(optionType) - ? createFunctionHeader(typeName, docInfo) - : createPropHeader(typeName, docInfo); + const { examples, args } = doc; + + const header = isFunction ? createFunctionHeader(base, doc) : createPropHeader(base, doc); header.classList.add('autocomplete-info-header'); - tooltipContainer.appendChild(header); + mainContent.appendChild(header); - if (docInfo.doc.description) { + if (doc.description) { const descriptionBody = document.createElement('div'); descriptionBody.classList.add('autocomplete-info-description'); const descriptionText = document.createElement('p'); descriptionText.innerHTML = sanitizeHtml( - docInfo.doc.description.replace(/`(.*?)`/g, '$1'), + doc.description.replace(/`(.*?)`/g, '$1'), ); descriptionBody.appendChild(descriptionText); - if (docInfo.doc.docURL) { + + if (doc.docURL) { const descriptionLink = document.createElement('a'); descriptionLink.setAttribute('target', '_blank'); - descriptionLink.setAttribute('href', docInfo.doc.docURL); - descriptionLink.innerText = i18n.autocompleteUIValues.docLinkLabel || 'Learn more'; + descriptionLink.setAttribute('href', doc.docURL); + descriptionLink.innerText = + i18n.autocompleteUIValues.docLinkLabel ?? i18n.baseText('generic.learnMore'); descriptionLink.addEventListener('mousedown', (event: MouseEvent) => { // This will prevent documentation popup closing before click // event gets to links event.preventDefault(); }); descriptionLink.classList.add('autocomplete-info-doc-link'); - descriptionBody.appendChild(descriptionLink); + descriptionText.appendChild(descriptionLink); } - tooltipContainer.appendChild(descriptionBody); + mainContent.appendChild(descriptionBody); + } + + if (args && args.length > 0) { + const argsList = document.createElement('ul'); + argsList.classList.add('autocomplete-info-args'); + + for (const arg of args) { + const argItem = document.createElement('li'); + const argName = document.createElement('span'); + argName.classList.add('autocomplete-info-arg-name'); + argName.textContent = arg.name; + argItem.appendChild(argName); + + if (arg.type) { + const argType = document.createElement('span'); + argType.classList.add('autocomplete-info-arg-type'); + argType.textContent = `: ${arg.type}`; + argItem.appendChild(argType); + } + + if (arg.description) { + const argDescription = document.createElement('span'); + argDescription.classList.add('autocomplete-info-arg-description'); + argDescription.innerHTML = `- ${sanitizeHtml( + arg.description.replace(/`(.*?)`/g, '$1'), + )}`; + + argItem.appendChild(argDescription); + } + + argsList.appendChild(argItem); + } + + mainContent.appendChild(argsList); + } + + if (examples && examples.length > 0) { + const examplesWrapper = document.createElement('div'); + examplesWrapper.classList.add('autocomplete-info-examples-wrapper'); + + const examplesContainer = document.createElement('div'); + examplesContainer.classList.add('autocomplete-info-examples'); + + const examplesTitle = document.createElement('div'); + examplesTitle.classList.add('autocomplete-info-examples-title'); + examplesTitle.textContent = i18n.baseText('codeNodeEditor.examples'); + examplesContainer.appendChild(examplesTitle); + + const examplePre = document.createElement('pre'); + const exampleCode = document.createElement('code'); + examplePre.appendChild(exampleCode); + examplesContainer.appendChild(examplePre); + examplesWrapper.appendChild(examplesContainer); + + examples.forEach((example, index) => { + if (example.description) { + const exampleComment = document.createElement('span'); + exampleComment.classList.add('autocomplete-info-example-comment'); + exampleComment.textContent = `// ${example.description}\n`; + exampleCode.appendChild(exampleComment); + } + + const exampleExpression = document.createElement('span'); + exampleExpression.classList.add('autocomplete-info-example-expr'); + exampleExpression.textContent = `${JSON.stringify(example.subject)}.${doc.name}(${example.args.map((arg) => JSON.stringify(arg)).join(', ')})\n`; + exampleCode.appendChild(exampleExpression); + + if (example.evaluated !== undefined) { + const exampleEvaluated = document.createElement('span'); + exampleEvaluated.textContent = `// => ${example.evaluated}\n`; + exampleCode.appendChild(exampleEvaluated); + } + + if (index !== examples.length - 1) { + exampleCode.textContent += '\n'; + } + }); + + tooltipContainer.appendChild(examplesWrapper); } return tooltipContainer; @@ -313,55 +428,57 @@ const createCompletionOption = ( return option; }; -const createFunctionHeader = (typeName: string, fn: { doc?: DocMetadata | undefined }) => { +const createFunctionHeader = (base: string, doc?: DocMetadata) => { const header = document.createElement('div'); - if (fn.doc) { - const typeNameSpan = document.createElement('span'); - typeNameSpan.innerHTML = typeName.slice(0, 1).toUpperCase() + typeName.slice(1) + '.'; - header.appendChild(typeNameSpan); + if (doc) { + const shortBase = base.split('.').pop() ?? base; + const baseSpan = document.createElement('span'); + baseSpan.textContent = shortBase + '.'; + header.appendChild(baseSpan); const functionNameSpan = document.createElement('span'); functionNameSpan.classList.add('autocomplete-info-name'); - functionNameSpan.innerHTML = `${fn.doc.name}`; + functionNameSpan.textContent = doc.name; header.appendChild(functionNameSpan); - let functionArgs = '('; - if (fn.doc.args) { - functionArgs += fn.doc.args - .map((arg) => { - let argString = `${arg.name}`; - if (arg.type) { - argString += `: ${arg.type}`; - } - return argString; - }) - .join(', '); - } - functionArgs += ')'; + + const openBracketsSpan = document.createElement('span'); + openBracketsSpan.textContent = '('; + header.appendChild(openBracketsSpan); + const argsSpan = document.createElement('span'); - argsSpan.classList.add('autocomplete-info-name-args'); - argsSpan.innerText = functionArgs; + doc.args?.forEach((arg, index, array) => { + const argSpan = document.createElement('span'); + argSpan.textContent = arg.name; + argSpan.classList.add('autocomplete-info-arg'); + argsSpan.appendChild(argSpan); + + if (index !== array.length - 1) { + const separatorSpan = document.createElement('span'); + separatorSpan.textContent = ', '; + argsSpan.appendChild(separatorSpan); + } + }); header.appendChild(argsSpan); - if (fn.doc.returnType) { - const returnTypeSpan = document.createElement('span'); - returnTypeSpan.innerHTML = ': ' + fn.doc.returnType; - header.appendChild(returnTypeSpan); - } + + const closeBracketsSpan = document.createElement('span'); + closeBracketsSpan.textContent = ')'; + header.appendChild(closeBracketsSpan); } return header; }; -const createPropHeader = (typeName: string, property: { doc?: DocMetadata | undefined }) => { +const createPropHeader = (typeName: string, doc?: DocMetadata) => { const header = document.createElement('div'); - if (property.doc) { + if (doc) { const typeNameSpan = document.createElement('span'); typeNameSpan.innerHTML = typeName.slice(0, 1).toUpperCase() + typeName.slice(1) + '.'; const propNameSpan = document.createElement('span'); propNameSpan.classList.add('autocomplete-info-name'); - propNameSpan.innerText = property.doc.name; + propNameSpan.innerText = doc.name; const returnTypeSpan = document.createElement('span'); - returnTypeSpan.innerHTML = ': ' + property.doc.returnType; + returnTypeSpan.innerHTML = ': ' + doc.returnType; header.appendChild(typeNameSpan); header.appendChild(propNameSpan); @@ -410,19 +527,16 @@ const objectOptions = (input: AutocompleteInput): Completion[] => { }; const infoKey = [name, key].join('.'); - option.info = createCompletionOption( - '', - key, - isFunction ? 'native-function' : 'keyword', - { - doc: { - name: key, - returnType: getType(resolvedProp), - description: i18n.proxyVars[infoKey], - }, + option.info = createCompletionOption({ + name: key, + base, + doc: { + name: key, + returnType: getType(resolvedProp), + description: i18n.proxyVars[infoKey], }, transformLabel, - ).info; + }).info; return option; }); @@ -436,11 +550,15 @@ const objectOptions = (input: AutocompleteInput): Completion[] => { base === 'Math'; if (skipObjectExtensions) { - return sortCompletionsAlpha([...localKeys, ...natives('object')]); + return sortCompletionsAlpha([...localKeys, ...natives({ base, typeName: 'object' })]); } return applySections({ - options: sortCompletionsAlpha([...localKeys, ...natives('object'), ...extensions('object')]), + options: sortCompletionsAlpha([ + ...localKeys, + ...natives({ base, typeName: 'object' }), + ...extensions({ base, typeName: 'object' }), + ]), recommended: OBJECT_RECOMMENDED_OPTIONS, recommendedSection: RECOMMENDED_METHODS_SECTION, methodsSection: OTHER_METHODS_SECTION, @@ -522,10 +640,10 @@ const isUrl = (url: string): boolean => { }; const stringOptions = (input: AutocompleteInput): Completion[] => { - const { resolved, transformLabel } = input; + const { base, resolved, transformLabel } = input; const options = sortCompletionsAlpha([ - ...natives('string', transformLabel), - ...extensions('string', false, transformLabel), + ...natives({ typeName: 'string', base, transformLabel }), + ...extensions({ typeName: 'string', base, includeHidden: false, transformLabel }), ]); if (validateFieldType('string', resolved, 'number').valid) { @@ -595,17 +713,20 @@ const stringOptions = (input: AutocompleteInput): Completion[] => { }); }; -const booleanOptions = (): Completion[] => { +const booleanOptions = (input: AutocompleteInput): Completion[] => { return applySections({ - options: sortCompletionsAlpha([...natives('boolean'), ...extensions('boolean')]), + options: sortCompletionsAlpha([ + ...natives({ typeName: 'boolean', base: input.base }), + ...extensions({ typeName: 'boolean', base: input.base }), + ]), }); }; const numberOptions = (input: AutocompleteInput): Completion[] => { - const { resolved, transformLabel } = input; + const { base, resolved, transformLabel } = input; const options = sortCompletionsAlpha([ - ...natives('number', transformLabel), - ...extensions('number', false, transformLabel), + ...natives({ typeName: 'number', base, transformLabel }), + ...extensions({ typeName: 'number', base, includeHidden: false, transformLabel }), ]); const ONLY_INTEGER = ['isEven()', 'isOdd()']; @@ -654,22 +775,24 @@ const numberOptions = (input: AutocompleteInput): Completion[] => { }; const dateOptions = (input: AutocompleteInput): Completion[] => { + const { transformLabel, base } = input; return applySections({ options: sortCompletionsAlpha([ - ...natives('date', input.transformLabel), - ...extensions('date', true, input.transformLabel), + ...natives({ typeName: 'date', base, transformLabel }), + ...extensions({ typeName: 'date', base, includeHidden: true, transformLabel }), ]), recommended: DATE_RECOMMENDED_OPTIONS, }); }; const luxonOptions = (input: AutocompleteInput): Completion[] => { + const { transformLabel, base } = input; return applySections({ options: sortCompletionsAlpha( uniqBy( [ - ...extensions('date', false, input.transformLabel), - ...luxonInstanceOptions(false, input.transformLabel), + ...extensions({ typeName: 'date', base, includeHidden: false, transformLabel }), + ...luxonInstanceOptions({ base, includeHidden: false, transformLabel }), ], (option) => option.label, ), @@ -680,11 +803,11 @@ const luxonOptions = (input: AutocompleteInput): Completion[] => { }; const arrayOptions = (input: AutocompleteInput): Completion[] => { - const { resolved, transformLabel } = input; + const { base, resolved, transformLabel } = input; const options = applySections({ options: sortCompletionsAlpha([ - ...natives('array', transformLabel), - ...extensions('array', false, transformLabel), + ...natives({ typeName: 'array', base, transformLabel }), + ...extensions({ typeName: 'array', base, includeHidden: false, transformLabel }), ]), recommended: ARRAY_RECOMMENDED_OPTIONS, methodsSection: OTHER_SECTION, @@ -709,12 +832,14 @@ function ensureKeyCanBeResolved(obj: IDataObject, key: string) { } } -export const variablesOptions = () => { +export const variablesOptions = (base: string) => { const environmentsStore = useEnvironmentsStore(); const variables = environmentsStore.variables; return variables.map((variable) => - createCompletionOption('Object', variable.key, 'keyword', { + createCompletionOption({ + base, + name: variable.key, doc: { name: variable.key, returnType: 'string', @@ -744,7 +869,9 @@ export const secretOptions = (base: string) => { return []; } return Object.entries(resolved).map(([secret, value]) => - createCompletionOption('', secret, 'keyword', { + createCompletionOption({ + base, + name: secret, doc: { name: secret, returnType: typeof value, @@ -758,11 +885,13 @@ export const secretOptions = (base: string) => { } }; -export const secretProvidersOptions = () => { +export const secretProvidersOptions = (base: string) => { const externalSecretsStore = useExternalSecretsStore(); return Object.keys(externalSecretsStore.secretsAsObject).map((provider) => - createCompletionOption('Object', provider, 'keyword', { + createCompletionOption({ + base, + name: provider, doc: { name: provider, returnType: 'object', @@ -776,10 +905,15 @@ export const secretProvidersOptions = () => { /** * Methods and fields defined on a Luxon `DateTime` class instance. */ -export const luxonInstanceOptions = ( +export const luxonInstanceOptions = ({ + base, includeHidden = false, - transformLabel: (label: string) => string = (label) => label, -) => { + transformLabel = (label) => label, +}: { + base: string; + includeHidden?: boolean; + transformLabel?: (label: string) => string; +}) => { const SKIP = new Set(['constructor', 'get', 'invalidExplanation', 'invalidReason']); return Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype)) @@ -787,15 +921,15 @@ export const luxonInstanceOptions = ( .sort(([a], [b]) => a.localeCompare(b)) .map(([key, descriptor]) => { const isFunction = typeof descriptor.value === 'function'; - const optionType = isFunction ? 'native-function' : 'keyword'; - return createLuxonAutocompleteOption( - key, - optionType, - luxonInstanceDocs, - i18n.luxonInstance, + return createLuxonAutocompleteOption({ + base, + name: key, + isFunction, + docs: luxonInstanceDocs, + translations: i18n.luxonInstance, includeHidden, transformLabel, - ) as Completion; + }) as Completion; }) .filter(Boolean); }; @@ -803,40 +937,49 @@ export const luxonInstanceOptions = ( /** * Methods defined on a Luxon `DateTime` class. */ -export const luxonStaticOptions = () => { +export const luxonStaticOptions = (base: string) => { const SKIP = new Set(['prototype', 'name', 'length', 'invalid']); return sortCompletionsAlpha( Object.keys(Object.getOwnPropertyDescriptors(DateTime)) .filter((key) => !SKIP.has(key) && !key.includes('_')) .map((key) => { - return createLuxonAutocompleteOption( - key, - 'native-function', - luxonStaticDocs, - i18n.luxonStatic, - ) as Completion; + return createLuxonAutocompleteOption({ + base, + name: key, + isFunction: true, + docs: luxonStaticDocs, + translations: i18n.luxonStatic, + }) as Completion; }) .filter(Boolean), ); }; -const createLuxonAutocompleteOption = ( - name: string, - type: AutocompleteOptionType, - docDefinition: NativeDoc, - translations: Record, +const createLuxonAutocompleteOption = ({ + name, + base, + docs, + translations, + isFunction = false, includeHidden = false, - transformLabel: (label: string) => string = (label) => label, -): Completion | null => { - const isFunction = isFunctionOption(type); + transformLabel = (label) => label, +}: { + name: string; + base: string; + docs: NativeDoc; + translations: Record; + isFunction?: boolean; + includeHidden?: boolean; + transformLabel?: (label: string) => string; +}): Completion | null => { const label = isFunction ? name + '()' : name; let doc: DocMetadata | undefined; - if (docDefinition.properties && docDefinition.properties.hasOwnProperty(name)) { - doc = docDefinition.properties[name].doc; - } else if (docDefinition.functions.hasOwnProperty(name)) { - doc = docDefinition.functions[name].doc; + if (docs.properties && docs.properties.hasOwnProperty(name)) { + doc = docs.properties[name].doc; + } else if (docs.functions.hasOwnProperty(name)) { + doc = docs.functions[name].doc; } else { // Use inferred/default values if docs are still not updated // This should happen when our doc specification becomes @@ -855,7 +998,6 @@ const createLuxonAutocompleteOption = ( const option: Completion = { label, - type, section: doc?.section, apply: applyCompletion({ hasArgs: hasRequiredArgs(doc), @@ -863,16 +1005,14 @@ const createLuxonAutocompleteOption = ( transformLabel, }), }; - option.info = createCompletionOption( - 'DateTime', + option.info = createCompletionOption({ + base, name, - type, - { - // Add translated description - doc: { ...doc, description: translations[name] } as DocMetadata, - }, + isFunction, + // Add translated description + doc: { ...doc, description: translations[name] } as DocMetadata, transformLabel, - ).info; + }).info; return option; }; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/typeGuards.ts b/packages/editor-ui/src/plugins/codemirror/completions/typeGuards.ts deleted file mode 100644 index 74cbe9ec47a6e..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/completions/typeGuards.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { AutocompleteOptionType, FunctionOptionType } from './types'; - -export const isFunctionOption = (value: AutocompleteOptionType): value is FunctionOptionType => { - return value === 'native-function' || value === 'extension-function'; -}; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 49e0223cbd820..8f90e1acd261a 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -389,6 +389,7 @@ "codeNodeEditor.askAi.generationFailedTooLarge": "Your workflow data is too large for AI to process. Simplify the data being sent into the Code node and retry.", "codeNodeEditor.tabs.askAi": "✨ Ask AI", "codeNodeEditor.tabs.code": "Code", + "codeNodeEditor.examples": "Examples", "collectionParameter.choose": "Choose...", "collectionParameter.noProperties": "No properties", "credentialEdit.credentialConfig.accountConnected": "Account connected", diff --git a/packages/editor-ui/src/styles/plugins/_codemirror.scss b/packages/editor-ui/src/styles/plugins/_codemirror.scss index c1eb3a1031390..52321dd18d437 100644 --- a/packages/editor-ui/src/styles/plugins/_codemirror.scss +++ b/packages/editor-ui/src/styles/plugins/_codemirror.scss @@ -20,12 +20,14 @@ > ul[role='listbox'] { font-family: var(--font-family-monospace); - max-height: min(220px, 50vh); - width: min(260px, 50vw); - min-width: 100%; - max-width: none; + height: min(220px, 50vh); + max-height: none; + max-width: 200px; + border: var(--border-base); border-radius: var(--border-radius-base); + border-top-right-radius: 0; + border-bottom-right-radius: 0; li[role='option'] { color: var(--color-text-base); @@ -80,40 +82,113 @@ } } -.autocomplete-info-container { +.autocomplete-info-main { display: flex; flex-direction: column; - padding: var(--spacing-4xs) 0; + gap: var(--spacing-2xs); + padding: var(--spacing-xs); } -.ͼ2 .cm-completionInfo { +.ͼ2 .cm-tooltip.cm-completionInfo { background-color: var(--color-background-xlight); border: var(--border-base); - margin-left: var(--spacing-5xs); border-radius: var(--border-radius-base); + border-left: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; line-height: var(--font-line-height-loose); + padding: 0; + // Overwrite codemirror positioning + top: 0 !important; + left: 100% !important; + right: auto !important; + max-width: 320px !important; + height: 100%; + overflow-y: auto; .autocomplete-info-header { color: var(--color-text-base); - font-size: var(--font-size-2xs); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); font-family: var(--font-family-monospace); line-height: var(--font-line-height-compact); - margin-bottom: var(--spacing-2xs); + margin-bottom: var(--spacing-4xs); } .autocomplete-info-name { color: var(--color-autocomplete-item-selected); } + .autocomplete-info-args { + list-style: none; + + li + li { + margin-top: var(--spacing-5xs); + } + } + + .autocomplete-info-arg { + color: var(--prim-color-alt-h); + } + + .autocomplete-info-arg-name { + color: var(--prim-color-alt-h); + } + + .autocomplete-info-arg-type { + color: var(--color-text-light); + } + + .autocomplete-info-arg-description { + color: var(--color-text-dark); + margin-left: var(--spacing-4xs); + } + .autocomplete-info-description { + color: var(--color-text-dark); + font-size: var(--font-size-2xs); + code { + font-size: var(--font-size-3xs); background-color: var(--color-background-base); - padding: 0 2px; + padding: var(--spacing-5xs) var(--spacing-4xs); } + p { - line-height: var(--font-line-height-compact); - margin-top: 0; - margin-bottom: var(--spacing-4xs); + line-height: var(--font-line-height-loose); } + + a { + margin: 0 0.5ch; + } + } + + .autocomplete-info-examples-wrapper { + background-color: var(--color-background-light); + } + + .autocomplete-info-examples { + border-top: var(--border-base); + padding: var(--spacing-xs); + + pre { + line-height: 1; + } + + code { + background: inherit; + } + } + + .autocomplete-info-examples-title { + text-transform: uppercase; + color: var(--color-text-dark); + font-size: var(--font-size-3xs); + font-weight: var(--font-weight-bold); + margin-bottom: var(--spacing-4xs); + } + + .autocomplete-info-examples-list { + list-style: none; } } diff --git a/packages/workflow/src/Extensions/ExpressionExtension.ts b/packages/workflow/src/Extensions/ExpressionExtension.ts index 097156b171308..882a7207118aa 100644 --- a/packages/workflow/src/Extensions/ExpressionExtension.ts +++ b/packages/workflow/src/Extensions/ExpressionExtension.ts @@ -16,6 +16,7 @@ import type { ExpressionKind } from 'ast-types/gen/kinds'; import type { ExpressionChunk, ExpressionCode } from './ExpressionParser'; import { joinExpression, splitExpression } from './ExpressionParser'; import { booleanExtensions } from './BooleanExtensions'; +import type { ExtensionMap } from './Extensions'; const EXPRESSION_EXTENDER = 'extend'; const EXPRESSION_EXTENDER_OPTIONAL = 'extendOptional'; @@ -28,7 +29,7 @@ function isNotEmpty(value: unknown) { return !isEmpty(value); } -export const EXTENSION_OBJECTS = [ +export const EXTENSION_OBJECTS: ExtensionMap[] = [ arrayExtensions, dateExtensions, numberExtensions, diff --git a/packages/workflow/src/Extensions/Extensions.ts b/packages/workflow/src/Extensions/Extensions.ts index 2fef4620ff5ac..44036d14c0671 100644 --- a/packages/workflow/src/Extensions/Extensions.ts +++ b/packages/workflow/src/Extensions/Extensions.ts @@ -12,6 +12,14 @@ export type NativeDoc = { functions: Record; }; +export type DocMetadataArgument = { name: string; type?: string; description?: string }; +export type DocMetadataExample = { + subject: unknown; + args: string[]; + description?: string; + evaluated?: unknown; +}; + export type DocMetadata = { name: string; returnType: string; @@ -19,6 +27,7 @@ export type DocMetadata = { section?: string; hidden?: boolean; aliases?: string[]; - args?: Array<{ name: string; type?: string }>; + args?: DocMetadataArgument[]; + examples?: DocMetadataExample[]; docURL?: string; }; From f6a6792d24169eea8aa24e322e68adebfb47e7bc Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Tue, 2 Apr 2024 12:32:18 +0200 Subject: [PATCH 02/30] Add examples for String.includes --- .../src/NativeMethods/String.methods.ts | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/workflow/src/NativeMethods/String.methods.ts b/packages/workflow/src/NativeMethods/String.methods.ts index 7689707f520ad..bc1c143e27bf0 100644 --- a/packages/workflow/src/NativeMethods/String.methods.ts +++ b/packages/workflow/src/NativeMethods/String.methods.ts @@ -84,8 +84,29 @@ export const stringMethods: NativeDoc = { 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/includes', returnType: 'boolean', args: [ - { name: 'searchString', type: 'string' }, - { name: 'position?', type: 'number' }, + { + name: 'searchString', + type: 'string', + description: 'A string to be searched for. Cannot be a regex.', + }, + { + name: 'position?', + type: 'number', + description: + 'The position within the string at which to begin searching for `searchString`. (Defaults to `0`)', + }, + ], + examples: [ + { + subject: 'Automation', + args: ['Auto'], + evaluated: true, + }, + { + subject: 'Automation', + args: ['nonexistent'], + evaluated: false, + }, ], }, }, From ac1e7b612ea69260f9d35de9da477bb76044a133 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Tue, 2 Apr 2024 15:44:19 +0200 Subject: [PATCH 03/30] Disable closeOnBlur --- .../src/plugins/codemirror/n8nLang.ts | 2 +- .../src/styles/plugins/_codemirror.scss | 32 +++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts index 8bce81bb703de..0e00cc7cb45f7 100644 --- a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts +++ b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts @@ -25,4 +25,4 @@ export function n8nLang() { ]); } -export const n8nAutocompletion = () => autocompletion({ icons: false }); +export const n8nAutocompletion = () => autocompletion({ icons: false, closeOnBlur: false }); diff --git a/packages/editor-ui/src/styles/plugins/_codemirror.scss b/packages/editor-ui/src/styles/plugins/_codemirror.scss index 52321dd18d437..8991a695c09ad 100644 --- a/packages/editor-ui/src/styles/plugins/_codemirror.scss +++ b/packages/editor-ui/src/styles/plugins/_codemirror.scss @@ -20,15 +20,23 @@ > ul[role='listbox'] { font-family: var(--font-family-monospace); - height: min(220px, 50vh); + height: min(250px, 50vh); max-height: none; max-width: 200px; border: var(--border-base); - border-radius: var(--border-radius-base); + border-top-left-radius: var(--border-radius-base); + border-bottom-left-radius: var(--border-radius-base); border-top-right-radius: 0; border-bottom-right-radius: 0; + &:has(+ .cm-completionInfo-left) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-top-right-radius: var(--border-radius-base); + border-bottom-right-radius: var(--border-radius-base); + } + li[role='option'] { color: var(--color-text-base); display: flex; @@ -92,8 +100,10 @@ .ͼ2 .cm-tooltip.cm-completionInfo { background-color: var(--color-background-xlight); border: var(--border-base); - border-radius: var(--border-radius-base); + box-shadow: var(--box-shadow-light); border-left: none; + border-bottom-right-radius: var(--border-radius-base); + border-top-right-radius: var(--border-radius-base); border-top-left-radius: 0; border-bottom-left-radius: 0; line-height: var(--font-line-height-loose); @@ -115,6 +125,22 @@ margin-bottom: var(--spacing-4xs); } + &.cm-completionInfo-left-narrow, + &.cm-completionInfo-right-narrow { + display: none; + } + + &.cm-completionInfo-left { + left: auto !important; + right: 100% !important; + border-bottom-right-radius: 0; + border-top-right-radius: 0; + border-top-left-radius: var(--border-radius-base); + border-bottom-left-radius: var(--border-radius-base); + border-left: var(--border-base); + border-right: none; + } + .autocomplete-info-name { color: var(--color-autocomplete-item-selected); } From e88f4b4481d55b2c6e9f61497fbb65285b8dabc5 Mon Sep 17 00:00:00 2001 From: Giulio Andreini Date: Tue, 9 Apr 2024 12:17:50 +0200 Subject: [PATCH 04/30] Infobox styling. --- .../src/styles/plugins/_codemirror.scss | 101 +++++++++++------- 1 file changed, 62 insertions(+), 39 deletions(-) diff --git a/packages/editor-ui/src/styles/plugins/_codemirror.scss b/packages/editor-ui/src/styles/plugins/_codemirror.scss index 8991a695c09ad..290e0eef11967 100644 --- a/packages/editor-ui/src/styles/plugins/_codemirror.scss +++ b/packages/editor-ui/src/styles/plugins/_codemirror.scss @@ -94,7 +94,7 @@ display: flex; flex-direction: column; gap: var(--spacing-2xs); - padding: var(--spacing-xs); + padding: var(--spacing-xs) var(--spacing-xs) var(--spacing-s) var(--spacing-xs); } .ͼ2 .cm-tooltip.cm-completionInfo { @@ -115,9 +115,10 @@ max-width: 320px !important; height: 100%; overflow-y: auto; + z-index: -1; .autocomplete-info-header { - color: var(--color-text-base); + color: var(--color-text-dark); font-size: var(--font-size-xs); font-weight: var(--font-weight-bold); font-family: var(--font-family-monospace); @@ -125,67 +126,69 @@ margin-bottom: var(--spacing-4xs); } - &.cm-completionInfo-left-narrow, - &.cm-completionInfo-right-narrow { - display: none; + .autocomplete-info-name { + color: var(--color-autocomplete-item-selected); } - &.cm-completionInfo-left { - left: auto !important; - right: 100% !important; - border-bottom-right-radius: 0; - border-top-right-radius: 0; - border-top-left-radius: var(--border-radius-base); - border-bottom-left-radius: var(--border-radius-base); - border-left: var(--border-base); - border-right: none; + .autocomplete-info-arg { + color: var(--color-text-base); + font-weight: var(--font-weight-regular); + display: inline-block; + padding-left: var(--spacing-5xs); + padding-right: var(--spacing-5xs); } - .autocomplete-info-name { - color: var(--color-autocomplete-item-selected); + .autocomplete-info-description { + color: var(--color-text-dark); + font-size: var(--font-size-2xs); + + code { + color: var(--color-text-base); + font-size: var(--font-size-2xs); + font-family: var(--font-family); + background-color: transparent; + } + + p { + line-height: var(--font-line-height-loose); + } + + a { + margin: 0 0.5ch; + } } .autocomplete-info-args { list-style: none; + margin-top: var(--spacing-3xs); - li + li { - margin-top: var(--spacing-5xs); + li { + text-indent: calc(var(--spacing-2xs) * -1); + margin-left: var(--spacing-2xs); } - } - .autocomplete-info-arg { - color: var(--prim-color-alt-h); + li + li { + margin-top: var(--spacing-4xs); + } } .autocomplete-info-arg-name { - color: var(--prim-color-alt-h); + color: var(--color-text-base); } .autocomplete-info-arg-type { - color: var(--color-text-light); + color: var(--color-text-dark); } .autocomplete-info-arg-description { color: var(--color-text-dark); margin-left: var(--spacing-4xs); - } - - .autocomplete-info-description { - color: var(--color-text-dark); - font-size: var(--font-size-2xs); code { - font-size: var(--font-size-3xs); - background-color: var(--color-background-base); - padding: var(--spacing-5xs) var(--spacing-4xs); - } - - p { - line-height: var(--font-line-height-loose); - } - - a { - margin: 0 0.5ch; + color: var(--color-text-base); + font-size: var(--font-size-2xs); + font-family: var(--font-family); + background-color: transparent; } } @@ -217,4 +220,24 @@ .autocomplete-info-examples-list { list-style: none; } + + &.cm-completionInfo-left-narrow, + &.cm-completionInfo-right-narrow { + display: none; + } + + &.cm-completionInfo-left { + left: auto !important; + right: 100% !important; + border-bottom-right-radius: 0; + border-top-right-radius: 0; + border-top-left-radius: var(--border-radius-base); + border-bottom-left-radius: var(--border-radius-base); + border-left: var(--border-base); + border-right: none; + } + + &.cm-completionInfo-right { + background-color: var(--color-background-light); + } } From 4bde4c828c0ee13f6ad6a612d7321cfb3134c22d Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Tue, 9 Apr 2024 16:34:10 +0200 Subject: [PATCH 05/30] Improve infobox DOM structure --- .../completions/datatype.completions.ts | 267 +++--------------- .../codemirror/completions/infoBoxRenderer.ts | 217 ++++++++++++++ .../src/plugins/i18n/locales/en.json | 1 + .../src/styles/plugins/_codemirror.scss | 28 +- .../workflow/src/Extensions/Extensions.ts | 7 +- packages/workflow/src/Extensions/index.ts | 8 +- packages/workflow/src/index.ts | 8 +- 7 files changed, 284 insertions(+), 252 deletions(-) create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/infoBoxRenderer.ts 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 bb6f3caaa937b..ac6ba66737647 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -3,7 +3,7 @@ import { VALID_EMAIL_REGEX } from '@/constants'; import { i18n } from '@/plugins/i18n'; import { useEnvironmentsStore } from '@/stores/environments.ee.store'; import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store'; -import { sanitizeHtml } from '@/utils/htmlUtils'; + import type { Completion, CompletionContext, @@ -51,6 +51,7 @@ import { splitBaseTail, stripExcessParens, } from './utils'; +import { createInfoBoxRenderer } from './infoBoxRenderer'; /** * Resolution-based completions offered according to datatype. @@ -69,15 +70,15 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul const isCredential = isCredentialsModalOpen(); if (base === 'DateTime') { - options = luxonStaticOptions(base).map(stripExcessParens(context)); + options = luxonStaticOptions().map(stripExcessParens(context)); } else if (base === 'Object') { options = objectGlobalOptions().map(stripExcessParens(context)); } else if (base === '$vars') { - options = variablesOptions(base); + options = variablesOptions(); } else if (/\$secrets\./.test(base) && isCredential) { options = secretOptions(base).map(stripExcessParens(context)); } else if (base === '$secrets' && isCredential) { - options = secretProvidersOptions(base); + options = secretProvidersOptions(); } else { let resolved: Resolved; @@ -151,7 +152,7 @@ function datatypeOptions(input: AutocompleteInput): Completion[] { } if (typeof resolved === 'boolean') { - return booleanOptions(input as AutocompleteInput); + return booleanOptions(); } if (resolved instanceof DateTime) { @@ -175,11 +176,9 @@ function datatypeOptions(input: AutocompleteInput): Completion[] { export const natives = ({ typeName, - base, transformLabel = (label) => label, }: { typeName: ExtensionTypeName; - base: string; transformLabel?: (label: string) => string; }): Completion[] => { const nativeDocs: NativeDoc = NativeMethods.find((ee) => ee.typeName.toLowerCase() === typeName); @@ -189,7 +188,6 @@ export const natives = ({ const nativeProps = nativeDocs.properties ? toOptions({ fnToDoc: nativeDocs.properties, - base, includeHidden: false, isFunction: false, transformLabel, @@ -198,7 +196,6 @@ export const natives = ({ const nativeMethods = toOptions({ fnToDoc: nativeDocs.functions, - base, includeHidden: false, isFunction: true, transformLabel, @@ -209,16 +206,14 @@ export const natives = ({ export const extensions = ({ typeName, - base, includeHidden = false, transformLabel = (label) => label, }: { typeName: ExtensionTypeName; - base: string; includeHidden?: boolean; transformLabel?: (label: string) => string; }) => { - const expressionExtensions: Extension = ExpressionExtensions.find( + const expressionExtensions = ExpressionExtensions.find( (ee) => ee.typeName.toLowerCase() === typeName, ); @@ -231,7 +226,7 @@ export const extensions = ({ {}, ); - return toOptions({ fnToDoc, base, isFunction: true, includeHidden, transformLabel }); + return toOptions({ fnToDoc, isFunction: true, includeHidden, transformLabel }); }; export const getType = (value: unknown): string => { @@ -254,13 +249,11 @@ export const getDetail = (base: string, value: unknown): string | undefined => { export const toOptions = ({ fnToDoc, - base, isFunction = false, includeHidden = false, transformLabel = (label) => label, }: { fnToDoc: FnToDoc; - base: string; isFunction?: boolean; includeHidden?: boolean; transformLabel?: (label: string) => string; @@ -270,7 +263,6 @@ export const toOptions = ({ .filter(([, docInfo]) => Boolean(docInfo.doc && !docInfo.doc?.hidden) || includeHidden) .map(([fnName, docInfo]) => { return createCompletionOption({ - base, name: fnName, doc: docInfo.doc, isFunction, @@ -280,13 +272,11 @@ export const toOptions = ({ }; const createCompletionOption = ({ - base, name, doc, isFunction = false, transformLabel = (label) => label, }: { - base: string; name: string; doc?: DocMetadata; isFunction?: boolean; @@ -302,191 +292,11 @@ const createCompletionOption = ({ transformLabel, }), }; - option.info = () => { - const tooltipContainer = document.createElement('div'); - tooltipContainer.classList.add('autocomplete-info-container'); - const mainContent = document.createElement('div'); - mainContent.classList.add('autocomplete-info-main'); - tooltipContainer.appendChild(mainContent); - - if (!doc) return null; - - const { examples, args } = doc; - - const header = isFunction ? createFunctionHeader(base, doc) : createPropHeader(base, doc); - header.classList.add('autocomplete-info-header'); - mainContent.appendChild(header); - - if (doc.description) { - const descriptionBody = document.createElement('div'); - descriptionBody.classList.add('autocomplete-info-description'); - const descriptionText = document.createElement('p'); - descriptionText.innerHTML = sanitizeHtml( - doc.description.replace(/`(.*?)`/g, '$1'), - ); - descriptionBody.appendChild(descriptionText); - - if (doc.docURL) { - const descriptionLink = document.createElement('a'); - descriptionLink.setAttribute('target', '_blank'); - descriptionLink.setAttribute('href', doc.docURL); - descriptionLink.innerText = - i18n.autocompleteUIValues.docLinkLabel ?? i18n.baseText('generic.learnMore'); - descriptionLink.addEventListener('mousedown', (event: MouseEvent) => { - // This will prevent documentation popup closing before click - // event gets to links - event.preventDefault(); - }); - descriptionLink.classList.add('autocomplete-info-doc-link'); - descriptionText.appendChild(descriptionLink); - } - mainContent.appendChild(descriptionBody); - } - - if (args && args.length > 0) { - const argsList = document.createElement('ul'); - argsList.classList.add('autocomplete-info-args'); - - for (const arg of args) { - const argItem = document.createElement('li'); - const argName = document.createElement('span'); - argName.classList.add('autocomplete-info-arg-name'); - argName.textContent = arg.name; - argItem.appendChild(argName); - - if (arg.type) { - const argType = document.createElement('span'); - argType.classList.add('autocomplete-info-arg-type'); - argType.textContent = `: ${arg.type}`; - argItem.appendChild(argType); - } - - if (arg.description) { - const argDescription = document.createElement('span'); - argDescription.classList.add('autocomplete-info-arg-description'); - argDescription.innerHTML = `- ${sanitizeHtml( - arg.description.replace(/`(.*?)`/g, '$1'), - )}`; - - argItem.appendChild(argDescription); - } - - argsList.appendChild(argItem); - } - - mainContent.appendChild(argsList); - } - - if (examples && examples.length > 0) { - const examplesWrapper = document.createElement('div'); - examplesWrapper.classList.add('autocomplete-info-examples-wrapper'); - - const examplesContainer = document.createElement('div'); - examplesContainer.classList.add('autocomplete-info-examples'); - - const examplesTitle = document.createElement('div'); - examplesTitle.classList.add('autocomplete-info-examples-title'); - examplesTitle.textContent = i18n.baseText('codeNodeEditor.examples'); - examplesContainer.appendChild(examplesTitle); - - const examplePre = document.createElement('pre'); - const exampleCode = document.createElement('code'); - examplePre.appendChild(exampleCode); - examplesContainer.appendChild(examplePre); - examplesWrapper.appendChild(examplesContainer); - - examples.forEach((example, index) => { - if (example.description) { - const exampleComment = document.createElement('span'); - exampleComment.classList.add('autocomplete-info-example-comment'); - exampleComment.textContent = `// ${example.description}\n`; - exampleCode.appendChild(exampleComment); - } - - const exampleExpression = document.createElement('span'); - exampleExpression.classList.add('autocomplete-info-example-expr'); - exampleExpression.textContent = `${JSON.stringify(example.subject)}.${doc.name}(${example.args.map((arg) => JSON.stringify(arg)).join(', ')})\n`; - exampleCode.appendChild(exampleExpression); - - if (example.evaluated !== undefined) { - const exampleEvaluated = document.createElement('span'); - exampleEvaluated.textContent = `// => ${example.evaluated}\n`; - exampleCode.appendChild(exampleEvaluated); - } - - if (index !== examples.length - 1) { - exampleCode.textContent += '\n'; - } - }); - - tooltipContainer.appendChild(examplesWrapper); - } - - return tooltipContainer; - }; + option.info = createInfoBoxRenderer(doc, isFunction); return option; }; -const createFunctionHeader = (base: string, doc?: DocMetadata) => { - const header = document.createElement('div'); - if (doc) { - const shortBase = base.split('.').pop() ?? base; - const baseSpan = document.createElement('span'); - baseSpan.textContent = shortBase + '.'; - header.appendChild(baseSpan); - - const functionNameSpan = document.createElement('span'); - functionNameSpan.classList.add('autocomplete-info-name'); - functionNameSpan.textContent = doc.name; - header.appendChild(functionNameSpan); - - const openBracketsSpan = document.createElement('span'); - openBracketsSpan.textContent = '('; - header.appendChild(openBracketsSpan); - - const argsSpan = document.createElement('span'); - doc.args?.forEach((arg, index, array) => { - const argSpan = document.createElement('span'); - argSpan.textContent = arg.name; - argSpan.classList.add('autocomplete-info-arg'); - argsSpan.appendChild(argSpan); - - if (index !== array.length - 1) { - const separatorSpan = document.createElement('span'); - separatorSpan.textContent = ', '; - argsSpan.appendChild(separatorSpan); - } - }); - header.appendChild(argsSpan); - - const closeBracketsSpan = document.createElement('span'); - closeBracketsSpan.textContent = ')'; - header.appendChild(closeBracketsSpan); - } - return header; -}; - -const createPropHeader = (typeName: string, doc?: DocMetadata) => { - const header = document.createElement('div'); - if (doc) { - const typeNameSpan = document.createElement('span'); - typeNameSpan.innerHTML = typeName.slice(0, 1).toUpperCase() + typeName.slice(1) + '.'; - - const propNameSpan = document.createElement('span'); - propNameSpan.classList.add('autocomplete-info-name'); - propNameSpan.innerText = doc.name; - - const returnTypeSpan = document.createElement('span'); - returnTypeSpan.innerHTML = ': ' + doc.returnType; - - header.appendChild(typeNameSpan); - header.appendChild(propNameSpan); - header.appendChild(returnTypeSpan); - } - return header; -}; - const objectOptions = (input: AutocompleteInput): Completion[] => { const { base, resolved, transformLabel } = input; const rank = setRank(['item', 'all', 'first', 'last']); @@ -529,7 +339,6 @@ const objectOptions = (input: AutocompleteInput): Completion[] => { const infoKey = [name, key].join('.'); option.info = createCompletionOption({ name: key, - base, doc: { name: key, returnType: getType(resolvedProp), @@ -550,14 +359,14 @@ const objectOptions = (input: AutocompleteInput): Completion[] => { base === 'Math'; if (skipObjectExtensions) { - return sortCompletionsAlpha([...localKeys, ...natives({ base, typeName: 'object' })]); + return sortCompletionsAlpha([...localKeys, ...natives({ typeName: 'object' })]); } return applySections({ options: sortCompletionsAlpha([ ...localKeys, - ...natives({ base, typeName: 'object' }), - ...extensions({ base, typeName: 'object' }), + ...natives({ typeName: 'object' }), + ...extensions({ typeName: 'object' }), ]), recommended: OBJECT_RECOMMENDED_OPTIONS, recommendedSection: RECOMMENDED_METHODS_SECTION, @@ -640,10 +449,10 @@ const isUrl = (url: string): boolean => { }; const stringOptions = (input: AutocompleteInput): Completion[] => { - const { base, resolved, transformLabel } = input; + const { resolved, transformLabel } = input; const options = sortCompletionsAlpha([ - ...natives({ typeName: 'string', base, transformLabel }), - ...extensions({ typeName: 'string', base, includeHidden: false, transformLabel }), + ...natives({ typeName: 'string', transformLabel }), + ...extensions({ typeName: 'string', includeHidden: false, transformLabel }), ]); if (validateFieldType('string', resolved, 'number').valid) { @@ -713,20 +522,20 @@ const stringOptions = (input: AutocompleteInput): Completion[] => { }); }; -const booleanOptions = (input: AutocompleteInput): Completion[] => { +const booleanOptions = (): Completion[] => { return applySections({ options: sortCompletionsAlpha([ - ...natives({ typeName: 'boolean', base: input.base }), - ...extensions({ typeName: 'boolean', base: input.base }), + ...natives({ typeName: 'boolean' }), + ...extensions({ typeName: 'boolean' }), ]), }); }; const numberOptions = (input: AutocompleteInput): Completion[] => { - const { base, resolved, transformLabel } = input; + const { resolved, transformLabel } = input; const options = sortCompletionsAlpha([ - ...natives({ typeName: 'number', base, transformLabel }), - ...extensions({ typeName: 'number', base, includeHidden: false, transformLabel }), + ...natives({ typeName: 'number', transformLabel }), + ...extensions({ typeName: 'number', includeHidden: false, transformLabel }), ]); const ONLY_INTEGER = ['isEven()', 'isOdd()']; @@ -775,24 +584,24 @@ const numberOptions = (input: AutocompleteInput): Completion[] => { }; const dateOptions = (input: AutocompleteInput): Completion[] => { - const { transformLabel, base } = input; + const { transformLabel } = input; return applySections({ options: sortCompletionsAlpha([ - ...natives({ typeName: 'date', base, transformLabel }), - ...extensions({ typeName: 'date', base, includeHidden: true, transformLabel }), + ...natives({ typeName: 'date', transformLabel }), + ...extensions({ typeName: 'date', includeHidden: true, transformLabel }), ]), recommended: DATE_RECOMMENDED_OPTIONS, }); }; const luxonOptions = (input: AutocompleteInput): Completion[] => { - const { transformLabel, base } = input; + const { transformLabel } = input; return applySections({ options: sortCompletionsAlpha( uniqBy( [ - ...extensions({ typeName: 'date', base, includeHidden: false, transformLabel }), - ...luxonInstanceOptions({ base, includeHidden: false, transformLabel }), + ...extensions({ typeName: 'date', includeHidden: false, transformLabel }), + ...luxonInstanceOptions({ includeHidden: false, transformLabel }), ], (option) => option.label, ), @@ -803,11 +612,11 @@ const luxonOptions = (input: AutocompleteInput): Completion[] => { }; const arrayOptions = (input: AutocompleteInput): Completion[] => { - const { base, resolved, transformLabel } = input; + const { resolved, transformLabel } = input; const options = applySections({ options: sortCompletionsAlpha([ - ...natives({ typeName: 'array', base, transformLabel }), - ...extensions({ typeName: 'array', base, includeHidden: false, transformLabel }), + ...natives({ typeName: 'array', transformLabel }), + ...extensions({ typeName: 'array', includeHidden: false, transformLabel }), ]), recommended: ARRAY_RECOMMENDED_OPTIONS, methodsSection: OTHER_SECTION, @@ -832,13 +641,12 @@ function ensureKeyCanBeResolved(obj: IDataObject, key: string) { } } -export const variablesOptions = (base: string) => { +export const variablesOptions = () => { const environmentsStore = useEnvironmentsStore(); const variables = environmentsStore.variables; return variables.map((variable) => createCompletionOption({ - base, name: variable.key, doc: { name: variable.key, @@ -870,7 +678,6 @@ export const secretOptions = (base: string) => { } return Object.entries(resolved).map(([secret, value]) => createCompletionOption({ - base, name: secret, doc: { name: secret, @@ -885,12 +692,11 @@ export const secretOptions = (base: string) => { } }; -export const secretProvidersOptions = (base: string) => { +export const secretProvidersOptions = () => { const externalSecretsStore = useExternalSecretsStore(); return Object.keys(externalSecretsStore.secretsAsObject).map((provider) => createCompletionOption({ - base, name: provider, doc: { name: provider, @@ -906,11 +712,9 @@ export const secretProvidersOptions = (base: string) => { * Methods and fields defined on a Luxon `DateTime` class instance. */ export const luxonInstanceOptions = ({ - base, includeHidden = false, transformLabel = (label) => label, }: { - base: string; includeHidden?: boolean; transformLabel?: (label: string) => string; }) => { @@ -922,7 +726,6 @@ export const luxonInstanceOptions = ({ .map(([key, descriptor]) => { const isFunction = typeof descriptor.value === 'function'; return createLuxonAutocompleteOption({ - base, name: key, isFunction, docs: luxonInstanceDocs, @@ -937,7 +740,7 @@ export const luxonInstanceOptions = ({ /** * Methods defined on a Luxon `DateTime` class. */ -export const luxonStaticOptions = (base: string) => { +export const luxonStaticOptions = () => { const SKIP = new Set(['prototype', 'name', 'length', 'invalid']); return sortCompletionsAlpha( @@ -945,7 +748,6 @@ export const luxonStaticOptions = (base: string) => { .filter((key) => !SKIP.has(key) && !key.includes('_')) .map((key) => { return createLuxonAutocompleteOption({ - base, name: key, isFunction: true, docs: luxonStaticDocs, @@ -958,7 +760,6 @@ export const luxonStaticOptions = (base: string) => { const createLuxonAutocompleteOption = ({ name, - base, docs, translations, isFunction = false, @@ -966,7 +767,6 @@ const createLuxonAutocompleteOption = ({ transformLabel = (label) => label, }: { name: string; - base: string; docs: NativeDoc; translations: Record; isFunction?: boolean; @@ -1006,7 +806,6 @@ const createLuxonAutocompleteOption = ({ }), }; option.info = createCompletionOption({ - base, name, isFunction, // Add translated description diff --git a/packages/editor-ui/src/plugins/codemirror/completions/infoBoxRenderer.ts b/packages/editor-ui/src/plugins/codemirror/completions/infoBoxRenderer.ts new file mode 100644 index 0000000000000..4588d89f7009e --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/infoBoxRenderer.ts @@ -0,0 +1,217 @@ +import type { DocMetadata, DocMetadataArgument, DocMetadataExample } from 'n8n-workflow'; +import { sanitizeHtml } from '@/utils/htmlUtils'; +import { i18n } from '@/plugins/i18n'; + +const renderFunctionHeader = (doc?: DocMetadata) => { + const header = document.createElement('div'); + if (doc) { + const functionNameSpan = document.createElement('span'); + functionNameSpan.classList.add('autocomplete-info-name'); + functionNameSpan.textContent = doc.name; + header.appendChild(functionNameSpan); + + const openBracketsSpan = document.createElement('span'); + openBracketsSpan.textContent = '('; + header.appendChild(openBracketsSpan); + + const argsSpan = document.createElement('span'); + doc.args?.forEach((arg, index, array) => { + const argSpan = document.createElement('span'); + argSpan.textContent = arg.name; + argSpan.classList.add('autocomplete-info-arg'); + argsSpan.appendChild(argSpan); + + if (index !== array.length - 1) { + const separatorSpan = document.createElement('span'); + separatorSpan.textContent = ', '; + argsSpan.appendChild(separatorSpan); + } + }); + header.appendChild(argsSpan); + + const closeBracketsSpan = document.createElement('span'); + closeBracketsSpan.textContent = ')'; + header.appendChild(closeBracketsSpan); + } + return header; +}; + +const renderPropHeader = (doc?: DocMetadata) => { + const header = document.createElement('div'); + if (doc) { + const propNameSpan = document.createElement('span'); + propNameSpan.classList.add('autocomplete-info-name'); + propNameSpan.innerText = doc.name; + + const returnTypeSpan = document.createElement('span'); + returnTypeSpan.innerHTML = ': ' + doc.returnType; + + header.appendChild(propNameSpan); + header.appendChild(returnTypeSpan); + } + return header; +}; + +const renderDescription = ({ + description, + docUrl, + example, + fnName, +}: { + description: string; + fnName: string; + docUrl?: string; + example?: DocMetadataExample; +}): HTMLElement => { + const descriptionBody = document.createElement('div'); + descriptionBody.classList.add('autocomplete-info-description'); + const descriptionText = document.createElement('p'); + descriptionText.innerHTML = sanitizeHtml(description.replace(/`(.*?)`/g, '$1')); + descriptionBody.appendChild(descriptionText); + + if (docUrl) { + const descriptionLink = document.createElement('a'); + descriptionLink.setAttribute('target', '_blank'); + descriptionLink.setAttribute('href', docUrl); + descriptionLink.innerText = + i18n.autocompleteUIValues.docLinkLabel ?? i18n.baseText('generic.learnMore'); + descriptionLink.addEventListener('mousedown', (event: MouseEvent) => { + // This will prevent documentation popup closing before click + // event gets to links + event.preventDefault(); + }); + descriptionLink.classList.add('autocomplete-info-doc-link'); + descriptionText.appendChild(descriptionLink); + } + + if (example) { + const renderedExample = renderExample(example, fnName); + descriptionBody.appendChild(renderedExample); + } + + return descriptionBody; +}; + +const renderArgs = (args: DocMetadataArgument[]): HTMLElement => { + const argsContainer = document.createElement('div'); + argsContainer.classList.add('autocomplete-info-args-container'); + + const argsTitle = document.createElement('div'); + argsTitle.classList.add('autocomplete-info-section-title'); + argsTitle.textContent = i18n.baseText('codeNodeEditor.parameters'); + argsContainer.appendChild(argsTitle); + + const argsList = document.createElement('ul'); + argsList.classList.add('autocomplete-info-args'); + + for (const arg of args) { + const argItem = document.createElement('li'); + const argName = document.createElement('span'); + argName.classList.add('autocomplete-info-arg-name'); + argName.textContent = arg.name; + argItem.appendChild(argName); + + if (arg.type) { + const argType = document.createElement('span'); + argType.classList.add('autocomplete-info-arg-type'); + argType.textContent = `: ${arg.type}`; + argItem.appendChild(argType); + } + + if (arg.description) { + const argDescription = document.createElement('span'); + argDescription.classList.add('autocomplete-info-arg-description'); + argDescription.innerHTML = `- ${sanitizeHtml( + arg.description.replace(/`(.*?)`/g, '$1'), + )}`; + + argItem.appendChild(argDescription); + } + + argsList.appendChild(argItem); + } + + argsContainer.appendChild(argsList); + return argsContainer; +}; + +const renderExample = (example: DocMetadataExample, fnName: string): HTMLElement => { + const examplePre = document.createElement('pre'); + examplePre.classList.add('autocomplete-info-example'); + const exampleCode = document.createElement('code'); + examplePre.appendChild(exampleCode); + + if (example.description) { + const exampleComment = document.createElement('span'); + exampleComment.classList.add('autocomplete-info-example-comment'); + exampleComment.textContent = `// ${example.description}\n`; + exampleCode.appendChild(exampleComment); + } + + const exampleExpression = document.createElement('span'); + exampleExpression.classList.add('autocomplete-info-example-expr'); + exampleExpression.textContent = `${JSON.stringify(example.subject)}.${fnName}(${example.args.map((arg) => JSON.stringify(arg)).join(', ')})\n`; + exampleCode.appendChild(exampleExpression); + + if (example.evaluated !== undefined) { + const exampleEvaluated = document.createElement('span'); + exampleEvaluated.textContent = `// => ${example.evaluated}\n`; + exampleCode.appendChild(exampleEvaluated); + } + + return examplePre; +}; + +const renderExamples = (examples: DocMetadataExample[], fnName: string): HTMLElement => { + const examplesContainer = document.createElement('div'); + examplesContainer.classList.add('autocomplete-info-examples'); + + const examplesTitle = document.createElement('div'); + examplesTitle.classList.add('autocomplete-info-section-title'); + examplesTitle.textContent = i18n.baseText('codeNodeEditor.examples'); + examplesContainer.appendChild(examplesTitle); + + for (const example of examples) { + const renderedExample = renderExample(example, fnName); + examplesContainer.appendChild(renderedExample); + } + + return examplesContainer; +}; + +export const createInfoBoxRenderer = + (doc?: DocMetadata, isFunction = false) => + (): HTMLElement | null => { + const tooltipContainer = document.createElement('div'); + tooltipContainer.classList.add('autocomplete-info-container'); + + if (!doc) return null; + + const { examples, args } = doc; + + const header = isFunction ? renderFunctionHeader(doc) : renderPropHeader(doc); + header.classList.add('autocomplete-info-header'); + tooltipContainer.appendChild(header); + + if (doc.description) { + const descriptionBody = renderDescription({ + fnName: doc.name, + description: doc.description, + docUrl: doc.docURL, + example: doc.examples?.[0], + }); + tooltipContainer.appendChild(descriptionBody); + } + + if (args && args.length > 0) { + const argsContainer = renderArgs(args); + tooltipContainer.appendChild(argsContainer); + } + + if (examples && examples.length > 0) { + const examplesContainer = renderExamples(examples, doc.name); + tooltipContainer.appendChild(examplesContainer); + } + + return tooltipContainer; + }; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index f2bcb4066d874..37cafa280cdd4 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -390,6 +390,7 @@ "codeNodeEditor.tabs.askAi": "✨ Ask AI", "codeNodeEditor.tabs.code": "Code", "codeNodeEditor.examples": "Examples", + "codeNodeEditor.parameters": "Parameters", "collectionParameter.choose": "Choose...", "collectionParameter.noProperties": "No properties", "credentialEdit.credentialConfig.accountConnected": "Account connected", diff --git a/packages/editor-ui/src/styles/plugins/_codemirror.scss b/packages/editor-ui/src/styles/plugins/_codemirror.scss index 290e0eef11967..42341922d46f6 100644 --- a/packages/editor-ui/src/styles/plugins/_codemirror.scss +++ b/packages/editor-ui/src/styles/plugins/_codemirror.scss @@ -90,11 +90,11 @@ } } -.autocomplete-info-main { +.autocomplete-info-container { display: flex; flex-direction: column; - gap: var(--spacing-2xs); - padding: var(--spacing-xs) var(--spacing-xs) var(--spacing-s) var(--spacing-xs); + gap: var(--spacing-s); + padding: var(--spacing-xs) var(--spacing-xs); } .ͼ2 .cm-tooltip.cm-completionInfo { @@ -123,7 +123,6 @@ font-weight: var(--font-weight-bold); font-family: var(--font-family-monospace); line-height: var(--font-line-height-compact); - margin-bottom: var(--spacing-4xs); } .autocomplete-info-name { @@ -134,14 +133,16 @@ color: var(--color-text-base); font-weight: var(--font-weight-regular); display: inline-block; - padding-left: var(--spacing-5xs); - padding-right: var(--spacing-5xs); } .autocomplete-info-description { color: var(--color-text-dark); font-size: var(--font-size-2xs); + .autocomplete-info-example { + margin-top: var(--spacing-3xs); + } + code { color: var(--color-text-base); font-size: var(--font-size-2xs); @@ -160,7 +161,6 @@ .autocomplete-info-args { list-style: none; - margin-top: var(--spacing-3xs); li { text-indent: calc(var(--spacing-2xs) * -1); @@ -183,6 +183,7 @@ .autocomplete-info-arg-description { color: var(--color-text-dark); margin-left: var(--spacing-4xs); + font-size: var(--font-size-2xs); code { color: var(--color-text-base); @@ -192,14 +193,7 @@ } } - .autocomplete-info-examples-wrapper { - background-color: var(--color-background-light); - } - .autocomplete-info-examples { - border-top: var(--border-base); - padding: var(--spacing-xs); - pre { line-height: 1; } @@ -209,7 +203,11 @@ } } - .autocomplete-info-examples-title { + .autocomplete-info-example + .autocomplete-info-example { + margin-top: var(--spacing-2xs); + } + + .autocomplete-info-section-title { text-transform: uppercase; color: var(--color-text-dark); font-size: var(--font-size-3xs); diff --git a/packages/workflow/src/Extensions/Extensions.ts b/packages/workflow/src/Extensions/Extensions.ts index 44036d14c0671..762e16fc9399a 100644 --- a/packages/workflow/src/Extensions/Extensions.ts +++ b/packages/workflow/src/Extensions/Extensions.ts @@ -12,7 +12,12 @@ export type NativeDoc = { functions: Record; }; -export type DocMetadataArgument = { name: string; type?: string; description?: string }; +export type DocMetadataArgument = { + name: string; + type?: string; + description?: string; + default?: unknown; +}; export type DocMetadataExample = { subject: unknown; args: string[]; diff --git a/packages/workflow/src/Extensions/index.ts b/packages/workflow/src/Extensions/index.ts index 1ff8812e4df16..39c4c6f613e4d 100644 --- a/packages/workflow/src/Extensions/index.ts +++ b/packages/workflow/src/Extensions/index.ts @@ -7,4 +7,10 @@ export { EXTENSION_OBJECTS as ExpressionExtensions, } from './ExpressionExtension'; -export type { DocMetadata, NativeDoc } from './Extensions'; +export type { + DocMetadata, + NativeDoc, + Extension, + DocMetadataArgument, + DocMetadataExample, +} from './Extensions'; diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index 7ac82621ce3f4..05f9f935b2295 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -51,7 +51,13 @@ export * as ExpressionParser from './Extensions/ExpressionParser'; export { NativeMethods } from './NativeMethods'; export * from './NodeParameters/FilterParameter'; -export type { DocMetadata, NativeDoc } from './Extensions'; +export type { + DocMetadata, + NativeDoc, + DocMetadataArgument, + DocMetadataExample, + Extension, +} from './Extensions'; declare module 'http' { export interface IncomingMessage { From 56ae4e55c2a25a03ac5e0e9319a1a2454ce64a9b Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Wed, 10 Apr 2024 11:15:55 +0200 Subject: [PATCH 06/30] Add string extension docs --- .../codemirror/completions/infoBoxRenderer.ts | 26 +-- .../plugins/codemirror/completions/utils.ts | 13 +- .../src/styles/plugins/_codemirror.scss | 5 + .../workflow/src/Extensions/DateExtensions.ts | 2 +- .../workflow/src/Extensions/Extensions.ts | 9 +- .../src/Extensions/StringExtensions.ts | 203 +++++++++++++++--- .../src/NativeMethods/String.methods.ts | 10 +- 7 files changed, 207 insertions(+), 61 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/infoBoxRenderer.ts b/packages/editor-ui/src/plugins/codemirror/completions/infoBoxRenderer.ts index 4588d89f7009e..cdcefde3af1fb 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/infoBoxRenderer.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/infoBoxRenderer.ts @@ -56,10 +56,8 @@ const renderDescription = ({ description, docUrl, example, - fnName, }: { description: string; - fnName: string; docUrl?: string; example?: DocMetadataExample; }): HTMLElement => { @@ -85,7 +83,7 @@ const renderDescription = ({ } if (example) { - const renderedExample = renderExample(example, fnName); + const renderedExample = renderExample(example); descriptionBody.appendChild(renderedExample); } @@ -109,6 +107,8 @@ const renderArgs = (args: DocMetadataArgument[]): HTMLElement => { const argName = document.createElement('span'); argName.classList.add('autocomplete-info-arg-name'); argName.textContent = arg.name; + if (arg.optional === true) argName.textContent += '?'; + argItem.appendChild(argName); if (arg.type) { @@ -135,22 +135,15 @@ const renderArgs = (args: DocMetadataArgument[]): HTMLElement => { return argsContainer; }; -const renderExample = (example: DocMetadataExample, fnName: string): HTMLElement => { +const renderExample = (example: DocMetadataExample): HTMLElement => { const examplePre = document.createElement('pre'); examplePre.classList.add('autocomplete-info-example'); const exampleCode = document.createElement('code'); examplePre.appendChild(exampleCode); - if (example.description) { - const exampleComment = document.createElement('span'); - exampleComment.classList.add('autocomplete-info-example-comment'); - exampleComment.textContent = `// ${example.description}\n`; - exampleCode.appendChild(exampleComment); - } - const exampleExpression = document.createElement('span'); exampleExpression.classList.add('autocomplete-info-example-expr'); - exampleExpression.textContent = `${JSON.stringify(example.subject)}.${fnName}(${example.args.map((arg) => JSON.stringify(arg)).join(', ')})\n`; + exampleExpression.textContent = example.example + '\n'; exampleCode.appendChild(exampleExpression); if (example.evaluated !== undefined) { @@ -162,7 +155,7 @@ const renderExample = (example: DocMetadataExample, fnName: string): HTMLElement return examplePre; }; -const renderExamples = (examples: DocMetadataExample[], fnName: string): HTMLElement => { +const renderExamples = (examples: DocMetadataExample[]): HTMLElement => { const examplesContainer = document.createElement('div'); examplesContainer.classList.add('autocomplete-info-examples'); @@ -172,7 +165,7 @@ const renderExamples = (examples: DocMetadataExample[], fnName: string): HTMLEle examplesContainer.appendChild(examplesTitle); for (const example of examples) { - const renderedExample = renderExample(example, fnName); + const renderedExample = renderExample(example); examplesContainer.appendChild(renderedExample); } @@ -195,10 +188,9 @@ export const createInfoBoxRenderer = if (doc.description) { const descriptionBody = renderDescription({ - fnName: doc.name, description: doc.description, docUrl: doc.docURL, - example: doc.examples?.[0], + example: doc.args && doc.args.length > 0 ? doc.examples?.[0] : undefined, }); tooltipContainer.appendChild(descriptionBody); } @@ -209,7 +201,7 @@ export const createInfoBoxRenderer = } if (examples && examples.length > 0) { - const examplesContainer = renderExamples(examples, doc.name); + const examplesContainer = renderExamples(examples); tooltipContainer.appendChild(examplesContainer); } diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts index bc66596cc2dd6..cfb790ee4336b 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -163,13 +163,18 @@ export const stripExcessParens = (context: CompletionContext) => (option: Comple return option; }; -export const getDefaultArgs = (doc?: DocMetadata): unknown[] => { - return doc?.args?.map((arg) => arg.default).filter(Boolean) ?? []; +export const getDefaultArgs = (doc?: DocMetadata): string[] => { + return ( + doc?.args + ?.filter((arg) => !arg.optional) + .map((arg) => arg.default) + .filter((def): def is string => !!def) ?? [] + ); }; export const insertDefaultArgs = (label: string, args: unknown[]): string => { if (!label.endsWith('()')) return label; - const argList = args.map((arg) => JSON.stringify(arg)).join(', '); + const argList = args.join(', '); const fnName = label.replace('()', ''); return `${fnName}(${argList})`; @@ -214,7 +219,7 @@ export const applyCompletion = export const hasRequiredArgs = (doc?: DocMetadata): boolean => { if (!doc) return false; - const requiredArgs = doc?.args?.filter((arg) => !arg.name.endsWith('?')) ?? []; + const requiredArgs = doc?.args?.filter((arg) => !arg.name.endsWith('?') && !arg.optional) ?? []; return requiredArgs.length > 0; }; diff --git a/packages/editor-ui/src/styles/plugins/_codemirror.scss b/packages/editor-ui/src/styles/plugins/_codemirror.scss index 42341922d46f6..96a6d52821fca 100644 --- a/packages/editor-ui/src/styles/plugins/_codemirror.scss +++ b/packages/editor-ui/src/styles/plugins/_codemirror.scss @@ -203,6 +203,11 @@ } } + .autocomplete-info-example code { + font-size: var(--font-size-3xs); + font-family: var(--font-family-monospace); + } + .autocomplete-info-example + .autocomplete-info-example { margin-top: var(--spacing-2xs); } diff --git a/packages/workflow/src/Extensions/DateExtensions.ts b/packages/workflow/src/Extensions/DateExtensions.ts index f0e8e670620d3..371ce02b3f6ab 100644 --- a/packages/workflow/src/Extensions/DateExtensions.ts +++ b/packages/workflow/src/Extensions/DateExtensions.ts @@ -286,7 +286,7 @@ format.doc = { description: 'Formats a Date in the given structure.', returnType: 'string', section: 'format', - args: [{ name: 'fmt', default: 'yyyy-MM-dd', type: 'TimeFormat' }], + args: [{ name: 'fmt', default: "'yyyy-MM-dd'", type: 'TimeFormat' }], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-format', }; diff --git a/packages/workflow/src/Extensions/Extensions.ts b/packages/workflow/src/Extensions/Extensions.ts index 762e16fc9399a..b54e9e41046cd 100644 --- a/packages/workflow/src/Extensions/Extensions.ts +++ b/packages/workflow/src/Extensions/Extensions.ts @@ -15,14 +15,13 @@ export type NativeDoc = { export type DocMetadataArgument = { name: string; type?: string; + optional?: boolean; description?: string; - default?: unknown; + default?: string; }; export type DocMetadataExample = { - subject: unknown; - args: string[]; - description?: string; - evaluated?: unknown; + example: string; + evaluated?: string; }; export type DocMetadata = { diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 15b0f32f2774d..56371423427a5 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -265,6 +265,16 @@ function toFloat(value: string) { return float; } +function toNumber(value: string) { + const num = Number(value.replace(CURRENCY_REGEXP, '')); + + if (isNaN(num)) { + throw new ExpressionExtensionError('cannot convert to number'); + } + + return num; +} + function quote(value: string, extraArgs: string[]) { const [quoteChar = '"'] = extraArgs; return `${quoteChar}${value @@ -405,20 +415,22 @@ function base64Decode(value: string): string { removeMarkdown.doc = { name: 'removeMarkdown', - description: 'Removes Markdown formatting from a string.', + description: 'Removes any Markdown formatting from the string. Also removes HTML tags.', section: 'edit', returnType: 'string', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-removeMarkdown', + examples: [{ example: '"*bold*, [link]()".removeMarkdown()', evaluated: '"bold, link"' }], }; removeTags.doc = { name: 'removeTags', - description: 'Removes tags, such as HTML or XML, from a string.', + description: 'Removes tags, such as HTML or XML, from the string.', section: 'edit', returnType: 'string', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-removeTags', + examples: [{ example: '"bold, link".removeTags()', evaluated: '"bold, link"' }], }; toDate.doc = { @@ -432,20 +444,34 @@ toDate.doc = { toDateTime.doc = { name: 'toDateTime', - description: 'Converts a string to a Luxon DateTime.', + description: + 'Converts the string to a DateTime. Useful for further transformation. Supported formats for the string are ISO 8601, HTTP, RFC2822, SQL and Unix timestamp in milliseconds. To parse other formats, use DateTime.fromFormat().', section: 'cast', returnType: 'DateTime', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toDateTime', + examples: [ + { example: '"2024-03-29T18:06:31.798+01:00".toDateTime()' }, + { example: '"Fri, 29 Mar 2024 18:08:01 +0100".toDateTime()' }, + { example: '"20240329".toDateTime()' }, + { example: '"1711732132990".toDateTime()' }, + ], }; toBoolean.doc = { name: 'toBoolean', - description: 'Converts a string to a boolean.', + description: + 'Converts the string to a boolean value. 0, "false" and "no" resolve to false, everything else to true.', section: 'cast', returnType: 'boolean', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toBoolean', + examples: [ + { example: '"true".toBoolean()', evaluated: 'true' }, + { example: '"false".toBoolean()', evaluated: 'false' }, + { example: '"0".toBoolean()', evaluated: 'false' }, + { example: '"hello".toBoolean()', evaluated: 'true' }, + ], }; toFloat.doc = { @@ -454,6 +480,7 @@ toFloat.doc = { section: 'cast', returnType: 'number', aliases: ['toDecimalNumber'], + hidden: true, docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toDecimalNumber', }; @@ -465,12 +492,15 @@ toInt.doc = { returnType: 'number', args: [{ name: 'radix?', type: 'number' }], aliases: ['toWholeNumber'], + hidden: true, docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toInt', }; toSentenceCase.doc = { name: 'toSentenceCase', - description: 'Formats a string to sentence case. Example: "This is a sentence".', + description: + 'Changes the capitalization of the string to sentence case. The first letter of each sentence is capitalized and all others are lowercased.', + examples: [{ example: '"quick! brown FOX".toSentenceCase()', evaluated: '"Quick! Brown fox"' }], section: 'case', returnType: 'string', docURL: @@ -479,7 +509,9 @@ toSentenceCase.doc = { toSnakeCase.doc = { name: 'toSnakeCase', - description: 'Formats a string to snake case. Example: "this_is_snake_case".', + description: + 'Changes the format of the string to snake case. Spaces and dashes are replaced by _, symbols are removed and all letters are lowercased.', + examples: [{ example: '"quick brown $FOX".toSnakeCase()', evaluated: '"quick_brown_fox"' }], section: 'case', returnType: 'string', docURL: @@ -489,7 +521,8 @@ toSnakeCase.doc = { toTitleCase.doc = { name: 'toTitleCase', description: - 'Formats a string to title case. Example: "This Is a Title". Will not change already uppercase letters to prevent losing information from acronyms and trademarks such as iPhone or FAANG.', + 'Changes the capitalization of the string to title case. The first letter of each word is capitalized and the others left unchanged. Short prepositions and conjunctions aren’t capitalized (e.g. ‘a’, ‘the’).', + examples: [{ example: '"quick a brown FOX".toTitleCase()', evaluated: '"Quick a Brown Fox"' }], section: 'case', returnType: 'string', docURL: @@ -498,31 +531,60 @@ toTitleCase.doc = { urlEncode.doc = { name: 'urlEncode', - description: 'Encodes a string to be used/included in a URL.', + description: + '"Encodes the string so that it can be used in a URL. Spaces and special characters are replaced with codes of the form %XX.', section: 'edit', - args: [{ name: 'entireString?', type: 'boolean' }], + args: [ + { + name: 'allChars', + optional: true, + description: + 'Whether to encode characters that are part of the URI syntax (e.g. =, ?)', + default: 'false', + type: 'boolean', + }, + ], returnType: 'string', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-urlEncode', + examples: [ + { example: '"name=Nathan Automat".urlEncode()', evaluated: '"name%3DNathan%20Automat"' }, + { example: '"name=Nathan Automat".urlEncode(true)', evaluated: '"name=Nathan%20Automat"' }, + ], }; urlDecode.doc = { name: 'urlDecode', description: - 'Decodes a URL-encoded string. It decodes any percent-encoded characters in the input string, and replaces them with their original characters.', + 'Decodes a URL-encoded string. Replaces any character codes in the form of %XX with their corresponding characters.', + args: [ + { + name: 'allChars', + optional: true, + description: + 'Whether to decode characters that are part of the URI syntax (e.g. =, ?)', + default: 'false', + type: 'boolean', + }, + ], section: 'edit', returnType: 'string', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-urlDecode', + examples: [ + { example: '"name%3DNathan%20Automat".urlDecode()', evaluated: '"name=Nathan Automat"' }, + { example: '"name%3DNathan%20Automat".urlDecode(true)', evaluated: '"name%3DNathan Automat"' }, + ], }; replaceSpecialChars.doc = { name: 'replaceSpecialChars', - description: 'Replaces non-ASCII characters in a string with an ASCII representation.', + description: 'Replaces special characters in the string with the closest ASCII character', section: 'edit', returnType: 'string', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-replaceSpecialChars', + examples: [{ example: '"déjà".replaceSpecialChars()', evaluated: '"deja"' }], }; length.doc = { @@ -536,117 +598,193 @@ length.doc = { isDomain.doc = { name: 'isDomain', - description: 'Checks if a string is a domain.', + description: 'Returns true if a string is a domain.', section: 'validation', returnType: 'boolean', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isDomain', + examples: [ + { example: '"n8n.io".isDomain()', evaluated: 'true' }, + { example: '"http://n8n.io".isDomain()', evaluated: 'false' }, + { example: '"hello".isDomain()', evaluated: 'false' }, + ], }; isEmail.doc = { name: 'isEmail', - description: 'Checks if a string is an email.', + description: 'Returns true if the string is an email.', section: 'validation', returnType: 'boolean', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isEmail', + examples: [ + { example: '"me@example.com".isEmail()', evaluated: 'true' }, + { example: '"It\'s me@example.com".isEmail()', evaluated: 'false' }, + { example: '"hello".isEmail()', evaluated: 'false' }, + ], }; isNumeric.doc = { name: 'isNumeric', - description: 'Checks if a string only contains digits.', + description: 'Returns true if the string represents a number.', section: 'validation', returnType: 'boolean', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isNumeric', + examples: [ + { example: '"1.2234".isNumeric()', evaluated: 'true' }, + { example: '"hello".isNumeric()', evaluated: 'false' }, + { example: '"123E23".isNumeric()', evaluated: 'true' }, + ], }; isUrl.doc = { name: 'isUrl', - description: 'Checks if a string is a valid URL.', + description: 'Returns true if a string is a valid URL', section: 'validation', returnType: 'boolean', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isUrl', + examples: [ + { example: '"https://n8n.io".isUrl()', evaluated: 'true' }, + { example: '"n8n.io".isUrl()', evaluated: 'false' }, + { example: '"hello".isUrl()', evaluated: 'false' }, + ], }; isEmpty.doc = { name: 'isEmpty', - description: 'Checks if a string is empty.', + description: 'Returns true if the string has no characters.', section: 'validation', returnType: 'boolean', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isEmpty', + examples: [ + { example: '"".isEmpty()', evaluated: 'true' }, + { example: '"hello".isEmpty()', evaluated: 'false' }, + ], }; isNotEmpty.doc = { name: 'isNotEmpty', - description: 'Checks if a string has content.', + description: 'Returns true if the string has at least one character.', section: 'validation', returnType: 'boolean', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isNotEmpty', + examples: [ + { example: '"hello".isNotEmpty()', evaluated: 'true' }, + { example: '"".isNotEmpty()', evaluated: 'false' }, + ], }; extractEmail.doc = { name: 'extractEmail', - description: 'Extracts an email from a string. Returns undefined if none is found.', + description: + 'Extracts the first email found in the string. Returns undefined if none is found.', section: 'edit', returnType: 'string', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractEmail', + examples: [ + { example: '"My email is me@example.com".extractEmail()', evaluated: "'me@example.com'" }, + ], }; extractDomain.doc = { name: 'extractDomain', description: - 'Extracts a domain from a string containing a valid URL. Returns undefined if none is found.', + 'If the string is an email address or URL, returns its domain (or undefined if nothing found). If the string also contains other content, try using extractEmail() or extractUrl() first.', section: 'edit', returnType: 'string', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractDomain', + examples: [ + { example: '"me@example.com".extractDomain()', evaluated: "'example.com'" }, + { example: '"http://n8n.io/workflows".extractDomain()', evaluated: "'n8n.io'" }, + { + example: '"It\'s me@example.com".extractEmail().extractDomain()', + evaluated: "'example.com'", + }, + ], }; extractUrl.doc = { name: 'extractUrl', - description: 'Extracts a URL from a string. Returns undefined if none is found.', + description: + 'Extracts the first URL found in the string. Returns undefined if none is found. Only recognizes full URLs, e.g. those starting with http.', section: 'edit', returnType: 'string', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractUrl', + examples: [{ example: '"Check out http://n8n.io".extractUrl()', evaluated: "'http://n8n.io'" }], }; extractUrlPath.doc = { name: 'extractUrlPath', - description: 'Extracts the path from a URL. Returns undefined if none is found.', + description: + 'Returns the part of a URL after the domain, or undefined if no URL found. If the string also contains other content, try using extractUrl() first.', section: 'edit', returnType: 'string', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractUrlPath', + examples: [ + { example: '"http://n8n.io/workflows".extractUrlPath()', evaluated: "'/workflows'" }, + { + example: '"Check out http://n8n.io/workflows".extractUrl().extractUrlPath()', + evaluated: "'/workflows'", + }, + ], }; hash.doc = { name: 'hash', - description: 'Returns a string hashed with the given algorithm. Default algorithm is `md5`.', + description: + 'Returns the string hashed with the given algorithm. Defaults to md5 if not specified.', section: 'edit', returnType: 'string', - args: [{ name: 'algo?', type: 'Algorithm' }], + args: [ + { + name: 'algo', + optional: true, + description: + 'The hashing algorithm to use. One of md5, base64, sha1, sha224, sha256, sha384, sha512, sha3, ripemd160\n ', + default: '"md5"', + type: 'string', + }, + ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-hash', + examples: [{ example: '"hello".hash()', evaluated: "'5d41402abc4b2a76b9719d911017c592'" }], }; quote.doc = { name: 'quote', - description: 'Returns a string wrapped in the quotation marks. Default quotation is `"`.', + description: + 'Wraps a string in quotation marks, and escapes any quotation marks already in the string. Useful when constructing JSON, SQL, etc.', section: 'edit', returnType: 'string', - args: [{ name: 'mark?', type: 'string' }], + args: [ + { + name: 'mark', + optional: true, + description: 'The type of quotation mark to use', + default: "'\"'", + type: 'string', + }, + ], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-quote', + examples: [{ example: '\'Nathan says "hi"\'.quote()', evaluated: '\'"Nathan says \\"hi\\""\'' }], }; parseJson.doc = { name: 'parseJson', description: - 'Parses a JSON string, constructing the JavaScript value or object described by the string.', + "Returns the JavaScript value or object represented by the string, or undefined if the string isn't valid JSON. Single-quoted JSON is not supported.", section: 'cast', returnType: 'any', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-parseJson', + examples: [ + { example: '\'{"name":"Nathan"}\'.parseJson()', evaluated: '\'{"name":"Nathan"}\'' }, + { example: "\"{'name':'Nathan'}\".parseJson()", evaluated: 'undefined' }, + { example: "'hello'.parseJson()", evaluated: 'undefined' }, + ], }; base64Encode.doc = { @@ -667,10 +805,18 @@ base64Decode.doc = { 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-base64Decode', }; +toNumber.doc = { + name: 'toNumber', + description: + "Converts a string representing a number to a number. Errors if the string doesn't start with a valid number.", + section: 'cast', + returnType: 'number', + docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toNumber', + examples: [{ example: '"123".toNumber()' }, { example: '123\n"1.23E10".toNumber()' }], +}; + const toDecimalNumber: Extension = toFloat.bind({}); -toDecimalNumber.doc = { ...toFloat.doc, hidden: true }; const toWholeNumber: Extension = toInt.bind({}); -toWholeNumber.doc = { ...toInt.doc, hidden: true }; export const stringExtensions: ExtensionMap = { typeName: 'String', @@ -682,6 +828,7 @@ export const stringExtensions: ExtensionMap = { toDateTime, toBoolean, toDecimalNumber, + toNumber, toFloat, toInt, toWholeNumber, diff --git a/packages/workflow/src/NativeMethods/String.methods.ts b/packages/workflow/src/NativeMethods/String.methods.ts index bc1c143e27bf0..093c381d5a2df 100644 --- a/packages/workflow/src/NativeMethods/String.methods.ts +++ b/packages/workflow/src/NativeMethods/String.methods.ts @@ -98,14 +98,12 @@ export const stringMethods: NativeDoc = { ], examples: [ { - subject: 'Automation', - args: ['Auto'], - evaluated: true, + example: '"Automation".includes("Auto")', + evaluated: 'true', }, { - subject: 'Automation', - args: ['nonexistent'], - evaluated: false, + example: '"Automation".includes("nonexistent")', + evaluated: 'false', }, ], }, From 9e92b668328dfcbd1688446e3787bbd853a05a46 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Wed, 10 Apr 2024 13:52:08 +0200 Subject: [PATCH 07/30] Extension methods tweaks --- .../plugins/codemirror/completions/constants.ts | 1 - .../completions/datatype.completions.ts | 10 +++------- .../luxon.instance.docs.ts | 10 +++++++--- .../workflow/src/Extensions/ArrayExtensions.ts | 7 +++++-- .../workflow/src/Extensions/DateExtensions.ts | 1 + .../workflow/src/Extensions/NumberExtensions.ts | 12 ++++++++++-- .../workflow/src/NativeMethods/Array.methods.ts | 15 +++++++++++++++ .../workflow/src/NativeMethods/Number.methods.ts | 11 ++++++++++- 8 files changed, 51 insertions(+), 16 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/constants.ts b/packages/editor-ui/src/plugins/codemirror/completions/constants.ts index cb8b3aeb3fadd..21b8287d18963 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/constants.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/constants.ts @@ -143,7 +143,6 @@ export const STRING_RECOMMENDED_OPTIONS = [ 'length', ]; -export const DATE_RECOMMENDED_OPTIONS = ['format()', 'minus()', 'plus()', 'extract()']; export const LUXON_RECOMMENDED_OPTIONS = ['format()', 'minus()', 'plus()', 'diff()', 'extract()']; export const OBJECT_RECOMMENDED_OPTIONS = ['keys()', 'values()', 'isEmpty()', 'hasField()']; export const ARRAY_RECOMMENDED_OPTIONS = ['length', 'last()', 'includes()', 'map()', 'filter()']; 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 ac6ba66737647..0383a210a153f 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -585,13 +585,9 @@ const numberOptions = (input: AutocompleteInput): Completion[] => { const dateOptions = (input: AutocompleteInput): Completion[] => { const { transformLabel } = input; - return applySections({ - options: sortCompletionsAlpha([ - ...natives({ typeName: 'date', transformLabel }), - ...extensions({ typeName: 'date', includeHidden: true, transformLabel }), - ]), - recommended: DATE_RECOMMENDED_OPTIONS, - }); + return extensions({ typeName: 'date', includeHidden: true, transformLabel }).filter( + (ext) => ext.label === 'toDateTime()', + ); }; const luxonOptions = (input: AutocompleteInput): Completion[] => { diff --git a/packages/editor-ui/src/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.instance.docs.ts b/packages/editor-ui/src/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.instance.docs.ts index 021ebabb70c97..28b8b9ab9ed5e 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.instance.docs.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.instance.docs.ts @@ -202,6 +202,7 @@ export const luxonInstanceDocs: Required = { doc: { name: 'weekYear', section: 'query', + hidden: true, docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeweekyear', returnType: 'number', }, @@ -226,6 +227,7 @@ export const luxonInstanceDocs: Required = { doc: { name: 'zoneName', section: 'query', + hidden: true, docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimezonename', returnType: 'string', }, @@ -270,6 +272,7 @@ export const luxonInstanceDocs: Required = { doc: { name: 'diff', section: 'compare', + hidden: true, docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimediff', returnType: 'Duration', args: [ @@ -297,7 +300,7 @@ export const luxonInstanceDocs: Required = { section: 'edit', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeendof', returnType: 'DateTime', - args: [{ name: 'unit', type: 'string' }], + args: [{ name: 'unit', type: 'string', default: "'month'" }], }, }, equals: { @@ -395,7 +398,7 @@ export const luxonInstanceDocs: Required = { section: 'edit', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimestartof', returnType: 'DateTime', - args: [{ name: 'unit', type: 'string' }], + args: [{ name: 'unit', type: 'string', default: "'month'" }], }, }, toBSON: { @@ -488,7 +491,7 @@ export const luxonInstanceDocs: Required = { toLocal: { doc: { name: 'toLocal', - section: 'format', + section: 'edit', docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimetolocal', returnType: 'DateTime', }, @@ -633,6 +636,7 @@ export const luxonInstanceDocs: Required = { doc: { name: 'until', section: 'compare', + hidden: true, docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimeuntil', returnType: 'Interval', args: [{ name: 'other', type: 'DateTime' }], diff --git a/packages/workflow/src/Extensions/ArrayExtensions.ts b/packages/workflow/src/Extensions/ArrayExtensions.ts index 23b2ca33c1f39..8ed8a1a525aaf 100644 --- a/packages/workflow/src/Extensions/ArrayExtensions.ts +++ b/packages/workflow/src/Extensions/ArrayExtensions.ts @@ -1,6 +1,6 @@ import { ExpressionError } from '../errors/expression.error'; import { ExpressionExtensionError } from '../errors/expression-extension.error'; -import type { ExtensionMap } from './Extensions'; +import type { Extension, ExtensionMap } from './Extensions'; import { compact as oCompact } from './ObjectExtensions'; import deepEqual from 'deep-equal'; @@ -511,10 +511,13 @@ toJsonString.doc = { returnType: 'string', }; +const removeDuplicates: Extension = unique.bind({}); +removeDuplicates.doc = { ...unique.doc, hidden: true }; + export const arrayExtensions: ExtensionMap = { typeName: 'Array', functions: { - removeDuplicates: unique, + removeDuplicates, unique, first, last, diff --git a/packages/workflow/src/Extensions/DateExtensions.ts b/packages/workflow/src/Extensions/DateExtensions.ts index 371ce02b3f6ab..6a1d81ea81272 100644 --- a/packages/workflow/src/Extensions/DateExtensions.ts +++ b/packages/workflow/src/Extensions/DateExtensions.ts @@ -304,6 +304,7 @@ isBetween.doc = { isInLast.doc = { name: 'isInLast', + hidden: true, description: 'Checks if a Date is within a given time period. Default unit is `minute`.', section: 'query', returnType: 'boolean', diff --git a/packages/workflow/src/Extensions/NumberExtensions.ts b/packages/workflow/src/Extensions/NumberExtensions.ts index caac368c64978..bd937bcc53065 100644 --- a/packages/workflow/src/Extensions/NumberExtensions.ts +++ b/packages/workflow/src/Extensions/NumberExtensions.ts @@ -53,10 +53,16 @@ function toFloat(value: number) { return value; } -type DateTimeFormat = 'ms' | 's' | 'excel'; +type DateTimeFormat = 'ms' | 's' | 'us' | 'excel'; function toDateTime(value: number, extraArgs: [DateTimeFormat]) { const [valueFormat = 'ms'] = extraArgs; + if (!['ms', 's', 'us', 'excel'].includes(valueFormat)) { + throw new ExpressionExtensionError( + `Unsupported format '${String(valueFormat)}'. toDateTime() supports 'ms', 's', 'us' and 'excel'.`, + ); + } + switch (valueFormat) { // Excel format is days since 1900 // There is a bug where 1900 is incorrectly treated as a leap year @@ -70,6 +76,8 @@ function toDateTime(value: number, extraArgs: [DateTimeFormat]) { } case 's': return DateTime.fromSeconds(value); + case 'us': + return DateTime.fromMillis(value / 1000); case 'ms': default: return DateTime.fromMillis(value); @@ -137,7 +145,7 @@ toBoolean.doc = { toDateTime.doc = { name: 'toDateTime', description: - "Converts a number to a DateTime. Defaults to milliseconds. Format can be 'ms' (milliseconds), 's' (seconds) or 'excel' (Excel 1900 format).", + "Converts a number to a DateTime. Defaults to milliseconds. Format can be 'ms' (milliseconds), 's' (seconds), 'us' (microseconds) or 'excel' (Excel 1900 format).", section: 'cast', returnType: 'DateTime', args: [{ name: 'format?', type: 'string' }], diff --git a/packages/workflow/src/NativeMethods/Array.methods.ts b/packages/workflow/src/NativeMethods/Array.methods.ts index 11687d6d62d1b..2fe75ee379870 100644 --- a/packages/workflow/src/NativeMethods/Array.methods.ts +++ b/packages/workflow/src/NativeMethods/Array.methods.ts @@ -53,6 +53,7 @@ export const arrayMethods: NativeDoc = { findIndex: { doc: { name: 'findIndex', + hidden: true, description: 'Returns the index of the first element in an array that passes the test `fn`. If none are found, -1 is returned.', docURL: @@ -64,6 +65,7 @@ export const arrayMethods: NativeDoc = { findLast: { doc: { name: 'findLast', + hidden: true, description: 'Returns the value of the last element that passes the test `fn`.', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/findLast', @@ -74,6 +76,7 @@ export const arrayMethods: NativeDoc = { findLastIndex: { doc: { name: 'findLastIndex', + hidden: true, description: 'Returns the index of the last element that satisfies the provided testing function. If none are found, -1 is returned.', docURL: @@ -183,6 +186,7 @@ export const arrayMethods: NativeDoc = { docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice', returnType: 'Array', + hidden: true, args: [ { name: 'start', type: 'number' }, { name: 'deleteCount?', type: 'number' }, @@ -195,11 +199,22 @@ export const arrayMethods: NativeDoc = { toString: { doc: { name: 'toString', + hidden: true, description: 'Returns a string representing the specified array and its elements.', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toString', returnType: 'string', }, }, + toSpliced: { + doc: { + name: 'toSpliced', + description: + 'Returns a new array with some elements removed and/or replaced at a given index. toSpliced() is the copying version of the splice() method', + docURL: + 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toSpliced', + returnType: 'Array', + }, + }, }, }; diff --git a/packages/workflow/src/NativeMethods/Number.methods.ts b/packages/workflow/src/NativeMethods/Number.methods.ts index 051643c569a02..13b418962de54 100644 --- a/packages/workflow/src/NativeMethods/Number.methods.ts +++ b/packages/workflow/src/NativeMethods/Number.methods.ts @@ -29,11 +29,20 @@ export const numberMethods: NativeDoc = { toString: { doc: { name: 'toString', - description: 'returns a string representing this number value.', + description: 'Returns a string representing this number value.', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toString', returnType: 'string', }, }, + toLocaleString: { + doc: { + name: 'toLocaleString', + description: 'Returns a string with a language-sensitive representation of this number.', + docURL: + 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString', + returnType: 'string', + }, + }, }, }; From f9920d6da450d08ad550c560728cdd8db28ee5e2 Mon Sep 17 00:00:00 2001 From: Giulio Andreini Date: Fri, 19 Apr 2024 11:51:59 +0200 Subject: [PATCH 08/30] Infobox styling. --- .../src/styles/plugins/_codemirror.scss | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/editor-ui/src/styles/plugins/_codemirror.scss b/packages/editor-ui/src/styles/plugins/_codemirror.scss index 96a6d52821fca..0783966919b4c 100644 --- a/packages/editor-ui/src/styles/plugins/_codemirror.scss +++ b/packages/editor-ui/src/styles/plugins/_codemirror.scss @@ -94,7 +94,7 @@ display: flex; flex-direction: column; gap: var(--spacing-s); - padding: var(--spacing-xs) var(--spacing-xs); + padding: var(--spacing-xs) 0; } .ͼ2 .cm-tooltip.cm-completionInfo { @@ -118,6 +118,7 @@ z-index: -1; .autocomplete-info-header { + padding: 0 var(--spacing-xs); color: var(--color-text-dark); font-size: var(--font-size-xs); font-weight: var(--font-weight-bold); @@ -136,6 +137,7 @@ } .autocomplete-info-description { + padding: 0 var(--spacing-xs); color: var(--color-text-dark); font-size: var(--font-size-2xs); @@ -160,6 +162,7 @@ } .autocomplete-info-args { + padding: 0 var(--spacing-xs); list-style: none; li { @@ -201,23 +204,35 @@ code { background: inherit; } + + .autocomplete-info-example { + padding: 0 var(--spacing-xs); + } } .autocomplete-info-example code { + display: block; font-size: var(--font-size-3xs); font-family: var(--font-family-monospace); + line-height: var(--font-line-height-compact); + + word-break: break-all; + white-space: pre-wrap; + word-wrap: break-word; } .autocomplete-info-example + .autocomplete-info-example { - margin-top: var(--spacing-2xs); + margin-top: var(--spacing-4xs); } .autocomplete-info-section-title { + margin-bottom: var(--spacing-3xs); + padding: 0 var(--spacing-xs) var(--spacing-3xs) var(--spacing-xs); + border-bottom: 1px solid var(--color-autocomplete-section-header-border); text-transform: uppercase; color: var(--color-text-dark); font-size: var(--font-size-3xs); font-weight: var(--font-weight-bold); - margin-bottom: var(--spacing-4xs); } .autocomplete-info-examples-list { From a372082e9621196215ab90cdac668c19a664c6b2 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Fri, 19 Apr 2024 17:00:20 +0200 Subject: [PATCH 09/30] Add all String examples/descriptions --- .../codemirror/completions/infoBoxRenderer.ts | 18 +- .../src/styles/plugins/_codemirror.scss | 6 +- .../workflow/src/Extensions/Extensions.ts | 1 + .../src/Extensions/StringExtensions.ts | 15 +- .../src/NativeMethods/String.methods.ts | 387 +++++++++++++++--- 5 files changed, 370 insertions(+), 57 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/infoBoxRenderer.ts b/packages/editor-ui/src/plugins/codemirror/completions/infoBoxRenderer.ts index cdcefde3af1fb..b7169ca7a04c3 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/infoBoxRenderer.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/infoBoxRenderer.ts @@ -141,13 +141,21 @@ const renderExample = (example: DocMetadataExample): HTMLElement => { const exampleCode = document.createElement('code'); examplePre.appendChild(exampleCode); + if (example.description) { + const exampleDescription = document.createElement('span'); + exampleDescription.classList.add('autocomplete-info-example-comment'); + exampleDescription.textContent = `// ${example.description}\n`; + exampleCode.appendChild(exampleDescription); + } + const exampleExpression = document.createElement('span'); exampleExpression.classList.add('autocomplete-info-example-expr'); exampleExpression.textContent = example.example + '\n'; exampleCode.appendChild(exampleExpression); - if (example.evaluated !== undefined) { + if (example.evaluated) { const exampleEvaluated = document.createElement('span'); + exampleEvaluated.classList.add('autocomplete-info-example-comment'); exampleEvaluated.textContent = `// => ${example.evaluated}\n`; exampleCode.appendChild(exampleEvaluated); } @@ -181,6 +189,8 @@ export const createInfoBoxRenderer = if (!doc) return null; const { examples, args } = doc; + const hasArgs = args && args.length > 0; + const hasExamples = examples && examples.length > 0; const header = isFunction ? renderFunctionHeader(doc) : renderPropHeader(doc); header.classList.add('autocomplete-info-header'); @@ -190,17 +200,17 @@ export const createInfoBoxRenderer = const descriptionBody = renderDescription({ description: doc.description, docUrl: doc.docURL, - example: doc.args && doc.args.length > 0 ? doc.examples?.[0] : undefined, + example: hasArgs && hasExamples ? examples[0] : undefined, }); tooltipContainer.appendChild(descriptionBody); } - if (args && args.length > 0) { + if (hasArgs) { const argsContainer = renderArgs(args); tooltipContainer.appendChild(argsContainer); } - if (examples && examples.length > 0) { + if (hasExamples && (examples.length > 1 || !hasArgs)) { const examplesContainer = renderExamples(examples); tooltipContainer.appendChild(examplesContainer); } diff --git a/packages/editor-ui/src/styles/plugins/_codemirror.scss b/packages/editor-ui/src/styles/plugins/_codemirror.scss index 0783966919b4c..9fdd4f0fc3431 100644 --- a/packages/editor-ui/src/styles/plugins/_codemirror.scss +++ b/packages/editor-ui/src/styles/plugins/_codemirror.scss @@ -210,13 +210,17 @@ } } + .autocomplete-info-example-comment { + color: var(--color-text-light); + } + .autocomplete-info-example code { display: block; font-size: var(--font-size-3xs); font-family: var(--font-family-monospace); line-height: var(--font-line-height-compact); - word-break: break-all; + word-break: break-word; white-space: pre-wrap; word-wrap: break-word; } diff --git a/packages/workflow/src/Extensions/Extensions.ts b/packages/workflow/src/Extensions/Extensions.ts index b54e9e41046cd..d4a206589bfba 100644 --- a/packages/workflow/src/Extensions/Extensions.ts +++ b/packages/workflow/src/Extensions/Extensions.ts @@ -22,6 +22,7 @@ export type DocMetadataArgument = { export type DocMetadataExample = { example: string; evaluated?: string; + description?: string; }; export type DocMetadata = { diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 56371423427a5..b2e30da3a27cc 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -461,7 +461,7 @@ toDateTime.doc = { toBoolean.doc = { name: 'toBoolean', description: - 'Converts the string to a boolean value. 0, "false" and "no" resolve to false, everything else to true.', + 'Converts the string to a boolean value. 0, false and no resolve to false, everything else to true. Case-insensitive.', section: 'cast', returnType: 'boolean', docURL: @@ -521,7 +521,7 @@ toSnakeCase.doc = { toTitleCase.doc = { name: 'toTitleCase', description: - 'Changes the capitalization of the string to title case. The first letter of each word is capitalized and the others left unchanged. Short prepositions and conjunctions aren’t capitalized (e.g. ‘a’, ‘the’).', + "Changes the capitalization of the string to title case. The first letter of each word is capitalized and the others left unchanged. Short prepositions and conjunctions aren't capitalized (e.g. 'a', 'the').", examples: [{ example: '"quick a brown FOX".toTitleCase()', evaluated: '"Quick a Brown Fox"' }], section: 'case', returnType: 'string', @@ -789,7 +789,8 @@ parseJson.doc = { base64Encode.doc = { name: 'base64Encode', - description: 'Converts a UTF-8-encoded string to a Base64 string.', + description: 'Converts plain text to a base64-encoded string', + examples: [{ example: '"hello".base64Encode()', evaluated: '"aGVsbG8="' }], section: 'edit', returnType: 'string', docURL: @@ -798,7 +799,8 @@ base64Encode.doc = { base64Decode.doc = { name: 'base64Decode', - description: 'Converts a Base64 string to a UTF-8 string.', + description: 'Converts a base64-encoded string to plain text', + examples: [{ example: '"aGVsbG8=".base64Decode()', evaluated: '"hello"' }], section: 'edit', returnType: 'string', docURL: @@ -812,7 +814,10 @@ toNumber.doc = { section: 'cast', returnType: 'number', docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toNumber', - examples: [{ example: '"123".toNumber()' }, { example: '123\n"1.23E10".toNumber()' }], + examples: [ + { example: '"123".toNumber()', evaluated: '123' }, + { example: '"1.23E10".toNumber()', evaluated: '12300000000' }, + ], }; const toDecimalNumber: Extension = toFloat.bind({}); diff --git a/packages/workflow/src/NativeMethods/String.methods.ts b/packages/workflow/src/NativeMethods/String.methods.ts index 093c381d5a2df..9bd25098ddbd8 100644 --- a/packages/workflow/src/NativeMethods/String.methods.ts +++ b/packages/workflow/src/NativeMethods/String.methods.ts @@ -6,7 +6,8 @@ export const stringMethods: NativeDoc = { length: { doc: { name: 'length', - description: 'Returns the number of characters in the string.', + description: 'The number of characters in the string', + examples: [{ example: '"hello".length', evaluated: '5' }], section: 'query', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length', @@ -18,67 +19,190 @@ export const stringMethods: NativeDoc = { concat: { doc: { name: 'concat', - description: 'Concatenates the string arguments to the calling string.', + description: + 'Joins one or more strings onto the end of the base string. Alternatively, use the + operator (see examples).', + examples: [ + { example: "'sea'.concat('food')", evaluated: "'seafood'" }, + { example: "'sea' + 'food'", evaluated: "'seafood'" }, + { example: "'work'.concat('a', 'holic')", evaluated: "'workaholic'" }, + ], section: 'edit', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/concat', + args: [ + { + name: 'string1', + optional: false, + description: 'The first string to append', + type: 'string', + }, + { + name: 'string2', + optional: true, + description: 'The second string to append', + type: 'string', + }, + { + name: 'stringN', + optional: true, + description: 'The Nth string to append', + type: 'string', + }, + ], returnType: 'string', }, }, endsWith: { doc: { name: 'endsWith', - description: 'Checks if a string ends with `searchString`.', + description: + 'Returns true if the string ends with searchString. Case-sensitive.', + examples: [ + { example: "'team'.endsWith('eam')", evaluated: 'true' }, + { example: "'team'.endsWith('Eam')", evaluated: 'false' }, + { + example: "'teaM'.toLowerCase().endsWith('eam')", + evaluated: 'true', + description: + "Returns false if the case doesn't match, so consider using .toLowerCase() first", + }, + ], section: 'query', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith', returnType: 'boolean', - args: [{ name: 'searchString', type: 'string' }], + args: [ + { + name: 'searchString', + optional: false, + description: 'The text to check against the end of the base string', + type: 'string', + }, + { + name: 'end', + optional: true, + description: 'The end position (index) to start searching from', + type: 'number', + }, + ], }, }, indexOf: { doc: { name: 'indexOf', - description: 'Returns the index of the first occurrence of `searchString`.', + description: + 'Returns the index (position) of the first occurrence of searchString within the base string, or -1 if not found. Case-sensitive.', + examples: [ + { example: "'steam'.indexOf('tea')", evaluated: '1' }, + { example: "'steam'.indexOf('i')", evaluated: '-1' }, + { + example: "'STEAM'.indexOf('tea')", + evaluated: '-1', + description: + "Returns -1 if the case doesn't match, so consider using .toLowerCase() first", + }, + { example: "'STEAM'.toLowerCase().indexOf('tea')", evaluated: '1' }, + ], section: 'query', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/indexOf', returnType: 'number', args: [ - { name: 'searchString', type: 'string' }, - { name: 'position?', type: 'number' }, + { + name: 'searchString', + optional: false, + description: 'The text to search for', + type: 'string', + }, + { + name: 'start', + optional: true, + description: 'The position (index) to start searching from', + default: '0', + type: 'number', + }, ], }, }, lastIndexOf: { doc: { name: 'lastIndexOf', - description: 'Returns the index of the last occurrence of `searchString`.', + description: + 'Returns the index (position) of the last occurrence of searchString within the base string, or -1 if not found. Case-sensitive.', + examples: [ + { example: "'canal'.lastIndexOf('a')", evaluated: '3' }, + { example: "'canal'.lastIndexOf('i')", evaluated: '-1' }, + { + example: "'CANAL'.lastIndexOf('a')", + evaluated: '-1', + description: + "Returns -1 if the case doesn't match, so consider using .toLowerCase() first", + }, + { example: "'CANAL'.toLowerCase().lastIndexOf('a')", evaluated: '3' }, + ], section: 'query', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/lastIndexOf', returnType: 'number', args: [ - { name: 'searchString', type: 'string' }, - { name: 'position?', type: 'number' }, + { + name: 'searchString', + optional: false, + description: 'The text to search for', + type: 'string', + }, + { + name: 'end', + optional: true, + description: 'The position (index) to stop searching at', + default: '0', + type: 'number', + }, ], }, }, match: { doc: { name: 'match', - description: 'Retrieves the result of matching a string against a regular expression.', + description: + 'Matches the string against a regular expression. Returns an array containing the first match, or all matches if the g flag is set in the regular expression. Returns null if no matches are found. \n\nFor checking whether text is present, consider includes() instead.', + examples: [ + { + example: '"rock and roll".match(/r[^ ]*/g)', + evaluated: "['rock', 'roll']", + description: "Match all words starting with 'r'", + }, + { + example: '"rock and roll".match(/r[^ ]*/)', + evaluated: "['rock']", + description: "Match first word starting with 'r' (no 'g' flag)", + }, + { + example: '"ROCK and roll".match(/r[^ ]*/ig)', + evaluated: "['ROCK', 'roll']", + description: "For case-insensitive, add 'i' flag", + }, + ], section: 'query', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match', returnType: 'Array', - args: [{ name: 'regexp', type: 'string|RegExp' }], + args: [ + { + name: 'regexp', + optional: false, + description: + 'A regular expression with the pattern to look for. Will look for multiple matches if the g flag is present (see examples).', + type: 'RegExp', + }, + ], }, }, includes: { doc: { name: 'includes', - description: 'Checks if `searchString` may be found within the calling string.', + description: + 'Returns true if the string contains the searchString. Case-sensitive.', section: 'query', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/includes', @@ -86,25 +210,28 @@ export const stringMethods: NativeDoc = { args: [ { name: 'searchString', + optional: false, + description: 'The text to search for', type: 'string', - description: 'A string to be searched for. Cannot be a regex.', }, { - name: 'position?', + name: 'start', + optional: true, + description: 'The position (index) to start searching from', + default: '0', type: 'number', - description: - 'The position within the string at which to begin searching for `searchString`. (Defaults to `0`)', }, ], examples: [ + { example: "'team'.includes('tea')", evaluated: 'true' }, + { example: "'team'.includes('i')", evaluated: 'false' }, { - example: '"Automation".includes("Auto")', - evaluated: 'true', - }, - { - example: '"Automation".includes("nonexistent")', + example: "'team'.includes('Tea')", evaluated: 'false', + description: + "Returns false if the case doesn't match, so consider using .toLowerCase() first", }, + { example: "'Team'.toLowerCase().includes('tea')", evaluated: 'true' }, ], }, }, @@ -112,54 +239,153 @@ export const stringMethods: NativeDoc = { doc: { name: 'replace', description: - 'Returns a string with matches of a `pattern` replaced by a `replacement`. If `pattern` is a string, only the first occurrence will be replaced.', + 'Returns a string with the first occurrence of pattern replaced by replacement. \n\nTo replace all occurrences, use replaceAll() instead.', section: 'edit', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace', returnType: 'string', args: [ - { name: 'pattern', type: 'string|RegExp' }, - { name: 'replacement', type: 'string' }, + { + name: 'pattern', + optional: false, + description: + 'The pattern in the string to replace. Can be a string to match or a regular expression.', + type: 'string|RegExp', + }, + { + name: 'replacement', + optional: false, + description: 'The new text to replace with', + type: 'string', + }, + ], + examples: [ + { + example: "'Red or blue or green'.replace('or', 'and')", + evaluated: "'Red and blue or green'", + }, + { + example: + 'let text = "Mr Blue has a blue house and a blue car";\ntext.replace(/blue/gi, "red");', + evaluated: "'Mr red has a red house and a red car'", + description: 'A global, case-insensitive replacement:', + }, + { + example: + 'let text = "Mr Blue has a blue house and a blue car";\ntext.replace(/blue|house|car/gi, (t) => t.toUpperCase());', + evaluated: "'Mr BLUE has a BLUE HOUSE and a BLUE CAR'", + description: 'A function to return the replacement text:', + }, ], }, }, replaceAll: { doc: { name: 'replaceAll', - description: 'Returns a string with matches of a `pattern` replaced by a `replacement`.', + description: + 'Returns a string with all occurrences of pattern replaced by replacement', + examples: [ + { + example: "'Red or blue or green'.replaceAll('or', 'and')", + evaluated: "'Red and blue and green'", + }, + { + example: + "text = 'Mr Blue has a blue car';\ntext.replaceAll(/blue|car/gi, t => t.toUpperCase())", + description: + "Uppercase any occurrences of 'blue' or 'car' (You must include the 'g' flag when using a regex)", + evaluated: "'Mr BLUE has a BLUE CAR'", + }, + { + example: 'text.replaceAll(/blue|car/gi, function(x){return x.toUpperCase()})', + evaluated: "'Mr BLUE has a BLUE CAR'", + description: 'Or with traditional function notation:', + }, + ], section: 'edit', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replaceAll', returnType: 'string', args: [ - { name: 'pattern', type: 'string|RegExp' }, - { name: 'replacement', type: 'string' }, + { + name: 'pattern', + optional: false, + description: + 'The pattern in the string to replace. Can be a string to match or a regular expression.', + type: 'string|RegExp', + }, + { + name: 'replacement', + optional: false, + description: + 'The new text to replace with. Can be a string or a function that returns a string (see examples).', + type: 'string|Function', + }, ], }, }, search: { doc: { name: 'search', - description: 'Returns a string that matches `pattern` within the given string.', + description: + 'Returns the index (position) of the first occurrence of a pattern within the string, or -1 if not found. The pattern is specified using a regular expression. To use text instead, see indexOf().', + examples: [ + { + example: '"Neat n8n node".search(/n[^ ]*/)', + evaluated: '5', + description: "Pos of first word starting with 'n'", + }, + { + example: '"Neat n8n node".search(/n[^ ]*/i)', + evaluated: '0', + description: + "Case-insensitive match with 'i'\nPos of first word starting with 'n' or 'N'", + }, + ], section: 'query', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/search', returnType: 'string', - args: [{ name: 'pattern', type: 'string|RegExp' }], + args: [ + { + name: 'regexp', + optional: false, + description: + 'A regular expression with the pattern to look for', + type: 'RegExp', + }, + ], }, }, slice: { doc: { name: 'slice', description: - 'Returns a section of a string. `indexEnd` defaults to the length of the string if not given.', + 'Extracts a fragment of the string at the given position. For more advanced extraction, see match().', + examples: [ + { example: "'Hello from n8n'.slice(0, 5)", evaluated: "'Hello'" }, + { example: "'Hello from n8n'.slice(6)", evaluated: "'from n8n'" }, + { example: "'Hello from n8n'.slice(-3)", evaluated: "'n8n'" }, + ], section: 'edit', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/slice', returnType: 'string', args: [ - { name: 'indexStart', type: 'number' }, - { name: 'indexEnd?', type: 'number' }, + { + name: 'start', + optional: false, + description: + 'The position to start from. Positions start at 0. Negative numbers count back from the end of the string.', + type: 'number', + }, + { + name: 'end', + optional: true, + description: + 'The position to select up to. The character at the end position is not included. Negative numbers select from the end of the string. If omitted, will extract to the end of the string.', + type: 'string', + }, ], }, }, @@ -167,28 +393,72 @@ export const stringMethods: NativeDoc = { doc: { name: 'split', description: - 'Returns the substrings that result from dividing the given string with `separator`.', + "Splits the string into an array of substrings. Each split is made at the separator, and the separator isn't included in the output. \n\nThe opposite of using join() on an array.", + examples: [ + { example: '"wind,fire,water".split(",")', evaluated: "['wind', 'fire', 'water']" }, + { example: '"me and you and her".split("and")', evaluated: "['me ', ' you ', ' her']" }, + { + example: '"me? you, and her".split(/[ ,?]+/)', + evaluated: "['me', 'you', 'and', 'her']", + description: "Split one or more of space, comma and '?' using a regular expression", + }, + ], section: 'edit', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/split', returnType: 'Array', args: [ - { name: 'separator', type: 'string|RegExp' }, - { name: 'limit?', type: 'number' }, + { + name: 'separator', + optional: true, + description: + 'The string (or regular expression) to use for splitting. If omitted, an array with the original string is returned.', + default: '""', + type: 'string', + }, + { + name: 'limit', + optional: true, + description: + 'The max number of array elements to return. Returns all elements if omitted.', + type: 'number', + }, ], }, }, startsWith: { doc: { name: 'startsWith', - description: 'Checks if the string begins with `searchString`.', + description: + 'Returns true if the string starts with searchString. Case-sensitive.', + examples: [ + { example: "'team'.startsWith('tea')", evaluated: 'true' }, + { example: "'team'.startsWith('Tea')", evaluated: 'false' }, + { + example: "'Team'.toLowerCase().startsWith('tea')", + evaluated: 'true', + description: + "Returns false if the case doesn't match, so consider using .toLowerCase() first", + }, + ], section: 'query', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith', returnType: 'boolean', args: [ - { name: 'searchString', type: 'string' }, - { name: 'position?', type: 'number' }, + { + name: 'searchString', + optional: false, + description: 'The text to check against the start of the base string', + type: 'string', + }, + { + name: 'start', + optional: true, + description: 'The position (index) to start searching from', + default: '0', + type: 'number', + }, ], }, }, @@ -196,31 +466,48 @@ export const stringMethods: NativeDoc = { doc: { name: 'substring', description: - 'Returns the part of the string from the start index up to and excluding the end index, or to the end of the string if no end index is supplied.', + 'Extracts a fragment of the string at the given position. For more advanced extraction, see match().', + examples: [ + { example: "'Hello from n8n'.substring(0, 5)", evaluated: "'Hello'" }, + { example: "'Hello from n8n'.substring(6)", evaluated: "'from n8n'" }, + ], section: 'edit', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/substring', returnType: 'string', args: [ - { name: 'indexStart', type: 'number' }, - { name: 'indexEnd?', type: 'number' }, + { + name: 'start', + optional: false, + description: 'The position to start from. Positions start at 0.', + type: 'number', + }, + { + name: 'end', + optional: true, + description: + 'The position to select up to. The character at the end position is not included. If omitted, will extract to the end of the string.', + type: 'string', + }, ], }, }, toLowerCase: { doc: { name: 'toLowerCase', - description: 'Formats a string to lowercase. Example: "this is lowercase”.', + description: 'Converts all letters in the string to lower case', section: 'case', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toLowerCase', returnType: 'string', + examples: [{ example: '"I\'m SHOUTing".toLowerCase()', evaluated: '"i\'m shouting"' }], }, }, toUpperCase: { doc: { name: 'toUpperCase', - description: 'Formats a string to lowercase. Example: "THIS IS UPPERCASE”.', + description: 'Converts all letters in the string to upper case (capitals)', + examples: [{ example: '"I\'m not angry".toUpperCase()', evaluated: '"I\'M NOT ANGRY"' }], section: 'case', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toUpperCase', @@ -230,7 +517,9 @@ export const stringMethods: NativeDoc = { trim: { doc: { name: 'trim', - description: 'Removes whitespace from both ends of a string and returns a new string.', + description: + 'Removes whitespace from both ends of the string. Whitespace includes new lines, tabs, spaces, etc.', + examples: [{ example: "' lonely '.trim()", evaluated: "'lonely'" }], section: 'edit', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/Trim', @@ -240,7 +529,9 @@ export const stringMethods: NativeDoc = { trimEnd: { doc: { name: 'trimEnd', - description: 'Removes whitespace from the end of a string and returns a new string.', + description: + 'Removes whitespace from the end of a string and returns a new string. Whitespace includes new lines, tabs, spaces, etc.', + examples: [{ example: "' lonely '.trimEnd()", evaluated: "' lonely'" }], section: 'edit', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trimEnd', @@ -250,7 +541,9 @@ export const stringMethods: NativeDoc = { trimStart: { doc: { name: 'trimStart', - description: 'Removes whitespace from the beginning of a string and returns a new string.', + description: + 'Removes whitespace from the beginning of a string and returns a new string. Whitespace includes new lines, tabs, spaces, etc.', + examples: [{ example: "' lonely '.trimStart()", evaluated: "'lonely '" }], section: 'edit', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trimStart', From e38663a83836347763af03a5124b7fbccf7ef75c Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Fri, 19 Apr 2024 17:01:24 +0200 Subject: [PATCH 10/30] Re-enable closeOnBlur --- packages/editor-ui/src/plugins/codemirror/n8nLang.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts index 0e00cc7cb45f7..8bce81bb703de 100644 --- a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts +++ b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts @@ -25,4 +25,4 @@ export function n8nLang() { ]); } -export const n8nAutocompletion = () => autocompletion({ icons: false, closeOnBlur: false }); +export const n8nAutocompletion = () => autocompletion({ icons: false }); From 7a64761b7c31dd3c516e65a4217786f3f35ea984 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Fri, 19 Apr 2024 17:10:55 +0200 Subject: [PATCH 11/30] Add test for microseconds toDateTime --- .../test/ExpressionExtensions/NumberExtensions.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts index faaeeadcf08de..1f41e853a1028 100644 --- a/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts @@ -77,6 +77,12 @@ describe('Data Transformation Functions', () => { '2015-05-19T20:00:00.000-04:00', ); }); + + test('from microseconds', () => { + expect(evaluate('={{ (1704085200000000).toDateTime("us").toISO() }}')).toEqual( + '2024-01-01T00:00:00.000-05:00', + ); + }); }); describe('toInt', () => { From 20332e042b9fcdc5013600e3d956618fe4475b19 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Mon, 22 Apr 2024 16:05:43 +0200 Subject: [PATCH 12/30] Fix tests --- .../completions/__tests__/completions.test.ts | 116 +++++++++++------- .../completions/datatype.completions.ts | 42 ++----- .../luxon.instance.docs.ts | 1 - 3 files changed, 87 insertions(+), 72 deletions(-) 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 28ff31972fde3..32dd764105f43 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 @@ -130,8 +130,10 @@ describe('Luxon method completions', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now()); expect(completions('{{ $now.| }}')).toHaveLength( - uniqBy(luxonInstanceOptions().concat(extensions('date')), (option) => option.label).length + - LUXON_RECOMMENDED_OPTIONS.length, + uniqBy( + luxonInstanceOptions().concat(extensions({ typeName: 'date' })), + (option) => option.label, + ).length + LUXON_RECOMMENDED_OPTIONS.length, ); }); @@ -140,8 +142,10 @@ describe('Luxon method completions', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now()); expect(completions('{{ $today.| }}')).toHaveLength( - uniqBy(luxonInstanceOptions().concat(extensions('date')), (option) => option.label).length + - LUXON_RECOMMENDED_OPTIONS.length, + uniqBy( + luxonInstanceOptions().concat(extensions({ typeName: 'date' })), + (option) => option.label, + ).length + LUXON_RECOMMENDED_OPTIONS.length, ); }); }); @@ -153,7 +157,9 @@ describe('Resolution-based completions', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('abc'); expect(completions('{{ "abc".| }}')).toHaveLength( - natives('string').length + extensions('string').length + STRING_RECOMMENDED_OPTIONS.length, + natives({ typeName: 'string' }).length + + extensions({ typeName: 'string' }).length + + STRING_RECOMMENDED_OPTIONS.length, ); }); @@ -163,7 +169,9 @@ describe('Resolution-based completions', () => { const result = completions('{{ "You \'owe\' me 200$".| }}'); - expect(result).toHaveLength(natives('string').length + extensions('string').length + 1); + expect(result).toHaveLength( + natives({ typeName: 'string' }).length + extensions({ typeName: 'string' }).length + 1, + ); }); test('should return completions for number literal: {{ (123).| }}', () => { @@ -171,7 +179,9 @@ describe('Resolution-based completions', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(123); expect(completions('{{ (123).| }}')).toHaveLength( - natives('number').length + extensions('number').length + ['isEven()', 'isOdd()'].length, + natives({ typeName: 'number' }).length + + extensions({ typeName: 'number' }).length + + ['isEven()', 'isOdd()'].length, ); }); @@ -180,7 +190,7 @@ describe('Resolution-based completions', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce([1, 2, 3]); expect(completions('{{ [1, 2, 3].| }}')).toHaveLength( - natives('array').length + extensions('array').length, + natives({ typeName: 'array' }).length + extensions({ typeName: 'array' }).length, ); }); @@ -192,7 +202,9 @@ describe('Resolution-based completions', () => { if (!found) throw new Error('Expected to find completion'); - expect(found).toHaveLength(natives('array').length + extensions('array').length); + expect(found).toHaveLength( + natives({ typeName: 'array' }).length + extensions({ typeName: 'array' }).length, + ); }); test('should return completions for object literal', () => { @@ -201,7 +213,7 @@ describe('Resolution-based completions', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(object); expect(completions('{{ ({ a: 1 }).| }}')).toHaveLength( - Object.keys(object).length + extensions('object').length, + Object.keys(object).length + extensions({ typeName: 'object' }).length, ); }); }); @@ -212,7 +224,9 @@ describe('Resolution-based completions', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('a'); expect(completions('{{ "abc"[0].| }}')).toHaveLength( - natives('string').length + extensions('string').length + STRING_RECOMMENDED_OPTIONS.length, + natives({ typeName: 'string' }).length + + extensions({ typeName: 'string' }).length + + STRING_RECOMMENDED_OPTIONS.length, ); }); }); @@ -225,7 +239,9 @@ describe('Resolution-based completions', () => { const found = completions('{{ Math.abs($input.item.json.num1).| }}'); if (!found) throw new Error('Expected to find completions'); expect(found).toHaveLength( - extensions('number').length + natives('number').length + ['isEven()', 'isOdd()'].length, + extensions({ typeName: 'number' }).length + + natives({ typeName: 'number' }).length + + ['isEven()', 'isOdd()'].length, ); }); @@ -240,8 +256,10 @@ describe('Resolution-based completions', () => { test('should return completions for complex expression: {{ $now.diff($now.diff($now.|)) }}', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(DateTime.now()); expect(completions('{{ $now.diff($now.diff($now.|)) }}')).toHaveLength( - uniqBy(luxonInstanceOptions().concat(extensions('date')), (option) => option.label).length + - LUXON_RECOMMENDED_OPTIONS.length, + uniqBy( + luxonInstanceOptions().concat(extensions({ typeName: 'date' })), + (option) => option.label, + ).length + LUXON_RECOMMENDED_OPTIONS.length, ); }); @@ -251,7 +269,9 @@ describe('Resolution-based completions', () => { const found = completions('{{ $execution.resumeUrl.includes($json.|) }}'); if (!found) throw new Error('Expected to find completions'); - expect(found).toHaveLength(Object.keys($json).length + extensions('object').length); + expect(found).toHaveLength( + Object.keys($json).length + extensions({ typeName: 'object' }).length, + ); }); test('should return completions for operation expression: {{ $now.day + $json. }}', () => { @@ -261,7 +281,9 @@ describe('Resolution-based completions', () => { if (!found) throw new Error('Expected to find completions'); - expect(found).toHaveLength(Object.keys($json).length + extensions('object').length); + expect(found).toHaveLength( + Object.keys($json).length + extensions({ typeName: 'object' }).length, + ); }); test('should return completions for operation expression: {{ Math.abs($now.day) >= 10 ? $now : Math.abs($json.). }}', () => { @@ -271,7 +293,9 @@ describe('Resolution-based completions', () => { if (!found) throw new Error('Expected to find completions'); - expect(found).toHaveLength(Object.keys($json).length + extensions('object').length); + expect(found).toHaveLength( + Object.keys($json).length + extensions({ typeName: 'object' }).length, + ); }); }); @@ -286,7 +310,9 @@ describe('Resolution-based completions', () => { if (!found) throw new Error('Expected to find completions'); expect(found).toHaveLength( - extensions('string').length + natives('string').length + STRING_RECOMMENDED_OPTIONS.length, + extensions({ typeName: 'string' }).length + + natives({ typeName: 'string' }).length + + STRING_RECOMMENDED_OPTIONS.length, ); expect(found.map((c) => c.label).every((l) => !l.endsWith('()'))); }); @@ -299,7 +325,9 @@ describe('Resolution-based completions', () => { if (!found) throw new Error('Expected to find completions'); expect(found).toHaveLength( - extensions('number').length + natives('number').length + ['isEven()', 'isOdd()'].length, + extensions({ typeName: 'number' }).length + + natives({ typeName: 'number' }).length + + ['isEven()', 'isOdd()'].length, ); expect(found.map((c) => c.label).every((l) => !l.endsWith('()'))); }); @@ -311,7 +339,9 @@ describe('Resolution-based completions', () => { if (!found) throw new Error('Expected to find completions'); - expect(found).toHaveLength(extensions('array').length + natives('array').length); + expect(found).toHaveLength( + extensions({ typeName: 'array' }).length + natives({ typeName: 'array' }).length, + ); expect(found.map((c) => c.label).every((l) => !l.endsWith('()'))); }); }); @@ -339,7 +369,6 @@ describe('Resolution-based completions', () => { { info: expect.any(Function), label: provider, - type: 'keyword', apply: expect.any(Function), }, ]); @@ -363,13 +392,11 @@ describe('Resolution-based completions', () => { { info: expect.any(Function), label: secrets[0], - type: 'keyword', apply: expect.any(Function), }, { info: expect.any(Function), label: secrets[1], - type: 'keyword', apply: expect.any(Function), }, ]); @@ -445,7 +472,9 @@ describe('Resolution-based completions', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue([$input.item]); expect(completions('{{ $input.all().| }}')).toHaveLength( - extensions('array').length + natives('array').length - ARRAY_NUMBER_ONLY_METHODS.length, + extensions({ typeName: 'array' }).length + + natives({ typeName: 'array' }).length - + ARRAY_NUMBER_ONLY_METHODS.length, ); }); @@ -453,7 +482,7 @@ describe('Resolution-based completions', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json); expect(completions('{{ $input.item.| }}')).toHaveLength( - Object.keys($input.item.json).length + extensions('object').length, + Object.keys($input.item.json).length + extensions({ typeName: 'object' }).length, ); }); @@ -461,7 +490,7 @@ describe('Resolution-based completions', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.first().json); expect(completions('{{ $input.first().| }}')).toHaveLength( - Object.keys($input.first().json).length + extensions('object').length, + Object.keys($input.first().json).length + extensions({ typeName: 'object' }).length, ); }); @@ -469,7 +498,7 @@ describe('Resolution-based completions', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.last().json); expect(completions('{{ $input.last().| }}')).toHaveLength( - Object.keys($input.last().json).length + extensions('object').length, + Object.keys($input.last().json).length + extensions({ typeName: 'object' }).length, ); }); @@ -477,7 +506,7 @@ describe('Resolution-based completions', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.all()[0].json); expect(completions('{{ $input.all()[0].| }}')).toHaveLength( - Object.keys($input.all()[0].json).length + extensions('object').length, + Object.keys($input.all()[0].json).length + extensions({ typeName: 'object' }).length, ); }); @@ -485,7 +514,9 @@ describe('Resolution-based completions', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.str); expect(completions('{{ $input.item.json.str.| }}')).toHaveLength( - extensions('string').length + natives('string').length + STRING_RECOMMENDED_OPTIONS.length, + extensions({ typeName: 'string' }).length + + natives({ typeName: 'string' }).length + + STRING_RECOMMENDED_OPTIONS.length, ); }); @@ -493,7 +524,9 @@ describe('Resolution-based completions', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.num); expect(completions('{{ $input.item.json.num.| }}')).toHaveLength( - extensions('number').length + natives('number').length + ['isEven()', 'isOdd()'].length, + extensions({ typeName: 'number' }).length + + natives({ typeName: 'number' }).length + + ['isEven()', 'isOdd()'].length, ); }); @@ -501,7 +534,7 @@ describe('Resolution-based completions', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.arr); expect(completions('{{ $input.item.json.arr.| }}')).toHaveLength( - extensions('array').length + natives('array').length, + extensions({ typeName: 'array' }).length + natives({ typeName: 'array' }).length, ); }); @@ -509,7 +542,7 @@ describe('Resolution-based completions', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue($input.item.json.obj); expect(completions('{{ $input.item.json.obj.| }}')).toHaveLength( - Object.keys($input.item.json.obj).length + extensions('object').length, + Object.keys($input.item.json.obj).length + extensions({ typeName: 'object' }).length, ); }); }); @@ -591,15 +624,12 @@ describe('Resolution-based completions', () => { ); }); - test('should recommend toInt(),toFloat() for: {{ "5.3".| }}', () => { + test('should recommend toNumber() for: {{ "5.3".| }}', () => { // @ts-expect-error Spied function is mistyped vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('5.3'); const options = completions('{{ "5.3".| }}'); expect(options?.[0]).toEqual( - expect.objectContaining({ label: 'toInt()', section: RECOMMENDED_SECTION }), - ); - expect(options?.[1]).toEqual( - expect.objectContaining({ label: 'toFloat()', section: RECOMMENDED_SECTION }), + expect.objectContaining({ label: 'toNumber()', section: RECOMMENDED_SECTION }), ); }); @@ -659,25 +689,25 @@ describe('Resolution-based completions', () => { ); }); - test('should recommend toDateTime("s") for: {{ (1900062210).| }}', () => { + test("should recommend toDateTime('s') for: {{ (1900062210).| }}", () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce( // @ts-expect-error Spied function is mistyped 1900062210, ); const options = completions('{{ (1900062210).| }}'); expect(options?.[0]).toEqual( - expect.objectContaining({ label: 'toDateTime("s")', section: RECOMMENDED_SECTION }), + expect.objectContaining({ label: "toDateTime('s')", section: RECOMMENDED_SECTION }), ); }); - test('should recommend toDateTime("ms") for: {{ (1900062210000).| }}', () => { + test("should recommend toDateTime('ms') for: {{ (1900062210000).| }}", () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce( // @ts-expect-error Spied function is mistyped 1900062210000, ); const options = completions('{{ (1900062210000).| }}'); expect(options?.[0]).toEqual( - expect.objectContaining({ label: 'toDateTime("ms")', section: RECOMMENDED_SECTION }), + expect.objectContaining({ label: "toDateTime('ms')", section: RECOMMENDED_SECTION }), ); }); @@ -714,7 +744,9 @@ describe('Resolution-based completions', () => { const result = completions('{{ $json.foo| }}', true); expect(result).toHaveLength( - extensions('string').length + natives('string').length + STRING_RECOMMENDED_OPTIONS.length, + extensions({ typeName: 'string' }).length + + natives({ typeName: 'string' }).length + + STRING_RECOMMENDED_OPTIONS.length, ); }); }); 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 2fbc53c9bac69..5234904a2968f 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -4,25 +4,6 @@ import { i18n } from '@/plugins/i18n'; import { useEnvironmentsStore } from '@/stores/environments.ee.store'; import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store'; -import { - setRank, - hasNoParams, - prefixMatch, - isAllowedInDotNotation, - isSplitInBatchesAbsent, - longestCommonPrefix, - splitBaseTail, - isPseudoParam, - stripExcessParens, - isCredentialsModalOpen, - applyCompletion, - sortCompletionsAlpha, - hasRequiredArgs, - getDefaultArgs, - insertDefaultArgs, - applyBracketAccessCompletion, - applyBracketAccess, -} from './utils'; import type { Completion, CompletionContext, @@ -36,7 +17,6 @@ import { Expression, ExpressionExtensions, NativeMethods, validateFieldType } fr import { ARRAY_NUMBER_ONLY_METHODS, ARRAY_RECOMMENDED_OPTIONS, - DATE_RECOMMENDED_OPTIONS, FIELDS_SECTION, LUXON_RECOMMENDED_OPTIONS, LUXON_SECTIONS, @@ -50,10 +30,13 @@ import { STRING_RECOMMENDED_OPTIONS, STRING_SECTIONS, } from './constants'; +import { createInfoBoxRenderer } from './infoBoxRenderer'; import { luxonInstanceDocs } from './nativesAutocompleteDocs/luxon.instance.docs'; import { luxonStaticDocs } from './nativesAutocompleteDocs/luxon.static.docs'; import type { AutocompleteInput, ExtensionTypeName, FnToDoc, Resolved } from './types'; import { + applyBracketAccess, + applyBracketAccessCompletion, applyCompletion, getDefaultArgs, hasNoParams, @@ -70,7 +53,6 @@ import { splitBaseTail, stripExcessParens, } from './utils'; -import { createInfoBoxRenderer } from './infoBoxRenderer'; /** * Resolution-based completions offered according to datatype. @@ -103,7 +85,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul try { resolved = resolveParameter(`={{ ${base} }}`); - } catch { + } catch (error) { return null; } @@ -111,7 +93,7 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul try { options = datatypeOptions({ resolved, base, tail }).map(stripExcessParens(context)); - } catch { + } catch (error) { return null; } } @@ -174,7 +156,7 @@ function datatypeOptions(input: AutocompleteInput): Completion[] { return booleanOptions(); } - if (resolved instanceof DateTime) { + if (DateTime.isDateTime(resolved)) { return luxonOptions(input as AutocompleteInput); } @@ -483,7 +465,7 @@ const stringOptions = (input: AutocompleteInput): Completion[] => { if (validateFieldType('string', resolved, 'number').valid) { return applySections({ options, - recommended: ['toInt()', 'toFloat()'], + recommended: ['toNumber()'], sections: STRING_SECTIONS, }); } @@ -573,7 +555,7 @@ const numberOptions = (input: AutocompleteInput): Completion[] => { if (isPlausableMillisDateTime) { return applySections({ options, - recommended: [{ label: 'toDateTime()', args: ['ms'] }], + recommended: [{ label: 'toDateTime()', args: ["'ms'"] }], }); } @@ -584,7 +566,7 @@ const numberOptions = (input: AutocompleteInput): Completion[] => { if (isPlausableSecondsDateTime) { return applySections({ options, - recommended: [{ label: 'toDateTime()', args: ['s'] }], + recommended: [{ label: 'toDateTime()', args: ["'s'"] }], }); } @@ -617,7 +599,7 @@ const dateOptions = (input: AutocompleteInput): Completion[] => { const luxonOptions = (input: AutocompleteInput): Completion[] => { const { transformLabel } = input; - return applySections({ + const result = applySections({ options: sortCompletionsAlpha( uniqBy( [ @@ -630,6 +612,8 @@ const luxonOptions = (input: AutocompleteInput): Completion[] => { recommended: LUXON_RECOMMENDED_OPTIONS, sections: LUXON_SECTIONS, }); + + return result; }; const arrayOptions = (input: AutocompleteInput): Completion[] => { @@ -738,7 +722,7 @@ export const luxonInstanceOptions = ({ }: { includeHidden?: boolean; transformLabel?: (label: string) => string; -}) => { +} = {}) => { const SKIP = new Set(['constructor', 'get', 'invalidExplanation', 'invalidReason']); return Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype)) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.instance.docs.ts b/packages/editor-ui/src/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.instance.docs.ts index 28b8b9ab9ed5e..ef4d7d07380a9 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.instance.docs.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/nativesAutocompleteDocs/luxon.instance.docs.ts @@ -272,7 +272,6 @@ export const luxonInstanceDocs: Required = { doc: { name: 'diff', section: 'compare', - hidden: true, docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetimediff', returnType: 'Duration', args: [ From 181f2e3f2ae3003e05b8aba06abae3d47e6e60d3 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Tue, 23 Apr 2024 10:59:30 +0200 Subject: [PATCH 13/30] Fix dollar completion info box layout --- .../InlineExpressionTip.vue | 2 +- .../codemirror/completions/constants.ts | 182 ++++++++++++++++-- .../completions/dollar.completions.ts | 49 ++++- .../codemirror/completions/infoBoxRenderer.ts | 1 + 4 files changed, 207 insertions(+), 27 deletions(-) diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionTip.vue b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionTip.vue index a8cbfb18f3f8d..9e0744025c26b 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionTip.vue +++ b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionTip.vue @@ -131,7 +131,7 @@ watchDebounced(