From 62df4331d448dfdabd51db33560a87dd5d805a13 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Wed, 22 May 2024 09:40:09 +0200 Subject: [PATCH 01/24] fix(editor): Emit change events from filter component on update (#9479) --- .../components/FilterConditions/Condition.vue | 10 ++- .../__tests__/FilterConditions.test.ts | 65 ++++++++++++++++++- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/editor-ui/src/components/FilterConditions/Condition.vue b/packages/editor-ui/src/components/FilterConditions/Condition.vue index 4a6671b89e7f2..5fb6568ea2360 100644 --- a/packages/editor-ui/src/components/FilterConditions/Condition.vue +++ b/packages/editor-ui/src/components/FilterConditions/Condition.vue @@ -21,6 +21,7 @@ import { operatorTypeToNodeProperty, resolveCondition, } from './utils'; +import { useDebounce } from '@/composables/useDebounce'; interface Props { path: string; @@ -47,6 +48,7 @@ const emit = defineEmits<{ }>(); const i18n = useI18n(); +const { debounce } = useDebounce(); const condition = ref(props.condition); @@ -101,12 +103,16 @@ const rightParameter = computed(() => { }; }); +const debouncedEmitUpdate = debounce(() => emit('update', condition.value), { debounceTime: 500 }); + const onLeftValueChange = (update: IUpdateInformation): void => { condition.value.leftValue = update.value; + debouncedEmitUpdate(); }; const onRightValueChange = (update: IUpdateInformation): void => { condition.value.rightValue = update.value; + debouncedEmitUpdate(); }; const onOperatorChange = (value: string): void => { @@ -117,7 +123,7 @@ const onOperatorChange = (value: string): void => { newOperator, }); - emit('update', condition.value); + debouncedEmitUpdate(); }; const onRemove = (): void => { @@ -125,7 +131,7 @@ const onRemove = (): void => { }; const onBlur = (): void => { - emit('update', condition.value); + debouncedEmitUpdate(); }; diff --git a/packages/editor-ui/src/components/FilterConditions/__tests__/FilterConditions.test.ts b/packages/editor-ui/src/components/FilterConditions/__tests__/FilterConditions.test.ts index 568d65c9bc8d5..a41a58759aea7 100644 --- a/packages/editor-ui/src/components/FilterConditions/__tests__/FilterConditions.test.ts +++ b/packages/editor-ui/src/components/FilterConditions/__tests__/FilterConditions.test.ts @@ -5,8 +5,9 @@ import { STORES } from '@/constants'; import { useNDVStore } from '@/stores/ndv.store'; import { createTestingPinia } from '@pinia/testing'; import userEvent from '@testing-library/user-event'; -import { within } from '@testing-library/vue'; +import { within, waitFor } from '@testing-library/vue'; import { getFilterOperator } from '../utils'; +import { get } from 'lodash-es'; const DEFAULT_SETUP = { pinia: createTestingPinia({ @@ -274,6 +275,68 @@ describe('FilterConditions.vue', () => { expect(conditions[0].querySelector('[data-test-id="filter-remove-condition"]')).toBeNull(); }); + it('can edit conditions', async () => { + const { getByTestId, emitted } = renderComponent({ + ...DEFAULT_SETUP, + props: { + ...DEFAULT_SETUP.props, + value: { + options: { + caseSensitive: true, + leftValue: '', + }, + conditions: [ + { + leftValue: '={{ $json.name }}', + rightValue: 'John', + operator: getFilterOperator('string:equals'), + }, + ], + }, + }, + }); + + const condition = getByTestId('filter-condition'); + await waitFor(() => + expect(within(condition).getByTestId('filter-condition-left')).toHaveTextContent( + '{{ $json.name }}', + ), + ); + + expect(emitted('valueChanged')).toBeUndefined(); + + const expressionEditor = within(condition) + .getByTestId('filter-condition-left') + .querySelector('.cm-line'); + + if (expressionEditor) { + await userEvent.type(expressionEditor, 'test'); + } + + await waitFor(() => { + expect(get(emitted('valueChanged')[0], '0.value.conditions.0.leftValue')).toEqual( + expect.stringContaining('test'), + ); + }); + + const parameterInput = within(condition) + .getByTestId('filter-condition-right') + .querySelector('input'); + + if (parameterInput) { + await userEvent.type(parameterInput, 'test'); + } + + await waitFor(() => { + expect(get(emitted('valueChanged')[0], '0.value.conditions.0.leftValue')).toEqual( + expect.stringContaining('test'), + ); + expect(get(emitted('valueChanged')[0], '0.value.conditions.0.rightValue')).toEqual( + expect.stringContaining('test'), + ); + }); + }); + it('renders correctly in read only mode', async () => { const { findAllByTestId, queryByTestId } = renderComponent({ props: { From 9da93680c28f9191eac7edc452e5123749e5c148 Mon Sep 17 00:00:00 2001 From: Giulio Cinelli Date: Wed, 22 May 2024 09:49:51 +0200 Subject: [PATCH 02/24] feat(Strava Node): Update to use sport type (#9462) Co-authored-by: Jonathan Bennetts --- .../nodes/Strava/ActivityDescription.ts | 138 ++++++++++++++++++ .../nodes-base/nodes/Strava/Strava.node.ts | 14 +- 2 files changed, 148 insertions(+), 4 deletions(-) diff --git a/packages/nodes-base/nodes/Strava/ActivityDescription.ts b/packages/nodes-base/nodes/Strava/ActivityDescription.ts index ddf7179ce45f2..4532ccebcfdcd 100644 --- a/packages/nodes-base/nodes/Strava/ActivityDescription.ts +++ b/packages/nodes-base/nodes/Strava/ActivityDescription.ts @@ -98,11 +98,80 @@ export const activityFields: INodeProperties[] = [ show: { resource: ['activity'], operation: ['create'], + '@version': [1], }, }, default: '', description: 'Type of activity. For example - Run, Ride etc.', }, + { + displayName: 'Sport Type', + name: 'sport_type', + type: 'options', + options: [ + { name: 'Alpine Ski', value: 'AlpineSki' }, + { name: 'Backcountry Ski', value: 'BackcountrySki' }, + { name: 'Badminton', value: 'Badminton' }, + { name: 'Canoeing', value: 'Canoeing' }, + { name: 'Crossfit', value: 'Crossfit' }, + { name: 'EBike Ride', value: 'EBikeRide' }, + { name: 'Elliptical', value: 'Elliptical' }, + { name: 'EMountain Bike Ride', value: 'EMountainBikeRide' }, + { name: 'Golf', value: 'Golf' }, + { name: 'Gravel Ride', value: 'GravelRide' }, + { name: 'Handcycle', value: 'Handcycle' }, + { name: 'HIIT', value: 'HighIntensityIntervalTraining' }, + { name: 'Hike', value: 'Hike' }, + { name: 'Ice Skate', value: 'IceSkate' }, + { name: 'Inline Skate', value: 'InlineSkate' }, + { name: 'Kayaking', value: 'Kayaking' }, + { name: 'Kitesurf', value: 'Kitesurf' }, + { name: 'Mountain Bike Ride', value: 'MountainBikeRide' }, + { name: 'Nordic Ski', value: 'NordicSki' }, + { name: 'Pickleball', value: 'Pickleball' }, + { name: 'Pilates', value: 'Pilates' }, + { name: 'Racquetball', value: 'Racquetball' }, + { name: 'Ride', value: 'Ride' }, + { name: 'Rock Climbing', value: 'RockClimbing' }, + { name: 'Roller Ski', value: 'RollerSki' }, + { name: 'Rowing', value: 'Rowing' }, + { name: 'Run', value: 'Run' }, + { name: 'Sail', value: 'Sail' }, + { name: 'Skateboard', value: 'Skateboard' }, + { name: 'Snowboard', value: 'Snowboard' }, + { name: 'Snowshoe', value: 'Snowshoe' }, + { name: 'Soccer', value: 'Soccer' }, + { name: 'Squash', value: 'Squash' }, + { name: 'Stair Stepper', value: 'StairStepper' }, + { name: 'Stand Up Paddling', value: 'StandUpPaddling' }, + { name: 'Surfing', value: 'Surfing' }, + { name: 'Swim', value: 'Swim' }, + { name: 'Table Tennis', value: 'TableTennis' }, + { name: 'Tennis', value: 'Tennis' }, + { name: 'Trail Run', value: 'TrailRun' }, + { name: 'Velomobile', value: 'Velomobile' }, + { name: 'Virtual Ride', value: 'VirtualRide' }, + { name: 'Virtual Row', value: 'VirtualRow' }, + { name: 'Virtual Run', value: 'VirtualRun' }, + { name: 'Walk', value: 'Walk' }, + { name: 'Weight Training', value: 'WeightTraining' }, + { name: 'Wheelchair', value: 'Wheelchair' }, + { name: 'Windsurf', value: 'Windsurf' }, + { name: 'Workout', value: 'Workout' }, + { name: 'Yoga', value: 'Yoga' }, + ], + default: 'Run', + description: 'Type of sport', + displayOptions: { + show: { + resource: ['activity'], + operation: ['create'], + }, + hide: { + '@version': [1], + }, + }, + }, { displayName: 'Start Date', name: 'startDate', @@ -254,6 +323,75 @@ export const activityFields: INodeProperties[] = [ type: 'string', default: '', description: 'Type of activity. For example - Run, Ride etc.', + displayOptions: { + show: { + '@version': [1], + }, + }, + }, + { + displayName: 'Sport Type', + name: 'sport_type', + type: 'options', + options: [ + { name: 'Alpine Ski', value: 'AlpineSki' }, + { name: 'Backcountry Ski', value: 'BackcountrySki' }, + { name: 'Badminton', value: 'Badminton' }, + { name: 'Canoeing', value: 'Canoeing' }, + { name: 'Crossfit', value: 'Crossfit' }, + { name: 'EBike Ride', value: 'EBikeRide' }, + { name: 'Elliptical', value: 'Elliptical' }, + { name: 'EMountain Bike Ride', value: 'EMountainBikeRide' }, + { name: 'Golf', value: 'Golf' }, + { name: 'Gravel Ride', value: 'GravelRide' }, + { name: 'Handcycle', value: 'Handcycle' }, + { name: 'HIIT', value: 'HighIntensityIntervalTraining' }, + { name: 'Hike', value: 'Hike' }, + { name: 'Ice Skate', value: 'IceSkate' }, + { name: 'Inline Skate', value: 'InlineSkate' }, + { name: 'Kayaking', value: 'Kayaking' }, + { name: 'Kitesurf', value: 'Kitesurf' }, + { name: 'Mountain Bike Ride', value: 'MountainBikeRide' }, + { name: 'Nordic Ski', value: 'NordicSki' }, + { name: 'Pickleball', value: 'Pickleball' }, + { name: 'Pilates', value: 'Pilates' }, + { name: 'Racquetball', value: 'Racquetball' }, + { name: 'Ride', value: 'Ride' }, + { name: 'Rock Climbing', value: 'RockClimbing' }, + { name: 'Roller Ski', value: 'RollerSki' }, + { name: 'Rowing', value: 'Rowing' }, + { name: 'Run', value: 'Run' }, + { name: 'Sail', value: 'Sail' }, + { name: 'Skateboard', value: 'Skateboard' }, + { name: 'Snowboard', value: 'Snowboard' }, + { name: 'Snowshoe', value: 'Snowshoe' }, + { name: 'Soccer', value: 'Soccer' }, + { name: 'Squash', value: 'Squash' }, + { name: 'Stair Stepper', value: 'StairStepper' }, + { name: 'Stand Up Paddling', value: 'StandUpPaddling' }, + { name: 'Surfing', value: 'Surfing' }, + { name: 'Swim', value: 'Swim' }, + { name: 'Table Tennis', value: 'TableTennis' }, + { name: 'Tennis', value: 'Tennis' }, + { name: 'Trail Run', value: 'TrailRun' }, + { name: 'Velomobile', value: 'Velomobile' }, + { name: 'Virtual Ride', value: 'VirtualRide' }, + { name: 'Virtual Row', value: 'VirtualRow' }, + { name: 'Virtual Run', value: 'VirtualRun' }, + { name: 'Walk', value: 'Walk' }, + { name: 'Weight Training', value: 'WeightTraining' }, + { name: 'Wheelchair', value: 'Wheelchair' }, + { name: 'Windsurf', value: 'Windsurf' }, + { name: 'Workout', value: 'Workout' }, + { name: 'Yoga', value: 'Yoga' }, + ], + default: 'Run', + description: 'Type of sport', + displayOptions: { + hide: { + '@version': [1], + }, + }, }, { displayName: 'Trainer', diff --git a/packages/nodes-base/nodes/Strava/Strava.node.ts b/packages/nodes-base/nodes/Strava/Strava.node.ts index f88c9c94dc905..f637009e0f990 100644 --- a/packages/nodes-base/nodes/Strava/Strava.node.ts +++ b/packages/nodes-base/nodes/Strava/Strava.node.ts @@ -17,7 +17,7 @@ export class Strava implements INodeType { name: 'strava', icon: 'file:strava.svg', group: ['input'], - version: 1, + version: [1, 1.1], subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', description: 'Consume Strava API', defaults: { @@ -56,6 +56,7 @@ export class Strava implements INodeType { const length = items.length; const qs: IDataObject = {}; let responseData; + const nodeVersion = this.getNode().typeVersion; const resource = this.getNodeParameter('resource', 0); const operation = this.getNodeParameter('operation', 0); for (let i = 0; i < length; i++) { @@ -65,8 +66,6 @@ export class Strava implements INodeType { if (operation === 'create') { const name = this.getNodeParameter('name', i) as string; - const type = this.getNodeParameter('type', i) as string; - const startDate = this.getNodeParameter('startDate', i) as string; const elapsedTime = this.getNodeParameter('elapsedTime', i) as number; @@ -83,11 +82,18 @@ export class Strava implements INodeType { const body: IDataObject = { name, - type, start_date_local: moment(startDate).toISOString(), elapsed_time: elapsedTime, }; + if (nodeVersion === 1) { + const type = this.getNodeParameter('type', i) as string; + body.type = type; + } else { + const sportType = this.getNodeParameter('sport_type', i) as string; + body.sport_type = sportType; + } + Object.assign(body, additionalFields); responseData = await stravaApiRequest.call(this, 'POST', '/activities', body); From 7cb431f50644a0896458b6562e8801189254b704 Mon Sep 17 00:00:00 2001 From: pemontto <939704+pemontto@users.noreply.github.com> Date: Wed, 22 May 2024 08:57:34 +0100 Subject: [PATCH 03/24] fix(core): Fix typos in common error messages (no-changelog) (#9478) --- packages/workflow/src/errors/abstract/node.error.ts | 10 +++++----- packages/workflow/test/NodeErrors.test.ts | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/workflow/src/errors/abstract/node.error.ts b/packages/workflow/src/errors/abstract/node.error.ts index 060751cd86c5e..dde40284f8abc 100644 --- a/packages/workflow/src/errors/abstract/node.error.ts +++ b/packages/workflow/src/errors/abstract/node.error.ts @@ -9,11 +9,11 @@ const COMMON_ERRORS: IDataObject = { // nodeJS errors ECONNREFUSED: 'The service refused the connection - perhaps it is offline', ECONNRESET: - 'The connection to the server wes closed unexpectedly, perhaps it is offline. You can retry request immidiately or wait and retry later.', + 'The connection to the server was closed unexpectedly, perhaps it is offline. You can retry the request immediately or wait and retry later.', ENOTFOUND: - 'The connection cannot be established, this usually occurs due to an incorrect host(domain) value', + 'The connection cannot be established, this usually occurs due to an incorrect host (domain) value', ETIMEDOUT: - "The connection timed out, consider setting 'Retry on Fail' option in the node settings", + "The connection timed out, consider setting the 'Retry on Fail' option in the node settings", ERRADDRINUSE: 'The port is already occupied by some other application, if possible change the port or kill the application that is using it', EADDRNOTAVAIL: 'The address is not available, ensure that you have the right IP address', @@ -21,8 +21,8 @@ const COMMON_ERRORS: IDataObject = { EHOSTUNREACH: 'The host is unreachable, perhaps the server is offline', EAI_AGAIN: 'The DNS server returned an error, perhaps the server is offline', ENOENT: 'The file or directory does not exist', - EISDIR: 'The file path expected but a given path is a directory', - ENOTDIR: 'The directory path expected but a given path is a file', + EISDIR: 'The file path was expected but the given path is a directory', + ENOTDIR: 'The directory path was expected but the given path is a file', EACCES: 'Forbidden by access permissions, make sure you have the right permissions', EEXIST: 'The file or directory already exists', EPERM: 'Operation not permitted, make sure you have the right permissions', diff --git a/packages/workflow/test/NodeErrors.test.ts b/packages/workflow/test/NodeErrors.test.ts index 8b9f7fefbbb91..405c5919325a3 100644 --- a/packages/workflow/test/NodeErrors.test.ts +++ b/packages/workflow/test/NodeErrors.test.ts @@ -81,7 +81,7 @@ describe('NodeErrors tests', () => { const nodeOperationError = new NodeOperationError(node, 'ENOTFOUND test error message'); expect(nodeOperationError.message).toEqual( - 'The connection cannot be established, this usually occurs due to an incorrect host(domain) value', + 'The connection cannot be established, this usually occurs due to an incorrect host (domain) value', ); }); @@ -89,7 +89,7 @@ describe('NodeErrors tests', () => { const nodeApiError = new NodeApiError(node, { message: 'ENOTFOUND test error message' }); expect(nodeApiError.message).toEqual( - 'The connection cannot be established, this usually occurs due to an incorrect host(domain) value', + 'The connection cannot be established, this usually occurs due to an incorrect host (domain) value', ); }); From a591f63e3ff51c19fe48185144725e881c418b23 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Wed, 22 May 2024 10:05:31 +0200 Subject: [PATCH 04/24] feat(editor): Add examples for root expression methods (#9373) --- .../completions/base.completions.ts | 4 +- .../completions/execution.completions.ts | 25 +- .../src/components/CodeNodeEditor/utils.ts | 13 + .../completions/__tests__/completions.test.ts | 14 +- .../codemirror/completions/constants.ts | 145 +++-- .../completions/datatype.completions.ts | 595 ++++++++++++++++-- .../completions/dollar.completions.ts | 11 +- .../luxon.instance.docs.ts | 8 +- .../completions/nonDollar.completions.ts | 25 +- .../plugins/codemirror/completions/utils.ts | 15 - packages/editor-ui/src/plugins/i18n/index.ts | 61 -- .../src/plugins/i18n/locales/en.json | 147 +++-- 12 files changed, 800 insertions(+), 263 deletions(-) diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts index 8c19880f485e4..e45075e025e44 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts @@ -1,5 +1,5 @@ import { NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION } from '../constants'; -import { addVarType } from '../utils'; +import { addInfoRenderer, addVarType } from '../utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import type { INodeUi } from '@/Interface'; import { useWorkflowsStore } from '@/stores/workflows.store'; @@ -128,7 +128,7 @@ export function useBaseCompletions( return { from: preCursor.from, - options, + options: options.map(addInfoRenderer), }; }; diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts index fcd229a86d920..47bad52b2a481 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts @@ -1,4 +1,4 @@ -import { addVarType, escape } from '../utils'; +import { addInfoRenderer, addVarType, escape } from '../utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import { useI18n } from '@/composables/useI18n'; @@ -18,15 +18,6 @@ export function useExecutionCompletions() { if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; - const buildLinkNode = (text: string) => { - const wrapper = document.createElement('span'); - // This is being loaded from the locales file. This could - // cause an XSS of some kind but multiple other locales strings - // do the same thing. - wrapper.innerHTML = text; - return () => wrapper; - }; - const options: Completion[] = [ { label: `${matcher}.id`, @@ -46,29 +37,25 @@ export function useExecutionCompletions() { }, { label: `${matcher}.customData.set("key", "value")`, - info: buildLinkNode(i18n.baseText('codeNodeEditor.completer.$execution.customData.set()')), + info: i18n.baseText('codeNodeEditor.completer.$execution.customData.set'), }, { label: `${matcher}.customData.get("key")`, - info: buildLinkNode(i18n.baseText('codeNodeEditor.completer.$execution.customData.get()')), + info: i18n.baseText('codeNodeEditor.completer.$execution.customData.get'), }, { label: `${matcher}.customData.setAll({})`, - info: buildLinkNode( - i18n.baseText('codeNodeEditor.completer.$execution.customData.setAll()'), - ), + info: i18n.baseText('codeNodeEditor.completer.$execution.customData.setAll'), }, { label: `${matcher}.customData.getAll()`, - info: buildLinkNode( - i18n.baseText('codeNodeEditor.completer.$execution.customData.getAll()'), - ), + info: i18n.baseText('codeNodeEditor.completer.$execution.customData.getAll'), }, ]; return { from: preCursor.from, - options: options.map(addVarType), + options: options.map(addVarType).map(addInfoRenderer), }; }; diff --git a/packages/editor-ui/src/components/CodeNodeEditor/utils.ts b/packages/editor-ui/src/components/CodeNodeEditor/utils.ts index be8560a61e352..d37bc876482df 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/utils.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/utils.ts @@ -2,6 +2,7 @@ import type * as esprima from 'esprima-next'; import type { Completion } from '@codemirror/autocomplete'; import type { Node } from 'estree'; import type { RangeNode } from './types'; +import { sanitizeHtml } from '@/utils/htmlUtils'; export function walk( node: Node | esprima.Program, @@ -40,3 +41,15 @@ export const escape = (str: string) => export const toVariableOption = (label: string) => ({ label, type: 'variable' }); export const addVarType = (option: Completion) => ({ ...option, type: 'variable' }); + +export const addInfoRenderer = (option: Completion): Completion => { + const { info } = option; + if (typeof info === 'string') { + option.info = () => { + const wrapper = document.createElement('span'); + wrapper.innerHTML = sanitizeHtml(info); + return wrapper; + }; + } + return option; +}; 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 0f8f41c899286..a203b01c32db7 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 @@ -772,11 +772,19 @@ describe('Resolution-based completions', () => { test('should not display type information for other completions', () => { vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue({ str: 'bar', + id: '123', + isExecuted: false, }); - expect(completions('{{ $execution.| }}')?.every((item) => !item.detail)).toBe(true); - expect(completions('{{ $input.params.| }}')?.every((item) => !item.detail)).toBe(true); - expect(completions('{{ $("My Node").| }}')?.every((item) => !item.detail)).toBe(true); + expect(completions('{{ $execution.| }}')).not.toContainEqual( + expect.objectContaining({ detail: expect.any(String) }), + ); + expect(completions('{{ $input.params.| }}')).not.toContainEqual( + expect.objectContaining({ detail: expect.any(String) }), + ); + expect(completions('{{ $("My Node").| }}')).not.toContainEqual( + expect.objectContaining({ detail: expect.any(String) }), + ); }); }); }); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/constants.ts b/packages/editor-ui/src/plugins/codemirror/completions/constants.ts index 1db422594b73b..866afc8c8231b 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/constants.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/constants.ts @@ -55,7 +55,7 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [ info: createInfoBoxRenderer({ name: '$json', returnType: 'object', - description: i18n.rootVars.$json, + description: i18n.baseText('codeNodeEditor.completer.json'), docURL: 'https://docs.n8n.io/data/data-structure/', }), }, @@ -65,7 +65,7 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [ info: createInfoBoxRenderer({ name: '$binary', returnType: 'object', - description: i18n.rootVars.$binary, + description: i18n.baseText('codeNodeEditor.completer.binary'), }), }, { @@ -74,7 +74,7 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [ info: createInfoBoxRenderer({ name: '$now', returnType: 'DateTime', - description: i18n.rootVars.$now, + description: i18n.baseText('codeNodeEditor.completer.$now'), }), }, { @@ -83,25 +83,36 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [ info: createInfoBoxRenderer( { name: '$if', - returnType: 'boolean', - description: i18n.rootVars.$if, + returnType: 'any', + description: i18n.baseText('codeNodeEditor.completer.$if'), args: [ { name: 'condition', - optional: false, + description: i18n.baseText('codeNodeEditor.completer.$if.args.condition'), type: 'boolean', }, { name: 'valueIfTrue', - optional: false, + description: i18n.baseText('codeNodeEditor.completer.$if.args.valueIfTrue'), type: 'any', }, { name: 'valueIfFalse', - optional: false, + description: i18n.baseText('codeNodeEditor.completer.$if.args.valueIfFalse'), type: 'any', }, ], + examples: [ + { + example: '$if($now.hour < 17, "Good day", "Good evening")', + description: i18n.baseText('codeNodeEditor.completer.$if.examples.1'), + }, + { + description: i18n.baseText('codeNodeEditor.completer.$if.examples.2'), + example: + '$if($now.hour < 10, "Good morning", $if($now.hour < 17, "Good day", "Good evening"))', + }, + ], }, true, ), @@ -112,20 +123,26 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [ info: createInfoBoxRenderer( { name: '$ifEmpty', - returnType: 'boolean', - description: i18n.rootVars.$ifEmpty, + returnType: 'any', + description: i18n.baseText('codeNodeEditor.completer.$ifEmpty'), args: [ { name: 'value', - optional: false, + description: i18n.baseText('codeNodeEditor.completer.$ifEmpty.args.value'), type: 'any', }, { name: 'valueIfEmpty', - optional: false, + description: i18n.baseText('codeNodeEditor.completer.$ifEmpty.args.valueIfEmpty'), type: 'any', }, ], + examples: [ + { + example: '"Hi " + $ifEmpty(name, "there")', + evaluated: 'e.g. "Hi Nathan" or "Hi there"', + }, + ], }, true, ), @@ -135,8 +152,8 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [ section: METADATA_SECTION, info: createInfoBoxRenderer({ name: '$execution', - returnType: 'object', - description: i18n.rootVars.$execution, + returnType: 'ExecData', + description: i18n.baseText('codeNodeEditor.completer.$execution'), }), }, { @@ -145,7 +162,7 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [ info: createInfoBoxRenderer({ name: '$itemIndex', returnType: 'number', - description: i18n.rootVars.$itemIndex, + description: i18n.baseText('codeNodeEditor.completer.$itemIndex'), }), }, { @@ -154,7 +171,7 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [ info: createInfoBoxRenderer({ name: '$input', returnType: 'object', - description: i18n.rootVars.$input, + description: i18n.baseText('codeNodeEditor.completer.$input'), }), }, { @@ -163,7 +180,7 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [ info: createInfoBoxRenderer({ name: '$parameter', returnType: 'object', - description: i18n.rootVars.$parameter, + description: i18n.baseText('codeNodeEditor.completer.$parameter'), }), }, { @@ -171,8 +188,8 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [ section: METADATA_SECTION, info: createInfoBoxRenderer({ name: '$prevNode', - returnType: 'object', - description: i18n.rootVars.$prevNode, + returnType: 'PrevNodeData', + description: i18n.baseText('codeNodeEditor.completer.$prevNode'), }), }, { @@ -181,7 +198,7 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [ info: createInfoBoxRenderer({ name: '$runIndex', returnType: 'number', - description: i18n.rootVars.$runIndex, + description: i18n.baseText('codeNodeEditor.completer.$runIndex'), }), }, { @@ -190,7 +207,7 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [ info: createInfoBoxRenderer({ name: '$today', returnType: 'DateTime', - description: i18n.rootVars.$today, + description: i18n.baseText('codeNodeEditor.completer.$today'), }), }, { @@ -199,7 +216,7 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [ info: createInfoBoxRenderer({ name: '$vars', returnType: 'object', - description: i18n.rootVars.$vars, + description: i18n.baseText('codeNodeEditor.completer.$vars'), }), }, { @@ -207,8 +224,8 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [ section: METADATA_SECTION, info: createInfoBoxRenderer({ name: '$workflow', - returnType: 'object', - description: i18n.rootVars.$workflow, + returnType: 'WorkflowData', + description: i18n.baseText('codeNodeEditor.completer.$workflow'), }), }, { @@ -217,8 +234,44 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [ info: createInfoBoxRenderer( { name: '$jmespath', + description: i18n.baseText('codeNodeEditor.completer.$jmespath'), returnType: 'any', - description: i18n.rootVars.$jmespath, + args: [ + { + name: 'obj', + description: i18n.baseText('codeNodeEditor.completer.$jmespath.args.obj'), + type: 'Object | Array', + }, + { + name: 'expression', + description: i18n.baseText('codeNodeEditor.completer.$jmespath.args.expression'), + type: 'string', + }, + ], + examples: [ + { + example: + 'data = {\n "people": [\n {\n "name": "Bob",\n "age": 20,\n "other": "foo"\n },\n {\n "name": "Fred",\n "age": 25,\n "other": "bar"\n },\n {\n "name": "George",\n "age": 30,\n "other": "baz"\n }\n ]\n}\n\n$jmespath(data.people, \'[*].name\')', + evaluated: "['Bob', 'Fred', 'George']", + description: i18n.baseText('codeNodeEditor.completer.$jmespath.examples.1'), + }, + { + example: "$jmespath(data.people, '[?age > `20`].[name, age]')", + evaluated: "[['Fred', 25], ['George', 30]]", + description: i18n.baseText('codeNodeEditor.completer.$jmespath.examples.2'), + }, + { + example: "$jmespath(data.people, '[?age > `20`].name | [0]')", + evaluated: 'Fred', + description: i18n.baseText('codeNodeEditor.completer.$jmespath.examples.3'), + }, + { + example: + 'data = {\n "reservations": [\n {\n "id": 1,\n "guests": [\n {\n "name": "Nathan",\n "requirements": {\n "room": "double",\n "meal": "vegetarian"\n }\n },\n {\n "name": "Meg",\n "requirements": {\n "room": "single"\n }\n }\n ]\n },\n {\n "id": 2,\n "guests": [\n {\n "name": "Lex",\n "requirements": {\n "room": "double"\n }\n }\n ]\n }\n ]\n}\n\n$jmespath(data, "reservations[].guests[?requirements.room==\'double\'][].name")', + evaluated: "['Nathan', 'Lex']", + description: i18n.baseText('codeNodeEditor.completer.$jmespath.examples.4'), + }, + ], }, true, ), @@ -230,24 +283,16 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [ { name: '$max', returnType: 'number', - description: i18n.rootVars.$max, + description: i18n.baseText('codeNodeEditor.completer.$max'), args: [ { - name: 'number1', - optional: false, - type: 'number', - }, - { - name: 'number2', - optional: true, - type: 'number', - }, - { - name: 'numberN', - optional: true, + name: 'numbers', + description: i18n.baseText('codeNodeEditor.completer.$max.args.numbers'), type: 'number', + variadic: true, }, ], + examples: [{ example: '$max(1, 5, 42, 0.5)', evaluated: '42' }], }, true, ), @@ -259,24 +304,16 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [ { name: '$min', returnType: 'number', - description: i18n.rootVars.$min, + description: i18n.baseText('codeNodeEditor.completer.$min'), args: [ { - name: 'number1', - optional: false, - type: 'number', - }, - { - name: 'number2', - optional: true, - type: 'number', - }, - { - name: 'numberN', - optional: true, + name: 'numbers', + description: i18n.baseText('codeNodeEditor.completer.$max.args.numbers'), + variadic: true, type: 'number', }, ], + examples: [{ example: '$min(1, 5, 42, 0.5)', evaluated: '0.5' }], }, true, ), @@ -284,7 +321,11 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [ { label: '$nodeVersion', section: METADATA_SECTION, - info: i18n.rootVars.$nodeVersion, + info: createInfoBoxRenderer({ + name: '$nodeVersion', + returnType: 'number', + description: i18n.baseText('codeNodeEditor.completer.$nodeVersion'), + }), }, ]; 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 733da5d7e0727..eab860d3d53aa 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -48,7 +48,6 @@ import { isSplitInBatchesAbsent, longestCommonPrefix, prefixMatch, - setRank, sortCompletionsAlpha, splitBaseTail, stripExcessParens, @@ -244,6 +243,20 @@ export const isInputData = (base: string): boolean => { ); }; +export const isItem = (input: AutocompleteInput): boolean => { + const { base, resolved } = input; + return /^(\$\(.*\)|\$input)/.test(base) && 'pairedItem' in resolved; +}; + +export const isBinary = (input: AutocompleteInput): boolean => { + const { base, resolved } = input; + return ( + /^(\$\(.*\)\..*\.binary\..*|\$binary)/.test(base) && + 'mimeType' in resolved && + 'fileExtension' in resolved + ); +}; + export const getDetail = (base: string, value: unknown): string | undefined => { const type = getType(value); if (!isInputData(base) || type === 'function') return undefined; @@ -300,29 +313,54 @@ const createCompletionOption = ({ return option; }; +const customObjectOptions = (input: AutocompleteInput): Completion[] => { + const { base, resolved } = input; + + if (!resolved) return []; + + if (base === '$execution') { + return executionOptions(); + } else if (base === '$execution.customData') { + return customDataOptions(); + } else if (base === '$workflow') { + return workflowOptions(); + } else if (base === '$input') { + return inputOptions(base); + } else if (base === '$prevNode') { + return prevNodeOptions(); + } else if (/^\$\(['"][\S\s]+['"]\)$/.test(base)) { + return nodeRefOptions(base); + } else if (base === '$response') { + return responseOptions(); + } else if (isItem(input)) { + return itemOptions(); + } else if (isBinary(input)) { + return binaryOptions(); + } + + return []; +}; + const objectOptions = (input: AutocompleteInput): Completion[] => { const { base, resolved, transformLabel = (label) => label } = input; - const rank = setRank(['item', 'all', 'first', 'last']); const SKIP = new Set(['__ob__', 'pairedItem']); if (isSplitInBatchesAbsent()) SKIP.add('context'); - const name = /^\$\(.*\)$/.test(base) ? '$()' : base; - - if (['$input', '$()'].includes(name) && hasNoParams(base)) SKIP.add('params'); - let rawKeys = Object.keys(resolved); - if (name === '$()') { - rawKeys = Reflect.ownKeys(resolved) as string[]; - } - if (base === 'Math') { const descriptors = Object.getOwnPropertyDescriptors(Math); rawKeys = Object.keys(descriptors).sort((a, b) => a.localeCompare(b)); } - const localKeys = rank(rawKeys) + const customOptions = customObjectOptions(input); + if (customOptions.length > 0) { + // Only return completions that are present in the resolved data + return customOptions.filter((option) => option.label in resolved); + } + + const localKeys = rawKeys .filter((key) => !SKIP.has(key) && !isPseudoParam(key)) .map((key) => { ensureKeyCanBeResolved(resolved, key); @@ -330,28 +368,26 @@ const objectOptions = (input: AutocompleteInput): Completion[] => { const resolvedProp = resolved[key]; const isFunction = typeof resolvedProp === 'function'; - const hasArgs = isFunction && resolvedProp.length > 0 && name !== '$()'; + const hasArgs = isFunction && resolvedProp.length > 0; const option: Completion = { label: isFunction ? key + '()' : key, - section: getObjectPropertySection({ name, key, isFunction }), + section: isFunction ? METHODS_SECTION : FIELDS_SECTION, apply: needsBracketAccess ? applyBracketAccessCompletion : applyCompletion({ hasArgs, transformLabel, }), - detail: getDetail(name, resolvedProp), + detail: getDetail(base, resolvedProp), }; - const infoKey = [name, key].join('.'); const infoName = needsBracketAccess ? applyBracketAccess(key) : key; option.info = createCompletionOption({ name: infoName, doc: { name: infoName, returnType: isFunction ? 'any' : getType(resolvedProp), - description: i18n.proxyVars[infoKey], }, isFunction, transformLabel, @@ -366,6 +402,7 @@ const objectOptions = (input: AutocompleteInput): Completion[] => { /json('])$/.test(base) || base === '$execution' || base.endsWith('params') || + base.endsWith('binary') || base === 'Math'; if (skipObjectExtensions) { @@ -386,23 +423,6 @@ const objectOptions = (input: AutocompleteInput): Completion[] => { }); }; -const getObjectPropertySection = ({ - name, - key, - isFunction, -}: { - name: string; - key: string; - isFunction: boolean; -}): CompletionSection => { - if (name === '$input' || name === '$()') { - if (key === 'item') return RECOMMENDED_SECTION; - return OTHER_SECTION; - } - - return isFunction ? METHODS_SECTION : FIELDS_SECTION; -}; - const applySections = ({ options, sections, @@ -685,6 +705,420 @@ export const variablesOptions = () => { ); }; +export const responseOptions = () => { + return [ + { + name: 'statusCode', + returnType: 'number', + docURL: 'https://docs.n8n.io/code/builtin/http-node-variables/', + description: i18n.baseText('codeNodeEditor.completer.$response.statusCode'), + }, + { + name: 'statusMessage', + returnType: 'string', + description: i18n.baseText('codeNodeEditor.completer.$response.statusMessage'), + }, + { + name: 'headers', + returnType: 'Object', + docURL: 'https://docs.n8n.io/code/builtin/http-node-variables/', + description: i18n.baseText('codeNodeEditor.completer.$response.headers'), + }, + { + name: 'body', + returnType: 'Object', + docURL: 'https://docs.n8n.io/code/builtin/http-node-variables/', + description: i18n.baseText('codeNodeEditor.completer.$response.body'), + }, + ].map((doc) => createCompletionOption({ name: doc.name, doc })); +}; + +export const executionOptions = () => { + return [ + { + name: 'id', + returnType: 'string', + description: i18n.baseText('codeNodeEditor.completer.$execution.id'), + }, + { + name: 'mode', + returnType: 'string', + description: i18n.baseText('codeNodeEditor.completer.$execution.mode'), + }, + + { + name: 'resumeUrl', + returnType: 'string', + docURL: 'https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/', + description: i18n.baseText('codeNodeEditor.completer.$execution.resumeUrl'), + }, + { + name: 'resumeFormUrl', + returnType: 'string', + docURL: 'https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/', + description: i18n.baseText('codeNodeEditor.completer.$execution.resumeFormUrl'), + }, + { + name: 'customData', + returnType: 'CustomData', + docURL: 'https://docs.n8n.io/workflows/executions/custom-executions-data/', + description: i18n.baseText('codeNodeEditor.completer.$execution.customData'), + }, + ].map((doc) => createCompletionOption({ name: doc.name, doc })); +}; + +export const customDataOptions = () => { + return [ + { + name: 'get', + returnType: 'any', + docURL: 'https://docs.n8n.io/workflows/executions/custom-executions-data/', + args: [ + { + name: 'key', + description: 'The key (identifier) under which the data is stored', + type: 'string', + }, + ], + description: i18n.baseText('codeNodeEditor.completer.$execution.customData.get'), + examples: [ + { + description: i18n.baseText( + 'codeNodeEditor.completer.$execution.customData.get.examples.1', + ), + example: '$execution.customData.get("user_email")', + evaluated: '"me@example.com"', + }, + ], + }, + { + name: 'set', + returnType: 'void', + args: [ + { + name: 'key', + description: i18n.baseText('codeNodeEditor.completer.$execution.customData.set.args.key'), + type: 'string', + }, + { + name: 'value', + description: i18n.baseText( + 'codeNodeEditor.completer.$execution.customData.set.args.value', + ), + type: 'any', + }, + ], + docURL: 'https://docs.n8n.io/workflows/executions/custom-executions-data/', + description: i18n.baseText('codeNodeEditor.completer.$execution.customData.set'), + examples: [ + { + description: i18n.baseText( + 'codeNodeEditor.completer.$execution.customData.set.examples.1', + ), + example: '$execution.customData.set("user_email", "me@example.com")', + }, + ], + }, + { + name: 'getAll', + returnType: 'object', + docURL: 'https://docs.n8n.io/workflows/executions/custom-executions-data/', + description: i18n.baseText('codeNodeEditor.completer.$execution.customData.getAll'), + examples: [ + { + example: '$execution.customData.getAll()', + evaluated: '{ user_email: "me@example.com", id: 1234 }', + }, + ], + }, + { + name: 'setAll', + returnType: 'void', + args: [ + { + name: 'obj', + description: i18n.baseText( + 'codeNodeEditor.completer.$execution.customData.setAll.args.obj', + ), + type: 'object', + }, + ], + docURL: 'https://docs.n8n.io/workflows/executions/custom-executions-data/', + description: i18n.baseText('codeNodeEditor.completer.$execution.customData.setAll'), + examples: [ + { example: '$execution.customData.setAll({ user_email: "me@example.com", id: 1234 })' }, + ], + }, + ].map((doc) => createCompletionOption({ name: doc.name, doc, isFunction: true })); +}; + +export const nodeRefOptions = (base: string) => { + const itemArgs = [ + { + name: 'branchIndex', + optional: true, + description: i18n.baseText('codeNodeEditor.completer.selector.args.branchIndex'), + default: '0', + type: 'number', + }, + { + name: 'runIndex', + optional: true, + description: i18n.baseText('codeNodeEditor.completer.selector.args.runIndex'), + default: '0', + type: 'number', + }, + ]; + + const options: Array<{ doc: DocMetadata; isFunction?: boolean }> = [ + { + doc: { + name: 'item', + returnType: 'Item', + docURL: 'https://docs.n8n.io/data/data-mapping/data-item-linking/', + description: i18n.baseText('codeNodeEditor.completer.selector.item'), + }, + }, + { + doc: { + name: 'isExecuted', + returnType: 'boolean', + description: i18n.baseText('codeNodeEditor.completer.selector.isExecuted'), + }, + }, + { + doc: { + name: 'params', + returnType: 'NodeParams', + description: i18n.baseText('codeNodeEditor.completer.selector.params'), + }, + }, + { + doc: { + name: 'itemMatching', + returnType: 'Item', + args: [ + { + name: 'currentItemIndex', + description: i18n.baseText( + 'codeNodeEditor.completer.selector.itemMatching.args.currentItemIndex', + ), + default: '0', + type: 'number', + }, + ], + docURL: 'https://docs.n8n.io/data/data-mapping/data-item-linking/', + description: i18n.baseText('codeNodeEditor.completer.selector.itemMatching'), + }, + isFunction: true, + }, + { + doc: { + name: 'first', + returnType: 'Item', + args: itemArgs, + description: i18n.baseText('codeNodeEditor.completer.selector.first'), + }, + isFunction: true, + }, + { + doc: { + name: 'last', + returnType: 'Item', + args: itemArgs, + description: i18n.baseText('codeNodeEditor.completer.selector.last'), + }, + isFunction: true, + }, + { + doc: { + name: 'all', + returnType: 'Item[]', + args: itemArgs, + description: i18n.baseText('codeNodeEditor.completer.selector.all'), + }, + isFunction: true, + }, + ]; + + return applySections({ + options: options + .filter((option) => !(option.doc.name === 'params' && hasNoParams(base))) + .map(({ doc, isFunction }) => createCompletionOption({ name: doc.name, doc, isFunction })), + sections: {}, + recommended: ['item'], + }); +}; + +export const inputOptions = (base: string) => { + const itemArgs = [ + { + name: 'branchIndex', + optional: true, + description: i18n.baseText('codeNodeEditor.completer.selector.args.branchIndex'), + default: '0', + type: 'number', + }, + { + name: 'runIndex', + optional: true, + description: i18n.baseText('codeNodeEditor.completer.selector.args.runIndex'), + default: '0', + type: 'number', + }, + ]; + + const options: Array<{ doc: DocMetadata; isFunction?: boolean }> = [ + { + doc: { + name: 'item', + returnType: 'Item', + docURL: 'https://docs.n8n.io/data/data-mapping/data-item-linking/', + description: i18n.baseText('codeNodeEditor.completer.selector.item'), + }, + }, + { + doc: { + name: 'params', + returnType: 'NodeParams', + description: i18n.baseText('codeNodeEditor.completer.selector.params'), + }, + }, + { + doc: { + name: 'first', + returnType: 'Item', + args: itemArgs, + description: i18n.baseText('codeNodeEditor.completer.selector.first'), + }, + isFunction: true, + }, + { + doc: { + name: 'last', + returnType: 'Item', + args: itemArgs, + description: i18n.baseText('codeNodeEditor.completer.selector.last'), + }, + isFunction: true, + }, + { + doc: { + name: 'all', + returnType: 'Item[]', + args: itemArgs, + description: i18n.baseText('codeNodeEditor.completer.selector.all'), + }, + isFunction: true, + }, + ]; + + return applySections({ + options: options + .filter((option) => !(option.doc.name === 'params' && hasNoParams(base))) + .map(({ doc, isFunction }) => createCompletionOption({ name: doc.name, doc, isFunction })), + recommended: ['item'], + sections: {}, + }); +}; + +export const prevNodeOptions = () => { + return [ + { + name: 'name', + returnType: 'string', + description: i18n.baseText('codeNodeEditor.completer.$prevNode.name'), + }, + { + name: 'outputIndex', + returnType: 'number', + description: i18n.baseText('codeNodeEditor.completer.$prevNode.outputIndex'), + }, + { + name: 'runIndex', + returnType: 'number', + description: i18n.baseText('codeNodeEditor.completer.$prevNode.runIndex'), + }, + ].map((doc) => createCompletionOption({ name: doc.name, doc })); +}; + +export const itemOptions = () => { + return [ + { + name: 'json', + returnType: 'object', + docURL: 'https://docs.n8n.io/data/data-structure/', + description: i18n.baseText('codeNodeEditor.completer.item.json'), + }, + { + name: 'binary', + returnType: 'object', + docURL: 'https://docs.n8n.io/data/data-structure/', + description: i18n.baseText('codeNodeEditor.completer.item.binary'), + }, + ].map((doc) => createCompletionOption({ name: doc.name, doc })); +}; + +export const binaryOptions = () => { + return [ + { + name: 'id', + returnType: 'String', + description: i18n.baseText('codeNodeEditor.completer.binary.id'), + }, + { + name: 'fileExtension', + returnType: 'string', + description: i18n.baseText('codeNodeEditor.completer.binary.fileExtension'), + }, + { + name: 'fileName', + returnType: 'string', + description: i18n.baseText('codeNodeEditor.completer.binary.fileName'), + }, + { + name: 'fileSize', + returnType: 'string', + description: i18n.baseText('codeNodeEditor.completer.binary.fileSize'), + }, + { + name: 'fileType', + returnType: 'String', + description: i18n.baseText('codeNodeEditor.completer.binary.fileType'), + }, + { + name: 'mimeType', + returnType: 'string', + description: i18n.baseText('codeNodeEditor.completer.binary.mimeType'), + }, + { + name: 'directory', + returnType: 'String', + description: i18n.baseText('codeNodeEditor.completer.binary.directory'), + }, + ].map((doc) => createCompletionOption({ name: doc.name, doc })); +}; + +export const workflowOptions = () => { + return [ + { + name: 'id', + returnType: 'string', + description: i18n.baseText('codeNodeEditor.completer.$workflow.id'), + }, + { + name: 'name', + returnType: 'string', + description: i18n.baseText('codeNodeEditor.completer.$workflow.name'), + }, + { + name: 'active', + returnType: 'boolean', + description: i18n.baseText('codeNodeEditor.completer.$workflow.active'), + }, + ].map((doc) => createCompletionOption({ name: doc.name, doc })); +}; + export const secretOptions = (base: string) => { const externalSecretsStore = useExternalSecretsStore(); let resolved: Resolved; @@ -841,18 +1275,89 @@ const createLuxonAutocompleteOption = ({ * Methods defined on the global `Object`. */ export const objectGlobalOptions = () => { - return ['assign', 'entries', 'keys', 'values'].map((key) => { - const option: Completion = { - label: key + '()', - type: 'function', - }; - - const info = i18n.globalObject[key]; - - if (info) option.info = info; - - return option; - }); + return [ + { + name: 'assign', + description: i18n.baseText('codeNodeEditor.completer.globalObject.assign'), + args: [ + { + name: 'target', + type: 'object', + }, + { + name: 'sources', + variadic: true, + type: 'object', + }, + ], + examples: [ + { + example: "Object.assign(\n {},\n { id: 1, name: 'Apple' },\n { name: 'Banana' }\n);", + evaluated: "{ id: 1, name: 'Banana' }", + }, + ], + returnType: 'object', + docURL: + 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign', + }, + { + name: 'entries', + returnType: 'Array<[string, any]>', + args: [ + { + name: 'obj', + type: 'object', + }, + ], + examples: [ + { + example: "Object.entries({ id: 1, name: 'Apple' })", + evaluated: "[['id', 1], ['name', 'Apple']]", + }, + ], + description: i18n.baseText('codeNodeEditor.completer.globalObject.entries'), + docURL: + 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/entries', + }, + { + name: 'keys', + args: [ + { + name: 'obj', + type: 'object', + }, + ], + examples: [ + { + example: "Object.keys({ id: 1, name: 'Apple' })", + evaluated: "['id', 'name']", + }, + ], + returnType: 'string[]', + description: i18n.baseText('codeNodeEditor.completer.globalObject.keys'), + docURL: + 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/keys', + }, + { + name: 'values', + args: [ + { + name: 'obj', + type: 'object', + }, + ], + examples: [ + { + example: "Object.values({ id: 1, name: 'Apple' })", + evaluated: "[1, 'Apple']", + }, + ], + description: i18n.baseText('codeNodeEditor.completer.globalObject.values'), + returnType: 'Array', + docURL: + 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/values', + }, + ].map((doc) => createCompletionOption({ name: doc.name, doc, isFunction: true })); }; const regexes = { 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 bb9d405355698..a055d1b1490c0 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts @@ -60,7 +60,8 @@ export function dollarOptions(): Completion[] { info: createInfoBoxRenderer({ name: '$pageCount', returnType: 'number', - description: i18n.rootVars.$pageCount, + docURL: 'https://docs.n8n.io/code/builtin/http-node-variables/', + description: i18n.baseText('codeNodeEditor.completer.$pageCount'), }), }, { @@ -68,8 +69,9 @@ export function dollarOptions(): Completion[] { section: RECOMMENDED_SECTION, info: createInfoBoxRenderer({ name: '$response', - returnType: 'object', - description: i18n.rootVars.$response, + returnType: 'HTTPResponse', + docURL: 'https://docs.n8n.io/code/builtin/http-node-variables/', + description: i18n.baseText('codeNodeEditor.completer.$response'), }), }, { @@ -78,7 +80,8 @@ export function dollarOptions(): Completion[] { info: createInfoBoxRenderer({ name: '$request', returnType: 'object', - description: i18n.rootVars.$request, + docURL: 'https://docs.n8n.io/code/builtin/http-node-variables/', + description: i18n.baseText('codeNodeEditor.completer.$request'), }), }, ]; 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 16d3d1befc88c..a9cd039828e65 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 @@ -960,13 +960,13 @@ export const luxonInstanceDocs: Required = { returnType: 'DateTime', args: [ { - name: 'zone', + name: 'offset', optional: true, description: i18n.baseText( - 'codeNodeEditor.completer.luxon.instanceMethods.toUTC.args.zone', + 'codeNodeEditor.completer.luxon.instanceMethods.toUTC.args.offset', ), - default: '"local"', - type: 'string', + default: '0', + type: 'number', }, { name: 'options', diff --git a/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts index c9757babf09ac..d6d230b42e177 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/nonDollar.completions.ts @@ -1,6 +1,7 @@ import { i18n } from '@/plugins/i18n'; import type { CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import { prefixMatch } from './utils'; +import { createInfoBoxRenderer } from './infoBoxRenderer'; /** * Completions offered at the initial position for any char other than `$`. @@ -23,16 +24,32 @@ export function nonDollarCompletions(context: CompletionContext): CompletionResu const nonDollarOptions = [ { label: 'DateTime', - type: 'keyword', - info: i18n.rootVars.DateTime, + info: createInfoBoxRenderer({ + name: 'DateTime', + returnType: 'DateTimeGlobal', + description: i18n.baseText('codeNodeEditor.completer.dateTime'), + docURL: 'https://moment.github.io/luxon/api-docs/index.html#datetime', + }), }, { label: 'Math', - type: 'keyword', + info: createInfoBoxRenderer({ + name: 'Math', + returnType: 'MathGlobal', + description: i18n.baseText('codeNodeEditor.completer.math'), + docURL: + 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math', + }), }, { label: 'Object', - type: 'keyword', + info: createInfoBoxRenderer({ + name: 'Object', + returnType: 'ObjectGlobal', + description: i18n.baseText('codeNodeEditor.completer.globalObject'), + docURL: + 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object', + }), }, ]; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts index 869359e6fbcdb..e40d45d07195a 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -61,21 +61,6 @@ export function longestCommonPrefix(...strings: string[]) { export const prefixMatch = (first: string, second: string) => first.startsWith(second) && first !== second; -/** - * Make a function to bring selected elements to the start of an array, in order. - */ -export const setRank = (selected: string[]) => (full: string[]) => { - const fullCopy = [...full]; - - [...selected].reverse().forEach((s) => { - const index = fullCopy.indexOf(s); - - if (index !== -1) fullCopy.unshift(fullCopy.splice(index, 1)[0]); - }); - - return fullCopy; -}; - export const isPseudoParam = (candidate: string) => { const PSEUDO_PARAMS = ['notice']; // user input disallowed diff --git a/packages/editor-ui/src/plugins/i18n/index.ts b/packages/editor-ui/src/plugins/i18n/index.ts index 491130e9cee45..36df6c6c8b402 100644 --- a/packages/editor-ui/src/plugins/i18n/index.ts +++ b/packages/editor-ui/src/plugins/i18n/index.ts @@ -357,67 +357,6 @@ export class I18nClass { }); } - rootVars = { - $binary: this.baseText('codeNodeEditor.completer.binary'), - $execution: this.baseText('codeNodeEditor.completer.$execution'), - $ifEmpty: this.baseText('codeNodeEditor.completer.$ifEmpty'), - $input: this.baseText('codeNodeEditor.completer.$input'), - $jmespath: this.baseText('codeNodeEditor.completer.$jmespath'), - $json: this.baseText('codeNodeEditor.completer.json'), - $itemIndex: this.baseText('codeNodeEditor.completer.$itemIndex'), - $now: this.baseText('codeNodeEditor.completer.$now'), - $parameter: this.baseText('codeNodeEditor.completer.$parameter'), - $prevNode: this.baseText('codeNodeEditor.completer.$prevNode'), - $if: this.baseText('codeNodeEditor.completer.$if'), - $max: this.baseText('codeNodeEditor.completer.$max'), - $min: this.baseText('codeNodeEditor.completer.$min'), - $runIndex: this.baseText('codeNodeEditor.completer.$runIndex'), - $today: this.baseText('codeNodeEditor.completer.$today'), - $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'), - $nodeVersion: this.baseText('codeNodeEditor.completer.$nodeVersion'), - } as const satisfies Record; - - proxyVars: Record = { - '$input.all': this.baseText('codeNodeEditor.completer.$input.all'), - '$input.first': this.baseText('codeNodeEditor.completer.$input.first'), - '$input.item': this.baseText('codeNodeEditor.completer.$input.item'), - '$input.last': this.baseText('codeNodeEditor.completer.$input.last'), - - '$().all': this.baseText('codeNodeEditor.completer.selector.all'), - '$().context': this.baseText('codeNodeEditor.completer.selector.context'), - '$().first': this.baseText('codeNodeEditor.completer.selector.first'), - '$().item': this.baseText('codeNodeEditor.completer.selector.item'), - '$().itemMatching': this.baseText('codeNodeEditor.completer.selector.itemMatching'), - '$().last': this.baseText('codeNodeEditor.completer.selector.last'), - '$().params': this.baseText('codeNodeEditor.completer.selector.params'), - '$().isExecuted': this.baseText('codeNodeEditor.completer.selector.isExecuted'), - - '$prevNode.name': this.baseText('codeNodeEditor.completer.$prevNode.name'), - '$prevNode.outputIndex': this.baseText('codeNodeEditor.completer.$prevNode.outputIndex'), - '$prevNode.runIndex': this.baseText('codeNodeEditor.completer.$prevNode.runIndex'), - - '$execution.id': this.baseText('codeNodeEditor.completer.$workflow.id'), - '$execution.mode': this.baseText('codeNodeEditor.completer.$execution.mode'), - '$execution.resumeUrl': this.baseText('codeNodeEditor.completer.$execution.resumeUrl'), - '$execution.resumeFormUrl': this.baseText('codeNodeEditor.completer.$execution.resumeFormUrl'), - - '$workflow.active': this.baseText('codeNodeEditor.completer.$workflow.active'), - '$workflow.id': this.baseText('codeNodeEditor.completer.$workflow.id'), - '$workflow.name': this.baseText('codeNodeEditor.completer.$workflow.name'), - }; - - globalObject: Record = { - assign: this.baseText('codeNodeEditor.completer.globalObject.assign'), - entries: this.baseText('codeNodeEditor.completer.globalObject.entries'), - keys: this.baseText('codeNodeEditor.completer.globalObject.keys'), - values: this.baseText('codeNodeEditor.completer.globalObject.values'), - }; - autocompleteUIValues: Record = { docLinkLabel: this.baseText('expressionEdit.learnMore'), }; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 9153d85de699f..6ebd2b2080732 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -6,10 +6,10 @@ "useJson": "Access the properties of an item under `.json`, e.g. `item.json`" }, "completer": { - "all": "Get all items", - "first": "Get the first item", - "last": "Get the last item", - "itemMatching": "Get the item matching a specified input item. Pass the input item index" + "all": "Returns an array of the node's output items", + "first": "Returns the first item output by the node", + "last": "Returns the last item output by the node", + "itemMatching": "Returns the matching item, i.e. the one used to produce the item in the current node at the specified index." } }, "name": "Name", @@ -190,55 +190,91 @@ "codeEdit.edit": "Edit", "codeNodeEditor.askAi": "✨ Ask AI", "codeNodeEditor.completer.$()": "Output data of the {nodeName} node", - "codeNodeEditor.completer.$execution": "Information about the current execution", - "codeNodeEditor.completer.$execution.id": "The ID of the current execution", - "codeNodeEditor.completer.$execution.mode": "How the execution was triggered: 'test' or 'production'", - "codeNodeEditor.completer.$execution.resumeUrl": "Used when using the 'wait' node to wait for a webhook. The webhook to call to resume execution", - "codeNodeEditor.completer.$execution.resumeFormUrl": "Used when using the 'wait' node to wait for a form submission. The url of form submitting which will resume execution", - "codeNodeEditor.completer.$execution.customData.set()": "Set custom data for the current execution. Learn More", - "codeNodeEditor.completer.$execution.customData.get()": "Get custom data set in the current execution. Learn More", - "codeNodeEditor.completer.$execution.customData.setAll()": "Set multiple custom data key/value pairs with an object for the current execution. Learn More", - "codeNodeEditor.completer.$execution.customData.getAll()": "Get all custom data for the current execution. Learn More", - "codeNodeEditor.completer.$ifEmpty": "Checks whether the first parameter is empty, and if so returns the second parameter. Otherwise returns the first parameter. The following count as empty: null/undefined values, empty strings, empty arrays, objects with no keys.", - "codeNodeEditor.completer.$input": "This node’s input data", + "codeNodeEditor.completer.$execution": "Retrieve or set metadata for the current execution", + "codeNodeEditor.completer.$execution.id": "The ID of the current workflow execution", + "codeNodeEditor.completer.$execution.mode": "Returns either test (meaning the execution was triggered by clicking a button in n8n) or production (meaning the execution was triggered automatically)", + "codeNodeEditor.completer.$execution.resumeUrl": "The webhook URL to call to resume a workflow waiting at a 'Wait' node.", + "codeNodeEditor.completer.$execution.resumeFormUrl": "The URL to access a form generated by the 'Wait' node.", + "codeNodeEditor.completer.$execution.customData": "Set and get custom execution data (e.g. to filter executions by). You can also do this with the 'Execution Data' node.", + "codeNodeEditor.completer.$execution.customData.set": "Stores custom execution data under the key specified. Use this to easily filter executions by this data.", + "codeNodeEditor.completer.$execution.customData.set.args.key": "The key (identifier) under which the data is stored", + "codeNodeEditor.completer.$execution.customData.set.args.value": "The data to store", + "codeNodeEditor.completer.$execution.customData.set.examples.1": "Store the user's email, to easily retrieve all execs related to that user later", + "codeNodeEditor.completer.$execution.customData.get": "Returns the custom execution data stored under the given key.", + "codeNodeEditor.completer.$execution.customData.get.args.key": "The key (identifier) under which the data is stored", + "codeNodeEditor.completer.$execution.customData.get.examples.1": "Get the user's email (which was previously stored)", + "codeNodeEditor.completer.$execution.customData.setAll": "Sets multiple key-value pairs of custom data for the execution. Use this to easily filter executions by this data.", + "codeNodeEditor.completer.$execution.customData.setAll.args.obj": "A JavaScript object containing key-value pairs of the data to set", + "codeNodeEditor.completer.$execution.customData.getAll": "Returns all the key-value pairs of custom data that have been set in the current execution.", + "codeNodeEditor.completer.$ifEmpty": "Returns the first parameter if it isn't empty, otherwise returns the second parameter. The following count as empty: \", [], {'{}'}, null, undefined", + "codeNodeEditor.completer.$ifEmpty.args.value": "The value to return, provided it isn't empty", + "codeNodeEditor.completer.$ifEmpty.args.valueIfEmpty": "What to return if value is empty", + "codeNodeEditor.completer.$input": "The input data of the current node", "codeNodeEditor.completer.$input.all": "@:_reusableBaseText.codeNodeEditor.completer.all", "codeNodeEditor.completer.$input.first": "@:_reusableBaseText.codeNodeEditor.completer.first", "codeNodeEditor.completer.$input.item": "The item that generated the current one", "codeNodeEditor.completer.$input.itemMatching": "@:_reusableBaseText.codeNodeEditor.completer.itemMatching", "codeNodeEditor.completer.$input.last": "@:_reusableBaseText.codeNodeEditor.completer.last", - "codeNodeEditor.completer.$itemIndex": "The position of the current item in the list of items", - "codeNodeEditor.completer.$jmespath": "Evaluate a JMESPath expression", - "codeNodeEditor.completer.$if": "Function that takes a condition and returns a value based on whether it's true or false.", - "codeNodeEditor.completer.$max": "Returns the largest of the numbers given as input parameters, or -Infinity if there are no parameters.", - "codeNodeEditor.completer.$min": "Returns the smallest of the numbers given as input parameters, or Infinity if there are no parameters.", - "codeNodeEditor.completer.$now": "The current timestamp (as a Luxon object)", - "codeNodeEditor.completer.$parameter": "The parameters of the current node", - "codeNodeEditor.completer.$prevNode": "The node providing the input data for this run", - "codeNodeEditor.completer.$prevNode.name": "The name of the node providing the input data for this run", - "codeNodeEditor.completer.$prevNode.outputIndex": "The output connector of the node providing input data for this run", - "codeNodeEditor.completer.$prevNode.runIndex": "The run of the node providing input data to the current one", - "codeNodeEditor.completer.$runIndex": "The index of the current run of this node", - "codeNodeEditor.completer.$today": "A timestamp representing the current day (at midnight, as a Luxon object)", - "codeNodeEditor.completer.$nodeVersion": "The type version of the current node", - "codeNodeEditor.completer.$vars": "The variables defined in your instance", + "codeNodeEditor.completer.$itemIndex": "The position of the item currently being processed in the list of input items", + "codeNodeEditor.completer.$jmespath": "Extracts data from an object (or array of objects) using a JMESPath expression. Useful for querying complex, nested objects. Returns undefined if the expression is invalid.", + "codeNodeEditor.completer.$jmespath.args.obj": "The Object or array of Objects to retrieve data from", + "codeNodeEditor.completer.$jmespath.args.expression": "A JMESPath expression defining the data to retrieve from the object", + "codeNodeEditor.completer.$jmespath.examples.1": "Get all names, in an array", + "codeNodeEditor.completer.$jmespath.examples.2": "Get the names and ages of everyone under 20", + "codeNodeEditor.completer.$jmespath.examples.3": "Get the name of the first person under 20", + "codeNodeEditor.completer.$jmespath.examples.4": "Get the names of all the guests in each reservation that require a double room", + "codeNodeEditor.completer.$if": "Returns one of two values depending on the condition. Similar to the ? operator in JavaScript.", + "codeNodeEditor.completer.$if.args.condition": "The check to make. Should evaluate to either true or false", + "codeNodeEditor.completer.$if.args.valueIfTrue": "The value to return if the condition is true", + "codeNodeEditor.completer.$if.args.valueIfFalse": "The value to return if the condition is false", + "codeNodeEditor.completer.$if.examples.1": "Return \"Good day\" if time is before 5pm, otherwise \"Good evening\"", + "codeNodeEditor.completer.$if.examples.2": "$if() calls can be combined\nReturn \"Good morning\" if time is before 10am, \"Good day\" it's before 5pm, otherwise \"Good evening\"", + "codeNodeEditor.completer.$max": "Returns the highest of the given numbers, or -Infinity if there are no parameters.", + "codeNodeEditor.completer.$max.args.numbers": "The numbers to compare", + "codeNodeEditor.completer.$min": "Returns the lowest of the given numbers, or Infinity if there are no parameters.", + "codeNodeEditor.completer.$now": "A DateTime representing the current moment. \n\nUses the workflow's time zone (which can be changed in the workflow settings).", + "codeNodeEditor.completer.$parameter": "The configuration settings of the current node. These are the parameters you fill out within the node's UI (e.g. its operation).", + "codeNodeEditor.completer.$prevNode": "Information about the node that the current input came from. \n\nWhen in a 'Merge' node, always uses the first input connector.", + "codeNodeEditor.completer.$prevNode.name": "The name of the node that the current input came from. \n\nAlways uses the current node's first input connector if there is more than one (e.g. in the 'Merge' node).", + "codeNodeEditor.completer.$prevNode.outputIndex": "The index of the output connector that the current input came from. Use this when the previous node had multiple outputs (such as an 'If' or 'Switch' node). \n\nAlways uses the current node's first input connector if there is more than one (e.g. in the 'Merge' node).", + "codeNodeEditor.completer.$prevNode.runIndex": "The run of the previous node that generated the current input. \n\nAlways uses the current node's first input connector if there is more than one (e.g. in the 'Merge' node). ", + "codeNodeEditor.completer.$runIndex": "The index of the current run of the current node execution. Starts at 0.", + "codeNodeEditor.completer.$nodeVersion": "The version of the current node (as displayed at the bottom of the nodes's settings pane)", + "codeNodeEditor.completer.$today": "A DateTime representing midnight at the start of the current day. \n\nUses the instance's time zone (unless overridden in the workflow's settings).", + "codeNodeEditor.completer.$vars": "The variables available to the workflow", "codeNodeEditor.completer.$vars.varName": "Variable set on this n8n instance. All variables evaluate to strings.", "codeNodeEditor.completer.$secrets": "The external secrets connected to your instance", "codeNodeEditor.completer.$secrets.provider": "External secrets providers connected to this n8n instance.", "codeNodeEditor.completer.$secrets.provider.varName": "External secrets connected to this n8n instance. All secrets evaluate to strings.", - "codeNodeEditor.completer.$workflow": "Information about the workflow", - "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", + "codeNodeEditor.completer.$workflow": "Information about the current workflow", + "codeNodeEditor.completer.$workflow.active": "Whether the workflow is active", + "codeNodeEditor.completer.$workflow.id": "The workflow ID. Can also be found in the workflow's URL.", + "codeNodeEditor.completer.$workflow.name": "The name of the workflow, as shown at the top of the editor", + "codeNodeEditor.completer.$response": "The response returned by the last HTTP call. Only available in the 'HTTP Request' node.", + "codeNodeEditor.completer.$response.headers": "The headers returned by the last HTTP call. Only available in the 'HTTP Request' node.", + "codeNodeEditor.completer.$response.statusCode": "The HTTP status code returned by the last HTTP call. Only available in the 'HTTP Request' node.", + "codeNodeEditor.completer.$response.statusMessage": "An optional message regarding the request status. Only available in the 'HTTP Request' node.", + "codeNodeEditor.completer.$response.body": "The body of the response object from the last HTTP call. Only available in the 'HTTP Request' node", + "codeNodeEditor.completer.$request": "The request object sent during the last run of the node. Only available in the 'HTTP Request' node.", + "codeNodeEditor.completer.$pageCount": "The number of results pages the node has fetched. Only available in the 'HTTP Request' node.", + "codeNodeEditor.completer.dateTime": "Luxon DateTime. Use this object to parse, format and manipulate dates and times.", + "codeNodeEditor.completer.binary": "Returns any binary input data to the current node, for the current item. Shorthand for $input.item.binary.", + "codeNodeEditor.completer.binary.mimeType": "A string representing the format of the file's contents, e.g. image/jpeg", + "codeNodeEditor.completer.binary.fileSize": "A string representing the size of the file (e.g. 1 kB)", + "codeNodeEditor.completer.binary.fileName": "The name of the file, including extension", + "codeNodeEditor.completer.binary.fileExtension": "The suffix attached to the filename (e.g. txt)", + "codeNodeEditor.completer.binary.fileType": "A string representing the type of the file, e.g. image. Corresponds to the first part of the MIME type.", + "codeNodeEditor.completer.binary.id": "The unique ID of the file. Used to identify the file when it is stored on disk or in a storage service such as S3.", + "codeNodeEditor.completer.binary.directory": "The path to the directory that the file is stored in. Useful for distinguishing between files with the same name in different directories. Not set if n8n is configured to store files in its database.", + "codeNodeEditor.completer.item.binary": "Returns any binary data the item contains.", + "codeNodeEditor.completer.item.json": "Returns the JSON data the item contains.", + "codeNodeEditor.completer.math": "Mathematical utility methods", + "codeNodeEditor.completer.globalObject": "Methods to manipulate JavaScript Objects", + "codeNodeEditor.completer.globalObject.assign": "Merge all enumerable object properties into a target object. Returns the modified target object.", "codeNodeEditor.completer.globalObject.entries": "The object's keys and values", "codeNodeEditor.completer.globalObject.keys": "The object's keys", "codeNodeEditor.completer.globalObject.values": "The object's values", - "codeNodeEditor.completer.json": "The item's JSON data. When in doubt, use this", + "codeNodeEditor.completer.json": "Returns the JSON input data to the current node, for the current item. Shorthand for $input.item.json.", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.expandFormat": "Produce the the fully expanded format token for the locale Does NOT quote characters, so quoted tokens will not round trip correctly.", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromFormat": "Create a DateTime from an input string and format string.", "codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromFormatExplain": "Explain how a string would be parsed by fromFormat().", @@ -347,8 +383,8 @@ "codeNodeEditor.completer.luxon.instanceMethods.toSQLTime": "Returns a string representation of this DateTime appropriate for use in SQL Time.", "codeNodeEditor.completer.luxon.instanceMethods.toSeconds": "Returns a Unix timestamp in seconds (the number elapsed since 1st Jan 1970)", "codeNodeEditor.completer.luxon.instanceMethods.toString": "Returns a string representation of the DateTime. Similar to toISO(). For more formatting options, see format() or toLocaleString().", - "codeNodeEditor.completer.luxon.instanceMethods.toUTC": "Converts the DateTime to the given time zone. The DateTime still represents the same moment unless specified in the options. See also toLocal() and toUTC().", - "codeNodeEditor.completer.luxon.instanceMethods.toUTC.args.zone": "A zone identifier, either in the format 'America/New_York', 'UTC+3', or the strings 'local' or 'utc'", + "codeNodeEditor.completer.luxon.instanceMethods.toUTC": "Converts a DateTime to the UTC time zone. The DateTime still represents the same moment unless specified in the parameters. Use setZone() to convert to other zones.", + "codeNodeEditor.completer.luxon.instanceMethods.toUTC.args.offset": "An offset from UTC in minutes", "codeNodeEditor.completer.luxon.instanceMethods.toUTC.args.opts": "Options that affect the output. Possible properties:\nkeepCalendarTime (boolean): Whether to keep the time the same and only change the offset. Defaults to false.", "codeNodeEditor.completer.luxon.instanceMethods.toUnixInteger": "Returns the epoch seconds (as a whole number) of this DateTime.", "codeNodeEditor.completer.luxon.instanceMethods.until": "Return an Interval spanning between this DateTime and another DateTime.", @@ -365,11 +401,14 @@ "codeNodeEditor.completer.selector.all": "@:_reusableBaseText.codeNodeEditor.completer.all", "codeNodeEditor.completer.selector.context": "Extra data about the node", "codeNodeEditor.completer.selector.first": "@:_reusableBaseText.codeNodeEditor.completer.first", - "codeNodeEditor.completer.selector.item": "The item that generated the current one", + "codeNodeEditor.completer.selector.item": "Returns the matching item, i.e. the one used to produce the current item in the current node.", + "codeNodeEditor.completer.selector.args.branchIndex": "The output branch of the node to use. Defaults to the first branch (index 0)", + "codeNodeEditor.completer.selector.args.runIndex": "The run of the node to use. Defaults to the first run (index 0)", "codeNodeEditor.completer.selector.itemMatching": "@:_reusableBaseText.codeNodeEditor.completer.itemMatching", + "codeNodeEditor.completer.selector.itemMatching.args.currentItemIndex": "The index of the item in the current node to be matched with.", "codeNodeEditor.completer.selector.last": "@:_reusableBaseText.codeNodeEditor.completer.last", - "codeNodeEditor.completer.selector.params": "The parameters of the node", - "codeNodeEditor.completer.selector.isExecuted": "Whether the node has executed", + "codeNodeEditor.completer.selector.params": "The configuration settings of the given node. These are the parameters you fill out within the node's UI (e.g. its operation).", + "codeNodeEditor.completer.selector.isExecuted": "Is true if the node has executed, false otherwise", "codeNodeEditor.completer.section.input": "Input", "codeNodeEditor.completer.section.prevNodes": "Earlier nodes", "codeNodeEditor.completer.section.metadata": "Metadata", @@ -883,7 +922,7 @@ "ndv.output.items": "{count} item | {count} items", "ndv.output.noOutputData.message": "n8n stops executing the workflow when a node has no output data. You can change this default behaviour via", "ndv.output.noOutputData.message.settings": "Settings", - "ndv.output.noOutputData.message.settingsOption": "> “Always Output Data”.", + "ndv.output.noOutputData.message.settingsOption": "> \"Always Output Data\".", "ndv.output.noOutputData.title": "No output data returned", "ndv.output.noOutputDataInBranch": "No output data in this branch", "ndv.output.of": " of ", @@ -1087,17 +1126,17 @@ "nodeErrorView.theErrorCauseIsTooLargeToBeDisplayed": "The error cause is too large to be displayed", "nodeErrorView.time": "Time", "nodeErrorView.inputPanel.previousNodeError.title": "Error running node '{nodeName}'", - "nodeErrorView.description.pairedItemInvalidInfo": "An expression here won't work because it uses .item and n8n can't figure out the matching item. This is because the node '{nodeCause}' returned incorrect matching information (for item {itemIndex} of run {runIndex}).

Try using .first(), .last() or .all()[index] instead of .item.", - "nodeErrorView.description.pairedItemNoInfo": "An expression here won't work because it uses .item and n8n can't figure out the matching item. The node '{nodeCause}' didn't return enough information.", - "nodeErrorView.description.pairedItemNoInfoCodeNode": "An expression here won't work because it uses .item and n8n can't figure out the matching item. You can either:
  • Add the missing information to the node '{nodeCause}'
  • Or use .first(), .last() or .all()[index] instead of .item
", + "nodeErrorView.description.pairedItemInvalidInfo": "An expression here won't work because it uses .item and n8n can't figure out the matching item. This is because the node '{nodeCause}' returned incorrect matching information (for item {itemIndex} of run {runIndex}).

Try using .first(), .last() or .all()[index] instead of .item.", + "nodeErrorView.description.pairedItemNoInfo": "An expression here won't work because it uses .item and n8n can't figure out the matching item. The node '{nodeCause}' didn't return enough information.", + "nodeErrorView.description.pairedItemNoInfoCodeNode": "An expression here won't work because it uses .item and n8n can't figure out the matching item. You can either:
  • Add the missing information to the node '{nodeCause}'
  • Or use .first(), .last() or .all()[index] instead of .item
", "nodeErrorView.description.pairedItemNoConnection": "There is no connection back to the node '{nodeCause}', but it's used in an expression here.

Please wire up the node (there can be other nodes in between).", "nodeErrorView.description.pairedItemNoConnectionCodeNode": "There is no connection back to the node '{nodeCause}', but it's used in code here.

Please wire up the node (there can be other nodes in between).", "nodeErrorView.description.noNodeExecutionData": "An expression references the node '{nodeCause}', but it hasn't been executed yet. Either change the expression, or re-wire your workflow to make sure that node executes first.", "nodeErrorView.description.nodeNotFound": "The node '{nodeCause}' doesn't exist, but it's used in an expression here.", "nodeErrorView.description.noInputConnection": "This node has no input data. Please make sure this node is connected to another node.", - "nodeErrorView.description.pairedItemMultipleMatches": "An expression here won't work because it uses .item and n8n can't figure out the matching item. (There are multiple possible matches)

Try using .first(), .last() or .all()[index] instead of .item or reference a different node.", - "nodeErrorView.description.pairedItemMultipleMatchesCodeNode": "The code here won't work because it uses .item and n8n can't figure out the matching item. (There are multiple possible matches)

Try using .first(), .last() or .all()[index] instead of .item or reference a different node.", - "nodeErrorView.description.pairedItemPinned": "The item-matching data in that node may be stale. It is needed by an expression in this node that uses .item.", + "nodeErrorView.description.pairedItemMultipleMatches": "An expression here won't work because it uses .item and n8n can't figure out the matching item. (There are multiple possible matches)

Try using .first(), .last() or .all()[index] instead of .item or reference a different node.", + "nodeErrorView.description.pairedItemMultipleMatchesCodeNode": "The code here won't work because it uses .item and n8n can't figure out the matching item. (There are multiple possible matches)

Try using .first(), .last() or .all()[index] instead of .item or reference a different node.", + "nodeErrorView.description.pairedItemPinned": "The item-matching data in that node may be stale. It is needed by an expression in this node that uses .item.", "nodeHelpers.credentialsUnset": "Credentials for '{credentialType}' are not set.", "nodeSettings.alwaysOutputData.description": "If active, will output a single, empty item when the output would have been empty. Use to prevent the workflow finishing on this node.", "nodeSettings.alwaysOutputData.displayName": "Always Output Data", @@ -1566,7 +1605,7 @@ "settings.users.confirmDataHandlingAfterDeletion": "What should we do with their data?", "settings.users.confirmUserDeletion": "Are you sure you want to delete this invited user?", "settings.users.delete": "Delete", - "settings.users.deleteConfirmationMessage": "Type “delete all data” to confirm", + "settings.users.deleteConfirmationMessage": "Type \"delete all data\" to confirm", "settings.users.deleteConfirmationText": "delete all data", "settings.users.deleteUser": "Delete {user}", "settings.users.actions.delete": "Delete User", From 7236a558b945c69fa5680e42c538af7c5276cc31 Mon Sep 17 00:00:00 2001 From: Egor Malykh Date: Wed, 22 May 2024 10:23:53 +0200 Subject: [PATCH 05/24] fix(RSS Feed Trigger Node): Use newest date instead of first item for new items (#9182) Co-authored-by: Jonathan Bennetts --- .../nodes-base/nodes/RssFeedRead/RssFeedReadTrigger.node.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/RssFeedRead/RssFeedReadTrigger.node.ts b/packages/nodes-base/nodes/RssFeedRead/RssFeedReadTrigger.node.ts index 114bcb15e3161..502e5431c2a4f 100644 --- a/packages/nodes-base/nodes/RssFeedRead/RssFeedReadTrigger.node.ts +++ b/packages/nodes-base/nodes/RssFeedRead/RssFeedReadTrigger.node.ts @@ -76,7 +76,10 @@ export class RssFeedReadTrigger implements INodeType { returnData.push(item); } }); - pollData.lastItemDate = feed.items[0].isoDate; + const maxIsoDate = feed.items.reduce((a, b) => + new Date(a.isoDate as string) > new Date(b.isoDate as string) ? a : b, + ).isoDate; + pollData.lastItemDate = maxIsoDate; } if (Array.isArray(returnData) && returnData.length !== 0) { From ffe034c72e07346cdbea4dda96c7e2c38ea73c45 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 22 May 2024 09:35:53 +0100 Subject: [PATCH 06/24] feat(Linear Node): Add identifier to outputs (#9469) --- packages/nodes-base/nodes/Linear/Queries.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/nodes-base/nodes/Linear/Queries.ts b/packages/nodes-base/nodes/Linear/Queries.ts index 46a0085aab191..8b6926f0bf220 100644 --- a/packages/nodes-base/nodes/Linear/Queries.ts +++ b/packages/nodes-base/nodes/Linear/Queries.ts @@ -59,6 +59,7 @@ export const query = { success issue { id, + identifier, title, priority archivedAt @@ -96,6 +97,7 @@ export const query = { return `query Issue($issueId: String!) { issue(id: $issueId) { id, + identifier, title, priority, archivedAt, @@ -135,6 +137,7 @@ export const query = { issues (first: $first, after: $after){ nodes { id, + identifier, title, priority archivedAt @@ -188,6 +191,7 @@ export const query = { success issue { id, + identifier, title, priority archivedAt From c1eef60ccdf0088971460ba122805d4e8135bd80 Mon Sep 17 00:00:00 2001 From: Omar Ajoue Date: Wed, 22 May 2024 10:26:49 +0100 Subject: [PATCH 07/24] test: Add tests for license manager reinit method (#9471) --- packages/cli/test/unit/License.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/cli/test/unit/License.test.ts b/packages/cli/test/unit/License.test.ts index 182656f39a8f1..317bfe1e6a667 100644 --- a/packages/cli/test/unit/License.test.ts +++ b/packages/cli/test/unit/License.test.ts @@ -252,4 +252,20 @@ describe('License', () => { }); }); }); + + describe('reinit', () => { + it('should reinitialize license manager', async () => { + const license = new License(mock(), mock(), mock(), mock(), mock()); + await license.init(); + + const initSpy = jest.spyOn(license, 'init'); + + await license.reinit(); + + expect(initSpy).toHaveBeenCalledWith('main', true); + + expect(LicenseManager.prototype.reset).toHaveBeenCalled(); + expect(LicenseManager.prototype.initialize).toHaveBeenCalled(); + }); + }); }); From 3761537880f53d9e54b0200a63b067dc3d154787 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Wed, 22 May 2024 13:17:52 +0300 Subject: [PATCH 08/24] fix(Gmail Trigger Node): Fetching duplicate emails (#9424) --- .../nodes/Google/Gmail/GmailTrigger.node.ts | 60 ++++++++++++++----- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/packages/nodes-base/nodes/Google/Gmail/GmailTrigger.node.ts b/packages/nodes-base/nodes/Google/Gmail/GmailTrigger.node.ts index ac6855d58a16c..526c0466be12c 100644 --- a/packages/nodes-base/nodes/Google/Gmail/GmailTrigger.node.ts +++ b/packages/nodes-base/nodes/Google/Gmail/GmailTrigger.node.ts @@ -23,7 +23,7 @@ export class GmailTrigger implements INodeType { name: 'gmailTrigger', icon: 'file:gmail.svg', group: ['trigger'], - version: 1, + version: [1, 1.1], description: 'Fetches emails from Gmail and starts the workflow on specified polling intervals.', subtitle: '={{"Gmail Trigger"}}', @@ -226,11 +226,24 @@ export class GmailTrigger implements INodeType { }; async poll(this: IPollFunctions): Promise { - const webhookData = this.getWorkflowStaticData('node'); + const workflowStaticData = this.getWorkflowStaticData('node'); + const node = this.getNode(); + + let nodeStaticData = workflowStaticData; + if (node.typeVersion > 1) { + const nodeName = node.name; + if (workflowStaticData[nodeName] === undefined) { + workflowStaticData[nodeName] = {} as IDataObject; + nodeStaticData = workflowStaticData[nodeName] as IDataObject; + } else { + nodeStaticData = workflowStaticData[nodeName] as IDataObject; + } + } + let responseData; const now = Math.floor(DateTime.now().toSeconds()).toString(); - const startDate = (webhookData.lastTimeChecked as string) || +now; + const startDate = (nodeStaticData.lastTimeChecked as string) || +now; const endDate = +now; const options = this.getNodeParameter('options', {}) as IDataObject; @@ -257,7 +270,7 @@ export class GmailTrigger implements INodeType { responseData = responseData.messages; if (!responseData?.length) { - webhookData.lastTimeChecked = endDate; + nodeStaticData.lastTimeChecked = endDate; return null; } @@ -297,11 +310,10 @@ export class GmailTrigger implements INodeType { ); } } catch (error) { - if (this.getMode() === 'manual' || !webhookData.lastTimeChecked) { + if (this.getMode() === 'manual' || !nodeStaticData.lastTimeChecked) { throw error; } const workflow = this.getWorkflow(); - const node = this.getNode(); this.logger.error( `There was a problem in '${node.name}' node in workflow '${workflow.id}': '${error.description}'`, { @@ -313,15 +325,31 @@ export class GmailTrigger implements INodeType { } if (!responseData?.length) { - webhookData.lastTimeChecked = endDate; + nodeStaticData.lastTimeChecked = endDate; return null; } - const getEmailDateAsSeconds = (email: IDataObject) => { - const { internalDate, date } = email; - return internalDate - ? +(internalDate as string) / 1000 - : +DateTime.fromJSDate(new Date(date as string)).toSeconds(); + const emailsWithInvalidDate = new Set(); + + const getEmailDateAsSeconds = (email: IDataObject): number => { + let date; + + if (email.internalDate) { + date = +(email.internalDate as string) / 1000; + } else if (email.date) { + date = +DateTime.fromJSDate(new Date(email.date as string)).toSeconds(); + } else { + date = +DateTime.fromJSDate( + new Date((email?.headers as IDataObject)?.date as string), + ).toSeconds(); + } + + if (!date || isNaN(date)) { + emailsWithInvalidDate.add(email.id as string); + return +startDate; + } + + return date; }; const lastEmailDate = (responseData as IDataObject[]).reduce((lastDate, { json }) => { @@ -336,10 +364,10 @@ export class GmailTrigger implements INodeType { ? duplicates.concat((json as IDataObject).id as string) : duplicates; }, - [] as string[], + Array.from(emailsWithInvalidDate), ); - const possibleDuplicates = (webhookData.possibleDuplicates as string[]) || []; + const possibleDuplicates = (nodeStaticData.possibleDuplicates as string[]) || []; if (possibleDuplicates.length) { responseData = (responseData as IDataObject[]).filter(({ json }) => { const { id } = json as IDataObject; @@ -347,8 +375,8 @@ export class GmailTrigger implements INodeType { }); } - webhookData.possibleDuplicates = nextPollPossibleDuplicates; - webhookData.lastTimeChecked = lastEmailDate || endDate; + nodeStaticData.possibleDuplicates = nextPollPossibleDuplicates; + nodeStaticData.lastTimeChecked = lastEmailDate || endDate; if (Array.isArray(responseData) && responseData.length) { return [responseData as INodeExecutionData[]]; From 870412f09302e83b93d25a3d90f3a7a6c9166445 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Wed, 22 May 2024 13:35:29 +0300 Subject: [PATCH 09/24] feat(core): Node hints improvements (no-changelog) (#9387) Co-authored-by: Giulio Andreini --- packages/design-system/src/css/_tokens.scss | 4 +- packages/editor-ui/src/components/RunData.vue | 40 ++++++++++++++---- .../nodes/Postgres/v2/actions/router.ts | 17 +++++++- .../nodes/Transform/SplitOut/SplitOut.node.ts | 42 +++++++++++++++---- packages/workflow/src/NodeHelpers.ts | 38 ++++++++++++++--- 5 files changed, 113 insertions(+), 28 deletions(-) diff --git a/packages/design-system/src/css/_tokens.scss b/packages/design-system/src/css/_tokens.scss index 7278517474b16..d40e4d8ae0c98 100644 --- a/packages/design-system/src/css/_tokens.scss +++ b/packages/design-system/src/css/_tokens.scss @@ -225,8 +225,8 @@ // Callout --color-callout-info-border: var(--color-foreground-base); --color-callout-info-background: var(--color-foreground-xlight); - --color-callout-info-font: var(--color-info); - --color-callout-info-icon: var(--color-info); + --color-callout-info-font: var(--color-text-base); + --color-callout-info-icon: var(--color-text-light); --color-callout-success-border: var(--color-success-light-2); --color-callout-success-background: var(--color-success-tint-2); --color-callout-success-font: var(--color-success); diff --git a/packages/editor-ui/src/components/RunData.vue b/packages/editor-ui/src/components/RunData.vue index cd970b91e3bc8..48afcd4b935d3 100644 --- a/packages/editor-ui/src/components/RunData.vue +++ b/packages/editor-ui/src/components/RunData.vue @@ -1021,6 +1021,24 @@ export default defineComponent({ showIoSearchNoMatchContent(): boolean { return this.hasNodeRun && !this.inputData.length && !!this.search; }, + parentNodeOutputData(): INodeExecutionData[] { + const workflow = this.workflowsStore.getCurrentWorkflow(); + + const parentNode = workflow.getParentNodesByDepth(this.node.name)[0]; + let parentNodeData: INodeExecutionData[] = []; + + if (parentNode?.name) { + parentNodeData = this.nodeHelpers.getNodeInputData( + workflow.getNode(parentNode?.name), + this.runIndex, + this.outputIndex, + 'input', + this.connectionType, + ); + } + + return parentNodeData; + }, }, watch: { node(newNode: INodeUi, prevNode: INodeUi) { @@ -1118,19 +1136,18 @@ export default defineComponent({ }, shouldHintBeDisplayed(hint: NodeHint): boolean { const { location, whenToDisplay } = hint; + if (location) { - if (location === 'ndv') { - return true; + if (location === 'ndv' && !['input', 'output'].includes(this.paneType)) { + return false; } - if (location === 'inputPane' && this.paneType === 'input') { - return true; + if (location === 'inputPane' && this.paneType !== 'input') { + return false; } - if (location === 'outputPane' && this.paneType === 'output') { - return true; + if (location === 'outputPane' && this.paneType !== 'output') { + return false; } - - return false; } if (whenToDisplay === 'afterExecution' && !this.hasNodeRun) { @@ -1150,7 +1167,12 @@ export default defineComponent({ if (workflowNode) { const executionHints = this.executionHints; - const nodeHints = NodeHelpers.getNodeHints(workflow, workflowNode, this.nodeType); + const nodeHints = NodeHelpers.getNodeHints(workflow, workflowNode, this.nodeType, { + runExecutionData: this.workflowExecution?.data ?? null, + runIndex: this.runIndex, + connectionInputData: this.parentNodeOutputData, + }); + return executionHints.concat(nodeHints).filter(this.shouldHintBeDisplayed); } } diff --git a/packages/nodes-base/nodes/Postgres/v2/actions/router.ts b/packages/nodes-base/nodes/Postgres/v2/actions/router.ts index 47cc0a3f40c85..75c9b7e944591 100644 --- a/packages/nodes-base/nodes/Postgres/v2/actions/router.ts +++ b/packages/nodes-base/nodes/Postgres/v2/actions/router.ts @@ -1,5 +1,5 @@ import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; -import { NodeOperationError } from 'n8n-workflow'; +import { NodeExecutionOutput, NodeOperationError } from 'n8n-workflow'; import { configurePostgres } from '../transport'; import { configureQueryRunner } from '../helpers/utils'; @@ -17,7 +17,8 @@ export async function router(this: IExecuteFunctions): Promise 1 && !node.executeOnce) { + return new NodeExecutionOutput( + [returnData], + [ + { + message: `This node ran ${items.length} times, once for each input item. To run for the first item only, enable 'execute once' in the node settings`, + location: 'outputPane', + }, + ], + ); + } + return [returnData]; } diff --git a/packages/nodes-base/nodes/Transform/SplitOut/SplitOut.node.ts b/packages/nodes-base/nodes/Transform/SplitOut/SplitOut.node.ts index 5a48662cb903c..121a63f519e26 100644 --- a/packages/nodes-base/nodes/Transform/SplitOut/SplitOut.node.ts +++ b/packages/nodes-base/nodes/Transform/SplitOut/SplitOut.node.ts @@ -1,14 +1,14 @@ import get from 'lodash/get'; import unset from 'lodash/unset'; -import { - type IBinaryData, - NodeOperationError, - deepCopy, - type IDataObject, - type IExecuteFunctions, - type INodeExecutionData, - type INodeType, - type INodeTypeDescription, +import { NodeOperationError, deepCopy, NodeExecutionOutput } from 'n8n-workflow'; +import type { + IBinaryData, + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeType, + INodeTypeDescription, + NodeExecutionHint, } from 'n8n-workflow'; import { prepareFieldsArray } from '../utils/utils'; @@ -111,6 +111,7 @@ export class SplitOut implements INodeType { async execute(this: IExecuteFunctions): Promise { const returnData: INodeExecutionData[] = []; const items = this.getInputData(); + const notFoundedFields: { [key: string]: boolean[] } = {}; for (let i = 0; i < items.length; i++) { const fieldsToSplitOut = (this.getNodeParameter('fieldToSplitOut', i) as string) @@ -160,6 +161,14 @@ export class SplitOut implements INodeType { if (entityToSplit === undefined) { entityToSplit = []; + if (!notFoundedFields[fieldToSplitOut]) { + notFoundedFields[fieldToSplitOut] = []; + } + notFoundedFields[fieldToSplitOut].push(false); + } else { + if (notFoundedFields[fieldToSplitOut]) { + notFoundedFields[fieldToSplitOut].push(true); + } } if (typeof entityToSplit !== 'object' || entityToSplit === null) { @@ -254,6 +263,21 @@ export class SplitOut implements INodeType { } } + if (Object.keys(notFoundedFields).length) { + const hints: NodeExecutionHint[] = []; + + for (const [field, values] of Object.entries(notFoundedFields)) { + if (values.every((value) => !value)) { + hints.push({ + message: `The field '${field}' wasn't found in any input item`, + location: 'outputPane', + }); + } + } + + if (hints.length) return new NodeExecutionOutput([returnData], hints); + } + return [returnData]; } } diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index 8149022d8834e..d930d023af6e7 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -43,6 +43,7 @@ import type { GenericValue, DisplayCondition, NodeHint, + INodeExecutionData, } from './Interfaces'; import { isFilterValue, @@ -1125,6 +1126,11 @@ export function getNodeHints( workflow: Workflow, node: INode, nodeTypeData: INodeTypeDescription, + nodeInputData?: { + runExecutionData: IRunExecutionData | null; + runIndex: number; + connectionInputData: INodeExecutionData[]; + }, ): NodeHint[] { const hints: NodeHint[] = []; @@ -1132,12 +1138,32 @@ export function getNodeHints( for (const hint of nodeTypeData.hints) { if (hint.displayCondition) { try { - const display = (workflow.expression.getSimpleParameterValue( - node, - hint.displayCondition, - 'internal', - {}, - ) || false) as boolean; + let display; + + if (nodeInputData === undefined) { + display = (workflow.expression.getSimpleParameterValue( + node, + hint.displayCondition, + 'internal', + {}, + ) || false) as boolean; + } else { + const { runExecutionData, runIndex, connectionInputData } = nodeInputData; + display = workflow.expression.getParameterValue( + hint.displayCondition, + runExecutionData ?? null, + runIndex, + 0, + node.name, + connectionInputData, + 'manual', + {}, + ); + } + + if (typeof display === 'string' && display.trim() === 'true') { + display = true; + } if (typeof display !== 'boolean') { console.warn( From ef9d4aba90c92f9b72a17de242a4ffeb7c034802 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Wed, 22 May 2024 15:28:09 +0300 Subject: [PATCH 10/24] fix: Update operations to run per item (#8967) Co-authored-by: Elias Meire --- .../credentials/CodaApi.credentials.ts | 11 +- .../nodes/Airtable/Airtable.node.ts | 3 +- .../v2/actions/record/search.operation.ts | 122 +++++---- .../Airtable/v2/actions/versionDescription.ts | 2 +- packages/nodes-base/nodes/Coda/Coda.node.ts | 245 +++++++++++------- .../nodes-base/nodes/Coda/TableDescription.ts | 3 +- .../GoogleFirebaseCloudFirestore.node.ts | 211 +++++++-------- .../actions/spreadsheet/delete.operation.ts | 1 - .../nodes/Microsoft/Sql/MicrosoftSql.node.ts | 79 ++++-- .../nodes/MongoDb/GenericFunctions.ts | 8 +- .../nodes-base/nodes/MongoDb/MongoDb.node.ts | 235 +++++++++-------- .../nodes/RssFeedRead/RssFeedRead.node.ts | 111 +++++--- 12 files changed, 600 insertions(+), 431 deletions(-) diff --git a/packages/nodes-base/credentials/CodaApi.credentials.ts b/packages/nodes-base/credentials/CodaApi.credentials.ts index 2639c660b7c84..06a96a7cbc1f1 100644 --- a/packages/nodes-base/credentials/CodaApi.credentials.ts +++ b/packages/nodes-base/credentials/CodaApi.credentials.ts @@ -1,4 +1,4 @@ -import type { ICredentialType, INodeProperties } from 'n8n-workflow'; +import type { ICredentialTestRequest, ICredentialType, INodeProperties } from 'n8n-workflow'; export class CodaApi implements ICredentialType { name = 'codaApi'; @@ -16,4 +16,13 @@ export class CodaApi implements ICredentialType { default: '', }, ]; + + test: ICredentialTestRequest = { + request: { + baseURL: 'https://coda.io/apis/v1/whoami', + headers: { + Authorization: '=Bearer {{$credentials.accessToken}}', + }, + }, + }; } diff --git a/packages/nodes-base/nodes/Airtable/Airtable.node.ts b/packages/nodes-base/nodes/Airtable/Airtable.node.ts index 5c3d15d0d9261..8478bc3ff4fc4 100644 --- a/packages/nodes-base/nodes/Airtable/Airtable.node.ts +++ b/packages/nodes-base/nodes/Airtable/Airtable.node.ts @@ -12,12 +12,13 @@ export class Airtable extends VersionedNodeType { icon: 'file:airtable.svg', group: ['input'], description: 'Read, update, write and delete data from Airtable', - defaultVersion: 2, + defaultVersion: 2.1, }; const nodeVersions: IVersionedNodeType['nodeVersions'] = { 1: new AirtableV1(baseDescription), 2: new AirtableV2(baseDescription), + 2.1: new AirtableV2(baseDescription), }; super(nodeVersions, baseDescription); diff --git a/packages/nodes-base/nodes/Airtable/v2/actions/record/search.operation.ts b/packages/nodes-base/nodes/Airtable/v2/actions/record/search.operation.ts index e731f8b170ddd..05a26e29a8981 100644 --- a/packages/nodes-base/nodes/Airtable/v2/actions/record/search.operation.ts +++ b/packages/nodes-base/nodes/Airtable/v2/actions/record/search.operation.ts @@ -149,74 +149,90 @@ export async function execute( base: string, table: string, ): Promise { - let returnData: INodeExecutionData[] = []; - - const body: IDataObject = {}; - const qs: IDataObject = {}; + const returnData: INodeExecutionData[] = []; + const nodeVersion = this.getNode().typeVersion; const endpoint = `${base}/${table}`; - try { - const returnAll = this.getNodeParameter('returnAll', 0); - const options = this.getNodeParameter('options', 0, {}); - const sort = this.getNodeParameter('sort', 0, {}) as IDataObject; - const filterByFormula = this.getNodeParameter('filterByFormula', 0) as string; + let itemsLength = items.length ? 1 : 0; + let fallbackPairedItems; - if (filterByFormula) { - qs.filterByFormula = filterByFormula; - } + if (nodeVersion >= 2.1) { + itemsLength = items.length; + } else { + fallbackPairedItems = generatePairedItemData(items.length); + } - if (options.fields) { - if (typeof options.fields === 'string') { - qs.fields = options.fields.split(',').map((field) => field.trim()); - } else { - qs.fields = options.fields as string[]; + for (let i = 0; i < itemsLength; i++) { + try { + const returnAll = this.getNodeParameter('returnAll', i); + const options = this.getNodeParameter('options', i, {}); + const sort = this.getNodeParameter('sort', i, {}) as IDataObject; + const filterByFormula = this.getNodeParameter('filterByFormula', i) as string; + + const body: IDataObject = {}; + const qs: IDataObject = {}; + + if (filterByFormula) { + qs.filterByFormula = filterByFormula; } - } - if (sort.property) { - qs.sort = sort.property; - } + if (options.fields) { + if (typeof options.fields === 'string') { + qs.fields = options.fields.split(',').map((field) => field.trim()); + } else { + qs.fields = options.fields as string[]; + } + } - if (options.view) { - qs.view = (options.view as IDataObject).value as string; - } + if (sort.property) { + qs.sort = sort.property; + } - let responseData; + if (options.view) { + qs.view = (options.view as IDataObject).value as string; + } - if (returnAll) { - responseData = await apiRequestAllItems.call(this, 'GET', endpoint, body, qs); - } else { - qs.maxRecords = this.getNodeParameter('limit', 0); - responseData = await apiRequest.call(this, 'GET', endpoint, body, qs); - } + let responseData; - returnData = responseData.records as INodeExecutionData[]; + if (returnAll) { + responseData = await apiRequestAllItems.call(this, 'GET', endpoint, body, qs); + } else { + qs.maxRecords = this.getNodeParameter('limit', i); + responseData = await apiRequest.call(this, 'GET', endpoint, body, qs); + } - if (options.downloadFields) { - const pairedItem = generatePairedItemData(items.length); - return await downloadRecordAttachments.call( - this, - responseData.records as IRecord[], - options.downloadFields as string[], - pairedItem, - ); - } + if (options.downloadFields) { + const itemWithAttachments = await downloadRecordAttachments.call( + this, + responseData.records as IRecord[], + options.downloadFields as string[], + fallbackPairedItems || [{ item: i }], + ); + returnData.push(...itemWithAttachments); + continue; + } + + let records = responseData.records; + + records = (records as IDataObject[]).map((record) => ({ + json: flattenOutput(record), + })) as INodeExecutionData[]; - returnData = returnData.map((record) => ({ - json: flattenOutput(record as IDataObject), - })); + const itemData = fallbackPairedItems || [{ item: i }]; - const itemData = generatePairedItemData(items.length); + const executionData = this.helpers.constructExecutionMetaData(records, { + itemData, + }); - returnData = this.helpers.constructExecutionMetaData(returnData, { - itemData, - }); - } catch (error) { - if (this.continueOnFail()) { - returnData.push({ json: { message: error.message, error } }); - } else { - throw error; + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ json: { message: error.message, error }, pairedItem: { item: i } }); + continue; + } else { + throw error; + } } } diff --git a/packages/nodes-base/nodes/Airtable/v2/actions/versionDescription.ts b/packages/nodes-base/nodes/Airtable/v2/actions/versionDescription.ts index 3665469456b4c..176d4b3ae9600 100644 --- a/packages/nodes-base/nodes/Airtable/v2/actions/versionDescription.ts +++ b/packages/nodes-base/nodes/Airtable/v2/actions/versionDescription.ts @@ -9,7 +9,7 @@ export const versionDescription: INodeTypeDescription = { name: 'airtable', icon: 'file:airtable.svg', group: ['input'], - version: 2, + version: [2, 2.1], subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}', description: 'Read, update, write and delete data from Airtable', defaults: { diff --git a/packages/nodes-base/nodes/Coda/Coda.node.ts b/packages/nodes-base/nodes/Coda/Coda.node.ts index 3a9644bb44a0e..49dd337e4e116 100644 --- a/packages/nodes-base/nodes/Coda/Coda.node.ts +++ b/packages/nodes-base/nodes/Coda/Coda.node.ts @@ -21,7 +21,7 @@ export class Coda implements INodeType { name: 'coda', icon: 'file:coda.svg', group: ['output'], - version: 1, + version: [1, 1.1], subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', description: 'Consume Coda API', defaults: { @@ -240,6 +240,7 @@ export class Coda implements INodeType { }; async execute(this: IExecuteFunctions): Promise { + const nodeVersion = this.getNode().typeVersion; const returnData: INodeExecutionData[] = []; const items = this.getInputData(); let responseData; @@ -363,61 +364,83 @@ export class Coda implements INodeType { } // https://coda.io/developers/apis/v1beta1#operation/listRows if (operation === 'getAllRows') { - const docId = this.getNodeParameter('docId', 0) as string; - const returnAll = this.getNodeParameter('returnAll', 0); - const tableId = this.getNodeParameter('tableId', 0) as string; - const options = this.getNodeParameter('options', 0); - const endpoint = `/docs/${docId}/tables/${tableId}/rows`; - if (options.useColumnNames === false) { - qs.useColumnNames = options.useColumnNames as boolean; - } else { - qs.useColumnNames = true; - } - if (options.valueFormat) { - qs.valueFormat = options.valueFormat as string; - } - if (options.sortBy) { - qs.sortBy = options.sortBy as string; - } - if (options.visibleOnly) { - qs.visibleOnly = options.visibleOnly as boolean; - } - if (options.query) { - qs.query = options.query as string; + let itemsLength = items.length ? 1 : 0; + + if (nodeVersion >= 1.1) { + itemsLength = items.length; } - try { - if (returnAll) { - responseData = await codaApiRequestAllItems.call( - this, - 'items', - 'GET', - endpoint, - {}, - qs, - ); + + for (let i = 0; i < itemsLength; i++) { + const docId = this.getNodeParameter('docId', i) as string; + const returnAll = this.getNodeParameter('returnAll', i); + const tableId = this.getNodeParameter('tableId', i) as string; + const options = this.getNodeParameter('options', i); + const endpoint = `/docs/${docId}/tables/${tableId}/rows`; + if (options.useColumnNames === false) { + qs.useColumnNames = options.useColumnNames as boolean; } else { - qs.limit = this.getNodeParameter('limit', 0); - responseData = await codaApiRequest.call(this, 'GET', endpoint, {}, qs); - responseData = responseData.items; + qs.useColumnNames = true; } - } catch (error) { - if (this.continueOnFail()) { - return [this.helpers.returnJsonArray({ error: error.message })]; + if (options.valueFormat) { + qs.valueFormat = options.valueFormat as string; } - throw new NodeApiError(this.getNode(), error as JsonObject); - } + if (options.sortBy) { + qs.sortBy = options.sortBy as string; + } + if (options.visibleOnly) { + qs.visibleOnly = options.visibleOnly as boolean; + } + if (options.query) { + qs.query = options.query as string; + } + try { + if (returnAll) { + responseData = await codaApiRequestAllItems.call( + this, + 'items', + 'GET', + endpoint, + {}, + qs, + ); + } else { + qs.limit = this.getNodeParameter('limit', i); + responseData = await codaApiRequest.call(this, 'GET', endpoint, {}, qs); + responseData = responseData.items; + } - if (options.rawData === true) { - return [this.helpers.returnJsonArray(responseData as IDataObject[])]; - } else { - for (const item of responseData) { - returnData.push({ - id: item.id, - ...item.values, - }); + if (options.rawData === true) { + for (const item of responseData) { + returnData.push({ + json: item, + pairedItem: [{ item: i }], + }); + } + } else { + for (const item of responseData) { + returnData.push({ + json: { + id: item.id, + ...item.values, + }, + pairedItem: [{ item: i }], + }); + } + } + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: [{ item: i }], + }); + continue; + } + if (error instanceof NodeApiError) throw error; + throw new NodeApiError(this.getNode(), error as JsonObject); } - return [this.helpers.returnJsonArray(returnData)]; } + + return [returnData]; } // https://coda.io/developers/apis/v1beta1#operation/deleteRows if (operation === 'deleteRow') { @@ -630,15 +653,15 @@ export class Coda implements INodeType { } //https://coda.io/developers/apis/v1beta1#operation/listControls if (operation === 'getAll') { + const returnAll = this.getNodeParameter('returnAll', 0); + qs.limit = this.getNodeParameter('limit', 0); for (let i = 0; i < items.length; i++) { try { - const returnAll = this.getNodeParameter('returnAll', 0); const docId = this.getNodeParameter('docId', i) as string; const endpoint = `/docs/${docId}/controls`; if (returnAll) { responseData = await codaApiRequestAllItems.call(this, 'items', 'GET', endpoint, {}); } else { - qs.limit = this.getNodeParameter('limit', 0); responseData = await codaApiRequest.call(this, 'GET', endpoint, {}, qs); responseData = responseData.items; } @@ -680,15 +703,15 @@ export class Coda implements INodeType { } //https://coda.io/developers/apis/v1beta1#operation/listViews if (operation === 'getAll') { + const returnAll = this.getNodeParameter('returnAll', 0); + qs.limit = this.getNodeParameter('limit', 0); for (let i = 0; i < items.length; i++) { try { - const returnAll = this.getNodeParameter('returnAll', 0); const docId = this.getNodeParameter('docId', i) as string; const endpoint = `/docs/${docId}/tables?tableTypes=view`; if (returnAll) { responseData = await codaApiRequestAllItems.call(this, 'items', 'GET', endpoint, {}); } else { - qs.limit = this.getNodeParameter('limit', 0); responseData = await codaApiRequest.call(this, 'GET', endpoint, {}, qs); responseData = responseData.items; } @@ -712,58 +735,80 @@ export class Coda implements INodeType { return [returnData]; } if (operation === 'getAllViewRows') { - const docId = this.getNodeParameter('docId', 0) as string; - const returnAll = this.getNodeParameter('returnAll', 0); - const viewId = this.getNodeParameter('viewId', 0) as string; - const options = this.getNodeParameter('options', 0); - const endpoint = `/docs/${docId}/tables/${viewId}/rows`; - if (options.useColumnNames === false) { - qs.useColumnNames = options.useColumnNames as boolean; - } else { - qs.useColumnNames = true; - } - if (options.valueFormat) { - qs.valueFormat = options.valueFormat as string; - } - if (options.sortBy) { - qs.sortBy = options.sortBy as string; - } - if (options.query) { - qs.query = options.query as string; + let itemsLength = items.length ? 1 : 0; + + if (nodeVersion >= 1.1) { + itemsLength = items.length; } - try { - if (returnAll) { - responseData = await codaApiRequestAllItems.call( - this, - 'items', - 'GET', - endpoint, - {}, - qs, - ); + + for (let i = 0; i < itemsLength; i++) { + const docId = this.getNodeParameter('docId', i) as string; + const returnAll = this.getNodeParameter('returnAll', i); + const viewId = this.getNodeParameter('viewId', i) as string; + const options = this.getNodeParameter('options', i); + const endpoint = `/docs/${docId}/tables/${viewId}/rows`; + if (options.useColumnNames === false) { + qs.useColumnNames = options.useColumnNames as boolean; } else { - qs.limit = this.getNodeParameter('limit', 0); - responseData = await codaApiRequest.call(this, 'GET', endpoint, {}, qs); - responseData = responseData.items; + qs.useColumnNames = true; } - } catch (error) { - if (this.continueOnFail()) { - return [this.helpers.returnJsonArray({ error: error.message })]; + if (options.valueFormat) { + qs.valueFormat = options.valueFormat as string; } - throw new NodeApiError(this.getNode(), error as JsonObject); - } + if (options.sortBy) { + qs.sortBy = options.sortBy as string; + } + if (options.query) { + qs.query = options.query as string; + } + try { + if (returnAll) { + responseData = await codaApiRequestAllItems.call( + this, + 'items', + 'GET', + endpoint, + {}, + qs, + ); + } else { + qs.limit = this.getNodeParameter('limit', i); + responseData = await codaApiRequest.call(this, 'GET', endpoint, {}, qs); + responseData = responseData.items; + } - if (options.rawData === true) { - return [this.helpers.returnJsonArray(responseData as IDataObject[])]; - } else { - for (const item of responseData) { - returnData.push({ - id: item.id, - ...item.values, - }); + if (options.rawData === true) { + for (const item of responseData) { + returnData.push({ + json: item, + pairedItem: [{ item: i }], + }); + } + } else { + for (const item of responseData) { + returnData.push({ + json: { + id: item.id, + ...item.values, + }, + pairedItem: [{ item: i }], + }); + } + } + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: [{ item: i }], + }); + continue; + } + if (error instanceof NodeApiError) throw error; + throw new NodeApiError(this.getNode(), error as JsonObject); } - return [this.helpers.returnJsonArray(returnData)]; } + + return [returnData]; } //https://coda.io/developers/apis/v1beta1#operation/deleteViewRow if (operation === 'deleteViewRow') { @@ -823,16 +868,16 @@ export class Coda implements INodeType { return [returnData]; } if (operation === 'getAllViewColumns') { + const returnAll = this.getNodeParameter('returnAll', 0); + qs.limit = this.getNodeParameter('limit', 0); for (let i = 0; i < items.length; i++) { try { - const returnAll = this.getNodeParameter('returnAll', 0); const docId = this.getNodeParameter('docId', i) as string; const viewId = this.getNodeParameter('viewId', i) as string; const endpoint = `/docs/${docId}/tables/${viewId}/columns`; if (returnAll) { responseData = await codaApiRequestAllItems.call(this, 'items', 'GET', endpoint, {}); } else { - qs.limit = this.getNodeParameter('limit', 0); responseData = await codaApiRequest.call(this, 'GET', endpoint, {}, qs); responseData = responseData.items; } diff --git a/packages/nodes-base/nodes/Coda/TableDescription.ts b/packages/nodes-base/nodes/Coda/TableDescription.ts index ec48fb15bed1d..6edea4a2e55b3 100644 --- a/packages/nodes-base/nodes/Coda/TableDescription.ts +++ b/packages/nodes-base/nodes/Coda/TableDescription.ts @@ -27,12 +27,13 @@ export const tableOperations: INodeProperties[] = [ { name: 'Get All Columns', value: 'getAllColumns', + description: 'Get all columns in a table', action: 'Get all columns', }, { name: 'Get All Rows', value: 'getAllRows', - description: 'Get all the rows', + description: 'Get all rows in a table', action: 'Get all rows', }, { diff --git a/packages/nodes-base/nodes/Google/Firebase/CloudFirestore/GoogleFirebaseCloudFirestore.node.ts b/packages/nodes-base/nodes/Google/Firebase/CloudFirestore/GoogleFirebaseCloudFirestore.node.ts index 49c76afbb43e1..edd70cc48be02 100644 --- a/packages/nodes-base/nodes/Google/Firebase/CloudFirestore/GoogleFirebaseCloudFirestore.node.ts +++ b/packages/nodes-base/nodes/Google/Firebase/CloudFirestore/GoogleFirebaseCloudFirestore.node.ts @@ -28,7 +28,7 @@ export class GoogleFirebaseCloudFirestore implements INodeType { // eslint-disable-next-line n8n-nodes-base/node-class-description-icon-not-svg icon: 'file:googleFirebaseCloudFirestore.png', group: ['input'], - version: 1, + version: [1, 1.1], subtitle: '={{$parameter["resource"] + ": " + $parameter["operation"]}}', description: 'Interact with Google Firebase - Cloud Firestore API', defaults: { @@ -94,15 +94,27 @@ export class GoogleFirebaseCloudFirestore implements INodeType { const itemData = generatePairedItemData(items.length); const returnData: INodeExecutionData[] = []; let responseData; + const resource = this.getNodeParameter('resource', 0); const operation = this.getNodeParameter('operation', 0); + const nodeVersion = this.getNode().typeVersion; + + let itemsLength = items.length ? 1 : 0; + let fallbackPairedItems; + + if (nodeVersion >= 1.1) { + itemsLength = items.length; + } else { + fallbackPairedItems = generatePairedItemData(items.length); + } + if (resource === 'document') { if (operation === 'get') { const projectId = this.getNodeParameter('projectId', 0) as string; const database = this.getNodeParameter('database', 0) as string; const simple = this.getNodeParameter('simple', 0) as boolean; - const documentList = items.map((item: IDataObject, i: number) => { + const documentList = items.map((_: IDataObject, i: number) => { const collection = this.getNodeParameter('collection', i) as string; const documentId = this.getNodeParameter('documentId', i) as string; return `projects/${projectId}/databases/${database}/documents/${collection}/${documentId}`; @@ -179,49 +191,64 @@ export class GoogleFirebaseCloudFirestore implements INodeType { }), ); } else if (operation === 'getAll') { - const projectId = this.getNodeParameter('projectId', 0) as string; - const database = this.getNodeParameter('database', 0) as string; - const collection = this.getNodeParameter('collection', 0) as string; - const returnAll = this.getNodeParameter('returnAll', 0); - const simple = this.getNodeParameter('simple', 0) as boolean; - - if (returnAll) { - responseData = await googleApiRequestAllItems.call( - this, - 'documents', - 'GET', - `/${projectId}/databases/${database}/documents/${collection}`, - ); - } else { - const limit = this.getNodeParameter('limit', 0); - const getAllResponse = (await googleApiRequest.call( - this, - 'GET', - `/${projectId}/databases/${database}/documents/${collection}`, - {}, - { pageSize: limit }, - )) as IDataObject; - responseData = getAllResponse.documents; - } + for (let i = 0; i < itemsLength; i++) { + try { + const projectId = this.getNodeParameter('projectId', i) as string; + const database = this.getNodeParameter('database', i) as string; + const collection = this.getNodeParameter('collection', i) as string; + const returnAll = this.getNodeParameter('returnAll', i); + const simple = this.getNodeParameter('simple', i) as boolean; + + if (returnAll) { + responseData = await googleApiRequestAllItems.call( + this, + 'documents', + 'GET', + `/${projectId}/databases/${database}/documents/${collection}`, + ); + } else { + const limit = this.getNodeParameter('limit', i); + const getAllResponse = (await googleApiRequest.call( + this, + 'GET', + `/${projectId}/databases/${database}/documents/${collection}`, + {}, + { pageSize: limit }, + )) as IDataObject; + responseData = getAllResponse.documents; + } - responseData = responseData.map((element: IDataObject) => { - element.id = (element.name as string).split('/').pop(); - return element; - }); + responseData = responseData.map((element: IDataObject) => { + element.id = (element.name as string).split('/').pop(); + return element; + }); - if (simple) { - responseData = responseData.map((element: IDataObject) => fullDocumentToJson(element)); - } + if (simple) { + responseData = responseData.map((element: IDataObject) => + fullDocumentToJson(element), + ); + } - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject[]), - { itemData }, - ); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject[]), + { itemData: fallbackPairedItems ?? [{ item: i }] }, + ); - returnData.push(...executionData); + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: fallbackPairedItems ?? [{ item: i }], + }); + continue; + } + throw error; + } + } } else if (operation === 'delete') { await Promise.all( - items.map(async (item: IDataObject, i: number) => { + items.map(async (_: IDataObject, i: number) => { const projectId = this.getNodeParameter('projectId', i) as string; const database = this.getNodeParameter('database', i) as string; const collection = this.getNodeParameter('collection', i) as string; @@ -295,44 +322,13 @@ export class GoogleFirebaseCloudFirestore implements INodeType { returnData.push(...executionData); } - - // } else if (operation === 'update') { - // const projectId = this.getNodeParameter('projectId', 0) as string; - // const database = this.getNodeParameter('database', 0) as string; - // const simple = this.getNodeParameter('simple', 0) as boolean; - - // await Promise.all(items.map(async (item: IDataObject, i: number) => { - // const collection = this.getNodeParameter('collection', i) as string; - // const updateKey = this.getNodeParameter('updateKey', i) as string; - // // @ts-ignore - // const documentId = item['json'][updateKey] as string; - // const columns = this.getNodeParameter('columns', i) as string; - // const columnList = columns.split(',').map(column => column.trim()) as string[]; - // const document = {}; - // columnList.map(column => { - // // @ts-ignore - // document[column] = item['json'].hasOwnProperty(column) ? jsonToDocument(item['json'][column]) : jsonToDocument(null); - // }); - // responseData = await googleApiRequest.call( - // this, - // 'PATCH', - // `/${projectId}/databases/${database}/documents/${collection}/${documentId}`, - // { fields: document }, - // { [`updateMask.fieldPaths`]: columnList }, - // ); - // if (simple === false) { - // returnData.push(responseData); - // } else { - // returnData.push(fullDocumentToJson(responseData as IDataObject)); - // } - // })); } else if (operation === 'query') { const projectId = this.getNodeParameter('projectId', 0) as string; const database = this.getNodeParameter('database', 0) as string; const simple = this.getNodeParameter('simple', 0) as boolean; await Promise.all( - items.map(async (item: IDataObject, i: number) => { + items.map(async (_: IDataObject, i: number) => { const query = this.getNodeParameter('query', i) as string; responseData = await googleApiRequest.call( this, @@ -369,38 +365,51 @@ export class GoogleFirebaseCloudFirestore implements INodeType { } } else if (resource === 'collection') { if (operation === 'getAll') { - const projectId = this.getNodeParameter('projectId', 0) as string; - const database = this.getNodeParameter('database', 0) as string; - const returnAll = this.getNodeParameter('returnAll', 0); - - if (returnAll) { - const getAllResponse = await googleApiRequestAllItems.call( - this, - 'collectionIds', - 'POST', - `/${projectId}/databases/${database}/documents:listCollectionIds`, - ); - // @ts-ignore - responseData = getAllResponse.map((o) => ({ name: o })); - } else { - const limit = this.getNodeParameter('limit', 0); - const getAllResponse = (await googleApiRequest.call( - this, - 'POST', - `/${projectId}/databases/${database}/documents:listCollectionIds`, - {}, - { pageSize: limit }, - )) as IDataObject; - // @ts-ignore - responseData = getAllResponse.collectionIds.map((o) => ({ name: o })); - } + for (let i = 0; i < itemsLength; i++) { + try { + const projectId = this.getNodeParameter('projectId', i) as string; + const database = this.getNodeParameter('database', i) as string; + const returnAll = this.getNodeParameter('returnAll', i); + + if (returnAll) { + const getAllResponse = await googleApiRequestAllItems.call( + this, + 'collectionIds', + 'POST', + `/${projectId}/databases/${database}/documents:listCollectionIds`, + ); + // @ts-ignore + responseData = getAllResponse.map((o) => ({ name: o })); + } else { + const limit = this.getNodeParameter('limit', i); + const getAllResponse = (await googleApiRequest.call( + this, + 'POST', + `/${projectId}/databases/${database}/documents:listCollectionIds`, + {}, + { pageSize: limit }, + )) as IDataObject; + // @ts-ignore + responseData = getAllResponse.collectionIds.map((o) => ({ name: o })); + } - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData as IDataObject[]), - { itemData }, - ); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject[]), + { itemData: fallbackPairedItems ?? [{ item: i }] }, + ); - returnData.push(...executionData); + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: fallbackPairedItems ?? [{ item: i }], + }); + continue; + } + throw error; + } + } } } diff --git a/packages/nodes-base/nodes/Google/Sheet/v2/actions/spreadsheet/delete.operation.ts b/packages/nodes-base/nodes/Google/Sheet/v2/actions/spreadsheet/delete.operation.ts index 1991b0c17d1d5..310efc8de7745 100644 --- a/packages/nodes-base/nodes/Google/Sheet/v2/actions/spreadsheet/delete.operation.ts +++ b/packages/nodes-base/nodes/Google/Sheet/v2/actions/spreadsheet/delete.operation.ts @@ -69,7 +69,6 @@ export async function execute(this: IExecuteFunctions): Promise= 1.1) { + for (let i = 0; i < items.length; i++) { + try { + let rawQuery = this.getNodeParameter('query', i) as string; + + for (const resolvable of getResolvables(rawQuery)) { + rawQuery = rawQuery.replace( + resolvable, + this.evaluateExpression(resolvable, i) as string, + ); + } + + const { recordsets }: IResult = await pool.request().query(rawQuery); + + const result: IDataObject[] = recordsets.length > 1 ? flatten(recordsets) : recordsets[0]; + + for (const entry of result) { + returnData.push({ + json: entry, + pairedItem: [{ item: i }], + }); + } + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: [{ item: i }], + }); + continue; + } + await pool.close(); + throw error; + } + } + + await pool.close(); + return [returnData]; + } try { if (operation === 'executeQuery') { let rawQuery = this.getNodeParameter('query', 0) as string; @@ -267,15 +306,19 @@ export class MicrosoftSql implements INodeType { const result = recordsets.length > 1 ? flatten(recordsets) : recordsets[0]; responseData = result; - } else if (operation === 'insert') { + } + + if (operation === 'insert') { const tables = createTableStruct(this.getNodeParameter, items); await insertOperation(tables, pool); responseData = items; - } else if (operation === 'update') { + } + + if (operation === 'update') { const updateKeys = items.map( - (item, index) => this.getNodeParameter('updateKey', index) as string, + (_, index) => this.getNodeParameter('updateKey', index) as string, ); const tables = createTableStruct( @@ -288,7 +331,9 @@ export class MicrosoftSql implements INodeType { await updateOperation(tables, pool); responseData = items; - } else if (operation === 'delete') { + } + + if (operation === 'delete') { const tables = items.reduce((acc, item, index) => { const table = this.getNodeParameter('table', index) as string; const deleteKey = this.getNodeParameter('deleteKey', index) as string; @@ -303,13 +348,14 @@ export class MicrosoftSql implements INodeType { }, {} as ITables); responseData = await deleteOperation(tables, pool); - } else { - await pool.close(); - throw new NodeOperationError( - this.getNode(), - `The operation "${operation}" is not supported!`, - ); } + + const itemData = generatePairedItemData(items.length); + + returnData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData), + { itemData }, + ); } catch (error) { if (this.continueOnFail()) { responseData = items; @@ -322,13 +368,6 @@ export class MicrosoftSql implements INodeType { // shuts down the connection pool associated with the db object to allow the process to finish await pool.close(); - const itemData = generatePairedItemData(items.length); - - const returnItems = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData), - { itemData }, - ); - - return [returnItems]; + return [returnData]; } } diff --git a/packages/nodes-base/nodes/MongoDb/GenericFunctions.ts b/packages/nodes-base/nodes/MongoDb/GenericFunctions.ts index 552ea52a38b68..32adcf0b79fa6 100644 --- a/packages/nodes-base/nodes/MongoDb/GenericFunctions.ts +++ b/packages/nodes-base/nodes/MongoDb/GenericFunctions.ts @@ -132,15 +132,17 @@ export function prepareFields(fields: string) { .filter((field) => !!field); } -export function stringifyObjectIDs(items: IDataObject[]) { +export function stringifyObjectIDs(items: INodeExecutionData[]) { items.forEach((item) => { if (item._id instanceof ObjectId) { - item._id = item._id.toString(); + item.json._id = item._id.toString(); } if (item.id instanceof ObjectId) { - item.id = item.id.toString(); + item.json.id = item.id.toString(); } }); + + return items; } export async function connectMongoClient(connectionString: string, credentials: IDataObject = {}) { diff --git a/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts b/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts index 89e5e1ef0acc9..32f55d4fbf124 100644 --- a/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts +++ b/packages/nodes-base/nodes/MongoDb/MongoDb.node.ts @@ -9,7 +9,7 @@ import type { INodeTypeDescription, JsonObject, } from 'n8n-workflow'; -import { ApplicationError, NodeOperationError } from 'n8n-workflow'; +import { ApplicationError } from 'n8n-workflow'; import type { FindOneAndReplaceOptions, @@ -38,7 +38,7 @@ export class MongoDb implements INodeType { name: 'mongoDb', icon: 'file:mongodb.svg', group: ['input'], - version: 1, + version: [1, 1.1], description: 'Find, insert and update documents in MongoDB', defaults: { name: 'MongoDB', @@ -108,101 +108,126 @@ export class MongoDb implements INodeType { const mdb = client.db(database); - let responseData: IDataObject | IDataObject[] = []; + let returnData: INodeExecutionData[] = []; const items = this.getInputData(); const operation = this.getNodeParameter('operation', 0); + const nodeVersion = this.getNode().typeVersion; - if (operation === 'aggregate') { - // ---------------------------------- - // aggregate - // ---------------------------------- + let itemsLength = items.length ? 1 : 0; + let fallbackPairedItems; - try { - const queryParameter = JSON.parse( - this.getNodeParameter('query', 0) as string, - ) as IDataObject; + if (nodeVersion >= 1.1) { + itemsLength = items.length; + } else { + fallbackPairedItems = generatePairedItemData(items.length); + } - if (queryParameter._id && typeof queryParameter._id === 'string') { - queryParameter._id = new ObjectId(queryParameter._id); - } + if (operation === 'aggregate') { + for (let i = 0; i < itemsLength; i++) { + try { + const queryParameter = JSON.parse( + this.getNodeParameter('query', i) as string, + ) as IDataObject; - const query = mdb - .collection(this.getNodeParameter('collection', 0) as string) - .aggregate(queryParameter as unknown as Document[]); + if (queryParameter._id && typeof queryParameter._id === 'string') { + queryParameter._id = new ObjectId(queryParameter._id); + } - responseData = await query.toArray(); - } catch (error) { - if (this.continueOnFail()) { - responseData = [{ error: (error as JsonObject).message }]; - } else { + const query = mdb + .collection(this.getNodeParameter('collection', i) as string) + .aggregate(queryParameter as unknown as Document[]); + + for (const entry of await query.toArray()) { + returnData.push({ json: entry, pairedItem: fallbackPairedItems ?? [{ item: i }] }); + } + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: (error as JsonObject).message }, + pairedItem: fallbackPairedItems ?? [{ item: i }], + }); + continue; + } throw error; } } - } else if (operation === 'delete') { - // ---------------------------------- - // delete - // ---------------------------------- + } - try { - const { deletedCount } = await mdb - .collection(this.getNodeParameter('collection', 0) as string) - .deleteMany(JSON.parse(this.getNodeParameter('query', 0) as string) as Document); + if (operation === 'delete') { + for (let i = 0; i < itemsLength; i++) { + try { + const { deletedCount } = await mdb + .collection(this.getNodeParameter('collection', i) as string) + .deleteMany(JSON.parse(this.getNodeParameter('query', i) as string) as Document); - responseData = [{ deletedCount }]; - } catch (error) { - if (this.continueOnFail()) { - responseData = [{ error: (error as JsonObject).message }]; - } else { + returnData.push({ + json: { deletedCount }, + pairedItem: fallbackPairedItems ?? [{ item: i }], + }); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: (error as JsonObject).message }, + pairedItem: fallbackPairedItems ?? [{ item: i }], + }); + continue; + } throw error; } } - } else if (operation === 'find') { - // ---------------------------------- - // find - // ---------------------------------- + } - try { - const queryParameter = JSON.parse( - this.getNodeParameter('query', 0) as string, - ) as IDataObject; + if (operation === 'find') { + for (let i = 0; i < itemsLength; i++) { + try { + const queryParameter = JSON.parse( + this.getNodeParameter('query', i) as string, + ) as IDataObject; - if (queryParameter._id && typeof queryParameter._id === 'string') { - queryParameter._id = new ObjectId(queryParameter._id); - } + if (queryParameter._id && typeof queryParameter._id === 'string') { + queryParameter._id = new ObjectId(queryParameter._id); + } - let query = mdb - .collection(this.getNodeParameter('collection', 0) as string) - .find(queryParameter as unknown as Document); - - const options = this.getNodeParameter('options', 0); - const limit = options.limit as number; - const skip = options.skip as number; - const sort = options.sort && (JSON.parse(options.sort as string) as Sort); - if (skip > 0) { - query = query.skip(skip); - } - if (limit > 0) { - query = query.limit(limit); - } - if (sort && Object.keys(sort).length !== 0 && sort.constructor === Object) { - query = query.sort(sort); - } - const queryResult = await query.toArray(); + let query = mdb + .collection(this.getNodeParameter('collection', i) as string) + .find(queryParameter as unknown as Document); - responseData = queryResult; - } catch (error) { - if (this.continueOnFail()) { - responseData = [{ error: (error as JsonObject).message }]; - } else { + const options = this.getNodeParameter('options', i); + const limit = options.limit as number; + const skip = options.skip as number; + const sort = options.sort && (JSON.parse(options.sort as string) as Sort); + + if (skip > 0) { + query = query.skip(skip); + } + if (limit > 0) { + query = query.limit(limit); + } + if (sort && Object.keys(sort).length !== 0 && sort.constructor === Object) { + query = query.sort(sort); + } + + const queryResult = await query.toArray(); + + for (const entry of queryResult) { + returnData.push({ json: entry, pairedItem: fallbackPairedItems ?? [{ item: i }] }); + } + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: (error as JsonObject).message }, + pairedItem: fallbackPairedItems ?? [{ item: i }], + }); + continue; + } throw error; } } - } else if (operation === 'findOneAndReplace') { - // ---------------------------------- - // findOneAndReplace - // ---------------------------------- + } + if (operation === 'findOneAndReplace') { + fallbackPairedItems = fallbackPairedItems ?? generatePairedItemData(items.length); const fields = prepareFields(this.getNodeParameter('fields', 0) as string); const useDotNotation = this.getNodeParameter('options.useDotNotation', 0, false) as boolean; const dateFields = prepareFields( @@ -237,12 +262,14 @@ export class MongoDb implements INodeType { } } - responseData = updateItems; - } else if (operation === 'findOneAndUpdate') { - // ---------------------------------- - // findOneAndUpdate - // ---------------------------------- + returnData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(updateItems), + { itemData: fallbackPairedItems }, + ); + } + if (operation === 'findOneAndUpdate') { + fallbackPairedItems = fallbackPairedItems ?? generatePairedItemData(items.length); const fields = prepareFields(this.getNodeParameter('fields', 0) as string); const useDotNotation = this.getNodeParameter('options.useDotNotation', 0, false) as boolean; const dateFields = prepareFields( @@ -277,11 +304,15 @@ export class MongoDb implements INodeType { } } - responseData = updateItems; - } else if (operation === 'insert') { - // ---------------------------------- - // insert - // ---------------------------------- + returnData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(updateItems), + { itemData: fallbackPairedItems }, + ); + } + + if (operation === 'insert') { + fallbackPairedItems = fallbackPairedItems ?? generatePairedItemData(items.length); + let responseData: IDataObject[] = []; try { // Prepare the data to insert and copy it to be returned const fields = prepareFields(this.getNodeParameter('fields', 0) as string); @@ -310,11 +341,15 @@ export class MongoDb implements INodeType { throw error; } } - } else if (operation === 'update') { - // ---------------------------------- - // update - // ---------------------------------- + returnData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData), + { itemData: fallbackPairedItems }, + ); + } + + if (operation === 'update') { + fallbackPairedItems = fallbackPairedItems ?? generatePairedItemData(items.length); const fields = prepareFields(this.getNodeParameter('fields', 0) as string); const useDotNotation = this.getNodeParameter('options.useDotNotation', 0, false) as boolean; const dateFields = prepareFields( @@ -349,30 +384,14 @@ export class MongoDb implements INodeType { } } - responseData = updateItems; - } else { - if (this.continueOnFail()) { - responseData = [{ error: `The operation "${operation}" is not supported!` }]; - } else { - throw new NodeOperationError( - this.getNode(), - `The operation "${operation}" is not supported!`, - { itemIndex: 0 }, - ); - } + returnData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(updateItems), + { itemData: fallbackPairedItems }, + ); } await client.close(); - stringifyObjectIDs(responseData); - - const itemData = generatePairedItemData(items.length); - - const returnItems = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData), - { itemData }, - ); - - return [returnItems]; + return [stringifyObjectIDs(returnData)]; } } diff --git a/packages/nodes-base/nodes/RssFeedRead/RssFeedRead.node.ts b/packages/nodes-base/nodes/RssFeedRead/RssFeedRead.node.ts index 615e21e4ee17c..e16db2fb1c374 100644 --- a/packages/nodes-base/nodes/RssFeedRead/RssFeedRead.node.ts +++ b/packages/nodes-base/nodes/RssFeedRead/RssFeedRead.node.ts @@ -28,7 +28,7 @@ export class RssFeedRead implements INodeType { name: 'rssFeedRead', icon: 'fa:rss', group: ['input'], - version: 1, + version: [1, 1.1], description: 'Reads data from an RSS Feed', defaults: { name: 'RSS Read', @@ -65,59 +65,88 @@ export class RssFeedRead implements INodeType { }; async execute(this: IExecuteFunctions): Promise { - const pairedItem = generatePairedItemData(this.getInputData().length); + const returnData: INodeExecutionData[] = []; + const nodeVersion = this.getNode().typeVersion; + const items = this.getInputData(); - try { - const url = this.getNodeParameter('url', 0) as string; - const options = this.getNodeParameter('options', 0); - const ignoreSSL = Boolean(options.ignoreSSL); + let itemsLength = items.length ? 1 : 0; + let fallbackPairedItems; - if (!url) { - throw new NodeOperationError(this.getNode(), 'The parameter "URL" has to be set!'); - } + if (nodeVersion >= 1.1) { + itemsLength = items.length; + } else { + fallbackPairedItems = generatePairedItemData(items.length); + } - if (!validateURL(url)) { - throw new NodeOperationError(this.getNode(), 'The provided "URL" is not valid!'); - } + for (let i = 0; i < itemsLength; i++) { + try { + const url = this.getNodeParameter('url', i) as string; + const options = this.getNodeParameter('options', i); + const ignoreSSL = Boolean(options.ignoreSSL); - const parser = new Parser({ - requestOptions: { - rejectUnauthorized: !ignoreSSL, - }, - }); + if (!url) { + throw new NodeOperationError(this.getNode(), 'The parameter "URL" has to be set!', { + itemIndex: i, + }); + } - let feed: Parser.Output; - try { - feed = await parser.parseURL(url); - } catch (error) { - if (error.code === 'ECONNREFUSED') { - throw new NodeOperationError( - this.getNode(), - `It was not possible to connect to the URL. Please make sure the URL "${url}" it is valid!`, - ); + if (!validateURL(url)) { + throw new NodeOperationError(this.getNode(), 'The provided "URL" is not valid!', { + itemIndex: i, + }); } - throw new NodeOperationError(this.getNode(), error as Error); - } + const parser = new Parser({ + requestOptions: { + rejectUnauthorized: !ignoreSSL, + }, + }); - const returnData: INodeExecutionData[] = []; + let feed: Parser.Output; + try { + feed = await parser.parseURL(url); + } catch (error) { + if (error.code === 'ECONNREFUSED') { + throw new NodeOperationError( + this.getNode(), + `It was not possible to connect to the URL. Please make sure the URL "${url}" it is valid!`, + { + itemIndex: i, + }, + ); + } - // For now we just take the items and ignore everything else - if (feed.items) { - feed.items.forEach((item) => { - returnData.push({ + throw new NodeOperationError(this.getNode(), error as Error, { + itemIndex: i, + }); + } + + // For now we just take the items and ignore everything else + if (feed.items) { + const feedItems = (feed.items as IDataObject[]).map((item) => ({ json: item, - pairedItem, + })) as INodeExecutionData[]; + + const itemData = fallbackPairedItems || [{ item: i }]; + + const executionData = this.helpers.constructExecutionMetaData(feedItems, { + itemData, }); - }); - } - return [returnData]; - } catch (error) { - if (this.continueOnFail()) { - return [[{ json: { error: error.message }, pairedItem }]]; + returnData.push(...executionData); + } + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: error.message }, + pairedItem: fallbackPairedItems || [{ item: i }], + }); + continue; + } + throw error; } - throw error; } + + return [returnData]; } } From 2fa46b6faac5618a10403066c3dddf4ea9def12c Mon Sep 17 00:00:00 2001 From: oleg Date: Wed, 22 May 2024 14:29:32 +0200 Subject: [PATCH 11/24] feat(Custom n8n Workflow Tool Node): Add support for tool input schema (#9470) Signed-off-by: Oleg Ivaniv --- .../agents/ConversationalAgent/execute.ts | 3 + .../agents/PlanAndExecuteAgent/execute.ts | 2 + .../agents/Agent/agents/ReActAgent/execute.ts | 2 + .../nodes-langchain/nodes/code/Code.node.ts | 2 + .../OutputParserStructured.node.ts | 118 +++++++------- .../tools/ToolWorkflow/ToolWorkflow.node.ts | 145 +++++++++++++----- packages/@n8n/nodes-langchain/package.json | 1 + .../@n8n/nodes-langchain/tsconfig.build.json | 3 +- packages/@n8n/nodes-langchain/tsconfig.json | 2 +- .../types/generate-schema.d.ts | 27 ++++ .../@n8n/nodes-langchain/types/zod.types.ts | 4 + .../nodes-langchain/utils/descriptions.ts | 65 ++++++++ .../nodes-langchain/utils/schemaParsing.ts | 81 ++++++++++ pnpm-lock.yaml | 16 ++ 14 files changed, 368 insertions(+), 103 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/types/generate-schema.d.ts create mode 100644 packages/@n8n/nodes-langchain/types/zod.types.ts create mode 100644 packages/@n8n/nodes-langchain/utils/schemaParsing.ts diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts index fd14107627de4..b204f74eddcec 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts @@ -13,6 +13,7 @@ import { getConnectedTools, } from '../../../../../utils/helpers'; import { getTracingConfig } from '../../../../../utils/tracing'; +import { throwIfToolSchema } from '../../../../../utils/schemaParsing'; export async function conversationalAgentExecute( this: IExecuteFunctions, @@ -111,6 +112,8 @@ export async function conversationalAgentExecute( returnData.push({ json: response }); } catch (error) { + throwIfToolSchema(this, error); + if (this.continueOnFail()) { returnData.push({ json: { error: error.message }, pairedItem: { item: itemIndex } }); continue; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts index 3957f867cd2ce..e8f25481d2054 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts @@ -16,6 +16,7 @@ import { getPromptInputByType, } from '../../../../../utils/helpers'; import { getTracingConfig } from '../../../../../utils/tracing'; +import { throwIfToolSchema } from '../../../../../utils/schemaParsing'; export async function planAndExecuteAgentExecute( this: IExecuteFunctions, @@ -91,6 +92,7 @@ export async function planAndExecuteAgentExecute( returnData.push({ json: response }); } catch (error) { + throwIfToolSchema(this, error); if (this.continueOnFail()) { returnData.push({ json: { error: error.message }, pairedItem: { item: itemIndex } }); continue; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts index a2a6392a5ff13..c83b7e99978af 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts @@ -18,6 +18,7 @@ import { isChatInstance, } from '../../../../../utils/helpers'; import { getTracingConfig } from '../../../../../utils/tracing'; +import { throwIfToolSchema } from '../../../../../utils/schemaParsing'; export async function reActAgentAgentExecute( this: IExecuteFunctions, @@ -112,6 +113,7 @@ export async function reActAgentAgentExecute( returnData.push({ json: response }); } catch (error) { + throwIfToolSchema(this, error); if (this.continueOnFail()) { returnData.push({ json: { error: error.message }, pairedItem: { item: itemIndex } }); continue; diff --git a/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts b/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts index be15860b27970..4100d12348032 100644 --- a/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts @@ -92,6 +92,8 @@ function getSandbox( // eslint-disable-next-line @typescript-eslint/unbound-method context.executeWorkflow = this.executeWorkflow; // eslint-disable-next-line @typescript-eslint/unbound-method + context.getWorkflowDataProxy = this.getWorkflowDataProxy; + // eslint-disable-next-line @typescript-eslint/unbound-method context.logger = this.logger; if (options?.addItems) { diff --git a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts index e41cd75fcb1cc..354ba8fbb0387 100644 --- a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/OutputParserStructured.node.ts @@ -13,11 +13,15 @@ import type { JSONSchema7 } from 'json-schema'; import { StructuredOutputParser } from 'langchain/output_parsers'; import { OutputParserException } from '@langchain/core/output_parsers'; import get from 'lodash/get'; -import { getSandboxContext } from 'n8n-nodes-base/dist/nodes/Code/Sandbox'; -import { JavaScriptSandbox } from 'n8n-nodes-base/dist/nodes/Code/JavaScriptSandbox'; -import { makeResolverFromLegacyOptions } from '@n8n/vm2'; +import type { JavaScriptSandbox } from 'n8n-nodes-base/dist/nodes/Code/JavaScriptSandbox'; import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; import { logWrapper } from '../../../utils/logWrapper'; +import { generateSchema, getSandboxWithZod } from '../../../utils/schemaParsing'; +import { + inputSchemaField, + jsonSchemaExampleField, + schemaTypeField, +} from '../../../utils/descriptions'; const STRUCTURED_OUTPUT_KEY = '__structured__output'; const STRUCTURED_OUTPUT_OBJECT_KEY = '__structured__output__object'; @@ -87,8 +91,8 @@ export class OutputParserStructured implements INodeType { name: 'outputParserStructured', icon: 'fa:code', group: ['transform'], - version: [1, 1.1], - defaultVersion: 1.1, + version: [1, 1.1, 1.2], + defaultVersion: 1.2, description: 'Return data in a defined JSON format', defaults: { name: 'Structured Output Parser', @@ -115,6 +119,33 @@ export class OutputParserStructured implements INodeType { outputNames: ['Output Parser'], properties: [ getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]), + { ...schemaTypeField, displayOptions: { show: { '@version': [{ _cnd: { gte: 1.2 } }] } } }, + { + ...jsonSchemaExampleField, + default: `{ + "state": "California", + "cities": ["Los Angeles", "San Francisco", "San Diego"] +}`, + }, + { + ...inputSchemaField, + displayName: 'JSON Schema', + description: 'JSON Schema to structure and validate the output against', + default: `{ + "type": "object", + "properties": { + "state": { + "type": "string" + }, + "cities": { + "type": "array", + "items": { + "type": "string" + } + } + } +}`, + }, { displayName: 'JSON Schema', name: 'jsonSchema', @@ -138,6 +169,11 @@ export class OutputParserStructured implements INodeType { rows: 10, }, required: true, + displayOptions: { + show: { + '@version': [{ _cnd: { lte: 1.1 } }], + }, + }, }, { displayName: @@ -145,72 +181,36 @@ export class OutputParserStructured implements INodeType { name: 'notice', type: 'notice', default: '', + displayOptions: { + hide: { + schemaType: ['fromJson'], + }, + }, }, ], }; async supplyData(this: IExecuteFunctions, itemIndex: number): Promise { - const schema = this.getNodeParameter('jsonSchema', itemIndex) as string; + const schemaType = this.getNodeParameter('schemaType', itemIndex, '') as 'fromJson' | 'manual'; + // We initialize these even though one of them will always be empty + // it makes it easer to navigate the ternary operator + const jsonExample = this.getNodeParameter('jsonSchemaExample', itemIndex, '') as string; + let inputSchema: string; - let itemSchema: JSONSchema7; - try { - itemSchema = jsonParse(schema); - - // If the type is not defined, we assume it's an object - if (itemSchema.type === undefined) { - itemSchema = { - type: 'object', - properties: itemSchema.properties ?? (itemSchema as { [key: string]: JSONSchema7 }), - }; - } - } catch (error) { - throw new NodeOperationError(this.getNode(), 'Error during parsing of JSON Schema.'); + if (this.getNode().typeVersion <= 1.1) { + inputSchema = this.getNodeParameter('jsonSchema', itemIndex, '') as string; + } else { + inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string; } - const vmResolver = makeResolverFromLegacyOptions({ - external: { - modules: ['json-schema-to-zod', 'zod'], - transitive: false, - }, - resolve(moduleName, parentDirname) { - if (moduleName === 'json-schema-to-zod') { - return require.resolve( - '@n8n/n8n-nodes-langchain/node_modules/json-schema-to-zod/dist/cjs/jsonSchemaToZod.js', - { - paths: [parentDirname], - }, - ); - } - if (moduleName === 'zod') { - return require.resolve('@n8n/n8n-nodes-langchain/node_modules/zod.cjs', { - paths: [parentDirname], - }); - } - return; - }, - builtin: [], - }); - const context = getSandboxContext.call(this, itemIndex); - // Make sure to remove the description from root schema - const { description, ...restOfSchema } = itemSchema; - const sandboxedSchema = new JavaScriptSandbox( - context, - ` - const { z } = require('zod'); - const { parseSchema } = require('json-schema-to-zod'); - const zodSchema = parseSchema(${JSON.stringify(restOfSchema)}); - const itemSchema = new Function('z', 'return (' + zodSchema + ')')(z) - return itemSchema - `, - itemIndex, - this.helpers, - { resolver: vmResolver }, - ); + const jsonSchema = + schemaType === 'fromJson' ? generateSchema(jsonExample) : jsonParse(inputSchema); + const zodSchemaSandbox = getSandboxWithZod(this, jsonSchema, 0); const nodeVersion = this.getNode().typeVersion; try { const parser = await N8nStructuredOutputParser.fromZedJsonSchema( - sandboxedSchema, + zodSchemaSandbox, nodeVersion, ); return { diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts index 5130cf7d2924d..3b06c841864da 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts @@ -9,16 +9,23 @@ import type { ExecutionError, IDataObject, } from 'n8n-workflow'; -import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow'; import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces'; import * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode'; -import { DynamicTool } from '@langchain/core/tools'; +import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; import get from 'lodash/get'; import isObject from 'lodash/isObject'; import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager'; +import type { JSONSchema7 } from 'json-schema'; import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; - +import type { DynamicZodObject } from '../../../types/zod.types'; +import { generateSchema, getSandboxWithZod } from '../../../utils/schemaParsing'; +import { + jsonSchemaExampleField, + schemaTypeField, + inputSchemaField, +} from '../../../utils/descriptions'; export class ToolWorkflow implements INodeType { description: INodeTypeDescription = { displayName: 'Custom n8n Workflow Tool', @@ -314,6 +321,21 @@ export class ToolWorkflow implements INodeType { }, ], }, + // ---------------------------------- + // Output Parsing + // ---------------------------------- + { + displayName: 'Specify Input Schema', + name: 'specifyInputSchema', + type: 'boolean', + description: + 'Whether to specify the schema for the function. This would require the LLM to provide the input in the correct format and would validate it against the schema.', + noDataExpression: true, + default: false, + }, + { ...schemaTypeField, displayOptions: { show: { specifyInputSchema: [true] } } }, + jsonSchemaExampleField, + inputSchemaField, ], }; @@ -321,8 +343,11 @@ export class ToolWorkflow implements INodeType { const name = this.getNodeParameter('name', itemIndex) as string; const description = this.getNodeParameter('description', itemIndex) as string; + const useSchema = this.getNodeParameter('specifyInputSchema', itemIndex) as boolean; + let tool: DynamicTool | DynamicStructuredTool | undefined = undefined; + const runFunction = async ( - query: string, + query: string | IDataObject, runManager?: CallbackManagerForToolRun, ): Promise => { const source = this.getNodeParameter('source', itemIndex) as string; @@ -416,50 +441,86 @@ export class ToolWorkflow implements INodeType { return response; }; - return { - response: new DynamicTool({ - name, - description, + const toolHandler = async ( + query: string | IDataObject, + runManager?: CallbackManagerForToolRun, + ): Promise => { + const { index } = this.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); - func: async (query: string, runManager?: CallbackManagerForToolRun): Promise => { - const { index } = this.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); + let response: string = ''; + let executionError: ExecutionError | undefined; + try { + response = await runFunction(query, runManager); + } catch (error) { + // TODO: Do some more testing. Issues here should actually fail the workflow + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + executionError = error; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + response = `There was an error: "${error.message}"`; + } - let response: string = ''; - let executionError: ExecutionError | undefined; - try { - response = await runFunction(query, runManager); - } catch (error) { - // TODO: Do some more testing. Issues here should actually fail the workflow - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - executionError = error; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - response = `There was an error: "${error.message}"`; - } + if (typeof response === 'number') { + response = (response as number).toString(); + } - if (typeof response === 'number') { - response = (response as number).toString(); - } + if (isObject(response)) { + response = JSON.stringify(response, null, 2); + } - if (isObject(response)) { - response = JSON.stringify(response, null, 2); - } + if (typeof response !== 'string') { + // TODO: Do some more testing. Issues here should actually fail the workflow + executionError = new NodeOperationError(this.getNode(), 'Wrong output type returned', { + description: `The response property should be a string, but it is an ${typeof response}`, + }); + response = `There was an error: "${executionError.message}"`; + } - if (typeof response !== 'string') { - // TODO: Do some more testing. Issues here should actually fail the workflow - executionError = new NodeOperationError(this.getNode(), 'Wrong output type returned', { - description: `The response property should be a string, but it is an ${typeof response}`, - }); - response = `There was an error: "${executionError.message}"`; - } + if (executionError) { + void this.addOutputData(NodeConnectionType.AiTool, index, executionError); + } else { + void this.addOutputData(NodeConnectionType.AiTool, index, [[{ json: { response } }]]); + } + return response; + }; - if (executionError) { - void this.addOutputData(NodeConnectionType.AiTool, index, executionError); - } else { - void this.addOutputData(NodeConnectionType.AiTool, index, [[{ json: { response } }]]); - } - return response; - }, - }), + const functionBase = { + name, + description, + func: toolHandler, + }; + + if (useSchema) { + try { + // We initialize these even though one of them will always be empty + // it makes it easer to navigate the ternary operator + const jsonExample = this.getNodeParameter('jsonSchemaExample', itemIndex, '') as string; + const inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string; + + const schemaType = this.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual'; + const jsonSchema = + schemaType === 'fromJson' + ? generateSchema(jsonExample) + : jsonParse(inputSchema); + + const zodSchemaSandbox = getSandboxWithZod(this, jsonSchema, 0); + const zodSchema = (await zodSchemaSandbox.runCode()) as DynamicZodObject; + + tool = new DynamicStructuredTool({ + schema: zodSchema, + ...functionBase, + }); + } catch (error) { + throw new NodeOperationError( + this.getNode(), + 'Error during parsing of JSON Schema. \n ' + error, + ); + } + } else { + tool = new DynamicTool(functionBase); + } + + return { + response: tool, }; } } diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 23854907f7534..d327b63fa8075 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -157,6 +157,7 @@ "d3-dsv": "2.0.0", "epub2": "3.0.2", "form-data": "4.0.0", + "generate-schema": "^2.6.0", "html-to-text": "9.0.5", "jest-mock-extended": "^3.0.4", "json-schema-to-zod": "2.0.14", diff --git a/packages/@n8n/nodes-langchain/tsconfig.build.json b/packages/@n8n/nodes-langchain/tsconfig.build.json index d7b07412f61eb..a3b8ff9a405a2 100644 --- a/packages/@n8n/nodes-langchain/tsconfig.build.json +++ b/packages/@n8n/nodes-langchain/tsconfig.build.json @@ -11,7 +11,8 @@ "credentials/**/*.ts", "nodes/**/*.ts", "nodes/**/*.json", - "credentials/translations/**/*.json" + "credentials/translations/**/*.json", + "types/*.ts" ], "exclude": ["nodes/**/*.test.ts", "test/**"] } diff --git a/packages/@n8n/nodes-langchain/tsconfig.json b/packages/@n8n/nodes-langchain/tsconfig.json index 8377c89500383..734160344cb9d 100644 --- a/packages/@n8n/nodes-langchain/tsconfig.json +++ b/packages/@n8n/nodes-langchain/tsconfig.json @@ -20,5 +20,5 @@ "skipLibCheck": true, "outDir": "./dist/" }, - "include": ["credentials/**/*", "nodes/**/*", "utils/**/*.ts", "nodes/**/*.json"] + "include": ["credentials/**/*", "nodes/**/*", "utils/**/*.ts", "nodes/**/*.json", "types/*.ts"] } diff --git a/packages/@n8n/nodes-langchain/types/generate-schema.d.ts b/packages/@n8n/nodes-langchain/types/generate-schema.d.ts new file mode 100644 index 0000000000000..90e0e15b05cac --- /dev/null +++ b/packages/@n8n/nodes-langchain/types/generate-schema.d.ts @@ -0,0 +1,27 @@ +declare module 'generate-schema' { + export interface SchemaObject { + $schema: string; + title?: string; + type: string; + properties?: { + [key: string]: SchemaObject | SchemaArray | SchemaProperty; + }; + required?: string[]; + items?: SchemaObject | SchemaArray; + } + + export interface SchemaArray { + type: string; + items?: SchemaObject | SchemaArray | SchemaProperty; + oneOf?: Array; + required?: string[]; + } + + export interface SchemaProperty { + type: string | string[]; + format?: string; + } + + export function json(title: string, schema: SchemaObject): SchemaObject; + export function json(schema: SchemaObject): SchemaObject; +} diff --git a/packages/@n8n/nodes-langchain/types/zod.types.ts b/packages/@n8n/nodes-langchain/types/zod.types.ts new file mode 100644 index 0000000000000..933bd1e33d34b --- /dev/null +++ b/packages/@n8n/nodes-langchain/types/zod.types.ts @@ -0,0 +1,4 @@ +import type { z } from 'zod'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type DynamicZodObject = z.ZodObject; diff --git a/packages/@n8n/nodes-langchain/utils/descriptions.ts b/packages/@n8n/nodes-langchain/utils/descriptions.ts index 19ef99213fb6e..b779df1be4a12 100644 --- a/packages/@n8n/nodes-langchain/utils/descriptions.ts +++ b/packages/@n8n/nodes-langchain/utils/descriptions.ts @@ -1,5 +1,70 @@ import type { INodeProperties } from 'n8n-workflow'; +export const schemaTypeField: INodeProperties = { + displayName: 'Schema Type', + name: 'schemaType', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Generate From JSON Example', + value: 'fromJson', + description: 'Generate a schema from an example JSON object', + }, + { + name: 'Define Below', + value: 'manual', + description: 'Define the JSON schema manually', + }, + ], + default: 'fromJson', + description: 'How to specify the schema for the function', +}; + +export const jsonSchemaExampleField: INodeProperties = { + displayName: 'JSON Example', + name: 'jsonSchemaExample', + type: 'json', + default: `{ + "some_input": "some_value" +}`, + noDataExpression: true, + typeOptions: { + rows: 10, + }, + displayOptions: { + show: { + schemaType: ['fromJson'], + }, + }, + description: 'Example JSON object to use to generate the schema', +}; + +export const inputSchemaField: INodeProperties = { + displayName: 'Input Schema', + name: 'inputSchema', + type: 'json', + default: `{ +"type": "object", +"properties": { + "some_input": { + "type": "string", + "description": "Some input to the function" + } + } +}`, + noDataExpression: true, + typeOptions: { + rows: 10, + }, + displayOptions: { + show: { + schemaType: ['manual'], + }, + }, + description: 'Schema to use for the function', +}; + export const promptTypeOptions: INodeProperties = { displayName: 'Prompt', name: 'promptType', diff --git a/packages/@n8n/nodes-langchain/utils/schemaParsing.ts b/packages/@n8n/nodes-langchain/utils/schemaParsing.ts new file mode 100644 index 0000000000000..8d5f61153dace --- /dev/null +++ b/packages/@n8n/nodes-langchain/utils/schemaParsing.ts @@ -0,0 +1,81 @@ +import { makeResolverFromLegacyOptions } from '@n8n/vm2'; +import { json as generateJsonSchema } from 'generate-schema'; +import type { SchemaObject } from 'generate-schema'; +import type { JSONSchema7 } from 'json-schema'; +import { JavaScriptSandbox } from 'n8n-nodes-base/dist/nodes/Code/JavaScriptSandbox'; +import { getSandboxContext } from 'n8n-nodes-base/dist/nodes/Code/Sandbox'; +import type { IExecuteFunctions } from 'n8n-workflow'; +import { NodeOperationError, jsonParse } from 'n8n-workflow'; + +const vmResolver = makeResolverFromLegacyOptions({ + external: { + modules: ['json-schema-to-zod', 'zod'], + transitive: false, + }, + resolve(moduleName, parentDirname) { + if (moduleName === 'json-schema-to-zod') { + return require.resolve( + '@n8n/n8n-nodes-langchain/node_modules/json-schema-to-zod/dist/cjs/jsonSchemaToZod.js', + { + paths: [parentDirname], + }, + ); + } + if (moduleName === 'zod') { + return require.resolve('@n8n/n8n-nodes-langchain/node_modules/zod.cjs', { + paths: [parentDirname], + }); + } + return; + }, + builtin: [], +}); + +export function getSandboxWithZod(ctx: IExecuteFunctions, schema: JSONSchema7, itemIndex: number) { + const context = getSandboxContext.call(ctx, itemIndex); + let itemSchema: JSONSchema7 = schema; + try { + // If the root type is not defined, we assume it's an object + if (itemSchema.type === undefined) { + itemSchema = { + type: 'object', + properties: itemSchema.properties ?? (itemSchema as { [key: string]: JSONSchema7 }), + }; + } + } catch (error) { + throw new NodeOperationError(ctx.getNode(), 'Error during parsing of JSON Schema.'); + } + + // Make sure to remove the description from root schema + const { description, ...restOfSchema } = itemSchema; + const sandboxedSchema = new JavaScriptSandbox( + context, + ` + const { z } = require('zod'); + const { parseSchema } = require('json-schema-to-zod'); + const zodSchema = parseSchema(${JSON.stringify(restOfSchema)}); + const itemSchema = new Function('z', 'return (' + zodSchema + ')')(z) + return itemSchema + `, + itemIndex, + ctx.helpers, + { resolver: vmResolver }, + ); + return sandboxedSchema; +} + +export function generateSchema(schemaString: string): JSONSchema7 { + const parsedSchema = jsonParse(schemaString); + + return generateJsonSchema(parsedSchema) as JSONSchema7; +} + +export function throwIfToolSchema(ctx: IExecuteFunctions, error: Error) { + if (error?.message?.includes('tool input did not match expected schema')) { + throw new NodeOperationError( + ctx.getNode(), + `${error.message}. + This is most likely because some of your tools are configured to require a specific schema. This is not supported by Conversational Agent. Remove the schema from the tool configuration or use Tools agent instead.`, + ); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc17b5847acb1..34499a5fada3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -315,6 +315,9 @@ importers: form-data: specifier: 4.0.0 version: 4.0.0 + generate-schema: + specifier: ^2.6.0 + version: 2.6.0 html-to-text: specifier: 9.0.5 version: 9.0.5 @@ -15175,6 +15178,14 @@ packages: is-property: 1.0.2 dev: false + /generate-schema@2.6.0: + resolution: {integrity: sha512-EUBKfJNzT8f91xUk5X5gKtnbdejZeE065UAJ3BCzE8VEbvwKI9Pm5jaWmqVeK1MYc1g5weAVFDTSJzN7ymtTqA==} + hasBin: true + dependencies: + commander: 2.20.3 + type-of-is: 3.5.1 + dev: false + /generic-pool@3.9.0: resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} engines: {node: '>= 4'} @@ -23560,6 +23571,11 @@ packages: media-typer: 0.3.0 mime-types: 2.1.35 + /type-of-is@3.5.1: + resolution: {integrity: sha512-SOnx8xygcAh8lvDU2exnK2bomASfNjzB3Qz71s2tw9QnX8fkAo7aC+D0H7FV0HjRKj94CKV2Hi71kVkkO6nOxg==} + engines: {node: '>=0.10.5'} + dev: false + /type@1.2.0: resolution: {integrity: sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==} dev: false From 3094f1b88616fb10433527b8a29e268eeda4ab66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Wed, 22 May 2024 14:53:23 +0200 Subject: [PATCH 12/24] fix(core): Detect DB connection aquisition deadlocks (no-changelog) (#9485) Co-authored-by: Danny Martini --- .github/workflows/ci-postgres-mysql.yml | 1 + .../credentials/credentials.service.ts | 1 + .../handlers/workflows/workflows.service.ts | 5 +-- packages/cli/src/WaitTracker.ts | 7 +--- .../cli/src/commands/import/credentials.ts | 38 ++++++++----------- packages/cli/src/commands/import/workflow.ts | 23 ++++------- .../src/credentials/credentials.service.ts | 5 ++- .../repositories/project.repository.ts | 6 ++- .../databases/subscribers/UserSubscriber.ts | 25 ++++++------ packages/cli/src/services/import.service.ts | 8 ++-- 10 files changed, 53 insertions(+), 66 deletions(-) diff --git a/.github/workflows/ci-postgres-mysql.yml b/.github/workflows/ci-postgres-mysql.yml index 2277917532d2d..9cf864b7074e2 100644 --- a/.github/workflows/ci-postgres-mysql.yml +++ b/.github/workflows/ci-postgres-mysql.yml @@ -102,6 +102,7 @@ jobs: timeout-minutes: 20 env: DB_POSTGRESDB_PASSWORD: password + DB_POSTGRESDB_POOL_SIZE: 1 # Detect connection pooling deadlocks steps: - uses: actions/checkout@v4.1.1 - run: corepack enable diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts index 6a7cfa208d802..20b7d5f949488 100644 --- a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts @@ -69,6 +69,7 @@ export async function saveCredential( const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( user.id, + transactionManager, ); Object.assign(newSharedCredential, { diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts index bb7b8bebd262a..d301e61c93966 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts @@ -7,7 +7,6 @@ import { SharedWorkflow, type WorkflowSharingRole } from '@db/entities/SharedWor import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import type { Project } from '@/databases/entities/Project'; -import { WorkflowTagMappingRepository } from '@db/repositories/workflowTagMapping.repository'; import { TagRepository } from '@db/repositories/tag.repository'; import { License } from '@/License'; import { WorkflowSharingService } from '@/workflows/workflowSharing.service'; @@ -113,9 +112,7 @@ export async function getWorkflowTags(workflowId: string) { export async function updateTags(workflowId: string, newTags: string[]): Promise { await Db.transaction(async (transactionManager) => { - const oldTags = await Container.get(WorkflowTagMappingRepository).findBy({ - workflowId, - }); + const oldTags = await transactionManager.findBy(WorkflowTagMapping, { workflowId }); if (oldTags.length > 0) { await transactionManager.delete(WorkflowTagMapping, oldTags); } diff --git a/packages/cli/src/WaitTracker.ts b/packages/cli/src/WaitTracker.ts index ae6dbf9a627ad..067a58e224544 100644 --- a/packages/cli/src/WaitTracker.ts +++ b/packages/cli/src/WaitTracker.ts @@ -3,7 +3,7 @@ import { ErrorReporterProxy as ErrorReporter, WorkflowOperationError, } from 'n8n-workflow'; -import { Container, Service } from 'typedi'; +import { Service } from 'typedi'; import type { ExecutionStopResult, IWorkflowExecutionDataProcess } from '@/Interfaces'; import { WorkflowRunner } from '@/WorkflowRunner'; import { ExecutionRepository } from '@db/repositories/execution.repository'; @@ -137,10 +137,7 @@ export class WaitTracker { fullExecutionData.waitTill = null; fullExecutionData.status = 'canceled'; - await Container.get(ExecutionRepository).updateExistingExecution( - executionId, - fullExecutionData, - ); + await this.executionRepository.updateExistingExecution(executionId, fullExecutionData); return { mode: fullExecutionData.mode, diff --git a/packages/cli/src/commands/import/credentials.ts b/packages/cli/src/commands/import/credentials.ts index 03e96aa646b89..6c47a25d96d13 100644 --- a/packages/cli/src/commands/import/credentials.ts +++ b/packages/cli/src/commands/import/credentials.ts @@ -13,9 +13,9 @@ import { BaseCommand } from '../BaseCommand'; import type { ICredentialsEncrypted } from 'n8n-workflow'; import { ApplicationError, jsonParse } from 'n8n-workflow'; import { UM_FIX_INSTRUCTION } from '@/constants'; -import { UserRepository } from '@db/repositories/user.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; -import type { Project } from '@/databases/entities/Project'; +import { Project } from '@/databases/entities/Project'; +import { User } from '@/databases/entities/User'; export class ImportCredentialsCommand extends BaseCommand { static description = 'Import credentials'; @@ -75,13 +75,13 @@ export class ImportCredentialsCommand extends BaseCommand { ); } - const project = await this.getProject(flags.userId, flags.projectId); - const credentials = await this.readCredentials(flags.input, flags.separate); await Db.getConnection().transaction(async (transactionManager) => { this.transactionManager = transactionManager; + const project = await this.getProject(flags.userId, flags.projectId); + const result = await this.checkRelations(credentials, flags.projectId, flags.userId); if (!result.success) { @@ -130,19 +130,6 @@ export class ImportCredentialsCommand extends BaseCommand { } } - private async getOwnerProject() { - const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); - if (!owner) { - throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); - } - - const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( - owner.id, - ); - - return project; - } - private async checkRelations( credentials: ICredentialsEncrypted[], projectId?: string, @@ -244,7 +231,7 @@ export class ImportCredentialsCommand extends BaseCommand { }); if (sharedCredential && sharedCredential.project.type === 'personal') { - const user = await Container.get(UserRepository).findOneByOrFail({ + const user = await this.transactionManager.findOneByOrFail(User, { projectRelations: { role: 'project:personalOwner', projectId: sharedCredential.projectId, @@ -263,13 +250,20 @@ export class ImportCredentialsCommand extends BaseCommand { private async getProject(userId?: string, projectId?: string) { if (projectId) { - return await Container.get(ProjectRepository).findOneByOrFail({ id: projectId }); + return await this.transactionManager.findOneByOrFail(Project, { id: projectId }); } - if (userId) { - return await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId); + if (!userId) { + const owner = await this.transactionManager.findOneBy(User, { role: 'global:owner' }); + if (!owner) { + throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); + } + userId = owner.id; } - return await this.getOwnerProject(); + return await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + userId, + this.transactionManager, + ); } } diff --git a/packages/cli/src/commands/import/workflow.ts b/packages/cli/src/commands/import/workflow.ts index 7a6b7c38f2426..87bb590d6b09c 100644 --- a/packages/cli/src/commands/import/workflow.ts +++ b/packages/cli/src/commands/import/workflow.ts @@ -160,19 +160,6 @@ export class ImportWorkflowsCommand extends BaseCommand { this.logger.info(`Successfully imported ${total} ${total === 1 ? 'workflow.' : 'workflows.'}`); } - private async getOwnerProject() { - const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); - if (!owner) { - throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); - } - - const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( - owner.id, - ); - - return project; - } - private async getWorkflowOwner(workflow: WorkflowEntity) { const sharing = await Container.get(SharedWorkflowRepository).findOne({ where: { workflowId: workflow.id, role: 'workflow:owner' }, @@ -234,10 +221,14 @@ export class ImportWorkflowsCommand extends BaseCommand { return await Container.get(ProjectRepository).findOneByOrFail({ id: projectId }); } - if (userId) { - return await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId); + if (!userId) { + const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); + if (!owner) { + throw new ApplicationError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); + } + userId = owner.id; } - return await this.getOwnerProject(); + return await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId); } } diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index d23dbf0cc1eb4..8ce9cdb1d1e4e 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -262,7 +262,10 @@ export class CredentialsService { const project = projectId === undefined - ? await this.projectRepository.getPersonalProjectForUserOrFail(user.id) + ? await this.projectRepository.getPersonalProjectForUserOrFail( + user.id, + transactionManager, + ) : await this.projectService.getProjectWithScope( user, projectId, diff --git a/packages/cli/src/databases/repositories/project.repository.ts b/packages/cli/src/databases/repositories/project.repository.ts index faae0bb9cf8e7..086dfbc7cf00b 100644 --- a/packages/cli/src/databases/repositories/project.repository.ts +++ b/packages/cli/src/databases/repositories/project.repository.ts @@ -17,8 +17,10 @@ export class ProjectRepository extends Repository { }); } - async getPersonalProjectForUserOrFail(userId: string) { - return await this.findOneOrFail({ + async getPersonalProjectForUserOrFail(userId: string, entityManager?: EntityManager) { + const em = entityManager ?? this.manager; + + return await em.findOneOrFail(Project, { where: { type: 'personal', projectRelations: { userId, role: 'project:personalOwner' } }, }); } diff --git a/packages/cli/src/databases/subscribers/UserSubscriber.ts b/packages/cli/src/databases/subscribers/UserSubscriber.ts index e5fad5bf53698..b925965a0c912 100644 --- a/packages/cli/src/databases/subscribers/UserSubscriber.ts +++ b/packages/cli/src/databases/subscribers/UserSubscriber.ts @@ -1,12 +1,12 @@ +import { Container } from 'typedi'; import type { EntitySubscriberInterface, UpdateEvent } from '@n8n/typeorm'; import { EventSubscriber } from '@n8n/typeorm'; -import { User } from '../entities/User'; -import Container from 'typedi'; -import { ProjectRepository } from '../repositories/project.repository'; import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow'; import { Logger } from '@/Logger'; -import { UserRepository } from '../repositories/user.repository'; + import { Project } from '../entities/Project'; +import { User } from '../entities/User'; +import { UserRepository } from '../repositories/user.repository'; @EventSubscriber() export class UserSubscriber implements EntitySubscriberInterface { @@ -27,14 +27,17 @@ export class UserSubscriber implements EntitySubscriberInterface { fields.includes('email') ) { const oldUser = event.databaseEntity; - const name = + const userEntity = newUserData instanceof User - ? newUserData.createPersonalProjectName() - : Container.get(UserRepository).create(newUserData).createPersonalProjectName(); + ? newUserData + : Container.get(UserRepository).create(newUserData); + + const projectName = userEntity.createPersonalProjectName(); - const project = await Container.get(ProjectRepository).getPersonalProjectForUser( - oldUser.id, - ); + const project = await event.manager.findOneBy(Project, { + type: 'personal', + projectRelations: { userId: oldUser.id }, + }); if (!project) { // Since this is benign we're not throwing the exception. We don't @@ -47,7 +50,7 @@ export class UserSubscriber implements EntitySubscriberInterface { return; } - project.name = name; + project.name = projectName; await event.manager.save(Project, project); } diff --git a/packages/cli/src/services/import.service.ts b/packages/cli/src/services/import.service.ts index c2226c65b9896..96892e2745273 100644 --- a/packages/cli/src/services/import.service.ts +++ b/packages/cli/src/services/import.service.ts @@ -1,4 +1,4 @@ -import Container, { Service } from 'typedi'; +import { Service } from 'typedi'; import { v4 as uuid } from 'uuid'; import { type INode, type INodeCredentialsDetails } from 'n8n-workflow'; @@ -8,11 +8,11 @@ import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { TagRepository } from '@db/repositories/tag.repository'; import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { replaceInvalidCredentials } from '@/WorkflowHelpers'; +import { Project } from '@db/entities/Project'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { WorkflowTagMapping } from '@db/entities/WorkflowTagMapping'; import type { TagEntity } from '@db/entities/TagEntity'; import type { ICredentialsDb } from '@/Interfaces'; -import { ProjectRepository } from '@/databases/repositories/project.repository'; @Service() export class ImportService { @@ -59,9 +59,7 @@ export class ImportService { const upsertResult = await tx.upsert(WorkflowEntity, workflow, ['id']); const workflowId = upsertResult.identifiers.at(0)?.id as string; - const personalProject = await Container.get(ProjectRepository).findOneByOrFail({ - id: projectId, - }); + const personalProject = await tx.findOneByOrFail(Project, { id: projectId }); // Create relationship if the workflow was inserted instead of updated. if (!exists) { From a217866cef6caaef9244f3d16d90f7027adc0c12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 22 May 2024 15:22:07 +0200 Subject: [PATCH 13/24] fix(core): Account for retry of execution aborted by pre-execute hook (#9474) --- .../errors/aborted-execution-retry.error.ts | 9 ++++++ .../cli/src/executions/execution.service.ts | 3 ++ .../unit/services/execution.service.test.ts | 30 +++++++++++++++++++ .../src/plugins/i18n/locales/en.json | 2 +- 4 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/errors/aborted-execution-retry.error.ts create mode 100644 packages/cli/test/unit/services/execution.service.test.ts diff --git a/packages/cli/src/errors/aborted-execution-retry.error.ts b/packages/cli/src/errors/aborted-execution-retry.error.ts new file mode 100644 index 0000000000000..20d8b57e14087 --- /dev/null +++ b/packages/cli/src/errors/aborted-execution-retry.error.ts @@ -0,0 +1,9 @@ +import { ApplicationError } from 'n8n-workflow'; + +export class AbortedExecutionRetryError extends ApplicationError { + constructor() { + super('The execution was aborted before starting, so it cannot be retried', { + level: 'warning', + }); + } +} diff --git a/packages/cli/src/executions/execution.service.ts b/packages/cli/src/executions/execution.service.ts index 2487dac2be556..c245e2124eece 100644 --- a/packages/cli/src/executions/execution.service.ts +++ b/packages/cli/src/executions/execution.service.ts @@ -36,6 +36,7 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import config from '@/config'; import { WaitTracker } from '@/WaitTracker'; import type { ExecutionEntity } from '@/databases/entities/ExecutionEntity'; +import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error'; export const schemaGetExecutionsQueryFilter = { $id: '/IGetExecutionsQueryFilter', @@ -129,6 +130,8 @@ export class ExecutionService { throw new NotFoundError(`The execution with the ID "${executionId}" does not exist.`); } + if (!execution.data.executionData) throw new AbortedExecutionRetryError(); + if (execution.finished) { throw new ApplicationError('The execution succeeded, so it cannot be retried.'); } diff --git a/packages/cli/test/unit/services/execution.service.test.ts b/packages/cli/test/unit/services/execution.service.test.ts new file mode 100644 index 0000000000000..e607fe0b6945f --- /dev/null +++ b/packages/cli/test/unit/services/execution.service.test.ts @@ -0,0 +1,30 @@ +import type { IExecutionResponse } from '@/Interfaces'; +import type { ExecutionRepository } from '@/databases/repositories/execution.repository'; +import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error'; +import { ExecutionService } from '@/executions/execution.service'; +import type { ExecutionRequest } from '@/executions/execution.types'; +import { mock } from 'jest-mock-extended'; + +describe('ExecutionService', () => { + const executionRepository = mock(); + const executionService = new ExecutionService( + mock(), + mock(), + mock(), + executionRepository, + mock(), + mock(), + mock(), + mock(), + ); + + it('should error on retrying an aborted execution', async () => { + const abortedExecutionData = mock({ data: { executionData: undefined } }); + executionRepository.findWithUnflattenedData.mockResolvedValue(abortedExecutionData); + const req = mock(); + + const retry = executionService.retry(req, []); + + await expect(retry).rejects.toThrow(AbortedExecutionRetryError); + }); +}); diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 6ebd2b2080732..023c8398df681 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -1218,7 +1218,7 @@ "nodeView.runButtonText.executeWorkflow": "Test workflow", "nodeView.runButtonText.executingWorkflow": "Executing workflow", "nodeView.runButtonText.waitingForTriggerEvent": "Waiting for trigger event", - "nodeView.showError.workflowError": "Workflow execution finished with an error", + "nodeView.showError.workflowError": "Workflow execution had an error", "nodeView.showError.getWorkflowDataFromUrl.title": "Problem loading workflow", "nodeView.showError.importWorkflowData.title": "Problem importing workflow", "nodeView.showError.mounted1.message": "There was a problem loading init data", From 62ee79689569b5d2c9823afac238e66e4c645d9b Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Wed, 22 May 2024 15:54:25 +0200 Subject: [PATCH 14/24] fix(editor): Fix node execution errors showing undefined (#9487) --- cypress/e2e/5-ndv.cy.ts | 27 ++- .../fixtures/Test_workflow_ndv_run_error.json | 162 ++++++++++++++++++ cypress/pages/ndv.ts | 2 + .../src/components/Error/NodeErrorView.vue | 39 +++-- .../Error/__tests__/NodeErrorView.test.ts | 66 +++++++ packages/editor-ui/src/components/RunData.vue | 7 +- 6 files changed, 272 insertions(+), 31 deletions(-) create mode 100644 cypress/fixtures/Test_workflow_ndv_run_error.json create mode 100644 packages/editor-ui/src/components/Error/__tests__/NodeErrorView.test.ts diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 04da53af234f9..76efdb32cc4bc 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -105,13 +105,26 @@ describe('NDV', () => { }); it('should show all validation errors when opening pasted node', () => { - cy.fixture('Test_workflow_ndv_errors.json').then((data) => { - cy.get('body').paste(JSON.stringify(data)); - workflowPage.getters.canvasNodes().should('have.have.length', 1); - workflowPage.actions.openNode('Airtable'); - cy.get('.has-issues').should('have.length', 3); - cy.get('[class*=hasIssues]').should('have.length', 1); - }); + cy.createFixtureWorkflow('Test_workflow_ndv_errors.json', 'Validation errors'); + workflowPage.getters.canvasNodes().should('have.have.length', 1); + workflowPage.actions.openNode('Airtable'); + cy.get('.has-issues').should('have.length', 3); + cy.get('[class*=hasIssues]').should('have.length', 1); + }); + + it('should render run errors correctly', () => { + cy.createFixtureWorkflow('Test_workflow_ndv_run_error.json', 'Run error'); + workflowPage.actions.openNode('Error'); + ndv.actions.execute(); + ndv.getters + .nodeRunErrorMessage() + .should('have.text', 'Info for expression missing from previous node'); + ndv.getters + .nodeRunErrorDescription() + .should( + 'contains.text', + "An expression here won't work because it uses .item and n8n can't figure out the matching item.", + ); }); it('should save workflow using keyboard shortcut from NDV', () => { diff --git a/cypress/fixtures/Test_workflow_ndv_run_error.json b/cypress/fixtures/Test_workflow_ndv_run_error.json new file mode 100644 index 0000000000000..45a045851de4b --- /dev/null +++ b/cypress/fixtures/Test_workflow_ndv_run_error.json @@ -0,0 +1,162 @@ +{ + "name": "My workflow 52", + "nodes": [ + { + "parameters": { + "jsCode": "\nreturn [\n {\n \"field\": \"the same\"\n }\n];" + }, + "id": "38c14c4a-7af1-4b04-be76-f8e474c95569", + "name": "Break pairedItem chain", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 240, + 1020 + ] + }, + { + "parameters": { + "options": {} + }, + "id": "78c4964a-c4e8-47e5-81f3-89ba778feb8b", + "name": "Edit Fields", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [ + 40, + 1020 + ] + }, + { + "parameters": {}, + "id": "4f4c6527-d565-448a-96bd-8f5414caf8cc", + "name": "When clicking \"Test workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -180, + 1020 + ] + }, + { + "parameters": { + "fields": { + "values": [ + { + "stringValue": "={{ $('Edit Fields').item.json.name }}" + } + ] + }, + "options": {} + }, + "id": "44f4e5da-bfe9-4dc3-8d1f-f38e9f364754", + "name": "Error", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [ + 460, + 1020 + ] + } + ], + "pinData": { + "Edit Fields": [ + { + "json": { + "id": "23423532", + "name": "Jay Gatsby", + "email": "gatsby@west-egg.com", + "notes": "Keeps asking about a green light??", + "country": "US", + "created": "1925-04-10" + } + }, + { + "json": { + "id": "23423533", + "name": "José Arcadio Buendía", + "email": "jab@macondo.co", + "notes": "Lots of people named after him. Very confusing", + "country": "CO", + "created": "1967-05-05" + } + }, + { + "json": { + "id": "23423534", + "name": "Max Sendak", + "email": "info@in-and-out-of-weeks.org", + "notes": "Keeps rolling his terrible eyes", + "country": "US", + "created": "1963-04-09" + } + }, + { + "json": { + "id": "23423535", + "name": "Zaphod Beeblebrox", + "email": "captain@heartofgold.com", + "notes": "Felt like I was talking to more than one person", + "country": null, + "created": "1979-10-12" + } + }, + { + "json": { + "id": "23423536", + "name": "Edmund Pevensie", + "email": "edmund@narnia.gov", + "notes": "Passionate sailor", + "country": "UK", + "created": "1950-10-16" + } + } + ] + }, + "connections": { + "Break pairedItem chain": { + "main": [ + [ + { + "node": "Error", + "type": "main", + "index": 0 + } + ] + ] + }, + "Edit Fields": { + "main": [ + [ + { + "node": "Break pairedItem chain", + "type": "main", + "index": 0 + } + ] + ] + }, + "When clicking \"Test workflow\"": { + "main": [ + [ + { + "node": "Edit Fields", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "ca53267f-4eb4-481d-9e09-ecb97f6b09e2", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "27cc9b56542ad45b38725555722c50a1c3fee1670bbb67980558314ee08517c4" + }, + "id": "6fr8GiRyMlZCiDQW", + "tags": [] + } diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index d9e40e42914e0..32cc4329b3190 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -124,6 +124,8 @@ export class NDV extends BasePage { codeEditorFullscreen: () => this.getters.codeEditorDialog().find('.cm-content'), nodeRunSuccessIndicator: () => cy.getByTestId('node-run-info-success'), nodeRunErrorIndicator: () => cy.getByTestId('node-run-info-danger'), + nodeRunErrorMessage: () => cy.getByTestId('node-error-message'), + nodeRunErrorDescription: () => cy.getByTestId('node-error-description'), }; actions = { diff --git a/packages/editor-ui/src/components/Error/NodeErrorView.vue b/packages/editor-ui/src/components/Error/NodeErrorView.vue index 7574d179120b2..1ebfc1ebee20a 100644 --- a/packages/editor-ui/src/components/Error/NodeErrorView.vue +++ b/packages/editor-ui/src/components/Error/NodeErrorView.vue @@ -1,6 +1,5 @@