diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 70848e67ea..ee0f46af58 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1235,6 +1235,7 @@ export interface NDVState { }; }; focusedMappableInput: string; + focusedInputPath: string; mappingTelemetry: { [key: string]: string | number | boolean }; hoveringItem: null | TargetItem; draggable: { diff --git a/packages/editor-ui/src/components/ParameterInputFull.vue b/packages/editor-ui/src/components/ParameterInputFull.vue index 2d573ffe0b..7678738f90 100644 --- a/packages/editor-ui/src/components/ParameterInputFull.vue +++ b/packages/editor-ui/src/components/ParameterInputFull.vue @@ -230,6 +230,7 @@ export default defineComponent({ if (!this.parameter.noDataExpression) { this.ndvStore.setMappableNDVInputFocus(this.parameter.displayName); } + this.ndvStore.setFocusedInputPath(this.path ?? ''); }, onBlur() { this.focused = false; @@ -239,6 +240,7 @@ export default defineComponent({ ) { this.ndvStore.setMappableNDVInputFocus(''); } + this.ndvStore.setFocusedInputPath(''); this.$emit('blur'); }, onMenuExpanded(expanded: boolean) { diff --git a/packages/editor-ui/src/composables/useWorkflowHelpers.ts b/packages/editor-ui/src/composables/useWorkflowHelpers.ts index 79bd573c67..42cfa76476 100644 --- a/packages/editor-ui/src/composables/useWorkflowHelpers.ts +++ b/packages/editor-ui/src/composables/useWorkflowHelpers.ts @@ -176,13 +176,17 @@ export function resolveParameter( }; if (activeNode?.type === HTTP_REQUEST_NODE_TYPE) { - // Add $response for HTTP Request-Nodes as it is used + const EMPTY_RESPONSE = { statusCode: 200, headers: {}, body: {} }; + const EMPTY_REQUEST = { headers: {}, body: {}, qs: {} }; + // Add $request,$response,$pageCount for HTTP Request-Nodes as it is used // in pagination expressions + additionalKeys.$pageCount = 0; additionalKeys.$response = get( executionData, ['data', 'executionData', 'contextData', `node:${activeNode.name}`, 'response'], - {}, + EMPTY_RESPONSE, ); + additionalKeys.$request = EMPTY_REQUEST; } let runIndexCurrent = opts?.targetItem?.runIndex ?? 0; 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 3a98b0cd59..69e312b803 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 @@ -545,16 +545,16 @@ describe('Resolution-based completions', () => { }); describe('recommended completions', () => { - test('should recommended toDate() for {{ "1-Feb-2024".| }}', () => { + test('should recommend toDateTime() for {{ "1-Feb-2024".| }}', () => { // @ts-expect-error Spied function is mistyped vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('1-Feb-2024'); expect(completions('{{ "1-Feb-2024".| }}')?.[0]).toEqual( - expect.objectContaining({ label: 'toDate()', section: RECOMMENDED_SECTION }), + expect.objectContaining({ label: 'toDateTime()', section: RECOMMENDED_SECTION }), ); }); - test('should recommended toInt(),toFloat() for: {{ "5.3".| }}', () => { + test('should recommend toInt(),toFloat() for: {{ "5.3".| }}', () => { // @ts-expect-error Spied function is mistyped vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce('5.3'); const options = completions('{{ "5.3".| }}'); @@ -566,7 +566,7 @@ describe('Resolution-based completions', () => { ); }); - test('should recommended extractEmail() for: {{ "string with test@n8n.io in it".| }}', () => { + test('should recommend extractEmail() for: {{ "string with test@n8n.io in it".| }}', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce( // @ts-expect-error Spied function is mistyped 'string with test@n8n.io in it', @@ -577,7 +577,7 @@ describe('Resolution-based completions', () => { ); }); - test('should recommended extractDomain() for: {{ "test@n8n.io".| }}', () => { + test('should recommend extractDomain(), isEmail() for: {{ "test@n8n.io".| }}', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce( // @ts-expect-error Spied function is mistyped 'test@n8n.io', @@ -586,9 +586,26 @@ describe('Resolution-based completions', () => { expect(options?.[0]).toEqual( expect.objectContaining({ label: 'extractDomain()', section: RECOMMENDED_SECTION }), ); + expect(options?.[1]).toEqual( + expect.objectContaining({ label: 'isEmail()', section: RECOMMENDED_SECTION }), + ); }); - test('should recommended round(),floor(),ceil() for: {{ (5.46).| }}', () => { + test('should recommend extractDomain(), extractUrlPath() for: {{ "https://n8n.io/pricing".| }}', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce( + // @ts-expect-error Spied function is mistyped + 'https://n8n.io/pricing', + ); + const options = completions('{{ "https://n8n.io/pricing".| }}'); + expect(options?.[0]).toEqual( + expect.objectContaining({ label: 'extractDomain()', section: RECOMMENDED_SECTION }), + ); + expect(options?.[1]).toEqual( + expect.objectContaining({ label: 'extractUrlPath()', section: RECOMMENDED_SECTION }), + ); + }); + + test('should recommend round(),floor(),ceil() for: {{ (5.46).| }}', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce( // @ts-expect-error Spied function is mistyped 5.46, @@ -604,6 +621,50 @@ describe('Resolution-based completions', () => { expect.objectContaining({ label: 'ceil()', section: RECOMMENDED_SECTION }), ); }); + + 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 }), + ); + }); + + 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 }), + ); + }); + + test('should recommend toBoolean() for: {{ (0).| }}', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce( + // @ts-expect-error Spied function is mistyped + 0, + ); + const options = completions('{{ (0).| }}'); + expect(options?.[0]).toEqual( + expect.objectContaining({ label: 'toBoolean()', section: RECOMMENDED_SECTION }), + ); + }); + + test('should recommend toBoolean() for: {{ "true".| }}', () => { + vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce( + // @ts-expect-error Spied function is mistyped + 'true', + ); + const options = completions('{{ "true".| }}'); + expect(options?.[0]).toEqual( + expect.objectContaining({ label: 'toBoolean()', section: RECOMMENDED_SECTION }), + ); + }); }); describe('explicit completions (opened by Ctrl+Space or programatically)', () => { diff --git a/packages/editor-ui/src/plugins/codemirror/completions/constants.ts b/packages/editor-ui/src/plugins/codemirror/completions/constants.ts index 1142f83b9d..cb8b3aeb3f 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/constants.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/constants.ts @@ -145,7 +145,7 @@ export const STRING_RECOMMENDED_OPTIONS = [ 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()']; +export const OBJECT_RECOMMENDED_OPTIONS = ['keys()', 'values()', 'isEmpty()', 'hasField()']; export const ARRAY_RECOMMENDED_OPTIONS = ['length', 'last()', 'includes()', 'map()', 'filter()']; export const ARRAY_NUMBER_ONLY_METHODS = ['max()', 'min()', 'sum()', 'average()']; 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 ebbd63ba91..126d96d3fe 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -17,6 +17,8 @@ import { applyCompletion, sortCompletionsAlpha, hasRequiredArgs, + getDefaultArgs, + insertDefaultArgs, } from './utils'; import type { Completion, @@ -155,6 +157,10 @@ function datatypeOptions(input: AutocompleteInput): Completion[] { return stringOptions(input as AutocompleteInput); } + if (typeof resolved === 'boolean') { + return booleanOptions(); + } + if (resolved instanceof DateTime) { return luxonOptions(input as AutocompleteInput); } @@ -239,7 +245,7 @@ export const toOptions = ( ) => { return Object.entries(fnToDoc) .sort((a, b) => a[0].localeCompare(b[0])) - .filter(([, docInfo]) => !docInfo.doc?.hidden || includeHidden) + .filter(([, docInfo]) => (docInfo.doc && !docInfo.doc?.hidden) || includeHidden) .map(([fnName, docInfo]) => { return createCompletionOption(typeName, fnName, optionType, docInfo, transformLabel); }); @@ -258,7 +264,11 @@ const createCompletionOption = ( label, type: optionType, section: docInfo.doc?.section, - apply: applyCompletion(hasRequiredArgs(docInfo?.doc), transformLabel), + apply: applyCompletion({ + hasArgs: hasRequiredArgs(docInfo?.doc), + defaultArgs: getDefaultArgs(docInfo?.doc), + transformLabel, + }), }; option.info = () => { @@ -395,8 +405,8 @@ const objectOptions = (input: AutocompleteInput): Completion[] => { label: isFunction ? key + '()' : key, type: isFunction ? 'function' : 'keyword', section: getObjectPropertySection({ name, key, isFunction }), + apply: applyCompletion({ hasArgs, transformLabel }), detail: getDetail(name, resolvedProp), - apply: applyCompletion(hasArgs, transformLabel), }; const infoKey = [name, key].join('.'); @@ -466,7 +476,7 @@ const applySections = ({ recommendedSection = RECOMMENDED_SECTION, }: { options: Completion[]; - recommended?: string[]; + recommended?: Array; recommendedSection?: CompletionSection; methodsSection?: CompletionSection; propSection?: CompletionSection; @@ -482,12 +492,12 @@ const applySections = ({ {} as Record, ); return recommended - .map( - (reco): Completion => ({ - ...optionByLabel[reco], - section: recommendedSection, - }), - ) + .map((reco): Completion => { + const option = optionByLabel[typeof reco === 'string' ? reco : reco.label]; + const label = + typeof reco === 'string' ? option.label : insertDefaultArgs(reco.label, reco.args); + return { ...option, label, section: recommendedSection }; + }) .concat( options .filter((option) => !excludeRecommended || !recommendedSet.has(option.label)) @@ -529,12 +539,12 @@ const stringOptions = (input: AutocompleteInput): Completion[] => { if (validateFieldType('string', resolved, 'dateTime').valid) { return applySections({ options, - recommended: ['toDate()'], + recommended: ['toDateTime()'], sections: STRING_SECTIONS, }); } - if (VALID_EMAIL_REGEX.test(resolved) || isUrl(resolved)) { + if (VALID_EMAIL_REGEX.test(resolved)) { return applySections({ options, recommended: ['extractDomain()', 'isEmail()', ...STRING_RECOMMENDED_OPTIONS], @@ -542,6 +552,14 @@ const stringOptions = (input: AutocompleteInput): Completion[] => { }); } + if (isUrl(resolved)) { + return applySections({ + options, + recommended: ['extractDomain()', 'extractUrlPath()', ...STRING_RECOMMENDED_OPTIONS], + sections: STRING_SECTIONS, + }); + } + if (resolved.split(/\s/).find((token) => VALID_EMAIL_REGEX.test(token))) { return applySections({ options, @@ -550,6 +568,26 @@ const stringOptions = (input: AutocompleteInput): Completion[] => { }); } + const trimmed = resolved.trim(); + if ( + (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')) + ) { + return applySections({ + options, + recommended: ['parseJson()', ...STRING_RECOMMENDED_OPTIONS], + sections: STRING_SECTIONS, + }); + } + + if (['true', 'false'].includes(resolved.toLocaleLowerCase())) { + return applySections({ + options, + recommended: ['toBoolean()', ...STRING_RECOMMENDED_OPTIONS], + sections: STRING_SECTIONS, + }); + } + return applySections({ options, recommended: STRING_RECOMMENDED_OPTIONS, @@ -557,6 +595,12 @@ const stringOptions = (input: AutocompleteInput): Completion[] => { }); }; +const booleanOptions = (): Completion[] => { + return applySections({ + options: sortCompletionsAlpha([...natives('boolean'), ...extensions('boolean')]), + }); +}; + const numberOptions = (input: AutocompleteInput): Completion[] => { const { resolved, transformLabel } = input; const options = sortCompletionsAlpha([ @@ -566,6 +610,36 @@ const numberOptions = (input: AutocompleteInput): Completion[] => { const ONLY_INTEGER = ['isEven()', 'isOdd()']; if (Number.isInteger(resolved)) { + const nowMillis = Date.now(); + const marginMillis = 946_707_779_000; // 30y + const isPlausableMillisDateTime = + resolved > nowMillis - marginMillis && resolved < nowMillis + marginMillis; + + if (isPlausableMillisDateTime) { + return applySections({ + options, + recommended: [{ label: 'toDateTime()', args: ['ms'] }], + }); + } + + const nowSeconds = nowMillis / 1000; + const marginSeconds = marginMillis / 1000; + const isPlausableSecondsDateTime = + resolved > nowSeconds - marginSeconds && resolved < nowSeconds + marginSeconds; + if (isPlausableSecondsDateTime) { + return applySections({ + options, + recommended: [{ label: 'toDateTime()', args: ['s'] }], + }); + } + + if (resolved === 0 || resolved === 1) { + return applySections({ + options, + recommended: ['toBoolean()'], + }); + } + return applySections({ options, recommended: ONLY_INTEGER, @@ -574,7 +648,7 @@ const numberOptions = (input: AutocompleteInput): Completion[] => { const exclude = new Set(ONLY_INTEGER); return applySections({ options: options.filter((option) => !exclude.has(option.label)), - recommended: ['round()', 'floor()', 'ceil()', 'toFixed()'], + recommended: ['round()', 'floor()', 'ceil()'], }); } }; @@ -775,7 +849,7 @@ const createLuxonAutocompleteOption = ( }; } - if (doc?.hidden && !includeHidden) { + if (!doc || (doc?.hidden && !includeHidden)) { return null; } @@ -783,7 +857,11 @@ const createLuxonAutocompleteOption = ( label, type, section: doc?.section, - apply: applyCompletion(hasRequiredArgs(doc), transformLabel), + apply: applyCompletion({ + hasArgs: hasRequiredArgs(doc), + defaultArgs: getDefaultArgs(doc), + transformLabel, + }), }; option.info = createCompletionOption( 'DateTime', diff --git a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts index fce8da0a26..ef0d7f4052 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts @@ -8,11 +8,12 @@ import { hasActiveNode, isCredentialsModalOpen, applyCompletion, + isInHttpNodePagination, } from './utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store'; import { escapeMappingString } from '@/utils/mappingUtils'; -import { PREVIOUS_NODES_SECTION, ROOT_DOLLAR_COMPLETIONS } from './constants'; +import { PREVIOUS_NODES_SECTION, RECOMMENDED_SECTION, ROOT_DOLLAR_COMPLETIONS } from './constants'; /** * Completions offered at the dollar position: `$|` @@ -48,6 +49,15 @@ export function dollarCompletions(context: CompletionContext): CompletionResult export function dollarOptions(): Completion[] { const SKIP = new Set(); + let recommendedCompletions: Completion[] = []; + + if (isInHttpNodePagination()) { + recommendedCompletions = [ + { label: '$pageCount', section: RECOMMENDED_SECTION, info: i18n.rootVars.$pageCount }, + { label: '$response', section: RECOMMENDED_SECTION, info: i18n.rootVars.$response }, + { label: '$request', section: RECOMMENDED_SECTION, info: i18n.rootVars.$request }, + ]; + } if (isCredentialsModalOpen()) { return useExternalSecretsStore().isEnterpriseExternalSecretsEnabled @@ -77,7 +87,9 @@ export function dollarOptions(): Completion[] { section: PREVIOUS_NODES_SECTION, })); - return ROOT_DOLLAR_COMPLETIONS.filter(({ label }) => !SKIP.has(label)) + return recommendedCompletions + .concat(ROOT_DOLLAR_COMPLETIONS) + .filter(({ label }) => !SKIP.has(label)) .concat(previousNodesCompletions) .map((completion) => ({ ...completion, apply: applyCompletion() })); } diff --git a/packages/editor-ui/src/plugins/codemirror/completions/types.ts b/packages/editor-ui/src/plugins/codemirror/completions/types.ts index a742d2f710..031eb12d6e 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/types.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/types.ts @@ -2,7 +2,7 @@ import type { DocMetadata } from 'n8n-workflow'; export type Resolved = unknown; -export type ExtensionTypeName = 'number' | 'string' | 'date' | 'array' | 'object'; +export type ExtensionTypeName = 'number' | 'string' | 'date' | 'array' | 'object' | 'boolean'; export type FnToDoc = { [fnName: string]: { doc?: DocMetadata } }; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts index 06c30dbc6d..7aad4fc754 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -1,4 +1,8 @@ -import { CREDENTIAL_EDIT_MODAL_KEY, SPLIT_IN_BATCHES_NODE_TYPE } from '@/constants'; +import { + CREDENTIAL_EDIT_MODAL_KEY, + HTTP_REQUEST_NODE_TYPE, + SPLIT_IN_BATCHES_NODE_TYPE, +} from '@/constants'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { resolveParameter, useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { useNDVStore } from '@/stores/ndv.store'; @@ -120,6 +124,14 @@ export function hasNoParams(toResolve: string) { export const isCredentialsModalOpen = () => useUIStore().modals[CREDENTIAL_EDIT_MODAL_KEY].open; +export const isInHttpNodePagination = () => { + const ndvStore = useNDVStore(); + return ( + ndvStore.activeNode?.type === HTTP_REQUEST_NODE_TYPE && + ndvStore.focusedInputPath.startsWith('parameters.options.pagination') + ); +}; + export const hasActiveNode = () => useNDVStore().activeNode?.name !== undefined; export const isSplitInBatchesAbsent = () => @@ -151,23 +163,50 @@ 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 insertDefaultArgs = (label: string, args: unknown[]): string => { + if (!label.endsWith('()')) return label; + const argList = args.map((arg) => JSON.stringify(arg)).join(', '); + const fnName = label.replace('()', ''); + + return `${fnName}(${argList})`; +}; + /** * When a function completion is selected, set the cursor correctly - * @example `.includes()` -> `.includes()` + * + * @example `.includes()` -> `.includes()` * @example `$max()` -> `$max()` */ export const applyCompletion = - (hasArgs = true, transform: (label: string) => string = (label) => label) => + ({ + hasArgs = true, + defaultArgs = [], + transformLabel = (label) => label, + }: { + hasArgs?: boolean; + defaultArgs?: unknown[]; + transformLabel?: (label: string) => string; + } = {}) => (view: EditorView, completion: Completion, from: number, to: number): void => { - const label = transform(completion.label); + const isFunction = completion.label.endsWith('()'); + const label = insertDefaultArgs(transformLabel(completion.label), defaultArgs); + const tx: TransactionSpec = { ...insertCompletionText(view.state, label, from, to), annotations: pickedCompletion.of(completion), }; - if (label.endsWith('()') && hasArgs) { - const cursorPosition = from + label.length - 1; - tx.selection = { anchor: cursorPosition, head: cursorPosition }; + if (isFunction) { + if (defaultArgs.length > 0) { + tx.selection = { anchor: from + label.indexOf('(') + 1, head: from + label.length - 1 }; + } else if (hasArgs) { + const cursorPosition = from + label.length - 1; + tx.selection = { anchor: cursorPosition, head: cursorPosition }; + } } view.dispatch(tx); diff --git a/packages/editor-ui/src/plugins/i18n/index.ts b/packages/editor-ui/src/plugins/i18n/index.ts index 85d357045c..1f2fe1395f 100644 --- a/packages/editor-ui/src/plugins/i18n/index.ts +++ b/packages/editor-ui/src/plugins/i18n/index.ts @@ -376,6 +376,9 @@ export class I18nClass { $vars: this.baseText('codeNodeEditor.completer.$vars'), $workflow: this.baseText('codeNodeEditor.completer.$workflow'), DateTime: this.baseText('codeNodeEditor.completer.dateTime'), + $request: this.baseText('codeNodeEditor.completer.$request'), + $response: this.baseText('codeNodeEditor.completer.$response'), + $pageCount: this.baseText('codeNodeEditor.completer.$pageCount'), } as const satisfies Record; proxyVars: Record = { diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 5131a7c9de..27a38464e3 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -206,6 +206,9 @@ "codeNodeEditor.completer.$workflow.active": "Whether the workflow is active or not (boolean)", "codeNodeEditor.completer.$workflow.id": "The ID of the workflow", "codeNodeEditor.completer.$workflow.name": "The name of the workflow", + "codeNodeEditor.completer.$response": "The response object received by the HTTP node.", + "codeNodeEditor.completer.$request": "The request object sent by the HTTP node.", + "codeNodeEditor.completer.$pageCount": "Tracks how many pages the HTTP node has fetched.", "codeNodeEditor.completer.dateTime": "Luxon DateTime. Use this object to parse, format and manipulate dates and times", "codeNodeEditor.completer.binary": "The item's binary (file) data", "codeNodeEditor.completer.globalObject.assign": "Copy of the object containing all enumerable own properties", diff --git a/packages/editor-ui/src/stores/ndv.store.ts b/packages/editor-ui/src/stores/ndv.store.ts index 7fb3b9c4d5..6fb4f2a528 100644 --- a/packages/editor-ui/src/stores/ndv.store.ts +++ b/packages/editor-ui/src/stores/ndv.store.ts @@ -44,6 +44,7 @@ export const useNDVStore = defineStore(STORES.NDV, { }, }, focusedMappableInput: '', + focusedInputPath: '', mappingTelemetry: {}, hoveringItem: null, draggable: { @@ -268,5 +269,8 @@ export const useNDVStore = defineStore(STORES.NDV, { }); } }, + setFocusedInputPath(path: string) { + this.focusedInputPath = path; + }, }, }); diff --git a/packages/workflow/src/Extensions/ArrayExtensions.ts b/packages/workflow/src/Extensions/ArrayExtensions.ts index b414014d0f..23b2ca33c1 100644 --- a/packages/workflow/src/Extensions/ArrayExtensions.ts +++ b/packages/workflow/src/Extensions/ArrayExtensions.ts @@ -320,6 +320,26 @@ function intersection(value: unknown[], extraArgs: unknown[][]): unknown[] { return unique(newArr, []); } +export function toJsonString(value: unknown[]) { + return JSON.stringify(value); +} + +export function toInt() { + return undefined; +} + +export function toFloat() { + return undefined; +} + +export function toBoolean() { + return undefined; +} + +export function toDateTime() { + return undefined; +} + average.doc = { name: 'average', description: 'Returns the mean average of all values in the array.', @@ -483,6 +503,14 @@ unique.doc = { docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-unique', }; +toJsonString.doc = { + name: 'toJsonString', + description: 'Converts an array to a JSON string', + docURL: + 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-toJsonString', + returnType: 'string', +}; + export const arrayExtensions: ExtensionMap = { typeName: 'Array', functions: { @@ -506,5 +534,10 @@ export const arrayExtensions: ExtensionMap = { union, difference, intersection, + toJsonString, + toInt, + toFloat, + toBoolean, + toDateTime, }, }; diff --git a/packages/workflow/src/Extensions/BooleanExtensions.ts b/packages/workflow/src/Extensions/BooleanExtensions.ts new file mode 100644 index 0000000000..c275c304c9 --- /dev/null +++ b/packages/workflow/src/Extensions/BooleanExtensions.ts @@ -0,0 +1,35 @@ +import type { ExtensionMap } from './Extensions'; + +export function toBoolean(value: boolean) { + return value; +} + +export function toInt(value: boolean) { + return value ? 1 : 0; +} + +export function toFloat(value: boolean) { + return value ? 1 : 0; +} + +export function toDateTime() { + return undefined; +} + +toInt.doc = { + name: 'toInt', + description: 'Converts a boolean to an integer. `false` is 0, `true` is 1.', + section: 'cast', + returnType: 'boolean', + docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/booleans/#boolean-toInt', +}; + +export const booleanExtensions: ExtensionMap = { + typeName: 'Boolean', + functions: { + toBoolean, + toInt, + toFloat, + toDateTime, + }, +}; diff --git a/packages/workflow/src/Extensions/DateExtensions.ts b/packages/workflow/src/Extensions/DateExtensions.ts index 9def636155..f0e8e67062 100644 --- a/packages/workflow/src/Extensions/DateExtensions.ts +++ b/packages/workflow/src/Extensions/DateExtensions.ts @@ -216,6 +216,25 @@ function plus( return DateTime.fromJSDate(date).plus(duration).toJSDate(); } +function toDateTime(date: Date | DateTime): DateTime { + if (isDateTime(date)) return date; + + return DateTime.fromJSDate(date); +} + +function toInt(date: Date | DateTime): number { + if (isDateTime(date)) { + return date.toMillis(); + } + return date.getTime(); +} + +const toFloat = toInt; + +function toBoolean() { + return undefined; +} + endOfMonth.doc = { name: 'endOfMonth', returnType: 'Date', @@ -267,7 +286,7 @@ format.doc = { description: 'Formats a Date in the given structure.', returnType: 'string', section: 'format', - args: [{ name: 'fmt', type: 'TimeFormat' }], + args: [{ name: 'fmt', default: 'yyyy-MM-dd', type: 'TimeFormat' }], docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-format', }; @@ -295,6 +314,15 @@ isInLast.doc = { docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-isInLast', }; +toDateTime.doc = { + name: 'toDateTime', + description: 'Convert a JavaScript Date to a Luxon DateTime.', + section: 'query', + returnType: 'DateTime', + hidden: true, + docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-toDateTime', +}; + minus.doc = { name: 'minus', description: 'Subtracts a given time period from a Date. Default unit is `milliseconds`.', @@ -332,5 +360,9 @@ export const dateExtensions: ExtensionMap = { minus, plus, format, + toDateTime, + toInt, + toFloat, + toBoolean, }, }; diff --git a/packages/workflow/src/Extensions/ExpressionExtension.ts b/packages/workflow/src/Extensions/ExpressionExtension.ts index 29a9bbee1f..097156b171 100644 --- a/packages/workflow/src/Extensions/ExpressionExtension.ts +++ b/packages/workflow/src/Extensions/ExpressionExtension.ts @@ -15,6 +15,7 @@ import type { ExpressionKind } from 'ast-types/gen/kinds'; import type { ExpressionChunk, ExpressionCode } from './ExpressionParser'; import { joinExpression, splitExpression } from './ExpressionParser'; +import { booleanExtensions } from './BooleanExtensions'; const EXPRESSION_EXTENDER = 'extend'; const EXPRESSION_EXTENDER_OPTIONAL = 'extendOptional'; @@ -33,6 +34,7 @@ export const EXTENSION_OBJECTS = [ numberExtensions, objectExtensions, stringExtensions, + booleanExtensions, ]; // eslint-disable-next-line @typescript-eslint/ban-types @@ -48,6 +50,7 @@ const EXPRESSION_EXTENSION_METHODS = Array.from( ...Object.keys(dateExtensions.functions), ...Object.keys(arrayExtensions.functions), ...Object.keys(objectExtensions.functions), + ...Object.keys(booleanExtensions.functions), ...Object.keys(genericExtensions), ]), ); @@ -455,7 +458,7 @@ function findExtendedFunction(input: unknown, functionName: string): FoundFuncti let foundFunction: Function | undefined; if (Array.isArray(input)) { foundFunction = arrayExtensions.functions[functionName]; - } else if (isDate(input) && functionName !== 'toDate') { + } else if (isDate(input) && functionName !== 'toDate' && functionName !== 'toDateTime') { // If it's a string date (from $json), convert it to a Date object, // unless that function is `toDate`, since `toDate` does something // very different on date objects @@ -469,6 +472,8 @@ function findExtendedFunction(input: unknown, functionName: string): FoundFuncti foundFunction = dateExtensions.functions[functionName]; } else if (input !== null && typeof input === 'object') { foundFunction = objectExtensions.functions[functionName]; + } else if (typeof input === 'boolean') { + foundFunction = booleanExtensions.functions[functionName]; } // Look for generic or builtin diff --git a/packages/workflow/src/Extensions/NumberExtensions.ts b/packages/workflow/src/Extensions/NumberExtensions.ts index 48d019331e..caac368c64 100644 --- a/packages/workflow/src/Extensions/NumberExtensions.ts +++ b/packages/workflow/src/Extensions/NumberExtensions.ts @@ -1,6 +1,7 @@ /** * @jest-environment jsdom */ +import { DateTime } from 'luxon'; import { ExpressionExtensionError } from '../errors/expression-extension.error'; import type { ExtensionMap } from './Extensions'; @@ -40,6 +41,41 @@ function round(value: number, extraArgs: number[]) { return +value.toFixed(decimalPlaces); } +function toBoolean(value: number) { + return value !== 0; +} + +function toInt(value: number) { + return round(value, []); +} + +function toFloat(value: number) { + return value; +} + +type DateTimeFormat = 'ms' | 's' | 'excel'; +function toDateTime(value: number, extraArgs: [DateTimeFormat]) { + const [valueFormat = 'ms'] = extraArgs; + + switch (valueFormat) { + // Excel format is days since 1900 + // There is a bug where 1900 is incorrectly treated as a leap year + case 'excel': { + const DAYS_BETWEEN_1900_1970 = 25567; + const DAYS_LEAP_YEAR_BUG_ADJUST = 2; + const SECONDS_IN_DAY = 86_400; + return DateTime.fromSeconds( + (value - (DAYS_BETWEEN_1900_1970 + DAYS_LEAP_YEAR_BUG_ADJUST)) * SECONDS_IN_DAY, + ); + } + case 's': + return DateTime.fromSeconds(value); + case 'ms': + default: + return DateTime.fromMillis(value); + } +} + ceil.doc = { name: 'ceil', description: 'Rounds up a number to a whole number.', @@ -89,6 +125,26 @@ round.doc = { docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/numbers/#number-round', }; +toBoolean.doc = { + name: 'toBoolean', + description: 'Converts a number to a boolean. 0 is `false`, all other numbers are `true`.', + section: 'cast', + returnType: 'boolean', + docURL: + 'https://docs.n8n.io/code/builtin/data-transformation-functions/numbers/#number-toBoolean', +}; + +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).", + section: 'cast', + returnType: 'DateTime', + args: [{ name: 'format?', type: 'string' }], + docURL: + 'https://docs.n8n.io/code/builtin/data-transformation-functions/numbers/#number-toDateTime', +}; + export const numberExtensions: ExtensionMap = { typeName: 'Number', functions: { @@ -98,5 +154,9 @@ export const numberExtensions: ExtensionMap = { round, isEven, isOdd, + toBoolean, + toInt, + toFloat, + toDateTime, }, }; diff --git a/packages/workflow/src/Extensions/ObjectExtensions.ts b/packages/workflow/src/Extensions/ObjectExtensions.ts index f0b45bf4d5..82da2f332d 100644 --- a/packages/workflow/src/Extensions/ObjectExtensions.ts +++ b/packages/workflow/src/Extensions/ObjectExtensions.ts @@ -88,6 +88,26 @@ export function urlEncode(value: object) { return new URLSearchParams(value as Record).toString(); } +export function toJsonString(value: object) { + return JSON.stringify(value); +} + +export function toInt() { + return undefined; +} + +export function toFloat() { + return undefined; +} + +export function toBoolean() { + return undefined; +} + +export function toDateTime() { + return undefined; +} + isEmpty.doc = { name: 'isEmpty', description: 'Checks if the Object has no key-value pairs.', @@ -168,6 +188,14 @@ values.doc = { returnType: 'Array', }; +toJsonString.doc = { + name: 'toJsonString', + description: 'Converts an object to a JSON string', + docURL: + 'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-toJsonString', + returnType: 'string', +}; + export const objectExtensions: ExtensionMap = { typeName: 'Object', functions: { @@ -181,5 +209,10 @@ export const objectExtensions: ExtensionMap = { urlEncode, keys, values, + toJsonString, + toInt, + toFloat, + toBoolean, + toDateTime, }, }; diff --git a/packages/workflow/src/Extensions/StringExtensions.ts b/packages/workflow/src/Extensions/StringExtensions.ts index 97e9ade553..959ce633d5 100644 --- a/packages/workflow/src/Extensions/StringExtensions.ts +++ b/packages/workflow/src/Extensions/StringExtensions.ts @@ -1,10 +1,12 @@ import SHA from 'jssha'; import MD5 from 'md5'; -import { encode } from 'js-base64'; +import { toBase64, fromBase64 } from 'js-base64'; import { titleCase } from 'title-case'; import type { Extension, ExtensionMap } from './Extensions'; import { transliterate } from 'transliteration'; import { ExpressionExtensionError } from '../errors/expression-extension.error'; +import type { DateTime } from 'luxon'; +import { tryToParseDateTime } from '../TypeValidation'; export const SupportedHashAlgorithms = [ 'md5', @@ -116,7 +118,7 @@ function hash(value: string, extraArgs: string[]): string { const algorithm = extraArgs[0]?.toLowerCase() ?? 'md5'; switch (algorithm) { case 'base64': - return encode(value); + return toBase64(value); case 'md5': return MD5(value); case 'sha1': @@ -214,6 +216,14 @@ function toDate(value: string): Date { return date; } +function toDateTime(value: string): DateTime { + try { + return tryToParseDateTime(value); + } catch (error) { + throw new ExpressionExtensionError('cannot convert to Luxon DateTime'); + } +} + function urlDecode(value: string, extraArgs: boolean[]): string { const [entireString = false] = extraArgs; if (entireString) { @@ -359,6 +369,37 @@ function extractUrl(value: string) { return matched[0]; } +function extractUrlPath(value: string) { + try { + const url = new URL(value); + return url.pathname; + } catch (error) { + return undefined; + } +} + +function parseJson(value: string): unknown { + try { + return JSON.parse(value); + } catch (error) { + return undefined; + } +} + +function toBoolean(value: string): boolean { + const normalized = value.toLowerCase(); + const FALSY = new Set(['false', 'no', '0']); + return normalized.length > 0 && !FALSY.has(normalized); +} + +function base64Encode(value: string): string { + return toBase64(value); +} + +function base64Decode(value: string): string { + return fromBase64(value); +} + removeMarkdown.doc = { name: 'removeMarkdown', description: 'Removes Markdown formatting from a string.', @@ -382,9 +423,28 @@ toDate.doc = { description: 'Converts a string to a date.', section: 'cast', returnType: 'Date', + hidden: true, docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toDate', }; +toDateTime.doc = { + name: 'toDateTime', + description: 'Converts a string to a Luxon DateTime.', + section: 'cast', + returnType: 'DateTime', + docURL: + 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toDateTime', +}; + +toBoolean.doc = { + name: 'toBoolean', + description: 'Converts a string to a boolean.', + section: 'cast', + returnType: 'boolean', + docURL: + 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toBoolean', +}; + toFloat.doc = { name: 'toFloat', description: 'Converts a string to a decimal number.', @@ -549,6 +609,15 @@ extractUrl.doc = { 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractUrl', }; +extractUrlPath.doc = { + name: 'extractUrlPath', + description: 'Extracts the path from a URL. Returns undefined if none is found.', + section: 'edit', + returnType: 'string', + docURL: + 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractUrlPath', +}; + hash.doc = { name: 'hash', description: 'Returns a string hashed with the given algorithm. Default algorithm is `md5`.', @@ -567,6 +636,34 @@ quote.doc = { docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-quote', }; +parseJson.doc = { + name: 'parseJson', + description: + 'Parses a JSON string, constructing the JavaScript value or object described by the string.', + section: 'cast', + returnType: 'any', + docURL: + 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-parseJson', +}; + +base64Encode.doc = { + name: 'base64Encode', + description: 'Converts a UTF-8-encoded string to a Base64 string.', + section: 'edit', + returnType: 'string', + docURL: + 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-base64Encode', +}; + +base64Decode.doc = { + name: 'base64Decode', + description: 'Converts a Base64 string to a UTF-8 string.', + section: 'edit', + returnType: 'string', + docURL: + 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-base64Decode', +}; + const toDecimalNumber: Extension = toFloat.bind({}); toDecimalNumber.doc = { ...toFloat.doc, hidden: true }; const toWholeNumber: Extension = toInt.bind({}); @@ -579,6 +676,8 @@ export const stringExtensions: ExtensionMap = { removeMarkdown, removeTags, toDate, + toDateTime, + toBoolean, toDecimalNumber, toFloat, toInt, @@ -600,5 +699,9 @@ export const stringExtensions: ExtensionMap = { extractEmail, extractDomain, extractUrl, + extractUrlPath, + parseJson, + base64Encode, + base64Decode, }, }; diff --git a/packages/workflow/src/NativeMethods/Boolean.methods.ts b/packages/workflow/src/NativeMethods/Boolean.methods.ts new file mode 100644 index 0000000000..a6c16ae102 --- /dev/null +++ b/packages/workflow/src/NativeMethods/Boolean.methods.ts @@ -0,0 +1,16 @@ +import type { NativeDoc } from '@/Extensions/Extensions'; + +export const booleanMethods: NativeDoc = { + typeName: 'Boolean', + functions: { + toString: { + doc: { + name: 'toString', + description: 'returns a string representing this boolean value.', + docURL: + 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Boolean/toString', + returnType: 'string', + }, + }, + }, +}; diff --git a/packages/workflow/src/NativeMethods/Number.methods.ts b/packages/workflow/src/NativeMethods/Number.methods.ts index f2c8d9884b..051643c569 100644 --- a/packages/workflow/src/NativeMethods/Number.methods.ts +++ b/packages/workflow/src/NativeMethods/Number.methods.ts @@ -6,6 +6,7 @@ export const numberMethods: NativeDoc = { toFixed: { doc: { name: 'toFixed', + hidden: true, description: 'Formats a number using fixed-point notation. `digits` defaults to null if not given.', docURL: @@ -17,6 +18,7 @@ export const numberMethods: NativeDoc = { toPrecision: { doc: { name: 'toPrecision', + hidden: true, description: 'Returns a string representing the number to the specified precision.', docURL: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toPrecision', @@ -24,5 +26,14 @@ export const numberMethods: NativeDoc = { args: [{ name: 'precision?', type: 'number' }], }, }, + toString: { + doc: { + name: 'toString', + 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', + }, + }, }, }; diff --git a/packages/workflow/src/NativeMethods/index.ts b/packages/workflow/src/NativeMethods/index.ts index 9f96784ac5..acf79c418d 100644 --- a/packages/workflow/src/NativeMethods/index.ts +++ b/packages/workflow/src/NativeMethods/index.ts @@ -3,7 +3,14 @@ import { arrayMethods } from './Array.methods'; import { numberMethods } from './Number.methods'; import { objectMethods } from './Object.Methods'; import type { NativeDoc } from '@/Extensions/Extensions'; +import { booleanMethods } from './Boolean.methods'; -const NATIVE_METHODS: NativeDoc[] = [stringMethods, arrayMethods, numberMethods, objectMethods]; +const NATIVE_METHODS: NativeDoc[] = [ + stringMethods, + arrayMethods, + numberMethods, + objectMethods, + booleanMethods, +]; export { NATIVE_METHODS as NativeMethods }; diff --git a/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts index 4af03ff073..299afed4c7 100644 --- a/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/ArrayExtensions.test.ts @@ -2,6 +2,7 @@ * @jest-environment jsdom */ +import { arrayExtensions } from '../../src/Extensions/ArrayExtensions'; import { evaluate } from './Helpers'; describe('Data Transformation Functions', () => { @@ -234,5 +235,27 @@ describe('Data Transformation Functions', () => { [16, 17, 18, 19, 20], ]); }); + + test('.toJsonString() should work on an array', () => { + expect(evaluate('={{ [true, 1, "one", {foo: "bar"}].toJsonString() }}')).toEqual( + '[true,1,"one",{"foo":"bar"}]', + ); + }); + + describe('Conversion methods', () => { + test('should exist but return undefined (to not break expressions with mixed data)', () => { + expect(evaluate('={{ numberList(1, 20).toInt() }}')).toBeUndefined(); + expect(evaluate('={{ numberList(1, 20).toFloat() }}')).toBeUndefined(); + expect(evaluate('={{ numberList(1, 20).toBoolean() }}')).toBeUndefined(); + expect(evaluate('={{ numberList(1, 20).toDateTime() }}')).toBeUndefined(); + }); + + test('should not have a doc (hidden from autocomplete)', () => { + expect(arrayExtensions.functions.toInt.doc).toBeUndefined(); + expect(arrayExtensions.functions.toFloat.doc).toBeUndefined(); + expect(arrayExtensions.functions.toBoolean.doc).toBeUndefined(); + expect(arrayExtensions.functions.toDateTime.doc).toBeUndefined(); + }); + }); }); }); diff --git a/packages/workflow/test/ExpressionExtensions/BooleanExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/BooleanExtensions.test.ts new file mode 100644 index 0000000000..eccb53e50b --- /dev/null +++ b/packages/workflow/test/ExpressionExtensions/BooleanExtensions.test.ts @@ -0,0 +1,39 @@ +/** + * @jest-environment jsdom + */ + +import { booleanExtensions } from '../../src/Extensions/BooleanExtensions'; +import { evaluate } from './Helpers'; + +describe('Data Transformation Functions', () => { + describe('Boolean Data Transformation Functions', () => { + describe('Conversion methods', () => { + describe('toInt/toFloat', () => { + test('should return 1 for true, 0 for false', () => { + expect(evaluate('={{ (true).toInt() }}')).toEqual(1); + expect(evaluate('={{ (true).toFloat() }}')).toEqual(1); + expect(evaluate('={{ (false).toInt() }}')).toEqual(0); + expect(evaluate('={{ (false).toFloat() }}')).toEqual(0); + }); + }); + + describe('toDateTime', () => { + test('should return undefined', () => { + expect(evaluate('={{ (true).toDateTime() }}')).toBeUndefined(); + }); + }); + + describe('toBoolean', () => { + test('should return itself', () => { + expect(evaluate('={{ (true).toDateTime() }}')).toBeUndefined(); + }); + }); + + test('should not have a doc (hidden from autocomplete)', () => { + expect(booleanExtensions.functions.toFloat.doc).toBeUndefined(); + expect(booleanExtensions.functions.toBoolean.doc).toBeUndefined(); + expect(booleanExtensions.functions.toDateTime.doc).toBeUndefined(); + }); + }); + }); +}); diff --git a/packages/workflow/test/ExpressionExtensions/DateExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/DateExtensions.test.ts index 9e914bf93f..e2fd830f11 100644 --- a/packages/workflow/test/ExpressionExtensions/DateExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/DateExtensions.test.ts @@ -5,6 +5,7 @@ import { DateTime } from 'luxon'; import { getGlobalState } from '@/GlobalState'; import { evaluate, getLocalISOString } from './Helpers'; +import { dateExtensions } from '../../src/Extensions/DateExtensions'; const { defaultTimezone } = getGlobalState(); @@ -107,5 +108,56 @@ describe('Data Transformation Functions', () => { evaluate("={{ $now.isBetween($now, '2023-06-23', '2023-09-21'.toDate()) }}"), ).toThrow(); }); + + describe('toDateTime', () => { + test('should return itself for DateTime', () => { + const result = evaluate( + "={{ DateTime.fromFormat('01-01-2024', 'dd-MM-yyyy').toDateTime() }}", + ) as unknown as DateTime; + expect(result).toBeInstanceOf(DateTime); + expect(result.day).toEqual(1); + expect(result.month).toEqual(1); + expect(result.year).toEqual(2024); + }); + + test('should return a DateTime for JS Date', () => { + const result = evaluate( + '={{ new Date(2024, 0, 1, 12).toDateTime() }}', + ) as unknown as DateTime; + expect(result).toBeInstanceOf(DateTime); + expect(result.day).toEqual(1); + expect(result.month).toEqual(1); + expect(result.year).toEqual(2024); + }); + }); + + describe('toInt/toFloat', () => { + test('should return milliseconds for DateTime', () => { + expect(evaluate("={{ DateTime.fromISO('2024-01-01T00:00:00.000Z').toInt() }}")).toEqual( + 1704067200000, + ); + }); + + test('should return milliseconds for JS Date', () => { + expect(evaluate('={{ new Date("2024-01-01T00:00:00.000Z").toFloat() }}')).toEqual( + 1704067200000, + ); + }); + + test('should not have a doc (hidden from autocomplete)', () => { + expect(dateExtensions.functions.toInt.doc).toBeUndefined(); + expect(dateExtensions.functions.toFloat.doc).toBeUndefined(); + }); + }); + + describe('toBoolean', () => { + test('should return undefined', () => { + expect(evaluate('={{ new Date("2024-01-01T00:00:00.000Z").toBoolean() }}')).toBeUndefined(); + }); + + test('should not have a doc (hidden from autocomplete)', () => { + expect(dateExtensions.functions.toBoolean.doc).toBeUndefined(); + }); + }); }); }); diff --git a/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts index 21259d904b..faaeeadcf0 100644 --- a/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/NumberExtensions.test.ts @@ -55,6 +55,52 @@ describe('Data Transformation Functions', () => { expect(() => evaluate('={{ (NaN).isEven() }}')).toThrow(); expect(() => evaluate('={{ (9.2).isEven() }}')).toThrow(); }); + + describe('toDateTime', () => { + test('from milliseconds (default)', () => { + expect(evaluate('={{ (1704085200000).toDateTime().toISO() }}')).toEqual( + '2024-01-01T00:00:00.000-05:00', + ); + expect(evaluate('={{ (1704085200000).toDateTime("ms").toISO() }}')).toEqual( + '2024-01-01T00:00:00.000-05:00', + ); + }); + + test('from seconds', () => { + expect(evaluate('={{ (1704085200).toDateTime("s").toISO() }}')).toEqual( + '2024-01-01T00:00:00.000-05:00', + ); + }); + + test('from Excel 1900 format', () => { + expect(evaluate('={{ (42144).toDateTime("excel").toISO() }}')).toEqual( + '2015-05-19T20:00:00.000-04:00', + ); + }); + }); + + describe('toInt', () => { + test('should round numbers', () => { + expect(evaluate('={{ (42144).toInt() }}')).toEqual(42144); + expect(evaluate('={{ (42144.345).toInt() }}')).toEqual(42144); + expect(evaluate('={{ (42144.545).toInt() }}')).toEqual(42145); + }); + }); + + describe('toFloat', () => { + test('should return itself', () => { + expect(evaluate('={{ (42144).toFloat() }}')).toEqual(42144); + expect(evaluate('={{ (42144.345).toFloat() }}')).toEqual(42144.345); + }); + }); + + describe('toBoolean', () => { + test('should return false for 0, 1 for other numbers', () => { + expect(evaluate('={{ (42144).toBoolean() }}')).toBe(true); + expect(evaluate('={{ (-1.549).toBoolean() }}')).toBe(true); + expect(evaluate('={{ (0).toBoolean() }}')).toBe(false); + }); + }); }); describe('Multiple expressions', () => { diff --git a/packages/workflow/test/ExpressionExtensions/ObjectExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/ObjectExtensions.test.ts index 38556f0878..4a78037488 100644 --- a/packages/workflow/test/ExpressionExtensions/ObjectExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/ObjectExtensions.test.ts @@ -1,3 +1,4 @@ +import { objectExtensions } from '../../src/Extensions/ObjectExtensions'; import { evaluate } from './Helpers'; describe('Data Transformation Functions', () => { @@ -89,5 +90,27 @@ describe('Data Transformation Functions', () => { test('.values should work on an object', () => { expect(evaluate('={{ ({ test1: 1, test2: "2" }).values() }}')).toEqual([1, '2']); }); + + test('.toJsonString() should work on an object', () => { + expect(evaluate('={{ ({ test1: 1, test2: "2" }).toJsonString() }}')).toEqual( + '{"test1":1,"test2":"2"}', + ); + }); + + describe('Conversion methods', () => { + test('should exist but return undefined (to not break expressions with mixed data)', () => { + expect(evaluate('={{ ({ test1: 1, test2: "2" }).toInt() }}')).toBeUndefined(); + expect(evaluate('={{ ({ test1: 1, test2: "2" }).toFloat() }}')).toBeUndefined(); + expect(evaluate('={{ ({ test1: 1, test2: "2" }).toBoolean() }}')).toBeUndefined(); + expect(evaluate('={{ ({ test1: 1, test2: "2" }).toDateTime() }}')).toBeUndefined(); + }); + + it('should not have a doc (hidden from autocomplete)', () => { + expect(objectExtensions.functions.toInt.doc).toBeUndefined(); + expect(objectExtensions.functions.toFloat.doc).toBeUndefined(); + expect(objectExtensions.functions.toBoolean.doc).toBeUndefined(); + expect(objectExtensions.functions.toDateTime.doc).toBeUndefined(); + }); + }); }); }); diff --git a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts index 16f298376f..cdff80759e 100644 --- a/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/StringExtensions.test.ts @@ -1,7 +1,9 @@ /** * @jest-environment jsdom */ +import { DateTime } from 'luxon'; import { evaluate } from './Helpers'; +import { ExpressionExtensionError } from '../../src/errors'; describe('Data Transformation Functions', () => { describe('String Data Transformation Functions', () => { @@ -244,5 +246,48 @@ describe('Data Transformation Functions', () => { expect(evaluate('={{ "aaaaaaaa".isEmail() }}')).toEqual(false); expect(evaluate('={{ "test @ n8n".isEmail() }}')).toEqual(false); }); + + test('.toDateTime should work on a variety of formats', () => { + expect(evaluate('={{ "Wed, 21 Oct 2015 07:28:00 GMT".toDateTime() }}')).toBeInstanceOf( + DateTime, + ); + expect(evaluate('={{ "2008-11-11".toDateTime() }}')).toBeInstanceOf(DateTime); + expect(evaluate('={{ "1-Feb-2024".toDateTime() }}')).toBeInstanceOf(DateTime); + expect(() => evaluate('={{ "hi".toDateTime() }}')).toThrowError( + new ExpressionExtensionError('cannot convert to Luxon DateTime'), + ); + }); + + test('.extractUrlPath should work on a string', () => { + expect( + evaluate('={{ "https://example.com/orders/1/detail#hash?foo=bar".extractUrlPath() }}'), + ).toEqual('/orders/1/detail'); + expect(evaluate('={{ "hi".extractUrlPath() }}')).toBeUndefined(); + }); + + test('.parseJson should work on a string', () => { + expect(evaluate('={{ \'{"test1":1,"test2":"2"}\'.parseJson() }}')).toEqual({ + test1: 1, + test2: '2', + }); + expect(evaluate('={{ "hi".parseJson() }}')).toBeUndefined(); + }); + + test('.toBoolean should work on a string', () => { + expect(evaluate('={{ "False".toBoolean() }}')).toBe(false); + expect(evaluate('={{ "".toBoolean() }}')).toBe(false); + expect(evaluate('={{ "0".toBoolean() }}')).toBe(false); + expect(evaluate('={{ "no".toBoolean() }}')).toBe(false); + expect(evaluate('={{ "TRUE".toBoolean() }}')).toBe(true); + expect(evaluate('={{ "hello".toBoolean() }}')).toBe(true); + }); + + test('.base64Encode should work on a string', () => { + expect(evaluate('={{ "n8n test".base64Encode() }}')).toBe('bjhuIHRlc3Q='); + }); + + test('.base64Decode should work on a string', () => { + expect(evaluate('={{ "bjhuIHRlc3Q=".base64Decode() }}')).toBe('n8n test'); + }); }); });