From 7227a29845fd178ced4d281597c62e7a03245456 Mon Sep 17 00:00:00 2001
From: Michael Kret <88898367+michael-radency@users.noreply.github.com>
Date: Fri, 10 Jan 2025 11:16:29 +0200
Subject: [PATCH] fix(Google Calendar Node): Updates and fixes (#10715)
Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com>
---
.../Google/Calendar/CalendarDescription.ts | 34 +++
.../nodes/Google/Calendar/EventDescription.ts | 167 ++++++++++++-
.../nodes/Google/Calendar/EventInterface.ts | 5 +
.../nodes/Google/Calendar/GenericFunctions.ts | 161 ++++++++++---
.../Google/Calendar/GoogleCalendar.node.json | 2 +-
.../Google/Calendar/GoogleCalendar.node.ts | 188 +++++++++++++--
.../Calendar/test/GeneircFunctions.test.ts | 89 ++++++-
.../Calendar/test/addNextOccurrence.test.ts | 89 +++++++
.../Calendar/test/node/event.getAll.test.ts | 221 ++++++++++++++++++
.../Calendar/test/node/event.update.test.ts | 2 +-
.../nodes-base/test/utils/utilities.test.ts | 60 +++++
packages/nodes-base/utils/utilities.ts | 34 +++
packages/workflow/src/NodeHelpers.ts | 2 +-
packages/workflow/test/NodeHelpers.test.ts | 53 +++++
14 files changed, 1051 insertions(+), 56 deletions(-)
create mode 100644 packages/nodes-base/nodes/Google/Calendar/test/addNextOccurrence.test.ts
create mode 100644 packages/nodes-base/nodes/Google/Calendar/test/node/event.getAll.test.ts
diff --git a/packages/nodes-base/nodes/Google/Calendar/CalendarDescription.ts b/packages/nodes-base/nodes/Google/Calendar/CalendarDescription.ts
index d2d2950f17..59e4591dd5 100644
--- a/packages/nodes-base/nodes/Google/Calendar/CalendarDescription.ts
+++ b/packages/nodes-base/nodes/Google/Calendar/CalendarDescription.ts
@@ -84,6 +84,7 @@ export const calendarFields: INodeProperties[] = [
show: {
operation: ['availability'],
resource: ['calendar'],
+ '@version': [{ _cnd: { lt: 1.3 } }],
},
},
default: '',
@@ -98,11 +99,44 @@ export const calendarFields: INodeProperties[] = [
show: {
operation: ['availability'],
resource: ['calendar'],
+ '@version': [{ _cnd: { lt: 1.3 } }],
},
},
default: '',
description: 'End of the interval',
},
+ {
+ displayName: 'Start Time',
+ name: 'timeMin',
+ type: 'dateTime',
+ required: true,
+ displayOptions: {
+ show: {
+ operation: ['availability'],
+ resource: ['calendar'],
+ '@version': [{ _cnd: { gte: 1.3 } }],
+ },
+ },
+ default: '={{ $now }}',
+ description:
+ 'Start of the interval, use expression to set a date, or switch to fixed mode to choose date from widget',
+ },
+ {
+ displayName: 'End Time',
+ name: 'timeMax',
+ type: 'dateTime',
+ required: true,
+ displayOptions: {
+ show: {
+ operation: ['availability'],
+ resource: ['calendar'],
+ '@version': [{ _cnd: { gte: 1.3 } }],
+ },
+ },
+ default: "={{ $now.plus(1, 'hour') }}",
+ description:
+ 'End of the interval, use expression to set a date, or switch to fixed mode to choose date from widget',
+ },
{
displayName: 'Options',
name: 'options',
diff --git a/packages/nodes-base/nodes/Google/Calendar/EventDescription.ts b/packages/nodes-base/nodes/Google/Calendar/EventDescription.ts
index 6892c88152..797445b893 100644
--- a/packages/nodes-base/nodes/Google/Calendar/EventDescription.ts
+++ b/packages/nodes-base/nodes/Google/Calendar/EventDescription.ts
@@ -112,6 +112,7 @@ export const eventFields: INodeProperties[] = [
show: {
operation: ['create'],
resource: ['event'],
+ '@version': [{ _cnd: { lt: 1.3 } }],
},
},
default: '',
@@ -126,11 +127,44 @@ export const eventFields: INodeProperties[] = [
show: {
operation: ['create'],
resource: ['event'],
+ '@version': [{ _cnd: { lt: 1.3 } }],
},
},
default: '',
description: 'End time of the event',
},
+ {
+ displayName: 'Start',
+ name: 'start',
+ type: 'dateTime',
+ required: true,
+ displayOptions: {
+ show: {
+ operation: ['create'],
+ resource: ['event'],
+ '@version': [{ _cnd: { gte: 1.3 } }],
+ },
+ },
+ default: '={{ $now }}',
+ description:
+ 'Start time of the event, use expression to set a date, or switch to fixed mode to choose date from widget',
+ },
+ {
+ displayName: 'End',
+ name: 'end',
+ type: 'dateTime',
+ required: true,
+ displayOptions: {
+ show: {
+ operation: ['create'],
+ resource: ['event'],
+ '@version': [{ _cnd: { gte: 1.3 } }],
+ },
+ },
+ default: "={{ $now.plus(1, 'hour') }}",
+ description:
+ 'End time of the event, use expression to set a date, or switch to fixed mode to choose date from widget',
+ },
{
displayName: 'Use Default Reminders',
name: 'useDefaultReminders',
@@ -553,6 +587,19 @@ export const eventFields: INodeProperties[] = [
description:
'The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned.',
},
+ {
+ displayName: 'Return Next Instance of Recurring Event',
+ name: 'returnNextInstance',
+ type: 'boolean',
+ default: false,
+ description:
+ 'Whether to return the next instance of a recurring event instead of the event itself',
+ displayOptions: {
+ show: {
+ '@version': [{ _cnd: { gte: 1.3 } }],
+ },
+ },
+ },
{
displayName: 'Timezone',
name: 'timeZone',
@@ -629,6 +676,36 @@ export const eventFields: INodeProperties[] = [
default: 50,
description: 'Max number of results to return',
},
+ {
+ displayName: 'After',
+ name: 'timeMin',
+ type: 'dateTime',
+ default: '={{ $now }}',
+ description:
+ 'At least some part of the event must be after this time, use expression to set a date, or switch to fixed mode to choose date from widget',
+ displayOptions: {
+ show: {
+ '@version': [{ _cnd: { gte: 1.3 } }],
+ operation: ['getAll'],
+ resource: ['event'],
+ },
+ },
+ },
+ {
+ displayName: 'Before',
+ name: 'timeMax',
+ type: 'dateTime',
+ default: '={{ $now.plus({ week: 1 }) }}',
+ description:
+ 'At least some part of the event must be before this time, use expression to set a date, or switch to fixed mode to choose date from widget',
+ displayOptions: {
+ show: {
+ '@version': [{ _cnd: { gte: 1.3 } }],
+ operation: ['getAll'],
+ resource: ['event'],
+ },
+ },
+ },
{
displayName: 'Options',
name: 'options',
@@ -647,14 +724,39 @@ export const eventFields: INodeProperties[] = [
name: 'timeMin',
type: 'dateTime',
default: '',
- description: 'At least some part of the event must be after this time',
+ description:
+ 'At least some part of the event must be after this time, use expression to set a date, or switch to fixed mode to choose date from widget',
+ displayOptions: {
+ hide: {
+ '@version': [{ _cnd: { gte: 1.3 } }],
+ },
+ },
},
{
displayName: 'Before',
name: 'timeMax',
type: 'dateTime',
default: '',
- description: 'At least some part of the event must be before this time',
+ description:
+ 'At least some part of the event must be before this time, use expression to set a date, or switch to fixed mode to choose date from widget',
+ displayOptions: {
+ hide: {
+ '@version': [{ _cnd: { gte: 1.3 } }],
+ },
+ },
+ },
+ {
+ displayName: 'Expand Events',
+ name: 'singleEvents',
+ type: 'boolean',
+ default: false,
+ description:
+ 'Whether to expand recurring events into instances and only return single one-off events and instances of recurring events, but not the underlying recurring events themselves',
+ displayOptions: {
+ hide: {
+ '@version': [{ _cnd: { gte: 1.3 } }],
+ },
+ },
},
{
displayName: 'Fields',
@@ -708,6 +810,34 @@ export const eventFields: INodeProperties[] = [
description:
'Free text search terms to find events that match these terms in any field, except for extended properties',
},
+ {
+ displayName: 'Recurring Event Handling',
+ name: 'recurringEventHandling',
+ type: 'options',
+ default: 'expand',
+ options: [
+ {
+ name: 'All Occurrences',
+ value: 'expand',
+ description: 'Return all instances of recurring event for specified time range',
+ },
+ {
+ name: 'First Occurrence',
+ value: 'first',
+ description: 'Return event with specified recurrence rule',
+ },
+ {
+ name: 'Next Occurrence',
+ value: 'next',
+ description: 'Return next instance of recurring event',
+ },
+ ],
+ displayOptions: {
+ show: {
+ '@version': [{ _cnd: { gte: 1.3 } }],
+ },
+ },
+ },
{
displayName: 'Show Deleted',
name: 'showDeleted',
@@ -723,14 +853,7 @@ export const eventFields: INodeProperties[] = [
default: false,
description: 'Whether to include hidden invitations in the result',
},
- {
- displayName: 'Single Events',
- name: 'singleEvents',
- type: 'boolean',
- default: false,
- description:
- 'Whether to expand recurring events into instances and only return single one-off events and instances of recurring events, but not the underlying recurring events themselves',
- },
+
{
displayName: 'Timezone',
name: 'timeZone',
@@ -797,6 +920,30 @@ export const eventFields: INodeProperties[] = [
},
default: '',
},
+ {
+ displayName: 'Modify',
+ name: 'modifyTarget',
+ type: 'options',
+ options: [
+ {
+ name: 'Recurring Event Instance',
+ value: 'instance',
+ },
+ {
+ name: 'Recurring Event',
+ value: 'event',
+ },
+ ],
+ default: 'instance',
+ displayOptions: {
+ show: {
+ '@version': [{ _cnd: { gte: 1.3 } }],
+ resource: ['event'],
+ operation: ['update'],
+ eventId: [{ _cnd: { includes: '_' } }],
+ },
+ },
+ },
{
displayName: 'Use Default Reminders',
name: 'useDefaultReminders',
diff --git a/packages/nodes-base/nodes/Google/Calendar/EventInterface.ts b/packages/nodes-base/nodes/Google/Calendar/EventInterface.ts
index d1907fbf2d..26212658d7 100644
--- a/packages/nodes-base/nodes/Google/Calendar/EventInterface.ts
+++ b/packages/nodes-base/nodes/Google/Calendar/EventInterface.ts
@@ -34,3 +34,8 @@ export interface IEvent {
visibility?: string;
conferenceData?: IConferenceData;
}
+
+export type RecurringEventInstance = {
+ recurringEventId?: string;
+ start: { dateTime: string; date: string };
+};
diff --git a/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts
index f440efaee9..100800c9f6 100644
--- a/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts
+++ b/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts
@@ -1,18 +1,22 @@
+import { DateTime } from 'luxon';
import moment from 'moment-timezone';
import type {
IDataObject,
IExecuteFunctions,
IHttpRequestMethods,
ILoadOptionsFunctions,
+ INode,
INodeListSearchItems,
INodeListSearchResult,
IPollFunctions,
IRequestOptions,
JsonObject,
} from 'n8n-workflow';
-import { NodeApiError } from 'n8n-workflow';
+import { NodeApiError, NodeOperationError, sleep } from 'n8n-workflow';
import { RRule } from 'rrule';
+import type { RecurringEventInstance } from './EventInterface';
+
export async function googleApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
method: IHttpRequestMethods,
@@ -50,7 +54,6 @@ export async function googleApiRequestAllItems(
propertyName: string,
method: IHttpRequestMethods,
endpoint: string,
-
body: any = {},
query: IDataObject = {},
): Promise {
@@ -127,58 +130,75 @@ export async function getTimezones(
return { results };
}
-type RecurentEvent = {
+export type RecurrentEvent = {
start: {
- dateTime: string;
- timeZone: string;
+ date?: string;
+ dateTime?: string;
+ timeZone?: string;
};
end: {
- dateTime: string;
- timeZone: string;
+ date?: string;
+ dateTime?: string;
+ timeZone?: string;
};
recurrence: string[];
nextOccurrence?: {
start: {
dateTime: string;
- timeZone: string;
+ timeZone?: string;
};
end: {
dateTime: string;
- timeZone: string;
+ timeZone?: string;
};
};
};
-export function addNextOccurrence(items: RecurentEvent[]) {
+export function addNextOccurrence(items: RecurrentEvent[]) {
for (const item of items) {
if (item.recurrence) {
let eventRecurrence;
try {
eventRecurrence = item.recurrence.find((r) => r.toUpperCase().startsWith('RRULE'));
+
if (!eventRecurrence) continue;
- const rrule = RRule.fromString(eventRecurrence);
+ const start = moment(item.start.dateTime || item.end.date).utc();
+ const end = moment(item.end.dateTime || item.end.date).utc();
+
+ const rruleWithStartDate = `DTSTART:${start.format(
+ 'YYYYMMDDTHHmmss',
+ )}Z\n${eventRecurrence}`;
+
+ const rrule = RRule.fromString(rruleWithStartDate);
+
const until = rrule.options?.until;
- const now = new Date();
- if (until && until < now) {
+ const now = moment().utc();
+
+ if (until && moment(until).isBefore(now)) {
continue;
}
- const nextOccurrence = rrule.after(new Date());
+ const nextDate = rrule.after(now.toDate(), false);
- item.nextOccurrence = {
- start: {
- dateTime: moment(nextOccurrence).format(),
- timeZone: item.start.timeZone,
- },
- end: {
- dateTime: moment(nextOccurrence)
- .add(moment(item.end.dateTime).diff(moment(item.start.dateTime)))
- .format(),
- timeZone: item.end.timeZone,
- },
- };
+ if (nextDate) {
+ const nextStart = moment(nextDate);
+
+ const duration = moment.duration(moment(end).diff(moment(start)));
+ const nextEnd = moment(nextStart).add(duration);
+
+ item.nextOccurrence = {
+ start: {
+ dateTime: nextStart.format(),
+ timeZone: item.start.timeZone,
+ },
+ end: {
+ dateTime: nextEnd.format(),
+ timeZone: item.end.timeZone,
+ },
+ };
+ }
} catch (error) {
console.log(`Error adding next occurrence ${eventRecurrence}`);
}
@@ -193,3 +213,92 @@ export function addTimezoneToDate(date: string, timezone: string) {
if (hasTimezone(date)) return date;
return moment.tz(date, timezone).utc().format();
}
+
+async function requestWithRetries(
+ node: INode,
+ requestFn: () => Promise,
+ retryCount: number = 0,
+ maxRetries: number = 10,
+ itemIndex: number = 0,
+): Promise {
+ try {
+ return await requestFn();
+ } catch (error) {
+ if (!(error instanceof NodeApiError)) {
+ throw new NodeOperationError(node, error.message, { itemIndex });
+ }
+
+ if (retryCount >= maxRetries) throw error;
+
+ if (error.httpCode === '403' || error.httpCode === '429') {
+ const delay = 1000 * Math.pow(2, retryCount);
+
+ console.log(`Rate limit hit. Retrying in ${delay}ms... (Attempt ${retryCount + 1})`);
+
+ await sleep(delay);
+ return await requestWithRetries(node, requestFn, retryCount + 1, maxRetries, itemIndex);
+ }
+
+ throw error;
+ }
+}
+
+export async function googleApiRequestWithRetries({
+ context,
+ method,
+ resource,
+ body = {},
+ qs = {},
+ uri,
+ headers = {},
+ itemIndex = 0,
+}: {
+ context: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions;
+ method: IHttpRequestMethods;
+ resource: string;
+ body?: any;
+ qs?: IDataObject;
+ uri?: string;
+ headers?: IDataObject;
+ itemIndex?: number;
+}) {
+ const requestFn = async (): Promise => {
+ return await googleApiRequest.call(context, method, resource, body, qs, uri, headers);
+ };
+
+ const retryCount = 0;
+ const maxRetries = 10;
+
+ return await requestWithRetries(context.getNode(), requestFn, retryCount, maxRetries, itemIndex);
+}
+
+export const eventExtendYearIntoFuture = (
+ data: RecurringEventInstance[],
+ timezone: string,
+ currentYear?: number, // for testing purposes
+) => {
+ const thisYear = currentYear || moment().tz(timezone).year();
+
+ return data.some((event) => {
+ if (!event.recurringEventId) return false;
+
+ const eventStart = event.start.dateTime || event.start.date;
+
+ const eventDateTime = moment(eventStart).tz(timezone);
+ if (!eventDateTime.isValid()) return false;
+
+ const targetYear = eventDateTime.year();
+
+ if (targetYear - thisYear >= 1) {
+ return true;
+ } else {
+ return false;
+ }
+ });
+};
+
+export function dateObjectToISO(date: T): string {
+ if (date instanceof DateTime) return date.toISO();
+ if (date instanceof Date) return date.toISOString();
+ return date as string;
+}
diff --git a/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.json b/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.json
index 737faf8fa1..690ea4f243 100644
--- a/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.json
+++ b/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.json
@@ -36,7 +36,7 @@
"url": "https://n8n.io/blog/your-business-doesnt-need-you-to-operate/"
},
{
- "label": "5 workflow automations for Mattermost that we love at n8n",
+ "label": "5 workflow automation for Mattermost that we love at n8n",
"icon": "🤖",
"url": "https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/"
},
diff --git a/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts b/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts
index 189f737888..505b1f525d 100644
--- a/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts
+++ b/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts
@@ -8,22 +8,33 @@ import type {
INodeType,
INodeTypeDescription,
JsonObject,
+ NodeExecutionHint,
+} from 'n8n-workflow';
+import {
+ NodeConnectionType,
+ NodeApiError,
+ NodeOperationError,
+ NodeExecutionOutput,
} from 'n8n-workflow';
-import { NodeConnectionType, NodeApiError, NodeOperationError } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { calendarFields, calendarOperations } from './CalendarDescription';
import { eventFields, eventOperations } from './EventDescription';
-import type { IEvent } from './EventInterface';
+import type { IEvent, RecurringEventInstance } from './EventInterface';
import {
addNextOccurrence,
addTimezoneToDate,
+ dateObjectToISO,
encodeURIComponentOnce,
+ eventExtendYearIntoFuture,
getCalendars,
getTimezones,
googleApiRequest,
googleApiRequestAllItems,
+ googleApiRequestWithRetries,
+ type RecurrentEvent,
} from './GenericFunctions';
+import { sortItemKeysByPriorityList } from '../../../utils/utilities';
export class GoogleCalendar implements INodeType {
description: INodeTypeDescription = {
@@ -31,7 +42,7 @@ export class GoogleCalendar implements INodeType {
name: 'googleCalendar',
icon: 'file:googleCalendar.svg',
group: ['input'],
- version: [1, 1.1, 1.2],
+ version: [1, 1.1, 1.2, 1.3],
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Google Calendar API',
defaults: {
@@ -132,6 +143,7 @@ export class GoogleCalendar implements INodeType {
const returnData: INodeExecutionData[] = [];
const length = items.length;
const qs: IDataObject = {};
+ const hints: NodeExecutionHint[] = [];
let responseData;
const resource = this.getNodeParameter('resource', 0);
@@ -148,8 +160,8 @@ export class GoogleCalendar implements INodeType {
const calendarId = decodeURIComponent(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
);
- const timeMin = this.getNodeParameter('timeMin', i) as string;
- const timeMax = this.getNodeParameter('timeMax', i) as string;
+ const timeMin = dateObjectToISO(this.getNodeParameter('timeMin', i));
+ const timeMax = dateObjectToISO(this.getNodeParameter('timeMax', i));
const options = this.getNodeParameter('options', i);
const outputFormat = options.outputFormat || 'availability';
const tz = this.getNodeParameter('options.timezone', i, '', {
@@ -200,8 +212,8 @@ export class GoogleCalendar implements INodeType {
const calendarId = encodeURIComponentOnce(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
);
- const start = this.getNodeParameter('start', i) as string;
- const end = this.getNodeParameter('end', i) as string;
+ const start = dateObjectToISO(this.getNodeParameter('start', i));
+ const end = dateObjectToISO(this.getNodeParameter('end', i));
const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean;
const additionalFields = this.getNodeParameter('additionalFields', i);
@@ -379,16 +391,33 @@ export class GoogleCalendar implements INodeType {
if (tz) {
qs.timeZone = tz;
}
- responseData = await googleApiRequest.call(
+ responseData = (await googleApiRequest.call(
this,
'GET',
`/calendar/v3/calendars/${calendarId}/events/${eventId}`,
{},
qs,
- );
+ )) as IDataObject;
if (responseData) {
- responseData = addNextOccurrence([responseData]);
+ if (nodeVersion >= 1.3 && options.returnNextInstance && responseData.recurrence) {
+ const eventInstances =
+ ((
+ (await googleApiRequest.call(
+ this,
+ 'GET',
+ `/calendar/v3/calendars/${calendarId}/events/${responseData.id}/instances`,
+ {},
+ {
+ timeMin: new Date().toISOString(),
+ maxResults: 1,
+ },
+ )) as IDataObject
+ ).items as IDataObject[]) || [];
+ responseData = eventInstances[0] ? [eventInstances[0]] : [responseData];
+ } else {
+ responseData = addNextOccurrence([responseData as RecurrentEvent]);
+ }
}
}
//https://developers.google.com/calendar/v3/reference/events/list
@@ -401,6 +430,22 @@ export class GoogleCalendar implements INodeType {
const tz = this.getNodeParameter('options.timeZone', i, '', {
extractValue: true,
}) as string;
+
+ if (nodeVersion >= 1.3) {
+ const timeMin = dateObjectToISO(this.getNodeParameter('timeMin', i));
+ const timeMax = dateObjectToISO(this.getNodeParameter('timeMax', i));
+ if (timeMin) {
+ qs.timeMin = addTimezoneToDate(timeMin as string, tz || timezone);
+ }
+ if (timeMax) {
+ qs.timeMax = addTimezoneToDate(timeMax as string, tz || timezone);
+ }
+
+ if (!options.recurringEventHandling || options.recurringEventHandling === 'expand') {
+ qs.singleEvents = true;
+ }
+ }
+
if (options.iCalUID) {
qs.iCalUID = options.iCalUID as string;
}
@@ -423,16 +468,19 @@ export class GoogleCalendar implements INodeType {
qs.singleEvents = options.singleEvents as boolean;
}
if (options.timeMax) {
- qs.timeMax = addTimezoneToDate(options.timeMax as string, tz || timezone);
+ qs.timeMax = addTimezoneToDate(dateObjectToISO(options.timeMax), tz || timezone);
}
if (options.timeMin) {
- qs.timeMin = addTimezoneToDate(options.timeMin as string, tz || timezone);
+ qs.timeMin = addTimezoneToDate(dateObjectToISO(options.timeMin), tz || timezone);
}
if (tz) {
qs.timeZone = tz;
}
if (options.updatedMin) {
- qs.updatedMin = addTimezoneToDate(options.updatedMin as string, tz || timezone);
+ qs.updatedMin = addTimezoneToDate(
+ dateObjectToISO(options.updatedMin),
+ tz || timezone,
+ );
}
if (options.fields) {
qs.fields = options.fields as string;
@@ -460,7 +508,76 @@ export class GoogleCalendar implements INodeType {
}
if (responseData) {
- responseData = addNextOccurrence(responseData);
+ if (nodeVersion >= 1.3 && options.recurringEventHandling === 'next') {
+ const updatedEvents: IDataObject[] = [];
+
+ for (const event of responseData) {
+ if (event.recurrence) {
+ const eventInstances =
+ ((
+ (await googleApiRequestWithRetries({
+ context: this,
+ method: 'GET',
+ resource: `/calendar/v3/calendars/${calendarId}/events/${event.id}/instances`,
+ qs: {
+ timeMin: new Date().toISOString(),
+ maxResults: 1,
+ },
+ itemIndex: i,
+ })) as IDataObject
+ ).items as IDataObject[]) || [];
+ updatedEvents.push(eventInstances[0] || event);
+ continue;
+ }
+
+ updatedEvents.push(event);
+ }
+ responseData = updatedEvents;
+ } else if (nodeVersion >= 1.3 && options.recurringEventHandling === 'first') {
+ responseData = responseData.filter((event: IDataObject) => {
+ if (
+ qs.timeMin &&
+ event.recurrence &&
+ event.created &&
+ event.created < qs.timeMin
+ ) {
+ return false;
+ }
+
+ if (
+ qs.timeMax &&
+ event.recurrence &&
+ event.created &&
+ event.created > qs.timeMax
+ ) {
+ return false;
+ }
+
+ return true;
+ });
+ } else if (nodeVersion < 1.3) {
+ // in node version above or equal to 1.3, this would correspond to the 'expand' option,
+ // so no need to add the next occurrence as event instances returned by the API
+ responseData = addNextOccurrence(responseData);
+ }
+
+ if (
+ !qs.timeMax &&
+ (!options.recurringEventHandling || options.recurringEventHandling === 'expand')
+ ) {
+ const suggestTrim = eventExtendYearIntoFuture(
+ responseData as RecurringEventInstance[],
+ timezone,
+ );
+
+ if (suggestTrim) {
+ hints.push({
+ message:
+ "Some events repeat far into the future. To return less of them, add a 'Before' date or change the 'Recurring Event Handling' option.",
+ location: 'outputPane',
+ });
+ }
+ }
}
}
//https://developers.google.com/calendar/v3/reference/events/patch
@@ -468,7 +585,22 @@ export class GoogleCalendar implements INodeType {
const calendarId = encodeURIComponentOnce(
this.getNodeParameter('calendar', i, '', { extractValue: true }) as string,
);
- const eventId = this.getNodeParameter('eventId', i) as string;
+ let eventId = this.getNodeParameter('eventId', i) as string;
+
+ if (nodeVersion >= 1.3) {
+ const modifyTarget = this.getNodeParameter('modifyTarget', i, 'instance') as string;
+ if (modifyTarget === 'event') {
+ const instance = (await googleApiRequest.call(
+ this,
+ 'GET',
+ `/calendar/v3/calendars/${calendarId}/events/${eventId}`,
+ {},
+ qs,
+ )) as IDataObject;
+ eventId = instance.recurringEventId as string;
+ }
+ }
+
const useDefaultReminders = this.getNodeParameter('useDefaultReminders', i) as boolean;
const updateFields = this.getNodeParameter('updateFields', i);
let updateTimezone = updateFields.timezone as string;
@@ -658,6 +790,30 @@ export class GoogleCalendar implements INodeType {
}
}
}
- return [returnData];
+
+ const keysPriorityList = [
+ 'id',
+ 'summary',
+ 'start',
+ 'end',
+ 'attendees',
+ 'creator',
+ 'organizer',
+ 'description',
+ 'location',
+ 'created',
+ 'updated',
+ ];
+
+ let nodeExecutionData = returnData;
+ if (nodeVersion >= 1.3) {
+ nodeExecutionData = sortItemKeysByPriorityList(returnData, keysPriorityList);
+ }
+
+ if (hints.length) {
+ return new NodeExecutionOutput([nodeExecutionData], hints);
+ }
+
+ return [nodeExecutionData];
}
}
diff --git a/packages/nodes-base/nodes/Google/Calendar/test/GeneircFunctions.test.ts b/packages/nodes-base/nodes/Google/Calendar/test/GeneircFunctions.test.ts
index 1da9122e2c..6dab75ee0a 100644
--- a/packages/nodes-base/nodes/Google/Calendar/test/GeneircFunctions.test.ts
+++ b/packages/nodes-base/nodes/Google/Calendar/test/GeneircFunctions.test.ts
@@ -1,4 +1,7 @@
-import { addTimezoneToDate } from '../GenericFunctions';
+import { DateTime } from 'luxon';
+
+import type { RecurringEventInstance } from '../EventInterface';
+import { addTimezoneToDate, dateObjectToISO, eventExtendYearIntoFuture } from '../GenericFunctions';
describe('addTimezoneToDate', () => {
it('should add timezone to date', () => {
@@ -18,3 +21,87 @@ describe('addTimezoneToDate', () => {
expect(result4).toBe('2021-09-01T12:00:00.000+08:00');
});
});
+
+describe('dateObjectToISO', () => {
+ test('should return ISO string for DateTime instance', () => {
+ const mockDateTime = DateTime.fromISO('2025-01-07T12:00:00');
+ const result = dateObjectToISO(mockDateTime);
+ expect(result).toBe('2025-01-07T12:00:00.000+00:00');
+ });
+
+ test('should return ISO string for Date instance', () => {
+ const mockDate = new Date('2025-01-07T12:00:00Z');
+ const result = dateObjectToISO(mockDate);
+ expect(result).toBe('2025-01-07T12:00:00.000Z');
+ });
+
+ test('should return string when input is not a DateTime or Date instance', () => {
+ const inputString = '2025-01-07T12:00:00';
+ const result = dateObjectToISO(inputString);
+ expect(result).toBe(inputString);
+ });
+});
+
+describe('eventExtendYearIntoFuture', () => {
+ const timezone = 'UTC';
+
+ it('should return true if any event extends into the next year', () => {
+ const events = [
+ {
+ recurringEventId: '123',
+ start: { dateTime: '2026-01-01T00:00:00Z', date: null },
+ },
+ ] as unknown as RecurringEventInstance[];
+
+ const result = eventExtendYearIntoFuture(events, timezone, 2025);
+ expect(result).toBe(true);
+ });
+
+ it('should return false if no event extends into the next year', () => {
+ const events = [
+ {
+ recurringEventId: '123',
+ start: { dateTime: '2025-12-31T23:59:59Z', date: null },
+ },
+ ] as unknown as RecurringEventInstance[];
+
+ const result = eventExtendYearIntoFuture(events, timezone, 2025);
+ expect(result).toBe(false);
+ });
+
+ it('should return false for invalid event start dates', () => {
+ const events = [
+ {
+ recurringEventId: '123',
+ start: { dateTime: 'invalid-date', date: null },
+ },
+ ] as unknown as RecurringEventInstance[];
+
+ const result = eventExtendYearIntoFuture(events, timezone, 2025);
+ expect(result).toBe(false);
+ });
+
+ it('should return false for events without a recurringEventId', () => {
+ const events = [
+ {
+ recurringEventId: null,
+ start: { dateTime: '2025-01-01T00:00:00Z', date: null },
+ },
+ ] as unknown as RecurringEventInstance[];
+
+ const result = eventExtendYearIntoFuture(events, timezone, 2025);
+ expect(result).toBe(false);
+ });
+
+ it('should handle events with only a date and no time', () => {
+ const events = [
+ {
+ recurringEventId: '123',
+ start: { dateTime: null, date: '2026-01-01' },
+ },
+ ] as unknown as RecurringEventInstance[];
+
+ const result = eventExtendYearIntoFuture(events, timezone, 2025);
+ expect(result).toBe(true);
+ });
+});
diff --git a/packages/nodes-base/nodes/Google/Calendar/test/addNextOccurrence.test.ts b/packages/nodes-base/nodes/Google/Calendar/test/addNextOccurrence.test.ts
new file mode 100644
index 0000000000..5390500ca6
--- /dev/null
+++ b/packages/nodes-base/nodes/Google/Calendar/test/addNextOccurrence.test.ts
@@ -0,0 +1,89 @@
+import moment from 'moment-timezone';
+
+import type { RecurrentEvent } from '../GenericFunctions';
+import { addNextOccurrence } from '../GenericFunctions';
+
+const mockNow = '2024-09-06T16:30:00+03:00';
+jest.spyOn(global.Date, 'now').mockImplementation(() => moment(mockNow).valueOf());
+
+describe('addNextOccurrence', () => {
+ it('should not modify event if no recurrence exists', () => {
+ const event: RecurrentEvent[] = [
+ {
+ start: {
+ dateTime: '2024-09-01T08:00:00Z',
+ timeZone: 'UTC',
+ },
+ end: {
+ dateTime: '2024-09-01T09:00:00Z',
+ timeZone: 'UTC',
+ },
+ recurrence: [],
+ },
+ ];
+
+ const result = addNextOccurrence(event);
+
+ expect(result[0].nextOccurrence).toBeUndefined();
+ });
+
+ it('should handle event with no RRULE correctly', () => {
+ const event: RecurrentEvent[] = [
+ {
+ start: {
+ dateTime: '2024-09-01T08:00:00Z',
+ timeZone: 'UTC',
+ },
+ end: {
+ dateTime: '2024-09-01T09:00:00Z',
+ timeZone: 'UTC',
+ },
+ recurrence: ['FREQ=WEEKLY;COUNT=2'],
+ },
+ ];
+
+ const result = addNextOccurrence(event);
+
+ expect(result[0].nextOccurrence).toBeUndefined();
+ });
+
+ it('should ignore recurrence if until date is in the past', () => {
+ const event: RecurrentEvent[] = [
+ {
+ start: {
+ dateTime: '2024-08-01T08:00:00Z',
+ timeZone: 'UTC',
+ },
+ end: {
+ dateTime: '2024-08-01T09:00:00Z',
+ timeZone: 'UTC',
+ },
+ recurrence: ['RRULE:FREQ=DAILY;UNTIL=20240805T000000Z'],
+ },
+ ];
+
+ const result = addNextOccurrence(event);
+
+ expect(result[0].nextOccurrence).toBeUndefined();
+ });
+
+ it('should handle errors gracefully without breaking and return unchanged event', () => {
+ const event: RecurrentEvent[] = [
+ {
+ start: {
+ dateTime: '2024-09-06T17:30:00+03:00',
+ timeZone: 'Europe/Berlin',
+ },
+ end: {
+ dateTime: '2024-09-06T18:00:00+03:00',
+ timeZone: 'Europe/Berlin',
+ },
+ recurrence: ['xxxxx'],
+ },
+ ];
+
+ const result = addNextOccurrence(event);
+
+ expect(result).toEqual(event);
+ });
+});
diff --git a/packages/nodes-base/nodes/Google/Calendar/test/node/event.getAll.test.ts b/packages/nodes-base/nodes/Google/Calendar/test/node/event.getAll.test.ts
new file mode 100644
index 0000000000..5cdaf3e25b
--- /dev/null
+++ b/packages/nodes-base/nodes/Google/Calendar/test/node/event.getAll.test.ts
@@ -0,0 +1,221 @@
+import type { MockProxy } from 'jest-mock-extended';
+import { mock } from 'jest-mock-extended';
+import type { INode, IExecuteFunctions, IDataObject, NodeExecutionOutput } from 'n8n-workflow';
+
+import * as genericFunctions from '../../GenericFunctions';
+import { GoogleCalendar } from '../../GoogleCalendar.node';
+
+let response: IDataObject[] | undefined = [];
+let responseWithRetries: IDataObject | undefined = {};
+
+jest.mock('../../GenericFunctions', () => {
+ const originalModule = jest.requireActual('../../GenericFunctions');
+ return {
+ ...originalModule,
+ getTimezones: jest.fn(),
+ googleApiRequest: jest.fn(),
+ googleApiRequestAllItems: jest.fn(async function () {
+ return (() => response)();
+ }),
+ googleApiRequestWithRetries: jest.fn(async function () {
+ return (() => responseWithRetries)();
+ }),
+ addNextOccurrence: jest.fn(function (data: IDataObject[]) {
+ return data;
+ }),
+ };
+});
+
+describe('Google Calendar Node', () => {
+ let googleCalendar: GoogleCalendar;
+ let mockExecuteFunctions: MockProxy;
+
+ beforeEach(() => {
+ googleCalendar = new GoogleCalendar();
+ mockExecuteFunctions = mock({
+ getInputData: jest.fn(),
+ getNode: jest.fn(),
+ getNodeParameter: jest.fn(),
+ getTimezone: jest.fn(),
+ helpers: {
+ constructExecutionMetaData: jest.fn().mockReturnValue([]),
+ },
+ });
+ response = undefined;
+ responseWithRetries = undefined;
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('Google Calendar > Event > Get Many', () => {
+ it('should configure get all request parameters in version 1.3', async () => {
+ // pre loop setup
+ mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
+ mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
+ mockExecuteFunctions.getNode.mockReturnValue(mock({ typeVersion: 1.3 }));
+
+ //operation setup
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
+ iCalUID: 'uid',
+ maxAttendees: 25,
+ orderBy: 'startTime',
+ query: 'test query',
+ recurringEventHandling: 'expand',
+ showDeleted: true,
+ showHiddenInvitations: true,
+ updatedMin: '2024-12-21T00:00:00',
+ }); //options
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-26T00:00:00'); //timeMax
+
+ await googleCalendar.execute.call(mockExecuteFunctions);
+
+ expect(genericFunctions.googleApiRequestAllItems).toHaveBeenCalledWith(
+ 'items',
+ 'GET',
+ '/calendar/v3/calendars/myCalendar/events',
+ {},
+ {
+ iCalUID: 'uid',
+ maxAttendees: 25,
+ orderBy: 'startTime',
+ q: 'test query',
+ showDeleted: true,
+ showHiddenInvitations: true,
+ singleEvents: true,
+ timeMax: '2024-12-25T23:00:00Z',
+ timeMin: '2024-12-19T23:00:00Z',
+ timeZone: 'Europe/Berlin',
+ updatedMin: '2024-12-20T23:00:00Z',
+ },
+ );
+ });
+
+ it('should configure get all recurringEventHandling equals next in version 1.3', async () => {
+ // pre loop setup
+ mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
+ mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
+ mockExecuteFunctions.getNode.mockReturnValue(mock({ typeVersion: 1.3 }));
+
+ //operation setup
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
+ recurringEventHandling: 'next',
+ }); //options
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-26T00:00:00'); //timeMax
+
+ response = [
+ {
+ recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
+ },
+ ];
+
+ responseWithRetries = { items: [] };
+
+ const result = await googleCalendar.execute.call(mockExecuteFunctions);
+
+ expect(genericFunctions.googleApiRequestAllItems).toHaveBeenCalledWith(
+ 'items',
+ 'GET',
+ '/calendar/v3/calendars/myCalendar/events',
+ {},
+ {
+ timeMax: '2024-12-25T23:00:00Z',
+ timeMin: '2024-12-19T23:00:00Z',
+ timeZone: 'Europe/Berlin',
+ },
+ );
+ expect(genericFunctions.googleApiRequestWithRetries).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: 'GET',
+ itemIndex: 0,
+ resource: '/calendar/v3/calendars/myCalendar/events/undefined/instances',
+ }),
+ );
+
+ expect(result).toEqual([[]]);
+ });
+
+ it('should configure get all recurringEventHandling equals first in version 1.3', async () => {
+ // pre loop setup
+ mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
+ mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
+ mockExecuteFunctions.getNode.mockReturnValue(mock({ typeVersion: 1.3 }));
+
+ //operation setup
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
+ recurringEventHandling: 'first',
+ }); //options
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-26T00:00:00'); //timeMax
+
+ response = [
+ {
+ recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
+ created: '2024-12-19T00:00:00',
+ },
+ {
+ recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
+ created: '2024-12-27T00:00:00',
+ },
+ ];
+
+ const result = await googleCalendar.execute.call(mockExecuteFunctions);
+
+ expect(result).toEqual([[]]);
+ });
+
+ it('should configure get all should have hint in version 1.3', async () => {
+ // pre loop setup
+ mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('getAll');
+ mockExecuteFunctions.getTimezone.mockReturnValueOnce('Europe/Berlin');
+ mockExecuteFunctions.getNode.mockReturnValue(mock({ typeVersion: 1.3 }));
+
+ //operation setup
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(true); //returnAll
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('myCalendar'); //calendar
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); //options
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('Europe/Berlin'); //options.timeZone
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('2024-12-20T00:00:00'); //timeMin
+ mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(''); //timeMax
+
+ response = [
+ {
+ recurrence: ['RRULE:FREQ=DAILY;COUNT=5'],
+ created: '2024-12-25T00:00:00',
+ recurringEventId: '1',
+ start: { dateTime: '2027-12-25T00:00:00' },
+ },
+ ];
+
+ const result = await googleCalendar.execute.call(mockExecuteFunctions);
+
+ expect((result as NodeExecutionOutput).getHints()).toEqual([
+ {
+ message:
+ "Some events repeat far into the future. To return less of them, add a 'Before' date or change the 'Recurring Event Handling' option.",
+ location: 'outputPane',
+ },
+ ]);
+ });
+ });
+});
diff --git a/packages/nodes-base/nodes/Google/Calendar/test/node/event.update.test.ts b/packages/nodes-base/nodes/Google/Calendar/test/node/event.update.test.ts
index b242c73063..1cada0cf06 100644
--- a/packages/nodes-base/nodes/Google/Calendar/test/node/event.update.test.ts
+++ b/packages/nodes-base/nodes/Google/Calendar/test/node/event.update.test.ts
@@ -14,7 +14,7 @@ jest.mock('../../GenericFunctions', () => ({
encodeURIComponentOnce: jest.fn(),
}));
-describe('RespondToWebhook Node', () => {
+describe('Google Calendar Node', () => {
let googleCalendar: GoogleCalendar;
let mockExecuteFunctions: MockProxy;
diff --git a/packages/nodes-base/test/utils/utilities.test.ts b/packages/nodes-base/test/utils/utilities.test.ts
index 4ffc0835bb..a30552f6f5 100644
--- a/packages/nodes-base/test/utils/utilities.test.ts
+++ b/packages/nodes-base/test/utils/utilities.test.ts
@@ -1,3 +1,5 @@
+import type { INodeExecutionData } from 'n8n-workflow';
+
import {
compareItems,
flattenKeys,
@@ -5,6 +7,7 @@ import {
getResolvables,
keysToLowercase,
shuffleArray,
+ sortItemKeysByPriorityList,
wrapData,
} from '@utils/utilities';
@@ -252,3 +255,60 @@ describe('compareItems', () => {
expect(result).toBe(true);
});
});
+
+describe('sortItemKeysByPriorityList', () => {
+ it('should reorder keys based on priority list', () => {
+ const data: INodeExecutionData[] = [{ json: { c: 3, a: 1, b: 2 } }];
+ const priorityList = ['b', 'a'];
+
+ const result = sortItemKeysByPriorityList(data, priorityList);
+
+ expect(Object.keys(result[0].json)).toEqual(['b', 'a', 'c']);
+ });
+
+ it('should sort keys not in the priority list alphabetically', () => {
+ const data: INodeExecutionData[] = [{ json: { c: 3, a: 1, b: 2, d: 4 } }];
+ const priorityList = ['b', 'a'];
+
+ const result = sortItemKeysByPriorityList(data, priorityList);
+
+ expect(Object.keys(result[0].json)).toEqual(['b', 'a', 'c', 'd']);
+ });
+
+ it('should sort all keys alphabetically when priority list is empty', () => {
+ const data: INodeExecutionData[] = [{ json: { c: 3, a: 1, b: 2 } }];
+ const priorityList: string[] = [];
+
+ const result = sortItemKeysByPriorityList(data, priorityList);
+
+ expect(Object.keys(result[0].json)).toEqual(['a', 'b', 'c']);
+ });
+
+ it('should handle an empty data array', () => {
+ const data: INodeExecutionData[] = [];
+ const priorityList = ['b', 'a'];
+
+ const result = sortItemKeysByPriorityList(data, priorityList);
+
+ // Expect an empty array since there is no data
+ expect(result).toEqual([]);
+ });
+
+ it('should handle a single object in the data array', () => {
+ const data: INodeExecutionData[] = [{ json: { d: 4, b: 2, a: 1 } }];
+ const priorityList = ['a', 'b', 'c'];
+
+ const result = sortItemKeysByPriorityList(data, priorityList);
+
+ expect(Object.keys(result[0].json)).toEqual(['a', 'b', 'd']);
+ });
+
+ it('should handle duplicate keys in the priority list gracefully', () => {
+ const data: INodeExecutionData[] = [{ json: { d: 4, b: 2, a: 1 } }];
+ const priorityList = ['a', 'b', 'a'];
+
+ const result = sortItemKeysByPriorityList(data, priorityList);
+
+ expect(Object.keys(result[0].json)).toEqual(['a', 'b', 'd']);
+ });
+});
diff --git a/packages/nodes-base/utils/utilities.ts b/packages/nodes-base/utils/utilities.ts
index cae18ae90b..74834b2aab 100644
--- a/packages/nodes-base/utils/utilities.ts
+++ b/packages/nodes-base/utils/utilities.ts
@@ -427,3 +427,37 @@ export function escapeHtml(text: string): string {
}
});
}
+
+/**
+ * Sorts each item json's keys by a priority list
+ *
+ * @param {INodeExecutionData[]} data The array of items which keys will be sorted
+ * @param {string[]} priorityList The priority list, keys of item.json will be sorted in this order first then alphabetically
+ */
+export function sortItemKeysByPriorityList(data: INodeExecutionData[], priorityList: string[]) {
+ return data.map((item) => {
+ const itemKeys = Object.keys(item.json);
+
+ const updatedKeysOrder = itemKeys.sort((a, b) => {
+ const indexA = priorityList.indexOf(a);
+ const indexB = priorityList.indexOf(b);
+
+ if (indexA !== -1 && indexB !== -1) {
+ return indexA - indexB;
+ } else if (indexA !== -1) {
+ return -1;
+ } else if (indexB !== -1) {
+ return 1;
+ }
+ return a.localeCompare(b);
+ });
+
+ const updatedItem: IDataObject = {};
+ for (const key of updatedKeysOrder) {
+ updatedItem[key] = item.json[key];
+ }
+
+ item.json = updatedItem;
+ return item;
+ });
+}
diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts
index 22883a73bb..bbeeca87c0 100644
--- a/packages/workflow/src/NodeHelpers.ts
+++ b/packages/workflow/src/NodeHelpers.ts
@@ -1404,7 +1404,7 @@ function addToIssuesIfMissing(
if (
(nodeProperties.type === 'string' && (value === '' || value === undefined)) ||
(nodeProperties.type === 'multiOptions' && Array.isArray(value) && value.length === 0) ||
- (nodeProperties.type === 'dateTime' && value === undefined) ||
+ (nodeProperties.type === 'dateTime' && (value === '' || value === undefined)) ||
(nodeProperties.type === 'options' && (value === '' || value === undefined)) ||
((nodeProperties.type === 'resourceLocator' || nodeProperties.type === 'workflowSelector') &&
!isValidResourceLocatorParameterValue(value as INodeParameterResourceLocator))
diff --git a/packages/workflow/test/NodeHelpers.test.ts b/packages/workflow/test/NodeHelpers.test.ts
index 8b3d9b3b02..964dedc6a9 100644
--- a/packages/workflow/test/NodeHelpers.test.ts
+++ b/packages/workflow/test/NodeHelpers.test.ts
@@ -4195,4 +4195,57 @@ describe('NodeHelpers', () => {
});
}
});
+
+ describe('getParameterIssues, required parameters validation', () => {
+ const testNode: INode = {
+ id: '12345',
+ name: 'Test Node',
+ typeVersion: 1,
+ type: 'n8n-nodes-base.testNode',
+ position: [1, 1],
+ parameters: {},
+ };
+
+ it('Should validate required dateTime parameters if empty string', () => {
+ const nodeProperties: INodeProperties = {
+ displayName: 'Date Time',
+ name: 'testDateTime',
+ type: 'dateTime',
+ default: '',
+ required: true,
+ };
+ const nodeValues: INodeParameters = {
+ testDateTime: '',
+ };
+
+ const result = getParameterIssues(nodeProperties, nodeValues, '', testNode);
+
+ expect(result).toEqual({
+ parameters: {
+ testDateTime: ['Parameter "Date Time" is required.'],
+ },
+ });
+ });
+
+ it('Should validate required dateTime parameters if empty undefined', () => {
+ const nodeProperties: INodeProperties = {
+ displayName: 'Date Time',
+ name: 'testDateTime',
+ type: 'dateTime',
+ default: '',
+ required: true,
+ };
+ const nodeValues: INodeParameters = {
+ testDateTime: undefined,
+ };
+
+ const result = getParameterIssues(nodeProperties, nodeValues, '', testNode);
+
+ expect(result).toEqual({
+ parameters: {
+ testDateTime: ['Parameter "Date Time" is required.'],
+ },
+ });
+ });
+ });
});