Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions nodes/Buffer/__tests__/google-business.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,37 @@ describe('Buffer Node - Create Post', () => {
}),
).rejects.toThrow('Google Business posts do not support video attachments');
});

// Workflows saved before "Specify Event Time" replaced the old "Full Day
// Event" toggle still have a `googleEventIsFullDay` value stored on the node,
// even though it's no longer part of this node's UI. These tests simulate
// that legacy saved shape to make sure old timed events don't silently
// become all-day events after upgrading.
describe('legacy googleEventIsFullDay workflows', () => {
it('should send the full start/end date-time and keep isFullDayEvent false for a legacy timed event', async () => {
const input = await executePostCreate({
...googleEventDefaults,
googleEventIsFullDay: false,
});
const meta = (input.metadata as IDataObject).google as IDataObject;
const details = meta.detailsEvent as IDataObject;
expect(details.startDate).toBe('2026-07-01T10:00:00.000Z');
expect(details.endDate).toBe('2026-07-01T18:00:00.000Z');
expect(details.isFullDayEvent).toBe(false);
});

it('should send the full start/end date-time and keep isFullDayEvent true for a legacy all-day event', async () => {
const input = await executePostCreate({
...googleEventDefaults,
googleEventIsFullDay: true,
});
const meta = (input.metadata as IDataObject).google as IDataObject;
const details = meta.detailsEvent as IDataObject;
expect(details.startDate).toBe('2026-07-01T10:00:00.000Z');
expect(details.endDate).toBe('2026-07-01T18:00:00.000Z');
expect(details.isFullDayEvent).toBe(true);
});
});
});

// -------------------------------------------------------
Expand Down
8 changes: 4 additions & 4 deletions nodes/Buffer/__tests__/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ export async function executePostCreate(params: Record<string, unknown>) {

const mockContext = {
getInputData: () => [{ json: {} }],
getNodeParameter: (name: string) => {
getNodeParameter: (name: string, _itemIndex: number, fallback?: unknown) => {
if (!(name in defaults)) {
return '';
return fallback !== undefined ? fallback : '';
}
return defaults[name];
},
Expand Down Expand Up @@ -103,10 +103,10 @@ export async function runExecute(

const mockContext = {
getInputData: () => itemsDefaults.map(() => ({ json: {} })),
getNodeParameter: (name: string, itemIndex: number) => {
getNodeParameter: (name: string, itemIndex: number, fallback?: unknown) => {
const defaults = itemsDefaults[itemIndex];
if (!defaults || !(name in defaults)) {
return '';
return fallback !== undefined ? fallback : '';
}
return defaults[name];
},
Expand Down
66 changes: 43 additions & 23 deletions nodes/Buffer/actions/post/networks/google-business.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,42 +46,62 @@ export function buildGoogleBusinessMetadata(ctx: IExecuteFunctions, itemIndex: n
const title = ctx.getNodeParameter('googleEventTitle', itemIndex) as string;
const startDate = ctx.getNodeParameter('googleEventStartDate', itemIndex) as string;
const endDate = ctx.getNodeParameter('googleEventEndDate', itemIndex) as string;
const hasTime = ctx.getNodeParameter('googleEventHasTime', itemIndex) as boolean;
const button = ctx.getNodeParameter('googleEventButton', itemIndex) as string;
const link = ctx.getNodeParameter('googleEventLink', itemIndex) as string;

// Workflows saved before "Specify Event Time" replaced the old "Full Day Event"
// toggle still carry a `googleEventIsFullDay` value in their stored parameters,
// even though that field no longer exists in this node's UI. The old behavior
// always sent the full start/end date-time and used this flag only to set
// isFullDayEvent, so replicate that exactly here rather than falling through to
// the new hasTime logic - otherwise old timed events would silently become
// all-day events (isFullDayEvent would flip from false to true) after upgrading.
const legacyIsFullDay = ctx.getNodeParameter('googleEventIsFullDay', itemIndex, null) as
| boolean
| null;

let eventStartDate: string;
let eventEndDate: string;
if (hasTime) {
const startTime = ctx.getNodeParameter('googleEventStartTime', itemIndex) as string;
const endTime = ctx.getNodeParameter('googleEventEndTime', itemIndex) as string;
if (!startTime || !endTime) {
throw new NodeOperationError(
ctx.getNode(),
'Please provide both an "Event Start Time" and "Event End Time", or disable "Specify Event Time" to create an all-day event.',
{ itemIndex },
);
}
if (!TIME_FORMAT_REGEX.test(startTime) || !TIME_FORMAT_REGEX.test(endTime)) {
throw new NodeOperationError(
ctx.getNode(),
'Event times must be in 24-hour HH:mm format, e.g. 14:30.',
{ itemIndex },
);
}
eventStartDate = combineDateAndTime(startDate, startTime);
eventEndDate = combineDateAndTime(endDate, endTime);
let isFullDayEvent: boolean;

if (legacyIsFullDay !== null) {
eventStartDate = new Date(startDate).toISOString();
eventEndDate = new Date(endDate).toISOString();
isFullDayEvent = legacyIsFullDay;
} else {
eventStartDate = toDateOnlyIso(startDate);
eventEndDate = toDateOnlyIso(endDate);
const hasTime = ctx.getNodeParameter('googleEventHasTime', itemIndex) as boolean;
if (hasTime) {
const startTime = ctx.getNodeParameter('googleEventStartTime', itemIndex) as string;
const endTime = ctx.getNodeParameter('googleEventEndTime', itemIndex) as string;
if (!startTime || !endTime) {
throw new NodeOperationError(
ctx.getNode(),
'Please provide both an "Event Start Time" and "Event End Time", or disable "Specify Event Time" to create an all-day event.',
{ itemIndex },
);
}
if (!TIME_FORMAT_REGEX.test(startTime) || !TIME_FORMAT_REGEX.test(endTime)) {
throw new NodeOperationError(
ctx.getNode(),
'Event times must be in 24-hour HH:mm format, e.g. 14:30.',
{ itemIndex },
);
}
eventStartDate = combineDateAndTime(startDate, startTime);
eventEndDate = combineDateAndTime(endDate, endTime);
} else {
eventStartDate = toDateOnlyIso(startDate);
eventEndDate = toDateOnlyIso(endDate);
}
isFullDayEvent = !hasTime;
}

if (title) googleMeta.title = title;
const details: IDataObject = {
title,
startDate: eventStartDate,
endDate: eventEndDate,
isFullDayEvent: !hasTime,
isFullDayEvent,
};
if (button) details.button = button;
if (link) details.link = link;
Expand Down
5 changes: 4 additions & 1 deletion nodes/Buffer/descriptions/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { INodeProperties } from 'n8n-workflow';
import { ideaProperties } from './idea.description';
import { postCoreProperties, postAttachmentProperties, postAssetProperties } from './post.description';
import { resourceProperty, postCoreProperties, postAttachmentProperties, postAssetProperties } from './post.description';
import { googleBusinessProperties } from './google-business.description';
import { pinterestProperties } from './pinterest.description';
import { facebookProperties } from './facebook.description';
Expand All @@ -9,7 +9,10 @@ import { youtubeProperties } from './youtube.description';

// Concatenated in the same order the fields originally appeared in Buffer.node.ts's `properties`
// array, so the field order in the n8n UI is unchanged by the split into per-resource/network files.
// The Resource selector must come first, since the Idea/Post fields that follow are conditionally
// shown based on its value.
export const properties: INodeProperties[] = [
...resourceProperty,
...ideaProperties,
...postCoreProperties,
...googleBusinessProperties,
Expand Down
109 changes: 59 additions & 50 deletions nodes/Buffer/descriptions/post.description.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import type { INodeProperties } from 'n8n-workflow';

// Resource, Post's Operation field, core post fields (channel/text/scheduling), and both
// schedulingType field variants (see the comment on the field below for why there are two).
export const postCoreProperties: INodeProperties[] = [
// ----------------------------------
// Resources
// ----------------------------------
// The Resource selector, kept as its own export so it can be placed first in the concatenated
// properties array (see descriptions/index.ts) — it must come before the Idea/Post-specific
// fields it controls, otherwise those fields render above it once "Idea" (the default) is
// selected, pushing the selector out of its expected position in the n8n UI.
export const resourceProperty: INodeProperties[] = [
{
displayName: 'Resource',
name: 'resource',
Expand All @@ -23,6 +22,11 @@ export const postCoreProperties: INodeProperties[] = [
],
default: 'idea',
},
];

// Post's Operation field, core post fields (channel/text/scheduling), and both schedulingType
// field variants (see the comment on the field below for why there are two).
export const postCoreProperties: INodeProperties[] = [
// ----------------------------------
// Operations
// ----------------------------------
Expand Down Expand Up @@ -236,30 +240,20 @@ export const postCoreProperties: INodeProperties[] = [
},
];

// The four attachmentType field variants: generic (hidden for YouTube/Google Business/Pinterest),
// YouTube-only (forces 'video'), Google Business-only (no video option), and Pinterest-only
// (forces 'image').
// The four attachmentType field variants: YouTube-only (forces 'video'), Google Business-only
// (no video option), Pinterest-only (forces 'image'), and generic (hidden for those three
// channels). The generic variant is deliberately listed LAST: n8n resolves a duplicate-named
// field's initial default (before an org/channel has been selected, so channelService is empty
// and no displayOptions match yet) from the LAST matching property definition in the array,
// not by evaluating displayOptions. Listing the generic 'none' variant last ensures a freshly
// added node defaults to "No Attachment" instead of whichever network-specific variant happens
// to be last.
export const postAttachmentProperties: INodeProperties[] = [
{
// YouTube posts are always videos, Google Business does not support video, and
// Pinterest posts are always images, so this field is hidden for those channels and
// replaced with the network-specific fields below (see the Scheduling Mode fields
// above for why this uses field-level displayOptions rather than per-option
// displayOptions).
displayName: 'Attachment Type',
name: 'attachmentType',
type: 'options',
options: [
{
name: 'No Attachment',
value: 'none',
description: 'Create a text-only post',
},
{
name: 'Image',
value: 'image',
description: 'Create a post with an image',
},
{
name: 'Video',
value: 'video',
Expand All @@ -270,49 +264,43 @@ export const postAttachmentProperties: INodeProperties[] = [
show: {
resource: ['post'],
operation: ['create'],
},
hide: {
channelService: [
'youtube', 'YouTube', 'YOUTUBE',
'google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS',
'pinterest', 'Pinterest', 'PINTEREST',
],
channelService: ['youtube', 'YouTube', 'YOUTUBE'],
},
},
default: 'none',
description: 'Type of attachment to add to the post',
default: 'video',
description: 'YouTube posts only support video attachments',
},
{
displayName: 'Attachment Type',
name: 'attachmentType',
type: 'options',
options: [
{
name: 'Video',
value: 'video',
description: 'Create a post with a video',
name: 'No Attachment',
value: 'none',
description: 'Create a text-only post',
},
{
name: 'Image',
value: 'image',
description: 'Create a post with an image',
},
],
displayOptions: {
show: {
resource: ['post'],
operation: ['create'],
channelService: ['youtube', 'YouTube', 'YOUTUBE'],
channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'],
},
},
default: 'video',
description: 'YouTube posts only support video attachments',
default: 'none',
description: 'Google Business posts do not support video attachments',
},
{
displayName: 'Attachment Type',
name: 'attachmentType',
type: 'options',
options: [
{
name: 'No Attachment',
value: 'none',
description: 'Create a text-only post',
},
{
name: 'Image',
value: 'image',
Expand All @@ -323,32 +311,53 @@ export const postAttachmentProperties: INodeProperties[] = [
show: {
resource: ['post'],
operation: ['create'],
channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'],
channelService: ['pinterest', 'Pinterest', 'PINTEREST'],
},
},
default: 'none',
description: 'Google Business posts do not support video attachments',
default: 'image',
description: 'Pinterest posts always require an image attachment',
},
{
// YouTube posts are always videos, Google Business does not support video, and
// Pinterest posts are always images, so this field is hidden for those channels and
// replaced with the network-specific fields above (see the Scheduling Mode fields
// above for why this uses field-level displayOptions rather than per-option
// displayOptions).
displayName: 'Attachment Type',
name: 'attachmentType',
type: 'options',
options: [
{
name: 'No Attachment',
value: 'none',
description: 'Create a text-only post',
},
{
name: 'Image',
value: 'image',
description: 'Create a post with an image',
},
{
name: 'Video',
value: 'video',
description: 'Create a post with a video',
},
],
displayOptions: {
show: {
resource: ['post'],
operation: ['create'],
channelService: ['pinterest', 'Pinterest', 'PINTEREST'],
},
hide: {
channelService: [
'youtube', 'YouTube', 'YOUTUBE',
'google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS',
'pinterest', 'Pinterest', 'PINTEREST',
],
},
},
default: 'image',
description: 'Pinterest posts always require an image attachment',
default: 'none',
description: 'Type of attachment to add to the post',
},
];

Expand Down
Loading