first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.googleSheets",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Data & Storage", "Productivity"],
|
||||
"resources": {
|
||||
"credentialDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/"
|
||||
}
|
||||
],
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.googlesheets/"
|
||||
}
|
||||
],
|
||||
"generic": [
|
||||
{
|
||||
"label": "Love at first sight: Ricardo’s n8n journey",
|
||||
"icon": "❤️",
|
||||
"url": "https://n8n.io/blog/love-at-first-sight-ricardos-n8n-journey/"
|
||||
},
|
||||
{
|
||||
"label": "Why business process automation with n8n can change your daily life",
|
||||
"icon": "🧬",
|
||||
"url": "https://n8n.io/blog/why-business-process-automation-with-n8n-can-change-your-daily-life/"
|
||||
},
|
||||
{
|
||||
"label": "Automatically Adding Expense Receipts to Google Sheets with Telegram, Mindee, Twilio, and n8n",
|
||||
"icon": "🧾",
|
||||
"url": "https://n8n.io/blog/automatically-adding-expense-receipts-to-google-sheets-with-telegram-mindee-twilio-and-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "Supercharging your conference registration process with n8n",
|
||||
"icon": "🎫",
|
||||
"url": "https://n8n.io/blog/supercharging-your-conference-registration-process-with-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "Creating triggers for n8n workflows using polling",
|
||||
"icon": "⏲",
|
||||
"url": "https://n8n.io/blog/creating-triggers-for-n8n-workflows-using-polling/"
|
||||
},
|
||||
{
|
||||
"label": "6 e-commerce workflows to power up your Shopify s",
|
||||
"icon": "store",
|
||||
"url": "https://n8n.io/blog/no-code-ecommerce-workflow-automations/"
|
||||
},
|
||||
{
|
||||
"label": "Migrating Community Metrics to Orbit using n8n",
|
||||
"icon": "📈",
|
||||
"url": "https://n8n.io/blog/migrating-community-metrics-to-orbit-using-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "15 Google apps you can combine and automate to increase productivity",
|
||||
"icon": "💡",
|
||||
"url": "https://n8n.io/blog/automate-google-apps-for-productivity/"
|
||||
},
|
||||
{
|
||||
"label": "Hey founders! Your business doesn't need you to operate",
|
||||
"icon": " 🖥️",
|
||||
"url": "https://n8n.io/blog/your-business-doesnt-need-you-to-operate/"
|
||||
},
|
||||
{
|
||||
"label": "How Honest Burgers Use Automation to Save $100k per year",
|
||||
"icon": "🍔",
|
||||
"url": "https://n8n.io/blog/how-honest-burgers-use-automation-to-save-100k-per-year/"
|
||||
},
|
||||
{
|
||||
"label": "How a digital strategist uses n8n for online marketing",
|
||||
"icon": "💻",
|
||||
"url": "https://n8n.io/blog/how-a-digital-strategist-uses-n8n-for-online-marketing/"
|
||||
},
|
||||
{
|
||||
"label": "Why this Product Manager loves workflow automation with n8n",
|
||||
"icon": "🧠",
|
||||
"url": "https://n8n.io/blog/why-this-product-manager-loves-workflow-automation-with-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "Sending Automated Congratulations with Google Sheets, Twilio, and n8n ",
|
||||
"icon": "🙌",
|
||||
"url": "https://n8n.io/blog/sending-automated-congratulations-with-google-sheets-twilio-and-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "How a Membership Development Manager automates his work and investments",
|
||||
"icon": "📈",
|
||||
"url": "https://n8n.io/blog/how-a-membership-development-manager-automates-his-work-and-investments/"
|
||||
},
|
||||
{
|
||||
"label": "7 no-code workflow automations for Amazon Web Services",
|
||||
"url": "https://n8n.io/blog/aws-workflow-automation/"
|
||||
}
|
||||
]
|
||||
},
|
||||
"alias": ["CSV", "Sheet", "Spreadsheet", "GS"]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
|
||||
import { VersionedNodeType } from 'n8n-workflow';
|
||||
|
||||
import { GoogleSheetsV1 } from './v1/GoogleSheetsV1.node';
|
||||
import { GoogleSheetsV2 } from './v2/GoogleSheetsV2.node';
|
||||
|
||||
export class GoogleSheets extends VersionedNodeType {
|
||||
constructor() {
|
||||
const baseDescription: INodeTypeBaseDescription = {
|
||||
displayName: 'Google Sheets',
|
||||
name: 'googleSheets',
|
||||
icon: 'file:googleSheets.svg',
|
||||
group: ['input', 'output'],
|
||||
defaultVersion: 4.7,
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Read, update and write data to Google Sheets',
|
||||
builderHint: {
|
||||
message:
|
||||
'For workflow data storage, DataTable with upsert avoids duplicates. Use Google Sheets when spreadsheet collaboration is specifically needed.',
|
||||
relatedNodes: [
|
||||
{
|
||||
nodeType: 'n8n-nodes-base.dataTable',
|
||||
relationHint: 'Prefer for workflow data storage with upsert',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||
1: new GoogleSheetsV1(baseDescription),
|
||||
2: new GoogleSheetsV1(baseDescription),
|
||||
3: new GoogleSheetsV2(baseDescription),
|
||||
4: new GoogleSheetsV2(baseDescription),
|
||||
4.1: new GoogleSheetsV2(baseDescription),
|
||||
4.2: new GoogleSheetsV2(baseDescription),
|
||||
4.3: new GoogleSheetsV2(baseDescription),
|
||||
4.4: new GoogleSheetsV2(baseDescription),
|
||||
4.5: new GoogleSheetsV2(baseDescription),
|
||||
4.6: new GoogleSheetsV2(baseDescription),
|
||||
4.7: new GoogleSheetsV2(baseDescription),
|
||||
};
|
||||
|
||||
super(nodeVersions, baseDescription);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.googleSheetsTrigger",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Data & Storage", "Productivity"],
|
||||
"resources": {
|
||||
"credentialDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/"
|
||||
}
|
||||
],
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.googlesheetstrigger/"
|
||||
}
|
||||
],
|
||||
"generic": []
|
||||
},
|
||||
"alias": ["CSV", "Spreadsheet", "GS"]
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
INodeProperties,
|
||||
IPollFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
arrayOfArraysToJson,
|
||||
BINARY_MIME_TYPE,
|
||||
compareRevisions,
|
||||
getRevisionFile,
|
||||
sheetBinaryToArrayOfArrays,
|
||||
} from './GoogleSheetsTrigger.utils';
|
||||
import { GOOGLE_DRIVE_FILE_URL_REGEX, GOOGLE_SHEETS_SHEET_URL_REGEX } from '../constants';
|
||||
import { GoogleSheet } from './v2/helpers/GoogleSheet';
|
||||
import type { ResourceLocator, ValueRenderOption } from './v2/helpers/GoogleSheets.types';
|
||||
import { sheetsSearch, spreadSheetsSearch } from './v2/methods/listSearch';
|
||||
import { getSheetHeaderRowAndSkipEmpty } from './v2/methods/loadOptions';
|
||||
import { apiRequest } from './v2/transport';
|
||||
|
||||
export const document: INodeProperties = {
|
||||
displayName: 'Document',
|
||||
name: 'documentId',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'spreadSheetsSearch',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: GOOGLE_DRIVE_FILE_URL_REGEX,
|
||||
},
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: GOOGLE_DRIVE_FILE_URL_REGEX,
|
||||
errorMessage: 'Not a valid Google Drive File URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '[a-zA-Z0-9\\-_]{2,}',
|
||||
errorMessage: 'Not a valid Google Drive File ID',
|
||||
},
|
||||
},
|
||||
],
|
||||
url: '=https://docs.google.com/spreadsheets/d/{{$value}}/edit',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sheet: INodeProperties = {
|
||||
displayName: 'Sheet',
|
||||
name: 'sheetName',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
// default: '', //empty string set to progresivly reveal fields
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['documentId.value'],
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'sheetsSearch',
|
||||
searchable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: GOOGLE_SHEETS_SHEET_URL_REGEX,
|
||||
},
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: GOOGLE_SHEETS_SHEET_URL_REGEX,
|
||||
errorMessage: 'Not a valid Sheet URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '((gid=)?[0-9]{1,})',
|
||||
errorMessage: 'Not a valid Sheet ID',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export class GoogleSheetsTrigger implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Google Sheets Trigger',
|
||||
name: 'googleSheetsTrigger',
|
||||
icon: 'file:googleSheets.svg',
|
||||
group: ['trigger'],
|
||||
version: 1,
|
||||
subtitle: '={{($parameter["event"])}}',
|
||||
description: 'Starts the workflow when Google Sheets events occur',
|
||||
defaults: {
|
||||
name: 'Google Sheets Trigger',
|
||||
},
|
||||
inputs: [],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'googleSheetsTriggerOAuth2Api',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['triggerOAuth2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
polling: true,
|
||||
properties: [
|
||||
// trigger shared logic with GoogleSheets node, leaving this here for compatibility
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'hidden',
|
||||
options: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'OAuth2 (recommended)',
|
||||
value: 'triggerOAuth2',
|
||||
},
|
||||
],
|
||||
default: 'triggerOAuth2',
|
||||
},
|
||||
document,
|
||||
sheet,
|
||||
{
|
||||
displayName: 'Trigger On',
|
||||
name: 'event',
|
||||
type: 'options',
|
||||
description:
|
||||
"It will be triggered also by newly created columns (if the 'Columns to Watch' option is not set)",
|
||||
options: [
|
||||
{
|
||||
name: 'Row Added',
|
||||
value: 'rowAdded',
|
||||
},
|
||||
{
|
||||
name: 'Row Updated',
|
||||
value: 'rowUpdate',
|
||||
},
|
||||
{
|
||||
name: 'Row Added or Updated',
|
||||
value: 'anyUpdate',
|
||||
},
|
||||
],
|
||||
default: 'anyUpdate',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Include in Output',
|
||||
name: 'includeInOutput',
|
||||
type: 'options',
|
||||
default: 'new',
|
||||
description: 'This option will be effective only when automatically executing the workflow',
|
||||
options: [
|
||||
{
|
||||
name: 'New Version',
|
||||
value: 'new',
|
||||
},
|
||||
{
|
||||
name: 'Old Version',
|
||||
value: 'old',
|
||||
},
|
||||
{
|
||||
name: 'Both Versions',
|
||||
value: 'both',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
hide: {
|
||||
event: ['rowAdded'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Columns to Watch',
|
||||
name: 'columnsToWatch',
|
||||
type: 'multiOptions',
|
||||
description:
|
||||
'Choose from the list, or specify IDs using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
loadOptionsMethod: 'getSheetHeaderRowAndSkipEmpty',
|
||||
},
|
||||
default: [],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/event': ['anyUpdate', 'rowUpdate'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Data Location on Sheet',
|
||||
name: 'dataLocationOnSheet',
|
||||
type: 'fixedCollection',
|
||||
placeholder: 'Select Range',
|
||||
default: { values: { rangeDefinition: 'specifyRangeA1' } },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Range Definition',
|
||||
name: 'rangeDefinition',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Specify Range (A1 Notation)',
|
||||
value: 'specifyRangeA1',
|
||||
description: 'Manually specify the data range',
|
||||
},
|
||||
{
|
||||
name: 'Specify Range (Rows)',
|
||||
value: 'specifyRange',
|
||||
description: 'Manually specify the data range',
|
||||
},
|
||||
],
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Header Row',
|
||||
name: 'headerRow',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
description:
|
||||
'Index of the row which contains the keys. Starts at 1. The incoming node data is matched to the keys for assignment. The matching is case sensitive.',
|
||||
hint: 'First row is row 1',
|
||||
displayOptions: {
|
||||
show: {
|
||||
rangeDefinition: ['specifyRange'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'First Data Row',
|
||||
name: 'firstDataRow',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 2,
|
||||
description:
|
||||
'Index of the first row which contains the actual data and not the keys. Starts with 1.',
|
||||
hint: 'First row is row 1',
|
||||
displayOptions: {
|
||||
show: {
|
||||
rangeDefinition: ['specifyRange'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Range',
|
||||
name: 'range',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'A:Z',
|
||||
description:
|
||||
'The table range to read from or to append data to. See the Google <a href="https://developers.google.com/sheets/api/guides/values#writing">documentation</a> for the details.',
|
||||
hint: 'You can specify both the rows and the columns, e.g. C4:E7',
|
||||
displayOptions: {
|
||||
show: {
|
||||
rangeDefinition: ['specifyRangeA1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Value Render',
|
||||
name: 'valueRender',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Unformatted',
|
||||
value: 'UNFORMATTED_VALUE',
|
||||
description: 'Values will be calculated, but not formatted in the reply',
|
||||
},
|
||||
{
|
||||
name: 'Formatted',
|
||||
value: 'FORMATTED_VALUE',
|
||||
description:
|
||||
"Values will be formatted and calculated according to the cell's formatting (based on the spreadsheet's locale)",
|
||||
},
|
||||
{
|
||||
name: 'Formula',
|
||||
value: 'FORMULA',
|
||||
description: 'Values will not be calculated. The reply will include the formulas.',
|
||||
},
|
||||
],
|
||||
default: 'UNFORMATTED_VALUE',
|
||||
description:
|
||||
'Determines how values will be rendered in the output. <a href="https://developers.google.com/sheets/api/reference/rest/v4/ValueRenderOption" target="_blank">More info</a>.',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'/event': ['anyUpdate', 'rowUpdate'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'DateTime Render',
|
||||
name: 'dateTimeRenderOption',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Serial Number',
|
||||
value: 'SERIAL_NUMBER',
|
||||
description:
|
||||
'Fields will be returned as doubles in "serial number" format (as popularized by Lotus 1-2-3)',
|
||||
},
|
||||
{
|
||||
name: 'Formatted String',
|
||||
value: 'FORMATTED_STRING',
|
||||
description:
|
||||
'Fields will be rendered as strings in their given number format (which depends on the spreadsheet locale)',
|
||||
},
|
||||
],
|
||||
default: 'SERIAL_NUMBER',
|
||||
description:
|
||||
'Determines how dates should be rendered in the output. <a href="https://developers.google.com/sheets/api/reference/rest/v4/DateTimeRenderOption" target="_blank">More info</a>.',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'/event': ['anyUpdate', 'rowUpdate'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
methods = {
|
||||
listSearch: { spreadSheetsSearch, sheetsSearch },
|
||||
loadOptions: { getSheetHeaderRowAndSkipEmpty },
|
||||
};
|
||||
|
||||
async poll(this: IPollFunctions): Promise<INodeExecutionData[][] | null> {
|
||||
try {
|
||||
const workflowStaticData = this.getWorkflowStaticData('node');
|
||||
const event = this.getNodeParameter('event', 0) as string;
|
||||
|
||||
const documentId = this.getNodeParameter('documentId', undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const sheetWithinDocument = this.getNodeParameter('sheetName', undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
const { mode: sheetMode } = this.getNodeParameter('sheetName', 0) as {
|
||||
mode: ResourceLocator;
|
||||
};
|
||||
|
||||
const googleSheet = new GoogleSheet(documentId, this);
|
||||
const { sheetId, title: sheetName } = await googleSheet.spreadsheetGetSheet(
|
||||
this.getNode(),
|
||||
sheetMode,
|
||||
sheetWithinDocument,
|
||||
);
|
||||
|
||||
const options = this.getNodeParameter('options') as IDataObject;
|
||||
|
||||
// If the documentId or sheetId changed, reset the workflow static data
|
||||
if (
|
||||
this.getMode() !== 'manual' &&
|
||||
(workflowStaticData.documentId !== documentId || workflowStaticData.sheetId !== sheetId)
|
||||
) {
|
||||
workflowStaticData.documentId = documentId;
|
||||
workflowStaticData.sheetId = sheetId;
|
||||
workflowStaticData.lastRevision = undefined;
|
||||
workflowStaticData.lastRevisionLink = undefined;
|
||||
workflowStaticData.lastIndexChecked = undefined;
|
||||
}
|
||||
|
||||
const previousRevision = workflowStaticData.lastRevision as number;
|
||||
const previousRevisionLink = workflowStaticData.lastRevisionLink as string;
|
||||
|
||||
if (event !== 'rowAdded') {
|
||||
let pageToken;
|
||||
do {
|
||||
const { revisions, nextPageToken } = await apiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
'',
|
||||
undefined,
|
||||
{
|
||||
fields: 'revisions(id, exportLinks), nextPageToken',
|
||||
pageToken,
|
||||
pageSize: 1000,
|
||||
},
|
||||
`https://www.googleapis.com/drive/v3/files/${documentId}/revisions`,
|
||||
);
|
||||
|
||||
if (nextPageToken) {
|
||||
pageToken = nextPageToken as string;
|
||||
} else {
|
||||
pageToken = undefined;
|
||||
|
||||
const lastRevision = +revisions[revisions.length - 1].id;
|
||||
if (lastRevision <= previousRevision) {
|
||||
return null;
|
||||
} else {
|
||||
if (this.getMode() !== 'manual') {
|
||||
workflowStaticData.lastRevision = lastRevision;
|
||||
workflowStaticData.lastRevisionLink =
|
||||
revisions[revisions.length - 1].exportLinks[BINARY_MIME_TYPE];
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (pageToken);
|
||||
}
|
||||
|
||||
let range = 'A:ZZZ';
|
||||
let keyRow = 1;
|
||||
let startIndex = 2;
|
||||
|
||||
let rangeDefinition = '';
|
||||
|
||||
const [from, to] = range.split(':');
|
||||
let keyRange = `${from}${keyRow}:${to}${keyRow}`;
|
||||
let rangeToCheck = `${from}${keyRow}:${to}`;
|
||||
|
||||
if (options.dataLocationOnSheet) {
|
||||
const locationDefine = (options.dataLocationOnSheet as IDataObject).values as IDataObject;
|
||||
rangeDefinition = locationDefine.rangeDefinition as string;
|
||||
|
||||
if (rangeDefinition === 'specifyRangeA1') {
|
||||
if (locationDefine.range === '') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"The field 'Range' is empty, please provide a range",
|
||||
);
|
||||
}
|
||||
range = locationDefine.range as string;
|
||||
}
|
||||
|
||||
if (rangeDefinition === 'specifyRange') {
|
||||
keyRow = parseInt(locationDefine.headerRow as string, 10);
|
||||
startIndex = parseInt(locationDefine.firstDataRow as string, 10);
|
||||
}
|
||||
|
||||
const [rangeFrom, rangeTo] = range.split(':');
|
||||
const cellDataFrom = rangeFrom.match(/([a-zA-Z]{1,10})([0-9]{0,10})/) || [];
|
||||
const cellDataTo = rangeTo.match(/([a-zA-Z]{1,10})([0-9]{0,10})/) || [];
|
||||
|
||||
if (rangeDefinition === 'specifyRangeA1' && cellDataFrom[2] !== undefined) {
|
||||
keyRange = `${cellDataFrom[1]}${+cellDataFrom[2]}:${cellDataTo[1]}${+cellDataFrom[2]}`;
|
||||
rangeToCheck = `${cellDataFrom[1]}${+cellDataFrom[2] + 1}:${rangeTo}`;
|
||||
} else {
|
||||
keyRange = `${cellDataFrom[1]}${keyRow}:${cellDataTo[1]}${keyRow}`;
|
||||
rangeToCheck = `${cellDataFrom[1]}${keyRow}:${rangeTo}`;
|
||||
}
|
||||
}
|
||||
|
||||
const qs: IDataObject = {};
|
||||
|
||||
Object.assign(qs, options);
|
||||
|
||||
if (event === 'rowAdded') {
|
||||
const [columns] = ((
|
||||
(await apiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
`/v4/spreadsheets/${documentId}/values/${encodeURIComponent(sheetName)}!${keyRange}`,
|
||||
)) as IDataObject
|
||||
).values as string[][]) || [[]];
|
||||
|
||||
if (!columns?.length) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Could not retrieve the columns from key row',
|
||||
);
|
||||
}
|
||||
|
||||
const sheetData = await googleSheet.getData(
|
||||
`${sheetName}!${rangeToCheck}`,
|
||||
(options.valueRender as ValueRenderOption) || 'UNFORMATTED_VALUE',
|
||||
(options.dateTimeRenderOption as string) || 'FORMATTED_STRING',
|
||||
);
|
||||
|
||||
if (Array.isArray(sheetData) && sheetData.length !== 0) {
|
||||
sheetData.splice(0, 1); // Remove header row
|
||||
}
|
||||
|
||||
let dataStartIndex = 0;
|
||||
if (rangeDefinition === 'specifyRange' && keyRow < startIndex) {
|
||||
dataStartIndex = startIndex - keyRow - 1;
|
||||
}
|
||||
|
||||
if (this.getMode() === 'manual') {
|
||||
if (Array.isArray(sheetData)) {
|
||||
const sheetDataFromStartIndex = sheetData.slice(dataStartIndex);
|
||||
const returnData = arrayOfArraysToJson(sheetDataFromStartIndex, columns);
|
||||
|
||||
if (Array.isArray(returnData) && returnData.length !== 0) {
|
||||
return [this.helpers.returnJsonArray(returnData)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(sheetData) && this.getMode() !== 'manual') {
|
||||
if (workflowStaticData.lastIndexChecked === undefined) {
|
||||
workflowStaticData.lastIndexChecked = sheetData.length;
|
||||
return null;
|
||||
}
|
||||
|
||||
const rowsStartIndex = Math.max(
|
||||
workflowStaticData.lastIndexChecked as number,
|
||||
dataStartIndex,
|
||||
);
|
||||
const addedRows = sheetData?.slice(rowsStartIndex) || [];
|
||||
const returnData = arrayOfArraysToJson(addedRows, columns);
|
||||
|
||||
workflowStaticData.lastIndexChecked = sheetData.length;
|
||||
|
||||
if (Array.isArray(returnData) && returnData.length !== 0) {
|
||||
return [this.helpers.returnJsonArray(returnData)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event === 'anyUpdate' || event === 'rowUpdate') {
|
||||
if (sheetName.length > 31) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Sheet name is too long choose a name with 31 characters or less',
|
||||
);
|
||||
}
|
||||
const sheetRange = `${sheetName}!${range}`;
|
||||
|
||||
let dataStartIndex = startIndex - 1;
|
||||
if (rangeDefinition !== 'specifyRangeA1') {
|
||||
dataStartIndex = keyRow < startIndex ? startIndex - 2 : startIndex - 1;
|
||||
}
|
||||
|
||||
const currentData =
|
||||
((await googleSheet.getData(
|
||||
sheetRange,
|
||||
'UNFORMATTED_VALUE',
|
||||
'SERIAL_NUMBER',
|
||||
)) as string[][]) || [];
|
||||
|
||||
if (previousRevision === undefined) {
|
||||
if (currentData.length === 0) {
|
||||
return [[]];
|
||||
}
|
||||
const zeroBasedKeyRow = keyRow - 1;
|
||||
const columns = currentData[zeroBasedKeyRow];
|
||||
currentData.splice(zeroBasedKeyRow, 1); // Remove key row
|
||||
|
||||
let returnData;
|
||||
if (rangeDefinition !== 'specifyRangeA1') {
|
||||
returnData = arrayOfArraysToJson(currentData.slice(dataStartIndex), columns);
|
||||
} else {
|
||||
returnData = arrayOfArraysToJson(currentData, columns);
|
||||
}
|
||||
|
||||
if (Array.isArray(returnData) && returnData.length !== 0 && this.getMode() === 'manual') {
|
||||
return [this.helpers.returnJsonArray(returnData)];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const previousRevisionBinaryData = await getRevisionFile.call(this, previousRevisionLink);
|
||||
|
||||
const previousRevisionSheetData =
|
||||
sheetBinaryToArrayOfArrays(
|
||||
previousRevisionBinaryData,
|
||||
sheetName,
|
||||
rangeDefinition === 'specifyRangeA1' ? range : undefined,
|
||||
) || [];
|
||||
|
||||
const includeInOutput = this.getNodeParameter('includeInOutput', 'new') as string;
|
||||
|
||||
let returnData;
|
||||
if (options.columnsToWatch) {
|
||||
returnData = compareRevisions(
|
||||
previousRevisionSheetData,
|
||||
currentData,
|
||||
keyRow,
|
||||
includeInOutput,
|
||||
options.columnsToWatch as string[],
|
||||
dataStartIndex,
|
||||
event,
|
||||
);
|
||||
} else {
|
||||
returnData = compareRevisions(
|
||||
previousRevisionSheetData,
|
||||
currentData,
|
||||
keyRow,
|
||||
includeInOutput,
|
||||
[],
|
||||
dataStartIndex,
|
||||
event,
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(returnData) && returnData.length !== 0) {
|
||||
return [this.helpers.returnJsonArray(returnData)];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error?.description
|
||||
?.toLowerCase()
|
||||
.includes('user does not have sufficient permissions for file')
|
||||
) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"Edit access to the document is required for the 'Row Update' and 'Row Added or Updated' triggers. Request edit access to the document's owner or select the 'Row Added' trigger in the 'Trigger On' dropdown.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
error?.error?.error?.message !== undefined &&
|
||||
!(error.error.error.message as string).toLocaleLowerCase().includes('unknown error') &&
|
||||
!(error.error.error.message as string).toLocaleLowerCase().includes('bad request')
|
||||
) {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [message, ...description] = (error.error.error.message as string).split('. ');
|
||||
if (message.toLowerCase() === 'access not configured') {
|
||||
message = 'Missing Google Drive API';
|
||||
}
|
||||
throw new NodeOperationError(this.getNode(), message, {
|
||||
description: description.join('.\n '),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import zip from 'lodash/zip';
|
||||
import type { IDataObject, IPollFunctions } from 'n8n-workflow';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
import type { SheetDataRow, SheetRangeData } from './v2/helpers/GoogleSheets.types';
|
||||
import { apiRequest } from './v2/transport';
|
||||
|
||||
export const BINARY_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
|
||||
type DiffData = Array<{
|
||||
rowIndex: number;
|
||||
previous: SheetDataRow;
|
||||
current: SheetDataRow;
|
||||
changeType: string;
|
||||
}>;
|
||||
|
||||
export async function getRevisionFile(this: IPollFunctions, exportLink: string) {
|
||||
const mimeType = BINARY_MIME_TYPE;
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
'',
|
||||
undefined,
|
||||
{ mimeType },
|
||||
exportLink,
|
||||
undefined,
|
||||
{
|
||||
resolveWithFullResponse: true,
|
||||
encoding: null,
|
||||
json: false,
|
||||
},
|
||||
);
|
||||
|
||||
return Buffer.from(response.body as string);
|
||||
}
|
||||
|
||||
export function sheetBinaryToArrayOfArrays(
|
||||
data: Buffer,
|
||||
sheetName: string,
|
||||
range: string | undefined,
|
||||
) {
|
||||
const workbook = XLSX.read(data, { type: 'buffer', sheets: [sheetName] });
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const sheetData: string[][] = sheet['!ref']
|
||||
? XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', range })
|
||||
: [];
|
||||
|
||||
const lastDataRowIndex = sheetData.reduce((lastRowIndex, row, rowIndex) => {
|
||||
if (row.some((cell) => cell !== '')) {
|
||||
return rowIndex;
|
||||
}
|
||||
return lastRowIndex;
|
||||
}, 0);
|
||||
|
||||
return sheetData.slice(0, lastDataRowIndex + 1);
|
||||
}
|
||||
|
||||
export function arrayOfArraysToJson(sheetData: SheetRangeData, columns: SheetDataRow) {
|
||||
const returnData: IDataObject[] = [];
|
||||
|
||||
for (let rowIndex = 0; rowIndex < sheetData.length; rowIndex++) {
|
||||
const rowData: IDataObject = {};
|
||||
|
||||
for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) {
|
||||
const columnName = columns[columnIndex];
|
||||
const cellValue = sheetData[rowIndex][columnIndex] || '';
|
||||
|
||||
rowData[columnName] = cellValue;
|
||||
}
|
||||
|
||||
returnData.push(rowData);
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
const getSpecificColumns = (
|
||||
row: SheetDataRow,
|
||||
selectedColumns: SheetDataRow,
|
||||
columns: SheetDataRow,
|
||||
) => {
|
||||
return row ? selectedColumns.map((column) => row[columns.indexOf(column) - 1]) : [];
|
||||
};
|
||||
|
||||
const extractVersionData = (
|
||||
data: DiffData,
|
||||
version: 'previous' | 'current',
|
||||
triggerEvent: string,
|
||||
) => {
|
||||
if (triggerEvent === 'anyUpdate') {
|
||||
return data.map(({ [version]: entry, rowIndex, changeType }) =>
|
||||
entry ? [rowIndex, changeType, ...entry] : [rowIndex, changeType],
|
||||
);
|
||||
}
|
||||
return data.map(({ [version]: entry, rowIndex }) => (entry ? [rowIndex, ...entry] : [rowIndex]));
|
||||
};
|
||||
|
||||
export function compareRevisions(
|
||||
previous: SheetRangeData,
|
||||
current: SheetRangeData,
|
||||
keyRow: number,
|
||||
includeInOutput: string,
|
||||
columnsToWatch: string[],
|
||||
dataStartIndex: number,
|
||||
event: string,
|
||||
) {
|
||||
const dataLength = current.length > previous.length ? current.length : previous.length;
|
||||
|
||||
const columnsRowIndex = keyRow - 1;
|
||||
const columnsInCurrent = current[columnsRowIndex];
|
||||
const columnsInPrevious = previous[columnsRowIndex];
|
||||
|
||||
let columns: SheetDataRow =
|
||||
event === 'anyUpdate' ? ['row_number', 'change_type'] : ['row_number'];
|
||||
if (columnsInCurrent !== undefined && columnsInPrevious !== undefined) {
|
||||
columns =
|
||||
columnsInCurrent.length > columnsInPrevious.length
|
||||
? columns.concat(columnsInCurrent)
|
||||
: columns.concat(columnsInPrevious);
|
||||
} else if (columnsInCurrent !== undefined) {
|
||||
columns = columns.concat(columnsInCurrent);
|
||||
} else if (columnsInPrevious !== undefined) {
|
||||
columns = columns.concat(columnsInPrevious);
|
||||
}
|
||||
|
||||
const diffData: DiffData = [];
|
||||
|
||||
for (let i = dataStartIndex; i < dataLength; i++) {
|
||||
if (i === columnsRowIndex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// sheets API omits trailing empty columns, xlsx does not - so we need to pad the shorter array
|
||||
if (Array.isArray(current[i]) && Array.isArray(previous[i])) {
|
||||
while (current[i].length < previous[i].length) {
|
||||
current[i].push('');
|
||||
}
|
||||
}
|
||||
|
||||
// if columnsToWatch is defined, only compare those columns
|
||||
if (columnsToWatch?.length) {
|
||||
const currentRow = getSpecificColumns(current[i], columnsToWatch, columns);
|
||||
const previousRow = getSpecificColumns(previous[i], columnsToWatch, columns);
|
||||
|
||||
if (isEqual(currentRow, previousRow)) continue;
|
||||
} else {
|
||||
if (isEqual(current[i], previous[i])) continue;
|
||||
}
|
||||
|
||||
if (event === 'rowUpdate' && (!previous[i] || previous[i].every((cell) => cell === '')))
|
||||
continue;
|
||||
|
||||
let changeType = 'updated';
|
||||
if (previous[i] === undefined) {
|
||||
previous[i] = current[i].map(() => '');
|
||||
changeType = 'added';
|
||||
}
|
||||
|
||||
if (current[i] === undefined) continue;
|
||||
|
||||
diffData.push({
|
||||
rowIndex: i + 1,
|
||||
previous: previous[i],
|
||||
current: current[i],
|
||||
changeType,
|
||||
});
|
||||
}
|
||||
|
||||
if (includeInOutput === 'old') {
|
||||
return arrayOfArraysToJson(extractVersionData(diffData, 'previous', event), columns);
|
||||
}
|
||||
if (includeInOutput === 'both') {
|
||||
const previousData = arrayOfArraysToJson(
|
||||
extractVersionData(diffData, 'previous', event),
|
||||
columns,
|
||||
).map((row) => ({ previous: row }));
|
||||
|
||||
const currentData = arrayOfArraysToJson(
|
||||
extractVersionData(diffData, 'current', event),
|
||||
columns,
|
||||
).map((row) => ({ current: row }));
|
||||
|
||||
const differences = currentData.map(({ current: currentRow }, index) => {
|
||||
const { row_number, ...rest } = currentRow;
|
||||
const returnData: IDataObject = {};
|
||||
returnData.row_number = row_number;
|
||||
|
||||
Object.keys(rest).forEach((key) => {
|
||||
const previousValue = previousData[index].previous[key];
|
||||
const currentValue = currentRow[key];
|
||||
|
||||
if (isEqual(previousValue, currentValue)) return;
|
||||
|
||||
returnData[key] = {
|
||||
previous: previousValue,
|
||||
current: currentValue,
|
||||
};
|
||||
});
|
||||
return { differences: returnData };
|
||||
});
|
||||
|
||||
return zip(previousData, currentData, differences).map((row) => Object.assign({}, ...row));
|
||||
}
|
||||
|
||||
return arrayOfArraysToJson(extractVersionData(diffData, 'current', event), columns);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><g fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"><path fill="#28B446" d="M35.69 1 52 17.225v39.087a3.67 3.67 0 0 1-1.084 2.61A3.7 3.7 0 0 1 48.293 60H12.707a3.7 3.7 0 0 1-2.623-1.078A3.67 3.67 0 0 1 9 56.312V4.688a3.67 3.67 0 0 1 1.084-2.61A3.7 3.7 0 0 1 12.707 1z"/><path fill="#6ACE7C" d="M35.69 1 52 17.225H39.397c-2.054 0-3.707-1.829-3.707-3.872z"/><path fill="#219B38" d="M39.211 17.225 52 22.48v-5.255z"/><path fill="#FFF" d="M20.12 31.975c0-.817.662-1.475 1.483-1.475h17.794c.821 0 1.482.658 1.482 1.475v15.487c0 .818-.661 1.475-1.482 1.475H21.603a1.476 1.476 0 0 1-1.482-1.474V31.974zm2.225 1.475h6.672v2.212h-6.672zm0 5.162h6.672v2.213h-6.672zm0 5.163h6.672v2.212h-6.672zm9.638-10.325h6.672v2.212h-6.672zm0 5.162h6.672v2.213h-6.672zm0 5.163h6.672v2.212h-6.672z"/><path fill="#28B446" d="M34.69 0 51 16.225v39.087a3.67 3.67 0 0 1-1.084 2.61A3.7 3.7 0 0 1 47.293 59H11.707a3.7 3.7 0 0 1-2.623-1.078A3.67 3.67 0 0 1 8 55.312V3.688a3.67 3.67 0 0 1 1.084-2.61A3.7 3.7 0 0 1 11.707 0z"/><path fill="#6ACE7C" d="M34.69 0 51 16.225H38.397c-2.054 0-3.707-1.829-3.707-3.872z"/><path fill="#219B38" d="M38.211 16.225 51 21.48v-5.255z"/><path fill="#FFF" d="M19.12 30.975c0-.817.662-1.475 1.483-1.475h17.794c.821 0 1.482.658 1.482 1.475v15.487c0 .818-.661 1.475-1.482 1.475H20.603a1.476 1.476 0 0 1-1.482-1.474V30.974zm2.225 1.475h6.672v2.212h-6.672zm0 5.162h6.672v2.213h-6.672zm0 5.163h6.672v2.212h-6.672zm9.638-10.325h6.672v2.212h-6.672zm0 5.162h6.672v2.213h-6.672zm0 5.163h6.672v2.212h-6.672z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,263 @@
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import nock from 'nock';
|
||||
|
||||
import { testPollingTriggerNode } from '@test/nodes/TriggerHelpers';
|
||||
|
||||
import { GoogleSheetsTrigger } from '../GoogleSheetsTrigger.node';
|
||||
|
||||
describe('GoogleSheetsTrigger', () => {
|
||||
const baseUrl = 'https://sheets.googleapis.com';
|
||||
|
||||
describe('rowAdded event', () => {
|
||||
it('should return rows without header', async () => {
|
||||
const scope = nock(baseUrl);
|
||||
scope
|
||||
.get('/v4/spreadsheets/testDocumentId')
|
||||
.query({ fields: 'sheets.properties' })
|
||||
.reply(200, {
|
||||
sheets: [{ properties: { sheetId: 1, title: 'testSheetName' } }],
|
||||
});
|
||||
scope
|
||||
.get((uri) => uri.startsWith('/v4/spreadsheets/testDocumentId/values/testSheetName!A1:ZZZ'))
|
||||
.times(2)
|
||||
.reply(200, {
|
||||
values: [
|
||||
['name', 'count'],
|
||||
['apple', 14],
|
||||
['banana', 12],
|
||||
],
|
||||
});
|
||||
|
||||
const { response } = await testPollingTriggerNode(GoogleSheetsTrigger, {
|
||||
credential: mockDeep(),
|
||||
mode: 'manual',
|
||||
node: {
|
||||
parameters: {
|
||||
documentId: 'testDocumentId',
|
||||
sheetName: 1,
|
||||
event: 'rowAdded',
|
||||
options: {
|
||||
dataLocationOnSheet: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
scope.done();
|
||||
|
||||
expect(response).toEqual([
|
||||
[{ json: { count: 14, name: 'apple' } }, { json: { count: 12, name: 'banana' } }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return rows starting from first data row', async () => {
|
||||
const scope = nock(baseUrl);
|
||||
scope
|
||||
.get('/v4/spreadsheets/testDocumentId')
|
||||
.query({ fields: 'sheets.properties' })
|
||||
.reply(200, {
|
||||
sheets: [{ properties: { sheetId: 1, title: 'testSheetName' } }],
|
||||
});
|
||||
scope
|
||||
.get((uri) => uri.startsWith('/v4/spreadsheets/testDocumentId/values/testSheetName!A5:ZZZ'))
|
||||
.times(2)
|
||||
.reply(200, {
|
||||
values: [
|
||||
['name', 'count'],
|
||||
['apple', 14],
|
||||
['banana', 12],
|
||||
['orange', 10],
|
||||
],
|
||||
});
|
||||
|
||||
const { response } = await testPollingTriggerNode(GoogleSheetsTrigger, {
|
||||
credential: mockDeep(),
|
||||
mode: 'manual',
|
||||
node: {
|
||||
parameters: {
|
||||
documentId: 'testDocumentId',
|
||||
sheetName: 1,
|
||||
event: 'rowAdded',
|
||||
options: {
|
||||
dataLocationOnSheet: {
|
||||
values: {
|
||||
rangeDefinition: 'specifyRange',
|
||||
headerRow: 5,
|
||||
firstDataRow: 7,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
scope.done();
|
||||
|
||||
expect(response).toEqual([
|
||||
[{ json: { count: 12, name: 'banana' } }, { json: { count: 10, name: 'orange' } }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return rows starting from header row when first data row is less than header row', async () => {
|
||||
const scope = nock(baseUrl);
|
||||
scope
|
||||
.get('/v4/spreadsheets/testDocumentId')
|
||||
.query({ fields: 'sheets.properties' })
|
||||
.reply(200, {
|
||||
sheets: [{ properties: { sheetId: 1, title: 'testSheetName' } }],
|
||||
});
|
||||
scope
|
||||
.get((uri) => uri.startsWith('/v4/spreadsheets/testDocumentId/values/testSheetName!A5:ZZZ'))
|
||||
.times(2)
|
||||
.reply(200, {
|
||||
values: [
|
||||
['name', 'count'],
|
||||
['apple', 14],
|
||||
['banana', 12],
|
||||
['orange', 10],
|
||||
],
|
||||
});
|
||||
|
||||
const { response } = await testPollingTriggerNode(GoogleSheetsTrigger, {
|
||||
credential: mockDeep(),
|
||||
mode: 'manual',
|
||||
node: {
|
||||
parameters: {
|
||||
documentId: 'testDocumentId',
|
||||
sheetName: 1,
|
||||
event: 'rowAdded',
|
||||
options: {
|
||||
dataLocationOnSheet: {
|
||||
values: {
|
||||
rangeDefinition: 'specifyRange',
|
||||
headerRow: 5,
|
||||
firstDataRow: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
scope.done();
|
||||
|
||||
expect(response).toEqual([
|
||||
[
|
||||
{ json: { count: 14, name: 'apple' } },
|
||||
{ json: { count: 12, name: 'banana' } },
|
||||
{ json: { count: 10, name: 'orange' } },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return rows starting from first data row in trigger mode', async () => {
|
||||
const scope = nock(baseUrl);
|
||||
scope
|
||||
.get('/v4/spreadsheets/testDocumentId')
|
||||
.query({ fields: 'sheets.properties' })
|
||||
.reply(200, {
|
||||
sheets: [{ properties: { sheetId: 1, title: 'testSheetName' } }],
|
||||
});
|
||||
scope
|
||||
.get((uri) => uri.startsWith('/v4/spreadsheets/testDocumentId/values/testSheetName!A5:ZZZ'))
|
||||
.times(2)
|
||||
.reply(200, {
|
||||
values: [
|
||||
['name', 'count'],
|
||||
['apple', 14],
|
||||
['banana', 12],
|
||||
['orange', 10],
|
||||
],
|
||||
});
|
||||
|
||||
const { response } = await testPollingTriggerNode(GoogleSheetsTrigger, {
|
||||
credential: mockDeep(),
|
||||
mode: 'trigger',
|
||||
workflowStaticData: {
|
||||
documentId: 'testDocumentId',
|
||||
sheetId: 1,
|
||||
lastIndexChecked: 0,
|
||||
},
|
||||
node: {
|
||||
parameters: {
|
||||
documentId: 'testDocumentId',
|
||||
sheetName: 1,
|
||||
event: 'rowAdded',
|
||||
options: {
|
||||
dataLocationOnSheet: {
|
||||
values: {
|
||||
rangeDefinition: 'specifyRange',
|
||||
headerRow: 5,
|
||||
firstDataRow: 7,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
scope.done();
|
||||
|
||||
expect(response).toEqual([
|
||||
[{ json: { count: 12, name: 'banana' } }, { json: { count: 10, name: 'orange' } }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return rows starting from header row when first data row is less than header row in trigger mode', async () => {
|
||||
const scope = nock(baseUrl);
|
||||
scope
|
||||
.get('/v4/spreadsheets/testDocumentId')
|
||||
.query({ fields: 'sheets.properties' })
|
||||
.reply(200, {
|
||||
sheets: [{ properties: { sheetId: 1, title: 'testSheetName' } }],
|
||||
});
|
||||
scope
|
||||
.get((uri) => uri.startsWith('/v4/spreadsheets/testDocumentId/values/testSheetName!A5:ZZZ'))
|
||||
.times(2)
|
||||
.reply(200, {
|
||||
values: [
|
||||
['name', 'count'],
|
||||
['apple', 14],
|
||||
['banana', 12],
|
||||
['orange', 10],
|
||||
],
|
||||
});
|
||||
|
||||
const { response } = await testPollingTriggerNode(GoogleSheetsTrigger, {
|
||||
credential: mockDeep(),
|
||||
mode: 'trigger',
|
||||
workflowStaticData: {
|
||||
documentId: 'testDocumentId',
|
||||
sheetId: 1,
|
||||
lastIndexChecked: 0,
|
||||
},
|
||||
node: {
|
||||
parameters: {
|
||||
documentId: 'testDocumentId',
|
||||
sheetName: 1,
|
||||
event: 'rowAdded',
|
||||
options: {
|
||||
dataLocationOnSheet: {
|
||||
values: {
|
||||
rangeDefinition: 'specifyRange',
|
||||
headerRow: 5,
|
||||
firstDataRow: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
scope.done();
|
||||
|
||||
expect(response).toEqual([
|
||||
[
|
||||
{ json: { count: 14, name: 'apple' } },
|
||||
{ json: { count: 12, name: 'banana' } },
|
||||
{ json: { count: 10, name: 'orange' } },
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,525 @@
|
||||
import type { IPollFunctions } from 'n8n-workflow';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
import {
|
||||
BINARY_MIME_TYPE,
|
||||
arrayOfArraysToJson,
|
||||
compareRevisions,
|
||||
getRevisionFile,
|
||||
sheetBinaryToArrayOfArrays,
|
||||
} from '../GoogleSheetsTrigger.utils';
|
||||
import { apiRequest } from '../v2/transport';
|
||||
|
||||
jest.mock('../v2/transport', () => ({
|
||||
apiRequest: {
|
||||
call: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('xlsx', () => ({
|
||||
read: jest.fn(),
|
||||
utils: {
|
||||
sheet_to_json: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('GoogleSheetsTrigger.utils', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('BINARY_MIME_TYPE', () => {
|
||||
it('should have correct Excel mime type', () => {
|
||||
expect(BINARY_MIME_TYPE).toBe(
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRevisionFile', () => {
|
||||
it('should make correct API call and return buffer', async () => {
|
||||
const mockPollFunctions: Partial<IPollFunctions> = {
|
||||
getNode: jest.fn(),
|
||||
};
|
||||
const exportLink = 'https://example.com/export';
|
||||
const mockResponse = {
|
||||
body: 'mock binary data',
|
||||
};
|
||||
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await getRevisionFile.call(mockPollFunctions as IPollFunctions, exportLink);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockPollFunctions,
|
||||
'GET',
|
||||
'',
|
||||
undefined,
|
||||
{ mimeType: BINARY_MIME_TYPE },
|
||||
exportLink,
|
||||
undefined,
|
||||
{
|
||||
resolveWithFullResponse: true,
|
||||
encoding: null,
|
||||
json: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual(Buffer.from('mock binary data'));
|
||||
});
|
||||
|
||||
it('should handle API request errors', async () => {
|
||||
const mockPollFunctions: Partial<IPollFunctions> = {
|
||||
getNode: jest.fn(),
|
||||
};
|
||||
const exportLink = 'https://example.com/export';
|
||||
const error = new Error('API request failed');
|
||||
|
||||
(apiRequest.call as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
getRevisionFile.call(mockPollFunctions as IPollFunctions, exportLink),
|
||||
).rejects.toThrow('API request failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sheetBinaryToArrayOfArrays', () => {
|
||||
const mockBuffer = Buffer.from('mock data');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should process sheet with data correctly', () => {
|
||||
const mockSheet = {
|
||||
'!ref': 'A1:C3',
|
||||
};
|
||||
const mockWorkbook = {
|
||||
Sheets: {
|
||||
Sheet1: mockSheet,
|
||||
},
|
||||
};
|
||||
|
||||
const mockSheetData = [
|
||||
['Name', 'Age', 'City'],
|
||||
['John', '30', 'NYC'],
|
||||
['Jane', '25', ''],
|
||||
];
|
||||
|
||||
(XLSX.read as jest.Mock).mockReturnValue(mockWorkbook);
|
||||
(XLSX.utils.sheet_to_json as jest.Mock).mockReturnValue(mockSheetData);
|
||||
|
||||
const result = sheetBinaryToArrayOfArrays(mockBuffer, 'Sheet1', 'A1:C3');
|
||||
|
||||
expect(XLSX.read).toHaveBeenCalledWith(mockBuffer, {
|
||||
type: 'buffer',
|
||||
sheets: ['Sheet1'],
|
||||
});
|
||||
expect(XLSX.utils.sheet_to_json).toHaveBeenCalledWith(mockSheet, {
|
||||
header: 1,
|
||||
defval: '',
|
||||
range: 'A1:C3',
|
||||
});
|
||||
expect(result).toEqual(mockSheetData);
|
||||
});
|
||||
|
||||
it('should handle empty sheet', () => {
|
||||
const mockSheet = {};
|
||||
const mockWorkbook = {
|
||||
Sheets: {
|
||||
Sheet1: mockSheet,
|
||||
},
|
||||
};
|
||||
|
||||
(XLSX.read as jest.Mock).mockReturnValue(mockWorkbook);
|
||||
|
||||
const result = sheetBinaryToArrayOfArrays(mockBuffer, 'Sheet1', undefined);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should trim empty trailing rows', () => {
|
||||
const mockSheet = {
|
||||
'!ref': 'A1:C5',
|
||||
};
|
||||
const mockWorkbook = {
|
||||
Sheets: {
|
||||
Sheet1: mockSheet,
|
||||
},
|
||||
};
|
||||
|
||||
const mockSheetData = [
|
||||
['Name', 'Age', 'City'],
|
||||
['John', '30', 'NYC'],
|
||||
['Jane', '25', 'LA'],
|
||||
['', '', ''],
|
||||
['', '', ''],
|
||||
];
|
||||
|
||||
(XLSX.read as jest.Mock).mockReturnValue(mockWorkbook);
|
||||
(XLSX.utils.sheet_to_json as jest.Mock).mockReturnValue(mockSheetData);
|
||||
|
||||
const result = sheetBinaryToArrayOfArrays(mockBuffer, 'Sheet1', undefined);
|
||||
|
||||
expect(result).toEqual([
|
||||
['Name', 'Age', 'City'],
|
||||
['John', '30', 'NYC'],
|
||||
['Jane', '25', 'LA'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle sheet with only header row', () => {
|
||||
const mockSheet = {
|
||||
'!ref': 'A1:C1',
|
||||
};
|
||||
const mockWorkbook = {
|
||||
Sheets: {
|
||||
Sheet1: mockSheet,
|
||||
},
|
||||
};
|
||||
|
||||
const mockSheetData = [['Name', 'Age', 'City']];
|
||||
|
||||
(XLSX.read as jest.Mock).mockReturnValue(mockWorkbook);
|
||||
(XLSX.utils.sheet_to_json as jest.Mock).mockReturnValue(mockSheetData);
|
||||
|
||||
const result = sheetBinaryToArrayOfArrays(mockBuffer, 'Sheet1', undefined);
|
||||
|
||||
expect(result).toEqual([['Name', 'Age', 'City']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('arrayOfArraysToJson', () => {
|
||||
it('should convert array of arrays to JSON objects', () => {
|
||||
const sheetData = [
|
||||
['John', '30', 'NYC'],
|
||||
['Jane', '25', 'LA'],
|
||||
['Bob', '35', 'SF'],
|
||||
];
|
||||
const columns = ['Name', 'Age', 'City'];
|
||||
|
||||
const result = arrayOfArraysToJson(sheetData, columns);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ Name: 'John', Age: '30', City: 'NYC' },
|
||||
{ Name: 'Jane', Age: '25', City: 'LA' },
|
||||
{ Name: 'Bob', Age: '35', City: 'SF' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle missing cell values', () => {
|
||||
const sheetData = [['John', '30'], ['Jane'], ['Bob', '35', 'SF']];
|
||||
const columns = ['Name', 'Age', 'City'];
|
||||
|
||||
const result = arrayOfArraysToJson(sheetData, columns);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ Name: 'John', Age: '30', City: '' },
|
||||
{ Name: 'Jane', Age: '', City: '' },
|
||||
{ Name: 'Bob', Age: '35', City: 'SF' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty sheet data', () => {
|
||||
const sheetData: string[][] = [];
|
||||
const columns = ['Name', 'Age', 'City'];
|
||||
|
||||
const result = arrayOfArraysToJson(sheetData, columns);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty columns', () => {
|
||||
const sheetData = [
|
||||
['John', '30', 'NYC'],
|
||||
['Jane', '25', 'LA'],
|
||||
];
|
||||
const columns: string[] = [];
|
||||
|
||||
const result = arrayOfArraysToJson(sheetData, columns);
|
||||
|
||||
expect(result).toEqual([{}, {}]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareRevisions', () => {
|
||||
const baseColumns = ['Name', 'Age', 'City'];
|
||||
|
||||
it('should detect row updates with current output', () => {
|
||||
const previousData = [baseColumns, ['John', '30', 'NYC'], ['Jane', '25', 'LA']];
|
||||
const currentData = [
|
||||
baseColumns,
|
||||
['John', '31', 'NYC'], // Age updated
|
||||
['Jane', '25', 'LA'], // No change
|
||||
['Bob', '35', 'SF'], // New row
|
||||
];
|
||||
|
||||
const result = compareRevisions(
|
||||
previousData,
|
||||
currentData,
|
||||
1, // keyRow
|
||||
'current', // includeInOutput
|
||||
[], // columnsToWatch
|
||||
0, // dataStartIndex
|
||||
'anyUpdate', // event
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
row_number: 2,
|
||||
change_type: 'updated',
|
||||
Name: 'John',
|
||||
Age: '31',
|
||||
City: 'NYC',
|
||||
},
|
||||
{
|
||||
row_number: 4,
|
||||
change_type: 'added',
|
||||
Name: 'Bob',
|
||||
Age: '35',
|
||||
City: 'SF',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should detect row updates with previous output', () => {
|
||||
const previousData = [baseColumns, ['John', '30', 'NYC'], ['Jane', '25', 'LA']];
|
||||
const currentData = [
|
||||
baseColumns,
|
||||
['John', '31', 'NYC'], // Age updated
|
||||
['Jane', '25', 'LA'], // No change
|
||||
['Bob', '35', 'SF'], // New row
|
||||
];
|
||||
|
||||
const result = compareRevisions(
|
||||
previousData,
|
||||
currentData,
|
||||
1, // keyRow
|
||||
'old', // includeInOutput
|
||||
[], // columnsToWatch
|
||||
0, // dataStartIndex
|
||||
'anyUpdate', // event
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
row_number: 2,
|
||||
change_type: 'updated',
|
||||
Name: 'John',
|
||||
Age: '30',
|
||||
City: 'NYC',
|
||||
},
|
||||
{
|
||||
row_number: 4,
|
||||
change_type: 'added', // New row is correctly marked as 'added'
|
||||
Name: '',
|
||||
Age: '',
|
||||
City: '',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should provide both previous and current data with differences', () => {
|
||||
const previousData = [baseColumns, ['John', '30', 'NYC'], ['Jane', '25', 'LA']];
|
||||
const currentData = [
|
||||
baseColumns,
|
||||
['John', '31', 'NYC'], // Age updated
|
||||
['Jane', '25', 'LA'], // No change
|
||||
['Bob', '35', 'SF'], // New row
|
||||
];
|
||||
|
||||
const result = compareRevisions(
|
||||
previousData,
|
||||
currentData,
|
||||
1, // keyRow
|
||||
'both', // includeInOutput
|
||||
[], // columnsToWatch
|
||||
0, // dataStartIndex
|
||||
'anyUpdate', // event
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toMatchObject({
|
||||
previous: {
|
||||
row_number: 2,
|
||||
change_type: 'updated',
|
||||
Name: 'John',
|
||||
Age: '30',
|
||||
City: 'NYC',
|
||||
},
|
||||
current: {
|
||||
row_number: 2,
|
||||
change_type: 'updated',
|
||||
Name: 'John',
|
||||
Age: '31',
|
||||
City: 'NYC',
|
||||
},
|
||||
differences: {
|
||||
row_number: 2,
|
||||
Age: {
|
||||
previous: '30',
|
||||
current: '31',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should watch only specific columns when specified', () => {
|
||||
// Create test data where only one row has a change in the watched column
|
||||
const previousForColumnTest = [baseColumns, ['John', '30', 'NYC'], ['Jane', '25', 'LA']];
|
||||
const currentForColumnTest = [
|
||||
baseColumns,
|
||||
['Johnny', '30', 'NYC'], // Name changed (watched column)
|
||||
['Jane', '26', 'LA'], // Age changed (not watched)
|
||||
];
|
||||
|
||||
const result = compareRevisions(
|
||||
previousForColumnTest,
|
||||
currentForColumnTest,
|
||||
1, // keyRow
|
||||
'current', // includeInOutput
|
||||
['Name'], // columnsToWatch - only watch Name column
|
||||
0, // dataStartIndex
|
||||
'anyUpdate', // event
|
||||
);
|
||||
|
||||
// NOTE: This test currently reflects buggy behavior - it detects Jane's Age change
|
||||
// even though only the Name column is being watched. The expected behavior would be
|
||||
// to detect John's Name change instead. This test should be updated when the
|
||||
// columnsToWatch functionality is fixed.
|
||||
expect(result).toEqual([
|
||||
{
|
||||
row_number: 3,
|
||||
change_type: 'updated',
|
||||
Name: 'Jane',
|
||||
Age: '26',
|
||||
City: 'LA',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle rowUpdate event by ignoring empty previous rows', () => {
|
||||
const previousWithEmpty = [
|
||||
baseColumns,
|
||||
['John', '30', 'NYC'],
|
||||
['', '', ''], // Empty row
|
||||
];
|
||||
const currentWithData = [
|
||||
baseColumns,
|
||||
['John', '30', 'NYC'],
|
||||
['Jane', '25', 'LA'], // Added data to previously empty row
|
||||
];
|
||||
|
||||
const result = compareRevisions(
|
||||
previousWithEmpty,
|
||||
currentWithData,
|
||||
1, // keyRow
|
||||
'current', // includeInOutput
|
||||
[], // columnsToWatch
|
||||
0, // dataStartIndex
|
||||
'rowUpdate', // event - should ignore empty previous rows
|
||||
);
|
||||
|
||||
expect(result).toEqual([]); // Empty previous row should be ignored for rowUpdate
|
||||
});
|
||||
|
||||
it('should handle different sheet sizes by padding shorter arrays', () => {
|
||||
const shorterPrevious = [
|
||||
['Name', 'Age'],
|
||||
['John', '30'],
|
||||
];
|
||||
const longerCurrent = [
|
||||
['Name', 'Age', 'City'],
|
||||
['John', '30', 'NYC'],
|
||||
];
|
||||
|
||||
const result = compareRevisions(
|
||||
shorterPrevious,
|
||||
longerCurrent,
|
||||
1, // keyRow
|
||||
'current', // includeInOutput
|
||||
[], // columnsToWatch
|
||||
0, // dataStartIndex
|
||||
'anyUpdate', // event
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
row_number: 2,
|
||||
change_type: 'updated',
|
||||
Name: 'John',
|
||||
Age: '30',
|
||||
City: 'NYC',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle event type without change_type column', () => {
|
||||
const previousData = [baseColumns, ['John', '30', 'NYC'], ['Jane', '25', 'LA']];
|
||||
const currentData = [
|
||||
baseColumns,
|
||||
['John', '31', 'NYC'], // Age updated
|
||||
['Jane', '25', 'LA'], // No change
|
||||
['Bob', '35', 'SF'], // New row
|
||||
];
|
||||
|
||||
const result = compareRevisions(
|
||||
previousData,
|
||||
currentData,
|
||||
1, // keyRow
|
||||
'current', // includeInOutput
|
||||
[], // columnsToWatch
|
||||
0, // dataStartIndex
|
||||
'rowAdded', // event - not anyUpdate, so no change_type column
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
row_number: 2,
|
||||
Name: 'John',
|
||||
Age: '31',
|
||||
City: 'NYC',
|
||||
},
|
||||
{
|
||||
row_number: 4,
|
||||
Name: 'Bob',
|
||||
Age: '35',
|
||||
City: 'SF',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip the key row when comparing data', () => {
|
||||
const dataWithKeyRowInMiddle = [
|
||||
['John', '30', 'NYC'],
|
||||
baseColumns, // Key row in position 1
|
||||
['Jane', '25', 'LA'],
|
||||
];
|
||||
const currentWithKeyRowInMiddle = [
|
||||
['John', '31', 'NYC'], // Updated
|
||||
baseColumns, // Key row in position 1 (should be skipped)
|
||||
['Jane', '25', 'LA'],
|
||||
];
|
||||
|
||||
const result = compareRevisions(
|
||||
dataWithKeyRowInMiddle,
|
||||
currentWithKeyRowInMiddle,
|
||||
2, // keyRow at index 1
|
||||
'current',
|
||||
[],
|
||||
0,
|
||||
'anyUpdate',
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
row_number: 1,
|
||||
change_type: 'updated',
|
||||
Name: 'John',
|
||||
Age: '31',
|
||||
City: 'NYC',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+594
@@ -0,0 +1,594 @@
|
||||
import { mock, mockDeep } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { execute } from '../../../../v2/actions/sheet/append.operation';
|
||||
import type { GoogleSheet } from '../../../../v2/helpers/GoogleSheet';
|
||||
import * as GoogleSheetsUtils from '../../../../v2/helpers/GoogleSheets.utils';
|
||||
|
||||
jest.mock('../../../../v2/helpers/GoogleSheets.utils', () => ({
|
||||
autoMapInputData: jest.fn(),
|
||||
mapFields: jest.fn(),
|
||||
checkForSchemaChanges: jest.fn(),
|
||||
cellFormatDefault: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Google Sheets Append Operation', () => {
|
||||
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
|
||||
let mockSheet: jest.Mocked<GoogleSheet>;
|
||||
let mockNode: jest.Mocked<INode>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
|
||||
|
||||
mockNode = mock<INode>({
|
||||
id: 'test-node',
|
||||
name: 'Google Sheets Append',
|
||||
type: 'n8n-nodes-base.googleSheets',
|
||||
typeVersion: 3,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
|
||||
mockSheet = mock<GoogleSheet>();
|
||||
mockSheet.getData = jest.fn();
|
||||
mockSheet.appendSheetData = jest.fn();
|
||||
mockSheet.appendEmptyRowsOrColumns = jest.fn();
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([
|
||||
{ json: { name: 'John', email: 'john@example.com' } },
|
||||
{ json: { name: 'Jane', email: 'jane@example.com' } },
|
||||
]);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
const mockParams: Record<string, any> = {
|
||||
dataMode: 'defineBelow',
|
||||
'columns.mappingMode': 'defineBelow',
|
||||
options: {},
|
||||
'columns.schema': [],
|
||||
};
|
||||
return mockParams[paramName];
|
||||
});
|
||||
|
||||
(GoogleSheetsUtils.autoMapInputData as jest.Mock).mockResolvedValue([
|
||||
{ name: 'John', email: 'john@example.com' },
|
||||
{ name: 'Jane', email: 'jane@example.com' },
|
||||
]);
|
||||
(GoogleSheetsUtils.mapFields as jest.Mock).mockReturnValue([
|
||||
{ name: 'John', email: 'john@example.com' },
|
||||
{ name: 'Jane', email: 'jane@example.com' },
|
||||
]);
|
||||
(GoogleSheetsUtils.cellFormatDefault as jest.Mock).mockReturnValue('USER_ENTERED');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('Basic Execution', () => {
|
||||
it('should execute successfully with valid parameters', async () => {
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions,
|
||||
mockSheet,
|
||||
'Sheet1!A1:B2',
|
||||
'sheet123',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
json: { name: 'John', email: 'john@example.com' },
|
||||
pairedItem: { item: 0 },
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
json: { name: 'Jane', email: 'jane@example.com' },
|
||||
pairedItem: { item: 1 },
|
||||
});
|
||||
expect(mockSheet.appendSheetData).toHaveBeenCalledWith({
|
||||
inputData: [
|
||||
{ name: 'John', email: 'john@example.com' },
|
||||
{ name: 'Jane', email: 'jane@example.com' },
|
||||
],
|
||||
range: 'Sheet1!A1:B2',
|
||||
keyRowIndex: 1,
|
||||
valueInputMode: 'USER_ENTERED',
|
||||
lastRow: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array when no input data', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([]);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions,
|
||||
mockSheet,
|
||||
'Sheet1!A1:B2',
|
||||
'sheet123',
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockSheet.appendSheetData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty array when dataMode is nothing', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'dataMode' || paramName === 'columns.mappingMode') {
|
||||
return 'nothing';
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions,
|
||||
mockSheet,
|
||||
'Sheet1!A1:B2',
|
||||
'sheet123',
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockSheet.appendSheetData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Mode Handling', () => {
|
||||
it('should use autoMapInputData mode when sheet is empty', async () => {
|
||||
mockSheet.getData.mockResolvedValue([]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalledWith(
|
||||
'Sheet1!A1:B2',
|
||||
mockSheet,
|
||||
[
|
||||
{ json: { name: 'John', email: 'john@example.com' }, pairedItem: { item: 0 } },
|
||||
{ json: { name: 'Jane', email: 'jane@example.com' }, pairedItem: { item: 1 } },
|
||||
],
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('should use defineBelow mode when sheet has data', async () => {
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(GoogleSheetsUtils.mapFields).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('should handle autoMapInputData mode explicitly', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'dataMode' || paramName === 'columns.mappingMode') {
|
||||
return 'autoMapInputData';
|
||||
}
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Node Version Handling', () => {
|
||||
it('should use dataMode parameter for node version < 4', async () => {
|
||||
mockNode.typeVersion = 3;
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'dataMode') return 'autoMapInputData';
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use columns.mappingMode parameter for node version >= 4', async () => {
|
||||
mockNode.typeVersion = 4;
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'columns.mappingMode') return 'autoMapInputData';
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Options and Configuration', () => {
|
||||
it('should handle custom header row', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'options') {
|
||||
return {
|
||||
locationDefine: {
|
||||
values: { headerRow: 2 },
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Header1', 'Header2'],
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(mockSheet.getData).toHaveBeenCalledWith('Sheet1!A1:B2', 'FORMATTED_VALUE');
|
||||
});
|
||||
|
||||
it('should handle useAppend option', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'options') {
|
||||
return { useAppend: true };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(mockSheet.appendSheetData).toHaveBeenCalledWith({
|
||||
inputData: [
|
||||
{ name: 'John', email: 'john@example.com' },
|
||||
{ name: 'Jane', email: 'jane@example.com' },
|
||||
],
|
||||
range: 'Sheet1!A1:B2',
|
||||
keyRowIndex: 1,
|
||||
valueInputMode: 'USER_ENTERED',
|
||||
useAppend: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle cell format option', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'options') {
|
||||
return { cellFormat: 'RAW' };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(mockSheet.appendSheetData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
valueInputMode: 'RAW',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should throw error when column names cannot be retrieved (node version >= 4.4)', async () => {
|
||||
mockNode.typeVersion = 4.4;
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'columns.mappingMode') return 'defineBelow';
|
||||
if (paramName === 'columns.schema') return [{ id: 'name' }, { id: 'email' }];
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
|
||||
// Mock checkForSchemaChanges to throw error
|
||||
(GoogleSheetsUtils.checkForSchemaChanges as jest.Mock).mockImplementation(() => {
|
||||
throw new NodeOperationError(mockNode, 'Column names were updated');
|
||||
});
|
||||
|
||||
await expect(
|
||||
execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123'),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should throw error when header row is out of bounds', async () => {
|
||||
mockNode.typeVersion = 4.4;
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'columns.mappingMode') return 'defineBelow';
|
||||
if (paramName === 'columns.schema') return [{ id: 'name' }, { id: 'email' }];
|
||||
if (paramName === 'options') {
|
||||
return {
|
||||
locationDefine: {
|
||||
values: { headerRow: 5 },
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123'),
|
||||
).rejects.toThrow('Could not retrieve the column names from row 5');
|
||||
});
|
||||
|
||||
it('should handle empty input data gracefully', async () => {
|
||||
(GoogleSheetsUtils.mapFields as jest.Mock).mockReturnValue([]);
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
|
||||
(GoogleSheetsUtils.autoMapInputData as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions,
|
||||
mockSheet,
|
||||
'Sheet1!A1:B2',
|
||||
'sheet123',
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockSheet.appendSheetData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Return Data Formatting', () => {
|
||||
it('should return original items for node version < 4', async () => {
|
||||
mockNode.typeVersion = 3;
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions,
|
||||
mockSheet,
|
||||
'Sheet1!A1:B2',
|
||||
'sheet123',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].json).toEqual({ name: 'John', email: 'john@example.com' });
|
||||
expect(result[0].pairedItem).toEqual({ item: 0 });
|
||||
});
|
||||
|
||||
it('should return mapped data for node version >= 4 with defineBelow mode', async () => {
|
||||
mockNode.typeVersion = 4;
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'columns.mappingMode') return 'defineBelow';
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions,
|
||||
mockSheet,
|
||||
'Sheet1!A1:B2',
|
||||
'sheet123',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].json).toEqual({ name: 'John', email: 'john@example.com' });
|
||||
expect(result[0].pairedItem).toEqual({ item: 0 });
|
||||
});
|
||||
|
||||
it('should return original items for autoMapInputData mode regardless of node version', async () => {
|
||||
mockNode.typeVersion = 4;
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'columns.mappingMode') return 'autoMapInputData';
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions,
|
||||
mockSheet,
|
||||
'Sheet1!A1:B2',
|
||||
'sheet123',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].json).toEqual({ name: 'John', email: 'john@example.com' });
|
||||
expect(result[0].pairedItem).toEqual({ item: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sheet Data Processing', () => {
|
||||
it('should calculate lastRow correctly when sheet has data', async () => {
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
['Jane', 'jane@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(mockSheet.appendEmptyRowsOrColumns).toHaveBeenCalledWith('sheet123', 1, 0);
|
||||
expect(mockSheet.appendSheetData).toHaveBeenCalledWith({
|
||||
inputData: [
|
||||
{ name: 'John', email: 'john@example.com' },
|
||||
{ name: 'Jane', email: 'jane@example.com' },
|
||||
],
|
||||
range: 'Sheet1!A1:B2',
|
||||
keyRowIndex: 1,
|
||||
valueInputMode: 'USER_ENTERED',
|
||||
lastRow: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty sheet data for lastRow calculation', async () => {
|
||||
mockSheet.getData.mockResolvedValue([]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(mockSheet.appendEmptyRowsOrColumns).toHaveBeenCalledWith('sheet123', 1, 0);
|
||||
expect(mockSheet.appendSheetData).toHaveBeenCalledWith({
|
||||
inputData: [
|
||||
{ name: 'John', email: 'john@example.com' },
|
||||
{ name: 'Jane', email: 'jane@example.com' },
|
||||
],
|
||||
range: 'Sheet1!A1:B2',
|
||||
keyRowIndex: 1,
|
||||
valueInputMode: 'USER_ENTERED',
|
||||
lastRow: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration with GoogleSheets Utils', () => {
|
||||
it('should call autoMapInputData with correct parameters', async () => {
|
||||
mockSheet.getData.mockResolvedValue([]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(GoogleSheetsUtils.autoMapInputData).toHaveBeenCalledWith(
|
||||
'Sheet1!A1:B2',
|
||||
mockSheet,
|
||||
[
|
||||
{ json: { name: 'John', email: 'john@example.com' }, pairedItem: { item: 0 } },
|
||||
{ json: { name: 'Jane', email: 'jane@example.com' }, pairedItem: { item: 1 } },
|
||||
],
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('should call mapFields with correct input size', async () => {
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(GoogleSheetsUtils.mapFields).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('should call checkForSchemaChanges for node version >= 4.4', async () => {
|
||||
mockNode.typeVersion = 4.4;
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'columns.mappingMode') return 'defineBelow';
|
||||
if (paramName === 'columns.schema') return [{ id: 'name' }, { id: 'email' }];
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123');
|
||||
|
||||
expect(GoogleSheetsUtils.checkForSchemaChanges).toHaveBeenCalledWith(
|
||||
mockNode,
|
||||
['Name', 'Email'],
|
||||
[{ id: 'name' }, { id: 'email' }],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle single item input', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([
|
||||
{ json: { name: 'John', email: 'john@example.com' } },
|
||||
]);
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions,
|
||||
mockSheet,
|
||||
'Sheet1!A1:B2',
|
||||
'sheet123',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].json).toEqual({ name: 'John', email: 'john@example.com' });
|
||||
});
|
||||
|
||||
it('should handle large input data', async () => {
|
||||
const largeInputData = Array.from({ length: 100 }, (_, i) => ({
|
||||
json: { name: `User${i}`, email: `user${i}@example.com` },
|
||||
}));
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(largeInputData);
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions,
|
||||
mockSheet,
|
||||
'Sheet1!A1:B2',
|
||||
'sheet123',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(100);
|
||||
expect(mockSheet.appendSheetData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle undefined options gracefully', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'options') return {};
|
||||
return {};
|
||||
});
|
||||
mockSheet.getData.mockResolvedValue([
|
||||
['Name', 'Email'],
|
||||
['John', 'john@example.com'],
|
||||
]);
|
||||
mockSheet.appendSheetData.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
execute.call(mockExecuteFunctions, mockSheet, 'Sheet1!A1:B2', 'sheet123'),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { execute } from '../../../../v2/actions/spreadsheet/create.operation';
|
||||
import { apiRequest } from '../../../../v2/transport';
|
||||
|
||||
jest.mock('../../../../v2/transport', () => ({
|
||||
apiRequest: {
|
||||
call: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Spreadsheet Create Operation', () => {
|
||||
let mockExecuteFunctions: IExecuteFunctions;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions = {
|
||||
getInputData: jest.fn().mockReturnValue([{}]),
|
||||
getNodeParameter: jest.fn(),
|
||||
helpers: {
|
||||
constructExecutionMetaData: jest.fn().mockImplementation((data) => data),
|
||||
},
|
||||
} as unknown as IExecuteFunctions;
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should create a basic spreadsheet with title only', async () => {
|
||||
const mockTitle = 'Test Spreadsheet';
|
||||
const mockResponse = { spreadsheetId: '1234', title: mockTitle };
|
||||
|
||||
mockExecuteFunctions.getNodeParameter = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(mockTitle)
|
||||
.mockReturnValueOnce({})
|
||||
.mockReturnValueOnce({});
|
||||
|
||||
(apiRequest.call as jest.Mock).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
'POST',
|
||||
'/v4/spreadsheets',
|
||||
{
|
||||
properties: {
|
||||
title: mockTitle,
|
||||
autoRecalc: undefined,
|
||||
locale: undefined,
|
||||
},
|
||||
sheets: [],
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({ json: mockResponse });
|
||||
});
|
||||
|
||||
it('should create spreadsheet with multiple sheets', async () => {
|
||||
const mockSheets = {
|
||||
sheetValues: [
|
||||
{ title: 'Sheet1', hidden: false },
|
||||
{ title: 'Sheet2', hidden: true },
|
||||
],
|
||||
};
|
||||
|
||||
mockExecuteFunctions.getNodeParameter = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce('Test Spreadsheet')
|
||||
.mockReturnValueOnce(mockSheets)
|
||||
.mockReturnValueOnce({});
|
||||
|
||||
const mockResponse = { spreadsheetId: '1234' };
|
||||
(apiRequest.call as jest.Mock).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
'POST',
|
||||
'/v4/spreadsheets',
|
||||
{
|
||||
properties: {
|
||||
title: 'Test Spreadsheet',
|
||||
autoRecalc: undefined,
|
||||
locale: undefined,
|
||||
},
|
||||
sheets: [
|
||||
{ properties: { title: 'Sheet1', hidden: false } },
|
||||
{ properties: { title: 'Sheet2', hidden: true } },
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle all options when creating spreadsheet', async () => {
|
||||
const mockOptions = {
|
||||
locale: 'en_US',
|
||||
autoRecalc: 'ON_CHANGE',
|
||||
};
|
||||
|
||||
mockExecuteFunctions.getNodeParameter = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce('Test Spreadsheet')
|
||||
.mockReturnValueOnce({})
|
||||
.mockReturnValueOnce(mockOptions);
|
||||
|
||||
const mockResponse = { spreadsheetId: '1234' };
|
||||
(apiRequest.call as jest.Mock).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
'POST',
|
||||
'/v4/spreadsheets',
|
||||
{
|
||||
properties: {
|
||||
title: 'Test Spreadsheet',
|
||||
autoRecalc: 'ON_CHANGE',
|
||||
locale: 'en_US',
|
||||
},
|
||||
sheets: [],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple input items', async () => {
|
||||
mockExecuteFunctions.getInputData = jest.fn().mockReturnValue([{}, {}]);
|
||||
|
||||
const mockResponse1 = { spreadsheetId: '1234' };
|
||||
const mockResponse2 = { spreadsheetId: '5678' };
|
||||
|
||||
mockExecuteFunctions.getNodeParameter = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce('Spreadsheet 1')
|
||||
.mockReturnValueOnce({})
|
||||
.mockReturnValueOnce({})
|
||||
.mockReturnValueOnce('Spreadsheet 2')
|
||||
.mockReturnValueOnce({})
|
||||
.mockReturnValueOnce({});
|
||||
|
||||
(apiRequest.call as jest.Mock)
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(apiRequest.call).toHaveBeenCalledTimes(2);
|
||||
expect(result[0]).toEqual({ json: mockResponse1 });
|
||||
expect(result[1]).toEqual({ json: mockResponse2 });
|
||||
});
|
||||
|
||||
it('should handle empty sheet properties', async () => {
|
||||
mockExecuteFunctions.getNodeParameter = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce('Test Spreadsheet')
|
||||
.mockReturnValueOnce({ sheetValues: [] })
|
||||
.mockReturnValueOnce({});
|
||||
|
||||
const mockResponse = { spreadsheetId: '1234' };
|
||||
(apiRequest.call as jest.Mock).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'POST',
|
||||
'/v4/spreadsheets',
|
||||
expect.objectContaining({
|
||||
sheets: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve undefined values for optional properties', async () => {
|
||||
mockExecuteFunctions.getNodeParameter = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce('Test Spreadsheet')
|
||||
.mockReturnValueOnce({})
|
||||
.mockReturnValueOnce({});
|
||||
|
||||
const mockResponse = { spreadsheetId: '1234' };
|
||||
(apiRequest.call as jest.Mock).mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await execute.call(mockExecuteFunctions);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(expect.anything(), 'POST', '/v4/spreadsheets', {
|
||||
properties: {
|
||||
title: 'Test Spreadsheet',
|
||||
autoRecalc: undefined,
|
||||
locale: undefined,
|
||||
},
|
||||
sheets: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { execute } from '../../../../v2/actions/spreadsheet/delete.operation';
|
||||
import { apiRequest } from '../../../../v2/transport';
|
||||
|
||||
jest.mock('../../../../v2/transport', () => ({
|
||||
apiRequest: {
|
||||
call: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('GoogleSheetsDeleteSpreadsheet', () => {
|
||||
let mockExecuteFunction: IExecuteFunctions;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteFunction = {
|
||||
getInputData: jest.fn().mockReturnValue([{}]),
|
||||
getNodeParameter: jest.fn(),
|
||||
helpers: {
|
||||
constructExecutionMetaData: jest.fn().mockImplementation((data) => [data]),
|
||||
},
|
||||
} as unknown as IExecuteFunctions;
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should successfully delete a spreadsheet', async () => {
|
||||
const documentId = '1234567890';
|
||||
const expectedUrl = `https://www.googleapis.com/drive/v3/files/${documentId}`;
|
||||
|
||||
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue(documentId);
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue({});
|
||||
|
||||
const result = await execute.call(mockExecuteFunction);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunction,
|
||||
'DELETE',
|
||||
'',
|
||||
{},
|
||||
{},
|
||||
expectedUrl,
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result).toEqual([[{ json: { success: true } }]]);
|
||||
});
|
||||
|
||||
it('should handle multiple input items', async () => {
|
||||
const documentIds = ['doc1', 'doc2', 'doc3'];
|
||||
mockExecuteFunction.getInputData = jest.fn().mockReturnValue([{}, {}, {}]);
|
||||
mockExecuteFunction.getNodeParameter = jest
|
||||
.fn()
|
||||
.mockImplementation((_, index) => documentIds[index]);
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue({});
|
||||
|
||||
const result = await execute.call(mockExecuteFunction);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledTimes(3);
|
||||
expect(result).toHaveLength(3);
|
||||
result.forEach((item) => {
|
||||
expect(item).toEqual([{ json: { success: true } }]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
const documentId = '1234567890';
|
||||
const errorMessage = 'File not found';
|
||||
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue(documentId);
|
||||
(apiRequest.call as jest.Mock).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
await expect(execute.call(mockExecuteFunction)).rejects.toThrow(Error);
|
||||
});
|
||||
|
||||
it('should validate document ID parameter', async () => {
|
||||
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue(undefined);
|
||||
await expect(execute.call(mockExecuteFunction)).rejects.toThrow();
|
||||
});
|
||||
|
||||
describe('Resource Locator Modes', () => {
|
||||
it('should handle list mode correctly', async () => {
|
||||
const documentId = '1234567890';
|
||||
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue({
|
||||
mode: 'list',
|
||||
value: documentId,
|
||||
});
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue({});
|
||||
|
||||
const result = await execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result).toEqual([[{ json: { success: true } }]]);
|
||||
});
|
||||
|
||||
it('should handle URL mode correctly', async () => {
|
||||
const documentUrl = 'https://docs.google.com/spreadsheets/d/1234567890/edit';
|
||||
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue({
|
||||
mode: 'url',
|
||||
value: documentUrl,
|
||||
});
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue({});
|
||||
|
||||
const result = await execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result).toEqual([[{ json: { success: true } }]]);
|
||||
});
|
||||
|
||||
it('should handle ID mode correctly', async () => {
|
||||
const documentId = '1234567890';
|
||||
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue({
|
||||
mode: 'id',
|
||||
value: documentId,
|
||||
});
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue({});
|
||||
|
||||
const result = await execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result).toEqual([[{ json: { success: true } }]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,955 @@
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
|
||||
import { apiRequest } from '../../../v2/transport';
|
||||
|
||||
jest.mock('../../../v2/transport', () => ({
|
||||
apiRequest: {
|
||||
call: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('GoogleSheet', () => {
|
||||
let googleSheet: GoogleSheet;
|
||||
const mockExecuteFunctions: Partial<IExecuteFunctions> = {
|
||||
getNode: jest.fn(),
|
||||
};
|
||||
const spreadsheetId = 'test-spreadsheet-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
googleSheet = new GoogleSheet(spreadsheetId, mockExecuteFunctions as IExecuteFunctions);
|
||||
});
|
||||
|
||||
describe('clearData', () => {
|
||||
it('should make correct API call to clear data', async () => {
|
||||
const range = 'Sheet1!A1:B2';
|
||||
await googleSheet.clearData(range);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${spreadsheetId}/values/${range}:clear`,
|
||||
{ spreadsheetId, range },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getData', () => {
|
||||
it('should retrieve data with correct parameters', async () => {
|
||||
const range = 'Sheet1!A1:B2';
|
||||
const valueRenderMode = 'UNFORMATTED_VALUE';
|
||||
const mockResponse = {
|
||||
values: [
|
||||
['1', '2'],
|
||||
['3', '4'],
|
||||
],
|
||||
};
|
||||
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await googleSheet.getData(range, valueRenderMode);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
'GET',
|
||||
`/v4/spreadsheets/${spreadsheetId}/values/${range}`,
|
||||
{},
|
||||
{
|
||||
valueRenderOption: valueRenderMode,
|
||||
dateTimeRenderOption: 'FORMATTED_STRING',
|
||||
},
|
||||
);
|
||||
expect(result).toEqual(mockResponse.values);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertSheetDataArrayToObjectArray', () => {
|
||||
it('should convert sheet data to object array correctly', () => {
|
||||
const data = [
|
||||
['name', 'age'],
|
||||
['John', '30'],
|
||||
['Jane', '25'],
|
||||
];
|
||||
const result = googleSheet.convertSheetDataArrayToObjectArray(data, 1, ['name', 'age']);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'John', age: '30' },
|
||||
{ name: 'Jane', age: '25' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty rows when addEmpty is false', () => {
|
||||
const data = [
|
||||
['name', 'age'],
|
||||
['John', '30'],
|
||||
['', ''],
|
||||
['Jane', '25'],
|
||||
];
|
||||
const result = googleSheet.convertSheetDataArrayToObjectArray(
|
||||
data,
|
||||
1,
|
||||
['name', 'age'],
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'John', age: '30' },
|
||||
// this row should be skipped but the code does not handle it
|
||||
{ name: '', age: '' },
|
||||
{ name: 'Jane', age: '25' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty columns when includeHeadersWithEmptyCells is true', () => {
|
||||
const data = [
|
||||
['name', 'age'],
|
||||
['John', '30'],
|
||||
['MARY', ''],
|
||||
['Jane', '25'],
|
||||
];
|
||||
const result = googleSheet.convertSheetDataArrayToObjectArray(
|
||||
data,
|
||||
1,
|
||||
['name', 'age'],
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'John', age: '30' },
|
||||
{ name: 'MARY', age: '' },
|
||||
{ name: 'Jane', age: '25' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle zero values correctly', () => {
|
||||
const data = [
|
||||
['name', 'age'],
|
||||
['John', 30],
|
||||
['Jane', 0],
|
||||
];
|
||||
|
||||
const result = googleSheet.convertSheetDataArrayToObjectArray(data, 1, ['name', 'age']);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'John', age: 30 },
|
||||
{ name: 'Jane', age: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle nullish values correctly', () => {
|
||||
const data = [
|
||||
['name', 'age'],
|
||||
['John', null as unknown as number],
|
||||
['Jane', undefined as unknown as number],
|
||||
];
|
||||
|
||||
const result = googleSheet.convertSheetDataArrayToObjectArray(data, 1, ['name', 'age']);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'John', age: '' },
|
||||
{ name: 'Jane', age: '' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lookupValues', () => {
|
||||
const inputData = [
|
||||
['name', 'age', 'city'],
|
||||
['John', '30', 'NY'],
|
||||
['Jane', '25', 'LA'],
|
||||
['Bob', '30', 'SF'],
|
||||
];
|
||||
|
||||
it('should find matching rows with OR combination', async () => {
|
||||
const lookupValues = [{ lookupColumn: 'age', lookupValue: '30' }];
|
||||
|
||||
const result = await googleSheet.lookupValues({
|
||||
inputData,
|
||||
keyRowIndex: 0,
|
||||
dataStartRowIndex: 1,
|
||||
lookupValues,
|
||||
returnAllMatches: true,
|
||||
combineFilters: 'OR',
|
||||
nodeVersion: 4.5,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'John', age: '30', city: 'NY' },
|
||||
{ name: 'Bob', age: '30', city: 'SF' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should find matching rows with OR combination and returnAllMatches is falsy at version 4.5', async () => {
|
||||
const lookupValues = [
|
||||
{ lookupColumn: 'age', lookupValue: '30' },
|
||||
{ lookupColumn: 'name', lookupValue: 'Jane' },
|
||||
];
|
||||
|
||||
const result = await googleSheet.lookupValues({
|
||||
inputData,
|
||||
keyRowIndex: 0,
|
||||
dataStartRowIndex: 1,
|
||||
lookupValues,
|
||||
combineFilters: 'OR',
|
||||
nodeVersion: 4.5,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'John', age: '30', city: 'NY' },
|
||||
{ name: 'Jane', age: '25', city: 'LA' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should find matching rows with OR combination and returnAllMatches is falsy at version 4.6', async () => {
|
||||
const lookupValues = [
|
||||
{ lookupColumn: 'age', lookupValue: '30' },
|
||||
{ lookupColumn: 'name', lookupValue: 'Jane' },
|
||||
];
|
||||
|
||||
const result = await googleSheet.lookupValues({
|
||||
inputData,
|
||||
keyRowIndex: 0,
|
||||
dataStartRowIndex: 1,
|
||||
lookupValues,
|
||||
combineFilters: 'OR',
|
||||
nodeVersion: 4.6,
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ name: 'John', age: '30', city: 'NY' }]);
|
||||
});
|
||||
|
||||
it('should find matching rows with AND combination', async () => {
|
||||
const lookupValues = [
|
||||
{ lookupColumn: 'age', lookupValue: '30' },
|
||||
{ lookupColumn: 'city', lookupValue: 'NY' },
|
||||
];
|
||||
|
||||
const result = await googleSheet.lookupValues({
|
||||
inputData,
|
||||
keyRowIndex: 0,
|
||||
dataStartRowIndex: 1,
|
||||
lookupValues,
|
||||
returnAllMatches: true,
|
||||
combineFilters: 'AND',
|
||||
nodeVersion: 4.5,
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ name: 'John', age: '30', city: 'NY' }]);
|
||||
});
|
||||
|
||||
it('should throw error for invalid key row', async () => {
|
||||
const lookupValues = [{ lookupColumn: 'age', lookupValue: '30' }];
|
||||
|
||||
await expect(
|
||||
googleSheet.lookupValues({
|
||||
inputData: [['name', 'age']],
|
||||
keyRowIndex: -1,
|
||||
dataStartRowIndex: 1,
|
||||
lookupValues,
|
||||
nodeVersion: 4.5,
|
||||
}),
|
||||
).rejects.toThrow('The key row does not exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendSheetData', () => {
|
||||
it('should correctly prepare and append data', async () => {
|
||||
const inputData = [
|
||||
{ name: 'John', age: '30' },
|
||||
{ name: 'Jane', age: '25' },
|
||||
];
|
||||
|
||||
const mockAppendResponse = {
|
||||
range: 'Sheet1!A1:B3',
|
||||
majorDimension: 'ROWS',
|
||||
values: [
|
||||
['name', 'age'],
|
||||
['John', '30'],
|
||||
['Jane', '25'],
|
||||
],
|
||||
};
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue(mockAppendResponse);
|
||||
|
||||
await googleSheet.appendSheetData({
|
||||
inputData,
|
||||
range: 'Sheet1!A:B',
|
||||
keyRowIndex: 0,
|
||||
valueInputMode: 'USER_ENTERED',
|
||||
});
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendEmptyRowsOrColumns', () => {
|
||||
it('should throw error when no rows or columns specified', async () => {
|
||||
await expect(googleSheet.appendEmptyRowsOrColumns('sheet1', 0, 0)).rejects.toThrow(
|
||||
'Must specify at least one column or row to add',
|
||||
);
|
||||
});
|
||||
|
||||
it('should make correct API call to append rows and columns', async () => {
|
||||
const sheetId = 'sheet1';
|
||||
await googleSheet.appendEmptyRowsOrColumns(sheetId, 2, 3);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${spreadsheetId}:batchUpdate`,
|
||||
{
|
||||
requests: [
|
||||
{
|
||||
appendDimension: {
|
||||
sheetId,
|
||||
dimension: 'ROWS',
|
||||
length: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
appendDimension: {
|
||||
sheetId,
|
||||
dimension: 'COLUMNS',
|
||||
length: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getData with dateTimeRenderOption', () => {
|
||||
it('should use custom dateTimeRenderOption when provided', async () => {
|
||||
const range = 'Sheet1!A1:B2';
|
||||
const valueRenderMode = 'FORMATTED_VALUE';
|
||||
const dateTimeRenderOption = 'SERIAL_NUMBER';
|
||||
|
||||
await googleSheet.getData(range, valueRenderMode, dateTimeRenderOption);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
'GET',
|
||||
`/v4/spreadsheets/${spreadsheetId}/values/${range}`,
|
||||
{},
|
||||
{
|
||||
valueRenderOption: valueRenderMode,
|
||||
dateTimeRenderOption,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spreadsheetGetSheets', () => {
|
||||
it('should retrieve spreadsheet sheets with correct parameters', async () => {
|
||||
const mockResponse = {
|
||||
sheets: [
|
||||
{ properties: { title: 'Sheet1', sheetId: 0 } },
|
||||
{ properties: { title: 'Sheet2', sheetId: 1 } },
|
||||
],
|
||||
};
|
||||
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await googleSheet.spreadsheetGetSheets();
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
'GET',
|
||||
`/v4/spreadsheets/${spreadsheetId}`,
|
||||
{},
|
||||
{ fields: 'sheets.properties' },
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spreadsheetGetSheet', () => {
|
||||
const mockResponse = {
|
||||
sheets: [
|
||||
{ properties: { title: 'Sheet1', sheetId: 0 } },
|
||||
{ properties: { title: 'TestSheet', sheetId: 123456789 } },
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
|
||||
});
|
||||
|
||||
it('should find sheet by name', async () => {
|
||||
const mockNode = { type: 'test-node' } as any;
|
||||
const result = await googleSheet.spreadsheetGetSheet(mockNode, 'name', 'TestSheet');
|
||||
|
||||
expect(result).toEqual({ title: 'TestSheet', sheetId: 123456789 });
|
||||
});
|
||||
|
||||
it('should find sheet by ID', async () => {
|
||||
const mockNode = { type: 'test-node' } as any;
|
||||
const result = await googleSheet.spreadsheetGetSheet(mockNode, 'id', '123456789');
|
||||
|
||||
expect(result).toEqual({ title: 'TestSheet', sheetId: 123456789 });
|
||||
});
|
||||
|
||||
it('should throw error when sheet not found by name', async () => {
|
||||
const mockNode = { type: 'test-node' } as any;
|
||||
|
||||
await expect(
|
||||
googleSheet.spreadsheetGetSheet(mockNode, 'name', 'NonExistentSheet'),
|
||||
).rejects.toThrow('Sheet with name NonExistentSheet not found');
|
||||
});
|
||||
|
||||
it('should throw error when sheet not found by ID', async () => {
|
||||
const mockNode = { type: 'test-node' } as any;
|
||||
|
||||
await expect(googleSheet.spreadsheetGetSheet(mockNode, 'id', '999999999')).rejects.toThrow(
|
||||
'Sheet with ID 999999999 not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDataRange', () => {
|
||||
it('should return grid properties for sheet', async () => {
|
||||
const mockResponse = {
|
||||
sheets: [
|
||||
{
|
||||
properties: {
|
||||
sheetId: '123',
|
||||
gridProperties: {
|
||||
rowCount: 100,
|
||||
columnCount: 26,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await googleSheet.getDataRange('123');
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
'GET',
|
||||
`/v4/spreadsheets/${spreadsheetId}`,
|
||||
{},
|
||||
{ fields: 'sheets.properties' },
|
||||
);
|
||||
expect(result).toEqual({ rowCount: 100, columnCount: 26 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('spreadsheetBatchUpdate', () => {
|
||||
it('should make correct API call for batch update', async () => {
|
||||
const requests = [
|
||||
{
|
||||
updateSheetProperties: {
|
||||
properties: { title: 'New Title' },
|
||||
fields: 'title',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await googleSheet.spreadsheetBatchUpdate(requests);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${spreadsheetId}:batchUpdate`,
|
||||
{ requests },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchUpdate', () => {
|
||||
it('should make correct API call for batch value update', async () => {
|
||||
const updateData = [
|
||||
{
|
||||
range: 'Sheet1!A1:B2',
|
||||
values: [
|
||||
['Name', 'Age'],
|
||||
['John', '30'],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
await googleSheet.batchUpdate(updateData, 'USER_ENTERED');
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${spreadsheetId}/values:batchUpdate`,
|
||||
{
|
||||
data: updateData,
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendData', () => {
|
||||
beforeEach(() => {
|
||||
// Mock getData to return existing data
|
||||
(apiRequest.call as jest.Mock).mockImplementation(async (_, method, _url) => {
|
||||
if (method === 'GET') {
|
||||
return { values: [['existing', 'row']] };
|
||||
}
|
||||
return { range: 'Sheet1!A2:B2' };
|
||||
});
|
||||
});
|
||||
|
||||
it('should append data with calculated last row', async () => {
|
||||
const data = [
|
||||
['John', '30'],
|
||||
['Jane', '25'],
|
||||
];
|
||||
|
||||
const result = await googleSheet.appendData('Sheet1!A:B', data, 'USER_ENTERED');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use provided last row', async () => {
|
||||
const data = [['John', '30']];
|
||||
|
||||
await googleSheet.appendData('Sheet1!A:B', data, 'USER_ENTERED', 5);
|
||||
|
||||
// Should use row 5 instead of calculating
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
'PUT',
|
||||
expect.stringContaining('Sheet1!5:5'),
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use append mode when useAppend is true', async () => {
|
||||
const data = [['John', '30']];
|
||||
|
||||
await googleSheet.appendData('Sheet1!A:B', data, 'USER_ENTERED', 5, true);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
'POST',
|
||||
expect.stringContaining(':append'),
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRows', () => {
|
||||
it('should make PUT request when useAppend is false', async () => {
|
||||
const data = [['John', '30']];
|
||||
|
||||
await googleSheet.updateRows('Sheet1', data, 'USER_ENTERED', 2);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
'PUT',
|
||||
`/v4/spreadsheets/${spreadsheetId}/values/Sheet1!2:2`,
|
||||
{
|
||||
range: 'Sheet1!2:2',
|
||||
values: data,
|
||||
},
|
||||
{ valueInputOption: 'USER_ENTERED' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should make POST request when useAppend is true', async () => {
|
||||
const data = [['John', '30']];
|
||||
|
||||
await googleSheet.updateRows('Sheet1', data, 'USER_ENTERED', 2, 2, true);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${spreadsheetId}/values/Sheet1!2:3:append`,
|
||||
{
|
||||
range: 'Sheet1!2:3',
|
||||
values: data,
|
||||
},
|
||||
{ valueInputOption: 'USER_ENTERED' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle range with rowsLength', async () => {
|
||||
const data = [
|
||||
['John', '30'],
|
||||
['Jane', '25'],
|
||||
];
|
||||
|
||||
await googleSheet.updateRows('Sheet1', data, 'USER_ENTERED', 2, 2);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
'PUT',
|
||||
`/v4/spreadsheets/${spreadsheetId}/values/Sheet1!2:3`,
|
||||
{
|
||||
range: 'Sheet1!2:3',
|
||||
values: data,
|
||||
},
|
||||
{ valueInputOption: 'USER_ENTERED' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('structureArrayDataByColumn', () => {
|
||||
it('should structure data using key row', () => {
|
||||
const inputData = [
|
||||
['Name', 'Age', 'City'],
|
||||
['John', '30', 'NYC'],
|
||||
['Jane', '25', 'LA'],
|
||||
];
|
||||
|
||||
const result = googleSheet.structureArrayDataByColumn(inputData, 0, 1);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ Name: 'John', Age: '30', City: 'NYC' },
|
||||
{ Name: 'Jane', Age: '25', City: 'LA' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty array for invalid key row', () => {
|
||||
const inputData = [
|
||||
['Name', 'Age'],
|
||||
['John', '30'],
|
||||
];
|
||||
|
||||
const result = googleSheet.structureArrayDataByColumn(inputData, -1, 1);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when dataStartRow < keyRow', () => {
|
||||
const inputData = [
|
||||
['Name', 'Age'],
|
||||
['John', '30'],
|
||||
];
|
||||
|
||||
const result = googleSheet.structureArrayDataByColumn(inputData, 1, 0);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle missing column names with fallback', () => {
|
||||
const inputData = [
|
||||
['Name', '', 'City'], // Empty column name
|
||||
['John', '30', 'NYC'],
|
||||
];
|
||||
|
||||
const result = googleSheet.structureArrayDataByColumn(inputData, 0, 1);
|
||||
|
||||
expect(result).toEqual([{ Name: 'John', col_1: '30', City: 'NYC' }]);
|
||||
});
|
||||
|
||||
it('should handle uneven row lengths', () => {
|
||||
const inputData = [
|
||||
['Name', 'Age', 'City'],
|
||||
['John', '30'], // Shorter row
|
||||
['Jane', '25', 'LA', 'Extra'], // Longer row
|
||||
];
|
||||
|
||||
const result = googleSheet.structureArrayDataByColumn(inputData, 0, 1);
|
||||
|
||||
// The function uses the longest row to create keys, generating col_3 for the extra column
|
||||
// Only properties with values are included, empty cells are omitted
|
||||
expect(result).toEqual([
|
||||
{ Name: 'John', Age: '30' },
|
||||
{ Name: 'Jane', Age: '25', City: 'LA', col_3: 'Extra' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testFilter', () => {
|
||||
it('should return column keys from key row', () => {
|
||||
const inputData = [
|
||||
['Name', 'Age', 'City'],
|
||||
['John', '30', 'NYC'],
|
||||
];
|
||||
|
||||
const result = googleSheet.testFilter(inputData, 0, 1);
|
||||
|
||||
expect(result).toEqual(['Name', 'Age', 'City']);
|
||||
});
|
||||
|
||||
it('should return empty array for invalid key row', () => {
|
||||
const inputData = [['Name', 'Age']];
|
||||
|
||||
const result = googleSheet.testFilter(inputData, -1, 1);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when keyRow >= inputData.length', () => {
|
||||
const inputData = [['Name', 'Age']];
|
||||
|
||||
const result = googleSheet.testFilter(inputData, 2, 1);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getColumnWithOffset', () => {
|
||||
it('should calculate column with positive offset', () => {
|
||||
const result = googleSheet.getColumnWithOffset('A', 2);
|
||||
|
||||
expect(result).toBe('C');
|
||||
});
|
||||
|
||||
it('should calculate column with zero offset', () => {
|
||||
const result = googleSheet.getColumnWithOffset('B', 0);
|
||||
|
||||
expect(result).toBe('B');
|
||||
});
|
||||
|
||||
it('should handle double letter columns', () => {
|
||||
const result = googleSheet.getColumnWithOffset('Z', 1);
|
||||
|
||||
expect(result).toBe('AA');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getColumnValues', () => {
|
||||
beforeEach(() => {
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue({
|
||||
values: [['header'], ['value1'], ['value2']],
|
||||
});
|
||||
});
|
||||
|
||||
it('should get column values from sheet data when provided', async () => {
|
||||
const sheetData = [
|
||||
['Name', 'Age'],
|
||||
['John', '30'],
|
||||
['Jane', '25'],
|
||||
];
|
||||
|
||||
const result = await googleSheet.getColumnValues({
|
||||
range: 'Sheet1!A:B',
|
||||
keyIndex: 0,
|
||||
dataStartRowIndex: 1,
|
||||
valueRenderMode: 'UNFORMATTED_VALUE',
|
||||
sheetData,
|
||||
});
|
||||
|
||||
expect(result).toEqual(['John', 'Jane']);
|
||||
});
|
||||
|
||||
it('should make API call when sheet data not provided', async () => {
|
||||
const result = await googleSheet.getColumnValues({
|
||||
range: 'Sheet1!A1:B10',
|
||||
keyIndex: 0,
|
||||
dataStartRowIndex: 1,
|
||||
valueRenderMode: 'UNFORMATTED_VALUE',
|
||||
});
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
'GET',
|
||||
expect.stringContaining('/values/Sheet1!A1:A10'),
|
||||
{},
|
||||
{ valueRenderOption: 'UNFORMATTED_VALUE', dateTimeRenderOption: 'FORMATTED_STRING' },
|
||||
);
|
||||
expect(result).toEqual(['value1', 'value2']);
|
||||
});
|
||||
|
||||
it('should throw error when column data cannot be retrieved', async () => {
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue({ values: undefined });
|
||||
|
||||
await expect(
|
||||
googleSheet.getColumnValues({
|
||||
range: 'Sheet1!A:B',
|
||||
keyIndex: 0,
|
||||
dataStartRowIndex: 1,
|
||||
valueRenderMode: 'UNFORMATTED_VALUE',
|
||||
}),
|
||||
).rejects.toThrow('Could not retrieve the data from key column');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareDataForUpdateOrUpsert', () => {
|
||||
beforeEach(() => {
|
||||
// Mock getData responses
|
||||
(apiRequest.call as jest.Mock).mockImplementation(
|
||||
(_: unknown, _method: unknown, url: string) => {
|
||||
if (url.includes('/values/Sheet1!A1:C1')) {
|
||||
return { values: [['Name', 'Age', 'City']] };
|
||||
}
|
||||
// Match the actual URL pattern generated by getColumnValues
|
||||
if (url.includes('/values/Sheet1!A1:A10') || url.includes('/values/Sheet1%21A1%3AA10')) {
|
||||
return { values: [['Name'], ['John'], ['Jane']] };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should prepare update data for existing records', async () => {
|
||||
const inputData = [
|
||||
{ Name: 'John', Age: '31', City: 'NYC' }, // Update existing
|
||||
{ Name: 'Bob', Age: '25', City: 'LA' }, // New record
|
||||
];
|
||||
|
||||
const result = await googleSheet.prepareDataForUpdateOrUpsert({
|
||||
inputData,
|
||||
indexKey: 'Name',
|
||||
range: 'Sheet1!A1:C10',
|
||||
keyRowIndex: 0,
|
||||
dataStartRowIndex: 1,
|
||||
valueRenderMode: 'UNFORMATTED_VALUE',
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
expect(result.updateData).toHaveLength(2); // Age and City updates for John
|
||||
expect(result.appendData).toHaveLength(1); // Bob should be appended
|
||||
expect(result.appendData[0]).toEqual({ Name: 'Bob', Age: '25', City: 'LA' });
|
||||
});
|
||||
|
||||
it('should throw error when index key not found and upsert is false', async () => {
|
||||
const inputData = [{ Name: 'John', Age: '31' }];
|
||||
|
||||
await expect(
|
||||
googleSheet.prepareDataForUpdateOrUpsert({
|
||||
inputData,
|
||||
indexKey: 'NonExistentKey',
|
||||
range: 'Sheet1!A1:C10',
|
||||
keyRowIndex: 0,
|
||||
dataStartRowIndex: 1,
|
||||
valueRenderMode: 'UNFORMATTED_VALUE',
|
||||
upsert: false,
|
||||
}),
|
||||
).rejects.toThrow('Could not find column for key "NonExistentKey"');
|
||||
});
|
||||
|
||||
it('should throw error when key row cannot be retrieved', async () => {
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue({ values: undefined });
|
||||
|
||||
const inputData = [{ Name: 'John' }];
|
||||
|
||||
await expect(
|
||||
googleSheet.prepareDataForUpdateOrUpsert({
|
||||
inputData,
|
||||
indexKey: 'Name',
|
||||
range: 'Sheet1!A1:C10',
|
||||
keyRowIndex: 0,
|
||||
dataStartRowIndex: 1,
|
||||
valueRenderMode: 'UNFORMATTED_VALUE',
|
||||
}),
|
||||
).rejects.toThrow('Could not retrieve the key row');
|
||||
});
|
||||
|
||||
it('should handle items without index key when upsert is true', async () => {
|
||||
const inputData = [{ Age: '30', City: 'NYC' }]; // No Name field
|
||||
|
||||
const result = await googleSheet.prepareDataForUpdateOrUpsert({
|
||||
inputData,
|
||||
indexKey: 'Name',
|
||||
range: 'Sheet1!A1:C10',
|
||||
keyRowIndex: 0,
|
||||
dataStartRowIndex: 1,
|
||||
valueRenderMode: 'UNFORMATTED_VALUE',
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
expect(result.updateData).toHaveLength(0);
|
||||
expect(result.appendData).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should stringify object values', async () => {
|
||||
const inputData = [{ Name: 'John', Age: { years: 30 }, City: 'NYC' }];
|
||||
|
||||
const result = await googleSheet.prepareDataForUpdateOrUpsert({
|
||||
inputData,
|
||||
indexKey: 'Name',
|
||||
range: 'Sheet1!A1:C10',
|
||||
keyRowIndex: 0,
|
||||
dataStartRowIndex: 1,
|
||||
valueRenderMode: 'UNFORMATTED_VALUE',
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
const ageUpdate = result.updateData.find((update) => update.range.includes('B'));
|
||||
expect(ageUpdate?.values[0][0]).toBe('{"years":30}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareDataForUpdatingByRowNumber', () => {
|
||||
it('should prepare update data using row numbers', () => {
|
||||
const inputData = [
|
||||
{ row_number: 2, Name: 'John', Age: '31' },
|
||||
{ row_number: 3, Name: 'Jane', Age: '26' },
|
||||
];
|
||||
const columnNamesList = [['row_number', 'Name', 'Age', 'City']];
|
||||
|
||||
const result = googleSheet.prepareDataForUpdatingByRowNumber(
|
||||
inputData,
|
||||
'Sheet1!A1:D10',
|
||||
columnNamesList,
|
||||
);
|
||||
|
||||
expect(result.updateData).toHaveLength(4); // 2 items × 2 fields each
|
||||
expect(result.updateData[0]).toEqual({
|
||||
range: 'Sheet1!B2',
|
||||
values: [['John']],
|
||||
});
|
||||
expect(result.updateData[1]).toEqual({
|
||||
range: 'Sheet1!C2',
|
||||
values: [['31']],
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip row_number field and null/undefined values', () => {
|
||||
const inputData = [{ row_number: 2, Name: 'John', Age: null, City: undefined }];
|
||||
const columnNamesList = [['row_number', 'Name', 'Age', 'City']];
|
||||
|
||||
const result = googleSheet.prepareDataForUpdatingByRowNumber(
|
||||
inputData,
|
||||
'Sheet1!A1:D10',
|
||||
columnNamesList,
|
||||
);
|
||||
|
||||
expect(result.updateData).toHaveLength(1); // Only Name field
|
||||
expect(result.updateData[0].range).toBe('Sheet1!B2');
|
||||
});
|
||||
|
||||
it('should stringify object values', () => {
|
||||
const inputData = [{ row_number: 2, Name: { first: 'John', last: 'Doe' } }];
|
||||
const columnNamesList = [['row_number', 'Name']];
|
||||
|
||||
const result = googleSheet.prepareDataForUpdatingByRowNumber(
|
||||
inputData,
|
||||
'Sheet1!A1:B10',
|
||||
columnNamesList,
|
||||
);
|
||||
|
||||
expect(result.updateData[0].values[0][0]).toBe('{"first":"John","last":"Doe"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('private method testing via clearData (encodeRange)', () => {
|
||||
it('should encode sheet name with special characters', async () => {
|
||||
const range = 'Sheet with spaces!A1:B2';
|
||||
await googleSheet.clearData(range);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${spreadsheetId}/values/Sheet%20with%20spaces!A1:B2:clear`,
|
||||
{ spreadsheetId, range },
|
||||
);
|
||||
});
|
||||
|
||||
it('should encode range without sheet reference', async () => {
|
||||
const range = 'Sheet with spaces';
|
||||
await googleSheet.clearData(range);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockExecuteFunctions,
|
||||
'POST',
|
||||
expect.stringContaining(encodeURIComponent("'Sheet with spaces'")),
|
||||
{ spreadsheetId, range },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import type { ILoadOptionsFunctions } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { sheetsSearch, spreadSheetsSearch } from '../../../v2/methods/listSearch';
|
||||
import { apiRequest } from '../../../v2/transport';
|
||||
|
||||
jest.mock('../../../v2/transport', () => ({
|
||||
apiRequest: {
|
||||
call: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Google Sheets Search Functions', () => {
|
||||
let mockLoadOptionsFunctions: Partial<ILoadOptionsFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockLoadOptionsFunctions = {
|
||||
getNode: jest.fn(),
|
||||
getNodeParameter: jest.fn(),
|
||||
};
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('spreadSheetsSearch', () => {
|
||||
it('should return search results without filter', async () => {
|
||||
const mockResponse = {
|
||||
files: [
|
||||
{ id: '1', name: 'Sheet1', webViewLink: 'https://sheet1.url' },
|
||||
{ id: '2', name: 'Sheet2', webViewLink: 'https://sheet2.url' },
|
||||
],
|
||||
nextPageToken: 'next-page',
|
||||
};
|
||||
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await spreadSheetsSearch.call(
|
||||
mockLoadOptionsFunctions as ILoadOptionsFunctions,
|
||||
);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockLoadOptionsFunctions,
|
||||
'GET',
|
||||
'',
|
||||
{},
|
||||
{
|
||||
q: "mimeType = 'application/vnd.google-apps.spreadsheet'",
|
||||
fields: 'nextPageToken, files(id, name, webViewLink)',
|
||||
orderBy: 'modifiedByMeTime desc,name_natural',
|
||||
includeItemsFromAllDrives: true,
|
||||
supportsAllDrives: true,
|
||||
},
|
||||
'https://www.googleapis.com/drive/v3/files',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
results: [
|
||||
{ name: 'Sheet1', value: '1', url: 'https://sheet1.url' },
|
||||
{ name: 'Sheet2', value: '2', url: 'https://sheet2.url' },
|
||||
],
|
||||
paginationToken: 'next-page',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle search with filter', async () => {
|
||||
const mockResponse = {
|
||||
files: [{ id: '1', name: 'TestSheet', webViewLink: 'https://test.url' }],
|
||||
};
|
||||
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await spreadSheetsSearch.call(
|
||||
mockLoadOptionsFunctions as ILoadOptionsFunctions,
|
||||
'Test',
|
||||
);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockLoadOptionsFunctions,
|
||||
'GET',
|
||||
'',
|
||||
{},
|
||||
{
|
||||
q: "name contains 'Test' and mimeType = 'application/vnd.google-apps.spreadsheet'",
|
||||
fields: 'nextPageToken, files(id, name, webViewLink)',
|
||||
orderBy: 'modifiedByMeTime desc,name_natural',
|
||||
includeItemsFromAllDrives: true,
|
||||
supportsAllDrives: true,
|
||||
},
|
||||
'https://www.googleapis.com/drive/v3/files',
|
||||
);
|
||||
|
||||
expect(result.results).toHaveLength(1);
|
||||
expect(result.results[0].name).toBe('TestSheet');
|
||||
});
|
||||
|
||||
it('should escape single quotes in filter', async () => {
|
||||
const mockResponse = { files: [] };
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
await spreadSheetsSearch.call(
|
||||
mockLoadOptionsFunctions as ILoadOptionsFunctions,
|
||||
"Test's Sheet",
|
||||
);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'GET',
|
||||
'',
|
||||
{},
|
||||
expect.objectContaining({
|
||||
q: "name contains 'Test\\'s Sheet' and mimeType = 'application/vnd.google-apps.spreadsheet'",
|
||||
}),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle pagination token', async () => {
|
||||
const mockResponse = { files: [] };
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
await spreadSheetsSearch.call(
|
||||
mockLoadOptionsFunctions as ILoadOptionsFunctions,
|
||||
undefined,
|
||||
'page-token',
|
||||
);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'GET',
|
||||
'',
|
||||
{},
|
||||
expect.objectContaining({
|
||||
pageToken: 'page-token',
|
||||
}),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sheetsSearch', () => {
|
||||
it('should return empty results when no documentId is provided', async () => {
|
||||
mockLoadOptionsFunctions.getNodeParameter = jest.fn().mockReturnValue(null);
|
||||
|
||||
const result = await sheetsSearch.call(mockLoadOptionsFunctions as ILoadOptionsFunctions);
|
||||
|
||||
expect(result).toEqual({ results: [] });
|
||||
expect(apiRequest.call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return sheets list for valid spreadsheet', async () => {
|
||||
mockLoadOptionsFunctions.getNodeParameter = jest.fn().mockReturnValue({
|
||||
mode: 'id',
|
||||
value: 'spreadsheet-id',
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
sheets: [
|
||||
{
|
||||
properties: {
|
||||
sheetId: 123,
|
||||
title: 'Sheet1',
|
||||
sheetType: 'GRID',
|
||||
},
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
sheetId: 456,
|
||||
title: 'Sheet2',
|
||||
sheetType: 'GRID',
|
||||
},
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
sheetId: 789,
|
||||
title: 'Chart1',
|
||||
sheetType: 'CHART',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await sheetsSearch.call(mockLoadOptionsFunctions as ILoadOptionsFunctions);
|
||||
|
||||
expect(apiRequest.call).toHaveBeenCalledWith(
|
||||
mockLoadOptionsFunctions,
|
||||
'GET',
|
||||
'/v4/spreadsheets/spreadsheet-id',
|
||||
{},
|
||||
{ fields: 'sheets.properties' },
|
||||
);
|
||||
|
||||
expect(result.results).toHaveLength(2); // Only GRID type sheets
|
||||
expect(result.results[0]).toEqual({
|
||||
name: 'Sheet1',
|
||||
value: 123,
|
||||
url: 'https://docs.google.com/spreadsheets/d/spreadsheet-id/edit#gid=123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle default sheet id when sheetId is not provided', async () => {
|
||||
mockLoadOptionsFunctions.getNodeParameter = jest.fn().mockReturnValue({
|
||||
mode: 'id',
|
||||
value: 'spreadsheet-id',
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
sheets: [
|
||||
{
|
||||
properties: {
|
||||
title: 'Sheet1',
|
||||
sheetType: 'GRID',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await sheetsSearch.call(mockLoadOptionsFunctions as ILoadOptionsFunctions);
|
||||
|
||||
expect(result.results[0].value).toBe('gid=0');
|
||||
});
|
||||
|
||||
it('should throw error when no data is returned', async () => {
|
||||
mockLoadOptionsFunctions.getNodeParameter = jest.fn().mockReturnValue({
|
||||
mode: 'id',
|
||||
value: 'spreadsheet-id',
|
||||
});
|
||||
mockLoadOptionsFunctions.getNode = jest.fn().mockReturnValue({});
|
||||
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
sheetsSearch.call(mockLoadOptionsFunctions as ILoadOptionsFunctions),
|
||||
).rejects.toThrow(
|
||||
new NodeOperationError(mockLoadOptionsFunctions.getNode(), 'No data got returned'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter out non-GRID type sheets', async () => {
|
||||
mockLoadOptionsFunctions.getNodeParameter = jest.fn().mockReturnValue({
|
||||
mode: 'id',
|
||||
value: 'spreadsheet-id',
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
sheets: [
|
||||
{
|
||||
properties: {
|
||||
sheetId: 123,
|
||||
title: 'Chart',
|
||||
sheetType: 'CHART',
|
||||
},
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
sheetId: 456,
|
||||
title: 'Grid',
|
||||
sheetType: 'GRID',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(apiRequest.call as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await sheetsSearch.call(mockLoadOptionsFunctions as ILoadOptionsFunctions);
|
||||
|
||||
expect(result.results).toHaveLength(1);
|
||||
expect(result.results[0].name).toBe('Grid');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { ILoadOptionsFunctions } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
getSheetHeaderRow,
|
||||
getSheetHeaderRowAndAddColumn,
|
||||
getSheetHeaderRowAndSkipEmpty,
|
||||
getSheetHeaderRowWithGeneratedColumnNames,
|
||||
getSheets,
|
||||
} from '../../../v2/methods/loadOptions';
|
||||
|
||||
jest.mock('../../../v2/helpers/GoogleSheets.utils');
|
||||
|
||||
const mockGoogleSheetInstance = {
|
||||
spreadsheetGetSheets: jest.fn(),
|
||||
spreadsheetGetSheet: jest.fn(),
|
||||
getData: jest.fn(),
|
||||
testFilter: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('../../../v2/helpers/GoogleSheet', () => ({
|
||||
GoogleSheet: jest.fn().mockImplementation(() => mockGoogleSheetInstance),
|
||||
}));
|
||||
|
||||
describe('Google Sheets Functions', () => {
|
||||
let mockLoadOptionsFunctions: Partial<ILoadOptionsFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockLoadOptionsFunctions = {
|
||||
getNodeParameter: jest.fn((paramName: string) => {
|
||||
if (paramName === 'documentId') {
|
||||
return { mode: 'mode', value: 'value' };
|
||||
}
|
||||
if (paramName === 'sheetName') {
|
||||
return { mode: 'Sheet1', value: 'Sheet1' };
|
||||
}
|
||||
}),
|
||||
getNode: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('getSheets', () => {
|
||||
it('should return an empty array if documentId is null', async () => {
|
||||
mockLoadOptionsFunctions.getNodeParameter = jest.fn().mockReturnValue(null);
|
||||
|
||||
const result = await getSheets.call(mockLoadOptionsFunctions as ILoadOptionsFunctions);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should throw an error if no data is returned', async () => {
|
||||
mockGoogleSheetInstance.spreadsheetGetSheets.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
getSheets.call(mockLoadOptionsFunctions as ILoadOptionsFunctions),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should return sheets with GRID type', async () => {
|
||||
mockGoogleSheetInstance.spreadsheetGetSheets.mockResolvedValue({
|
||||
sheets: [
|
||||
{ properties: { sheetType: 'GRID', title: 'Sheet1', sheetId: '123' } },
|
||||
{ properties: { sheetType: 'OTHER', title: 'Sheet2', sheetId: '456' } },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getSheets.call(mockLoadOptionsFunctions as ILoadOptionsFunctions);
|
||||
expect(result).toEqual([{ name: 'Sheet1', value: '123' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSheetHeaderRow', () => {
|
||||
it('should return an empty array if documentId is null', async () => {
|
||||
mockLoadOptionsFunctions.getNodeParameter = jest.fn().mockReturnValue(null);
|
||||
|
||||
const result = await getSheetHeaderRow.call(
|
||||
mockLoadOptionsFunctions as ILoadOptionsFunctions,
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should throw an error if no data is returned', async () => {
|
||||
mockGoogleSheetInstance.spreadsheetGetSheet.mockResolvedValue({
|
||||
title: 'Sheet1',
|
||||
});
|
||||
mockGoogleSheetInstance.getData.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
getSheetHeaderRow.call(mockLoadOptionsFunctions as ILoadOptionsFunctions),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should return column headers', async () => {
|
||||
mockGoogleSheetInstance.spreadsheetGetSheet.mockResolvedValue({
|
||||
title: 'Sheet1',
|
||||
});
|
||||
mockGoogleSheetInstance.getData.mockResolvedValue([['Header1', 'Header2', 'Header3']]);
|
||||
mockGoogleSheetInstance.testFilter.mockReturnValue(['Header1', 'Header2', 'Header3']);
|
||||
|
||||
const result = await getSheetHeaderRow.call(
|
||||
mockLoadOptionsFunctions as ILoadOptionsFunctions,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{ name: 'Header1', value: 'Header1' },
|
||||
{ name: 'Header2', value: 'Header2' },
|
||||
{ name: 'Header3', value: 'Header3' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSheetHeaderRowAndAddColumn', () => {
|
||||
it('should add a new column and exclude the column to match on', async () => {
|
||||
mockGoogleSheetInstance.spreadsheetGetSheet.mockResolvedValue({
|
||||
title: 'Sheet1',
|
||||
});
|
||||
mockGoogleSheetInstance.getData.mockResolvedValue([['Header1']]);
|
||||
mockGoogleSheetInstance.testFilter.mockReturnValue(['Header1']);
|
||||
|
||||
const result = await getSheetHeaderRowAndAddColumn.call(
|
||||
mockLoadOptionsFunctions as ILoadOptionsFunctions,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'Header1', value: 'Header1' },
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
{ name: 'New column ...', value: 'newColumn' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSheetHeaderRowWithGeneratedColumnNames', () => {
|
||||
it('should generate column names for empty values', async () => {
|
||||
mockGoogleSheetInstance.spreadsheetGetSheet.mockResolvedValue({
|
||||
title: 'Sheet1',
|
||||
});
|
||||
mockGoogleSheetInstance.getData.mockResolvedValue([['', 'Header1', '']]);
|
||||
mockGoogleSheetInstance.testFilter.mockReturnValue(['', 'Header1', '']);
|
||||
|
||||
const result = await getSheetHeaderRowWithGeneratedColumnNames.call(
|
||||
mockLoadOptionsFunctions as ILoadOptionsFunctions,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
{ name: 'col_1', value: 'col_1' },
|
||||
{ name: 'Header1', value: 'Header1' },
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
{ name: 'col_3', value: 'col_3' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSheetHeaderRowAndSkipEmpty', () => {
|
||||
it('should skip columns with empty values', async () => {
|
||||
mockGoogleSheetInstance.spreadsheetGetSheet.mockResolvedValue({
|
||||
title: 'Sheet1',
|
||||
});
|
||||
mockGoogleSheetInstance.getData.mockResolvedValue([['', 'Header1', '']]);
|
||||
mockGoogleSheetInstance.testFilter.mockReturnValue(['', 'Header1', '']);
|
||||
|
||||
const result = await getSheetHeaderRowAndSkipEmpty.call(
|
||||
mockLoadOptionsFunctions as ILoadOptionsFunctions,
|
||||
);
|
||||
|
||||
expect(result).toEqual([{ name: 'Header1', value: 'Header1' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { MockProxy } from 'jest-mock-extended';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { ILoadOptionsFunctions, INode } from 'n8n-workflow';
|
||||
|
||||
import { getMappingColumns } from '../../../v2/methods/resourceMapping';
|
||||
|
||||
jest.mock('../../../v2/helpers/GoogleSheets.utils');
|
||||
|
||||
const mockGoogleSheetInstance = {
|
||||
spreadsheetGetSheets: jest.fn(),
|
||||
spreadsheetGetSheet: jest.fn(),
|
||||
getData: jest.fn(),
|
||||
testFilter: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('../../../v2/helpers/GoogleSheet', () => ({
|
||||
GoogleSheet: jest.fn().mockImplementation(() => mockGoogleSheetInstance),
|
||||
}));
|
||||
|
||||
describe('Google Sheets, getMappingColumns', () => {
|
||||
let loadOptionsFunctions: MockProxy<ILoadOptionsFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
loadOptionsFunctions = mock<ILoadOptionsFunctions>();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should map columns and add row number for update operation', async () => {
|
||||
loadOptionsFunctions.getNode.mockReturnValue({} as INode);
|
||||
loadOptionsFunctions.getNodeParameter
|
||||
.mockReturnValueOnce({ mode: 'id', value: 'spreadsheetId' }) // documentId
|
||||
.mockReturnValueOnce({ mode: 'name', value: 'Sheet1' }) // sheetName
|
||||
.mockReturnValueOnce({ mode: 'name' }) // sheetName mode
|
||||
.mockReturnValueOnce({}) // options.locationDefine.values
|
||||
.mockReturnValueOnce('update'); // operation
|
||||
|
||||
mockGoogleSheetInstance.spreadsheetGetSheet.mockResolvedValueOnce({
|
||||
title: 'Sheet1',
|
||||
sheetId: 1,
|
||||
});
|
||||
mockGoogleSheetInstance.getData.mockResolvedValueOnce([['id', 'name', 'email']]);
|
||||
mockGoogleSheetInstance.testFilter.mockReturnValueOnce(['id', 'name', 'email']);
|
||||
|
||||
const result = await getMappingColumns.call(loadOptionsFunctions);
|
||||
|
||||
expect(result.fields).toHaveLength(4);
|
||||
expect(result.fields).toEqual([
|
||||
{
|
||||
canBeUsedToMatch: true,
|
||||
defaultMatch: true,
|
||||
display: true,
|
||||
displayName: 'id',
|
||||
id: 'id',
|
||||
required: false,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
canBeUsedToMatch: true,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
displayName: 'name',
|
||||
id: 'name',
|
||||
required: false,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
canBeUsedToMatch: true,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
displayName: 'email',
|
||||
id: 'email',
|
||||
required: false,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
canBeUsedToMatch: true,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
displayName: 'row_number',
|
||||
id: 'row_number',
|
||||
readOnly: true,
|
||||
removed: true,
|
||||
required: false,
|
||||
type: 'number',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should map columns and add row number for appendOrUpdate operation', async () => {
|
||||
loadOptionsFunctions.getNode.mockReturnValue({} as INode);
|
||||
loadOptionsFunctions.getNodeParameter
|
||||
.mockReturnValueOnce({ mode: 'id', value: 'spreadsheetId' }) // documentId
|
||||
.mockReturnValueOnce({ mode: 'name', value: 'Sheet1' }) // sheetName
|
||||
.mockReturnValueOnce({ mode: 'name' }) // sheetName mode
|
||||
.mockReturnValueOnce({ headerRow: 10 }) // options.locationDefine.values
|
||||
.mockReturnValueOnce('appendOrUpdate'); // operation
|
||||
|
||||
mockGoogleSheetInstance.spreadsheetGetSheet.mockResolvedValueOnce({
|
||||
title: 'Sheet1',
|
||||
sheetId: 1,
|
||||
});
|
||||
mockGoogleSheetInstance.getData.mockResolvedValueOnce([['id', 'name', 'email']]);
|
||||
mockGoogleSheetInstance.testFilter.mockReturnValueOnce(['id', 'name', 'email']);
|
||||
|
||||
const result = await getMappingColumns.call(loadOptionsFunctions);
|
||||
|
||||
expect(result.fields).toHaveLength(3);
|
||||
expect(mockGoogleSheetInstance.getData).toHaveBeenCalledWith('Sheet1!10:10', 'FORMATTED_VALUE');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { MockProxy } from 'jest-mock-extended';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
|
||||
import { execute } from '../../../v2/actions/sheet/append.operation';
|
||||
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
|
||||
|
||||
describe('Google Sheet - Append', () => {
|
||||
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
|
||||
let mockGoogleSheet: MockProxy<GoogleSheet>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
mockGoogleSheet = mock<GoogleSheet>();
|
||||
});
|
||||
|
||||
it('should insert input data if sheet is empty', async () => {
|
||||
const inputData = [
|
||||
{
|
||||
json: {
|
||||
row_number: 3,
|
||||
name: 'NEW NAME',
|
||||
text: 'NEW TEXT',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
input: undefined,
|
||||
},
|
||||
},
|
||||
];
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce(inputData);
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>({ typeVersion: 4.5 }));
|
||||
mockExecuteFunctions.getNodeParameter
|
||||
.mockReturnValueOnce('USER_ENTERED') // valueInputMode
|
||||
.mockReturnValueOnce({}); // options
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('defineBelow'); // dataMode
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce(undefined);
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce(undefined);
|
||||
mockGoogleSheet.updateRows.mockResolvedValueOnce(undefined);
|
||||
|
||||
mockGoogleSheet.updateRows.mockResolvedValueOnce([]);
|
||||
|
||||
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
|
||||
|
||||
expect(mockGoogleSheet.updateRows).toHaveBeenCalledWith('Sheet1', [['name', 'text']], 'RAW', 1);
|
||||
expect(mockGoogleSheet.appendEmptyRowsOrColumns).toHaveBeenCalledWith('1234', 1, 0);
|
||||
expect(mockGoogleSheet.appendSheetData).toHaveBeenCalledWith({
|
||||
inputData: [{ name: 'NEW NAME', text: 'NEW TEXT' }],
|
||||
keyRowIndex: 1,
|
||||
lastRow: 2,
|
||||
range: 'Sheet1',
|
||||
valueInputMode: 'USER_ENTERED',
|
||||
});
|
||||
expect(inputData[0].json).toEqual({ row_number: 3, name: 'NEW NAME', text: 'NEW TEXT' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,859 @@
|
||||
import type { MockProxy } from 'jest-mock-extended';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { execute } from '../../../v2/actions/sheet/appendOrUpdate.operation';
|
||||
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
|
||||
|
||||
describe('Google Sheet - Append or Update', () => {
|
||||
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
|
||||
let mockGoogleSheet: MockProxy<GoogleSheet>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
mockGoogleSheet = mock<GoogleSheet>();
|
||||
});
|
||||
|
||||
it('should insert input data if sheet is empty', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: {
|
||||
row_number: 3,
|
||||
name: 'NEW NAME',
|
||||
text: 'NEW TEXT',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
input: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>({ typeVersion: 4.5 }));
|
||||
mockExecuteFunctions.getNodeParameter
|
||||
.mockReturnValueOnce('USER_ENTERED') // valueInputMode
|
||||
.mockReturnValueOnce({}); // options
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('defineBelow'); // dataMode
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce([]); // columns.schema
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(['row_number']); // columnsToMatchOn
|
||||
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>());
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce([]); // columns.matchingColumns
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce(undefined);
|
||||
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.updateRows.mockResolvedValueOnce([]);
|
||||
|
||||
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
|
||||
updateData: [],
|
||||
appendData: [
|
||||
{
|
||||
row_number: 3,
|
||||
name: 'NEW NAME',
|
||||
text: 'NEW TEXT',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
|
||||
|
||||
expect(mockGoogleSheet.getColumnValues).toHaveBeenCalledWith({
|
||||
dataStartRowIndex: 1,
|
||||
keyIndex: -1,
|
||||
range: 'Sheet1!A:Z',
|
||||
sheetData: [['name', 'text']],
|
||||
valueRenderMode: 'UNFORMATTED_VALUE',
|
||||
});
|
||||
|
||||
expect(mockGoogleSheet.updateRows).toHaveBeenCalledWith(
|
||||
'Sheet1',
|
||||
[['name', 'text']],
|
||||
'USER_ENTERED',
|
||||
1,
|
||||
);
|
||||
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith({
|
||||
columnNamesList: [['name', 'text']],
|
||||
columnValuesList: [],
|
||||
dataStartRowIndex: 1,
|
||||
indexKey: 'row_number',
|
||||
inputData: [{ name: 'NEW NAME', row_number: 3, text: 'NEW TEXT' }],
|
||||
keyRowIndex: 0,
|
||||
range: 'Sheet1!A:Z',
|
||||
upsert: true,
|
||||
valueRenderMode: 'UNFORMATTED_VALUE',
|
||||
});
|
||||
expect(mockGoogleSheet.appendEmptyRowsOrColumns).toHaveBeenCalledWith('1234', 1, 0);
|
||||
expect(mockGoogleSheet.appendSheetData).toHaveBeenCalledWith({
|
||||
columnNamesList: [['name', 'text']],
|
||||
inputData: [{ name: 'NEW NAME', row_number: 3, text: 'NEW TEXT' }],
|
||||
keyRowIndex: 1,
|
||||
lastRow: 2,
|
||||
range: 'Sheet1!A:Z',
|
||||
valueInputMode: 'USER_ENTERED',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when no column names can be retrieved and sheet has data', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: { id: 1, name: 'Test' },
|
||||
pairedItem: { item: 0, input: undefined },
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
|
||||
mockExecuteFunctions.getNodeParameter
|
||||
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
|
||||
.mockReturnValueOnce({}) // options
|
||||
.mockReturnValueOnce('defineBelow') // dataMode
|
||||
.mockReturnValueOnce('id') // columnToMatchOn
|
||||
.mockReturnValueOnce('1') // valueToMatchOn
|
||||
.mockReturnValueOnce([{ column: 'name', fieldValue: 'Test Name' }]); // fieldsUi.values
|
||||
|
||||
// Mock sheet with data but no header row at keyRowIndex
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([
|
||||
undefined as any, // No header row at keyRowIndex 0
|
||||
['some', 'data', 'here'], // Has data but not at header position
|
||||
]);
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
|
||||
updateData: [],
|
||||
appendData: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234'),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should handle custom header row and first data row positions', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: { id: 1, name: 'Test' },
|
||||
pairedItem: { item: 0, input: undefined },
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
|
||||
mockExecuteFunctions.getNodeParameter
|
||||
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
|
||||
.mockReturnValueOnce({
|
||||
locationDefine: {
|
||||
values: {
|
||||
headerRow: '3',
|
||||
firstDataRow: '5',
|
||||
},
|
||||
},
|
||||
}) // options with custom positions
|
||||
.mockReturnValueOnce('defineBelow') // dataMode
|
||||
.mockReturnValueOnce('id') // columnToMatchOn
|
||||
.mockReturnValueOnce('1') // valueToMatchOn
|
||||
.mockReturnValueOnce([{ column: 'name', fieldValue: 'Test Name' }]); // fieldsUi.values
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([
|
||||
[],
|
||||
[],
|
||||
['id', 'name'], // Header at row 3 (index 2)
|
||||
[],
|
||||
['1', 'Old Name'], // Data starts at row 5 (index 4)
|
||||
]);
|
||||
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce(['1']);
|
||||
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
|
||||
updateData: [],
|
||||
appendData: [{ id: '1', name: 'Test Name' }],
|
||||
});
|
||||
|
||||
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
|
||||
|
||||
expect(mockGoogleSheet.getColumnValues).toHaveBeenCalledWith({
|
||||
dataStartRowIndex: 4, // firstDataRow - 1
|
||||
keyIndex: 0,
|
||||
range: 'Sheet1!A:Z',
|
||||
sheetData: expect.any(Array),
|
||||
valueRenderMode: 'UNFORMATTED_VALUE',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle data mode "nothing" by skipping processing', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{ json: { id: 1, name: 'Test' }, pairedItem: { item: 0, input: undefined } },
|
||||
{ json: { id: 2, name: 'Test2' }, pairedItem: { item: 1, input: undefined } },
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
|
||||
mockExecuteFunctions.getNodeParameter
|
||||
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
|
||||
.mockReturnValueOnce({}) // options
|
||||
.mockReturnValueOnce('nothing') // dataMode
|
||||
.mockReturnValueOnce('id'); // columnToMatchOn
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
|
||||
updateData: [],
|
||||
appendData: [],
|
||||
});
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
|
||||
|
||||
// Should return original items since dataMode is 'nothing'
|
||||
expect(result).toEqual([
|
||||
{ json: { id: 1, name: 'Test' }, pairedItem: { item: 0 } },
|
||||
{ json: { id: 2, name: 'Test2' }, pairedItem: { item: 1 } },
|
||||
]);
|
||||
|
||||
// Should not call update or append operations
|
||||
expect(mockGoogleSheet.batchUpdate).not.toHaveBeenCalled();
|
||||
expect(mockGoogleSheet.appendSheetData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle autoMapInputData with ignoreIt option', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: { id: 1, name: 'Test', extraField: 'should be ignored' },
|
||||
pairedItem: { item: 0, input: undefined },
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
|
||||
mockExecuteFunctions.getNodeParameter
|
||||
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
|
||||
.mockReturnValueOnce({ handlingExtraData: 'ignoreIt' }) // options
|
||||
.mockReturnValueOnce('autoMapInputData'); // dataMode
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
|
||||
updateData: [],
|
||||
appendData: [{ id: 1, name: 'Test', extraField: 'should be ignored' }],
|
||||
});
|
||||
|
||||
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
|
||||
|
||||
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
inputData: [{ id: 1, name: 'Test', extraField: 'should be ignored' }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle autoMapInputData with error option and throw on unexpected fields', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: { id: 1, name: 'Test', unexpectedField: 'error' },
|
||||
pairedItem: { item: 0, input: undefined },
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
|
||||
mockExecuteFunctions.getNodeParameter
|
||||
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
|
||||
.mockReturnValueOnce({ handlingExtraData: 'error' }) // options
|
||||
.mockReturnValueOnce('autoMapInputData'); // dataMode
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(
|
||||
execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234'),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should handle autoMapInputData with insertInNewColumn option', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: { id: 1, name: 'Test', newField: 'new column value' },
|
||||
pairedItem: { item: 0, input: undefined },
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
|
||||
mockExecuteFunctions.getNodeParameter
|
||||
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
|
||||
.mockReturnValueOnce({ handlingExtraData: 'insertInNewColumn' }) // options
|
||||
.mockReturnValueOnce('autoMapInputData'); // dataMode
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.updateRows.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
|
||||
updateData: [],
|
||||
appendData: [{ id: 1, name: 'Test', newField: 'new column value' }],
|
||||
});
|
||||
|
||||
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
|
||||
|
||||
// Should update header row with new column
|
||||
expect(mockGoogleSheet.updateRows).toHaveBeenCalledWith(
|
||||
'Sheet1',
|
||||
[['id', 'name', 'newField']],
|
||||
'RAW',
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when valueToMatchOn is empty in defineBelow mode (v3)', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: { id: 1, name: 'Test' },
|
||||
pairedItem: { item: 0, input: undefined },
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
|
||||
mockExecuteFunctions.getNodeParameter
|
||||
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
|
||||
.mockReturnValueOnce({}) // options
|
||||
.mockReturnValueOnce('defineBelow') // dataMode
|
||||
.mockReturnValueOnce('id') // columnToMatchOn
|
||||
.mockReturnValueOnce(''); // empty valueToMatchOn
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(
|
||||
execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234'),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should throw error when no values are provided in defineBelow mode (v3)', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: { id: 1, name: 'Test' },
|
||||
pairedItem: { item: 0, input: undefined },
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
|
||||
mockExecuteFunctions.getNodeParameter
|
||||
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
|
||||
.mockReturnValueOnce({}) // options
|
||||
.mockReturnValueOnce('defineBelow') // dataMode
|
||||
.mockReturnValueOnce('id') // columnToMatchOn
|
||||
.mockReturnValueOnce('1') // valueToMatchOn
|
||||
.mockReturnValueOnce([]); // empty fieldsUi.values
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(
|
||||
execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234'),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should handle newColumn type in defineBelow mode (v3)', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: { id: 1, name: 'Test' },
|
||||
pairedItem: { item: 0, input: undefined },
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 3 }));
|
||||
mockExecuteFunctions.getNodeParameter
|
||||
.mockReturnValueOnce('USER_ENTERED') // options.cellFormat
|
||||
.mockReturnValueOnce({}) // options
|
||||
.mockReturnValueOnce('defineBelow') // dataMode
|
||||
.mockReturnValueOnce('id') // columnToMatchOn
|
||||
.mockReturnValueOnce('1') // valueToMatchOn
|
||||
.mockReturnValueOnce([
|
||||
{ column: 'name', fieldValue: 'Updated Name' },
|
||||
{ column: 'newColumn', columnName: 'description', fieldValue: 'New Description' },
|
||||
]); // fieldsUi.values with new column
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.updateRows.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
|
||||
updateData: [],
|
||||
appendData: [{ id: '1', name: 'Updated Name', description: 'New Description' }],
|
||||
});
|
||||
|
||||
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
|
||||
|
||||
// Should update header with new column
|
||||
expect(mockGoogleSheet.updateRows).toHaveBeenCalledWith(
|
||||
'Sheet1',
|
||||
[['id', 'name', 'description']],
|
||||
'RAW',
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle both update and append data', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: { id: 1, name: 'Test' },
|
||||
pairedItem: { item: 0, input: undefined },
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 4.5 }));
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, _itemIndex, fallback) => {
|
||||
const params: Record<string, any> = {
|
||||
'options.cellFormat': 'USER_ENTERED',
|
||||
options: {},
|
||||
'columns.mappingMode': 'defineBelow',
|
||||
'columns.schema': [],
|
||||
'columns.matchingColumns': ['id'],
|
||||
'columns.value': { id: 1, name: 'Updated Name' },
|
||||
'columns.value[id]': 1,
|
||||
};
|
||||
return params[paramName] ?? fallback;
|
||||
});
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce(['1']);
|
||||
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
|
||||
updateData: [{ range: 'A2:B2', values: [['1', 'Updated Name']] }],
|
||||
appendData: [{ id: 2, name: 'New Item' }],
|
||||
});
|
||||
|
||||
mockGoogleSheet.batchUpdate.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
|
||||
|
||||
expect(mockGoogleSheet.batchUpdate).toHaveBeenCalledWith(
|
||||
[{ range: 'A2:B2', values: [['1', 'Updated Name']] }],
|
||||
'USER_ENTERED',
|
||||
);
|
||||
expect(mockGoogleSheet.appendSheetData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use useAppend option when appending data', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: { id: 1, name: 'Test' },
|
||||
pairedItem: { item: 0, input: undefined },
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 4.5 }));
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, _itemIndex, fallback) => {
|
||||
const params: Record<string, any> = {
|
||||
'options.cellFormat': 'USER_ENTERED',
|
||||
options: { useAppend: true },
|
||||
'columns.mappingMode': 'defineBelow',
|
||||
'columns.schema': [],
|
||||
'columns.matchingColumns': ['id'],
|
||||
'columns.value': { id: 1, name: 'Test Name' },
|
||||
'columns.value[id]': 1,
|
||||
};
|
||||
return params[paramName] ?? fallback;
|
||||
});
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
|
||||
updateData: [],
|
||||
appendData: [{ id: 1, name: 'Test Name' }],
|
||||
});
|
||||
|
||||
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
|
||||
|
||||
expect(mockGoogleSheet.appendSheetData).toHaveBeenCalledWith({
|
||||
columnNamesList: [['id', 'name']],
|
||||
inputData: [{ id: 1, name: 'Test Name' }],
|
||||
keyRowIndex: 1,
|
||||
lastRow: 2,
|
||||
range: 'Sheet1!A:Z',
|
||||
useAppend: true,
|
||||
valueInputMode: 'USER_ENTERED',
|
||||
});
|
||||
|
||||
// Should NOT call appendEmptyRowsOrColumns when useAppend is true
|
||||
expect(mockGoogleSheet.appendEmptyRowsOrColumns).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle v4 with empty columns.value and throw error', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: { id: 1, name: 'Test' },
|
||||
pairedItem: { item: 0, input: undefined },
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 4.5 }));
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, _itemIndex, fallback) => {
|
||||
const params: Record<string, any> = {
|
||||
'options.cellFormat': 'USER_ENTERED',
|
||||
options: {},
|
||||
'columns.mappingMode': 'defineBelow',
|
||||
'columns.schema': [],
|
||||
'columns.matchingColumns': ['id'],
|
||||
'columns.value': {},
|
||||
};
|
||||
return params[paramName] ?? fallback;
|
||||
});
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(
|
||||
execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234'),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should handle v4 with null/undefined values by converting to empty string', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: { id: 1, name: 'Test' },
|
||||
pairedItem: { item: 0, input: undefined },
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 4.5 }));
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, _itemIndex, fallback) => {
|
||||
const params: Record<string, any> = {
|
||||
'options.cellFormat': 'USER_ENTERED',
|
||||
options: {},
|
||||
'columns.mappingMode': 'defineBelow',
|
||||
'columns.schema': [],
|
||||
'columns.matchingColumns': ['id'],
|
||||
'columns.value': { id: 1, name: null, description: undefined },
|
||||
'columns.value[id]': 1,
|
||||
};
|
||||
return params[paramName] ?? fallback;
|
||||
});
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name', 'description']]);
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
|
||||
updateData: [],
|
||||
appendData: [{ id: 1, name: '', description: '' }],
|
||||
});
|
||||
|
||||
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
|
||||
|
||||
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
inputData: [{ id: 1, name: '', description: '' }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return mapped values for v4+ in defineBelow mode', async () => {
|
||||
const mappedValues = [
|
||||
{ id: 1, name: 'Test Name' },
|
||||
{ id: 2, name: 'Another Test' },
|
||||
];
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{ json: { id: 1 }, pairedItem: { item: 0, input: undefined } },
|
||||
{ json: { id: 2 }, pairedItem: { item: 1, input: undefined } },
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 4.5 }));
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, itemIndex, fallback) => {
|
||||
const params: Record<string, any> = {
|
||||
'options.cellFormat': 'USER_ENTERED',
|
||||
options: {},
|
||||
'columns.mappingMode': 'defineBelow',
|
||||
'columns.schema': [],
|
||||
'columns.matchingColumns': ['id'],
|
||||
'columns.value[id]': itemIndex === 0 ? 1 : 2,
|
||||
};
|
||||
|
||||
// Handle per-item columns.value calls
|
||||
if (paramName === 'columns.value' && typeof itemIndex === 'number') {
|
||||
return mappedValues[itemIndex];
|
||||
}
|
||||
|
||||
return params[paramName] ?? fallback;
|
||||
});
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
|
||||
|
||||
// Mock prepareDataForUpdateOrUpsert to be called for each item
|
||||
mockGoogleSheet.prepareDataForUpdateOrUpsert
|
||||
.mockResolvedValueOnce({
|
||||
updateData: [],
|
||||
appendData: [mappedValues[0]],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
updateData: [],
|
||||
appendData: [mappedValues[1]],
|
||||
});
|
||||
|
||||
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
|
||||
|
||||
expect(result).toEqual([
|
||||
{ json: mappedValues[0], pairedItem: { item: 0 } },
|
||||
{ json: mappedValues[1], pairedItem: { item: 1 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle custom valueRenderMode option', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: { id: 1, name: 'Test' },
|
||||
pairedItem: { item: 0, input: undefined },
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 4.5 }));
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, _itemIndex, fallback) => {
|
||||
const params: Record<string, any> = {
|
||||
'options.cellFormat': 'USER_ENTERED',
|
||||
options: { valueRenderMode: 'FORMATTED_VALUE' },
|
||||
'columns.mappingMode': 'defineBelow',
|
||||
'columns.schema': [],
|
||||
'columns.matchingColumns': ['id'],
|
||||
'columns.value': { id: 1, name: 'Test Name' },
|
||||
'columns.value[id]': 1,
|
||||
};
|
||||
return params[paramName] ?? fallback;
|
||||
});
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
|
||||
updateData: [],
|
||||
appendData: [{ id: 1, name: 'Test Name' }],
|
||||
});
|
||||
|
||||
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
|
||||
|
||||
expect(mockGoogleSheet.getColumnValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
valueRenderMode: 'FORMATTED_VALUE',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
valueRenderMode: 'FORMATTED_VALUE',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call checkForSchemaChanges for node version >= 4.4', async () => {
|
||||
const mockSchema = [
|
||||
{ id: 'id', displayName: 'ID' },
|
||||
{ id: 'name', displayName: 'Name' },
|
||||
];
|
||||
const mockNode = mock<INode>({ typeVersion: 4.5 });
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: { id: 1, name: 'Test' },
|
||||
pairedItem: { item: 0, input: undefined },
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName, _itemIndex, fallback) => {
|
||||
const params: Record<string, any> = {
|
||||
'options.cellFormat': 'USER_ENTERED',
|
||||
options: {},
|
||||
'columns.mappingMode': 'defineBelow',
|
||||
'columns.schema': mockSchema,
|
||||
'columns.matchingColumns': ['id'],
|
||||
'columns.value': { id: 1, name: 'Test Name' },
|
||||
'columns.value[id]': 1,
|
||||
};
|
||||
return params[paramName] ?? fallback;
|
||||
});
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([['id', 'name']]);
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
|
||||
updateData: [],
|
||||
appendData: [{ id: 1, name: 'Test Name' }],
|
||||
});
|
||||
|
||||
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
|
||||
|
||||
// Mock the checkForSchemaChanges import to verify it's called
|
||||
const GoogleSheetsUtils = await import('../../../v2/helpers/GoogleSheets.utils');
|
||||
jest.spyOn(GoogleSheetsUtils, 'checkForSchemaChanges').mockImplementation(() => {});
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
|
||||
|
||||
expect(GoogleSheetsUtils.checkForSchemaChanges).toHaveBeenCalledWith(
|
||||
mockNode, // node
|
||||
['id', 'name'], // columnNames
|
||||
mockSchema, // schema
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Google Sheet - Append or Update v4.6 vs v4.7 Behavior', () => {
|
||||
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
|
||||
let mockGoogleSheet: MockProxy<GoogleSheet>;
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('v4.6: empty string in UI gets filtered out, field not sent to backend', async () => {
|
||||
mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
mockGoogleSheet = mock<GoogleSheet>();
|
||||
|
||||
mockExecuteFunctions.getNode
|
||||
.mockReturnValueOnce(mock<INode>({ typeVersion: 4.6 }))
|
||||
.mockReturnValueOnce(mock<INode>({ typeVersion: 4.6 }));
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: {},
|
||||
pairedItem: { item: 0, input: undefined },
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: Record<string, any> = {
|
||||
'options.cellFormat': 'USER_ENTERED',
|
||||
options: {},
|
||||
'columns.mappingMode': 'defineBelow',
|
||||
'columns.schema': [],
|
||||
'columns.matchingColumns': ['id'],
|
||||
'columns.value': {
|
||||
id: 1,
|
||||
name: 'John',
|
||||
// email field is NOT present here because user typed '' in UI
|
||||
// and v4.6 frontend filtered it out (allowEmptyValues: false)
|
||||
},
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([
|
||||
['id', 'name', 'email'],
|
||||
['1', 'Old Name', 'old@email.com'],
|
||||
]);
|
||||
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce(['1']);
|
||||
mockGoogleSheet.updateRows.mockResolvedValueOnce([]);
|
||||
|
||||
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
|
||||
updateData: [],
|
||||
appendData: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'John',
|
||||
// email is not included, so it keeps old value
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
|
||||
|
||||
// v4.6: Only fields with non-empty values are sent to prepareDataForUpdateOrUpsert
|
||||
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
inputData: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'John',
|
||||
// email is NOT in the inputData, so cell keeps old value
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('v4.7: empty string in UI is preserved and sent to backend to clear cell', async () => {
|
||||
mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
mockGoogleSheet = mock<GoogleSheet>();
|
||||
|
||||
mockExecuteFunctions.getNode
|
||||
.mockReturnValueOnce(mock<INode>({ typeVersion: 4.7 }))
|
||||
.mockReturnValueOnce(mock<INode>({ typeVersion: 4.7 }));
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: {},
|
||||
pairedItem: { item: 0, input: undefined },
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: Record<string, any> = {
|
||||
'options.cellFormat': 'USER_ENTERED',
|
||||
options: {},
|
||||
'columns.mappingMode': 'defineBelow',
|
||||
'columns.schema': [],
|
||||
'columns.matchingColumns': ['id'],
|
||||
'columns.value': {
|
||||
id: 1,
|
||||
name: 'John',
|
||||
email: '', // Empty string is preserved in v4.7 (allowEmptyValues: true)
|
||||
},
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([
|
||||
['id', 'name', 'email'],
|
||||
['1', 'Old Name', 'old@email.com'],
|
||||
]);
|
||||
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce(['1']);
|
||||
mockGoogleSheet.updateRows.mockResolvedValueOnce([]);
|
||||
|
||||
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
|
||||
updateData: [],
|
||||
appendData: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'John',
|
||||
email: '', // Empty string will clear the cell
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]);
|
||||
mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]);
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234');
|
||||
|
||||
// v4.7: Empty strings are preserved and sent to prepareDataForUpdateOrUpsert
|
||||
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
inputData: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'John',
|
||||
email: '', // Empty string is preserved and will clear the cell
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { execute } from '../../../v2/actions/sheet/clear.operation';
|
||||
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
|
||||
|
||||
describe('Google Sheet - Clear', () => {
|
||||
let mockExecuteFunctions: Partial<IExecuteFunctions>;
|
||||
let mockSheet: Partial<GoogleSheet>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions = {
|
||||
getInputData: jest.fn().mockReturnValue([{ json: {} }]),
|
||||
getNodeParameter: jest.fn(),
|
||||
} as Partial<IExecuteFunctions>;
|
||||
|
||||
mockSheet = {
|
||||
clearData: jest.fn(),
|
||||
getData: jest.fn().mockResolvedValue([['Header1', 'Header2']]), // Mock first-row data
|
||||
updateRows: jest.fn(),
|
||||
} as Partial<GoogleSheet>;
|
||||
});
|
||||
|
||||
test('should clear the whole sheet', async () => {
|
||||
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
|
||||
if (param === 'clear') return 'wholeSheet';
|
||||
return false;
|
||||
}) as unknown as IExecuteFunctions['getNodeParameter'];
|
||||
|
||||
await execute.call(
|
||||
mockExecuteFunctions as IExecuteFunctions,
|
||||
mockSheet as GoogleSheet,
|
||||
'Sheet1',
|
||||
);
|
||||
|
||||
expect(mockSheet.clearData).toHaveBeenCalledWith('Sheet1');
|
||||
});
|
||||
|
||||
test('should clear specific rows', async () => {
|
||||
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
|
||||
if (param === 'clear') return 'specificRows';
|
||||
if (param === 'startIndex') return 2;
|
||||
if (param === 'rowsToDelete') return 3;
|
||||
return false;
|
||||
}) as unknown as IExecuteFunctions['getNodeParameter'];
|
||||
|
||||
await execute.call(
|
||||
mockExecuteFunctions as IExecuteFunctions,
|
||||
mockSheet as GoogleSheet,
|
||||
'Sheet1',
|
||||
);
|
||||
|
||||
expect(mockSheet.clearData).toHaveBeenCalledWith('Sheet1!2:4');
|
||||
});
|
||||
|
||||
test('should clear specific columns', async () => {
|
||||
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
|
||||
if (param === 'clear') return 'specificColumns';
|
||||
if (param === 'startIndex') return 'B';
|
||||
if (param === 'columnsToDelete') return 2;
|
||||
return false;
|
||||
}) as unknown as IExecuteFunctions['getNodeParameter'];
|
||||
|
||||
await execute.call(
|
||||
mockExecuteFunctions as IExecuteFunctions,
|
||||
mockSheet as GoogleSheet,
|
||||
'Sheet1',
|
||||
);
|
||||
|
||||
expect(mockSheet.clearData).toHaveBeenCalledWith('Sheet1!B:C');
|
||||
});
|
||||
|
||||
test('should clear a specific range', async () => {
|
||||
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
|
||||
if (param === 'clear') return 'specificRange';
|
||||
if (param === 'range') return 'A1:C5';
|
||||
return false;
|
||||
}) as unknown as IExecuteFunctions['getNodeParameter'];
|
||||
|
||||
await execute.call(
|
||||
mockExecuteFunctions as IExecuteFunctions,
|
||||
mockSheet as GoogleSheet,
|
||||
'Sheet1',
|
||||
);
|
||||
|
||||
expect(mockSheet.clearData).toHaveBeenCalledWith('Sheet1!A1:C5');
|
||||
});
|
||||
|
||||
test('should keep the first row when clearing the whole sheet', async () => {
|
||||
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
|
||||
if (param === 'clear') return 'wholeSheet';
|
||||
if (param === 'keepFirstRow') return true;
|
||||
return false;
|
||||
}) as unknown as IExecuteFunctions['getNodeParameter'];
|
||||
|
||||
await execute.call(
|
||||
mockExecuteFunctions as IExecuteFunctions,
|
||||
mockSheet as GoogleSheet,
|
||||
'Sheet1',
|
||||
);
|
||||
|
||||
expect(mockSheet.getData).toHaveBeenCalledWith('Sheet1!1:1', 'FORMATTED_VALUE');
|
||||
expect(mockSheet.clearData).toHaveBeenCalledWith('Sheet1');
|
||||
expect(mockSheet.updateRows).toHaveBeenCalledWith('Sheet1', [['Header1', 'Header2']], 'RAW', 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { execute } from '../../../v2/actions/sheet/create.operation';
|
||||
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
|
||||
import { getExistingSheetNames, hexToRgb } from '../../../v2/helpers/GoogleSheets.utils';
|
||||
import { apiRequest } from '../../../v2/transport';
|
||||
|
||||
jest.mock('../../../v2/helpers/GoogleSheets.utils', () => ({
|
||||
getExistingSheetNames: jest.fn(),
|
||||
hexToRgb: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../v2/transport', () => ({
|
||||
apiRequest: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Google Sheet - Create', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
const mockExecuteFunctions = {
|
||||
getInputData: jest.fn(),
|
||||
getNodeParameter: jest.fn(),
|
||||
helpers: {
|
||||
constructExecutionMetaData: jest.fn(),
|
||||
},
|
||||
} as unknown as Partial<IExecuteFunctions>;
|
||||
|
||||
const sheet = {} as Partial<GoogleSheet>;
|
||||
const sheetName = 'test-sheet';
|
||||
|
||||
test('should create a new sheet with given title and options', async () => {
|
||||
const items = [{ json: {} }];
|
||||
const existingSheetNames = ['existing-sheet'];
|
||||
const sheetTitle = 'new-sheet';
|
||||
const options = { tabColor: '0aa55c' };
|
||||
const rgbColor = { red: 10, green: 165, blue: 92 };
|
||||
const responseData = {
|
||||
replies: [{ addSheet: { properties: { title: sheetTitle } } }],
|
||||
};
|
||||
|
||||
(mockExecuteFunctions.getInputData as jest.Mock).mockReturnValue(items);
|
||||
(mockExecuteFunctions.getNodeParameter as jest.Mock).mockImplementation((paramName: string) => {
|
||||
if (paramName === 'title') return sheetTitle;
|
||||
if (paramName === 'options') return options;
|
||||
});
|
||||
(getExistingSheetNames as jest.Mock).mockResolvedValue(existingSheetNames);
|
||||
(hexToRgb as jest.Mock).mockReturnValue(rgbColor);
|
||||
(apiRequest as jest.Mock).mockResolvedValue(responseData);
|
||||
(mockExecuteFunctions as IExecuteFunctions).helpers.constructExecutionMetaData = jest
|
||||
.fn()
|
||||
.mockReturnValue([{ json: responseData }]);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions as IExecuteFunctions,
|
||||
sheet as GoogleSheet,
|
||||
sheetName,
|
||||
);
|
||||
|
||||
expect(result).toEqual([{ json: responseData }]);
|
||||
expect(getExistingSheetNames).toHaveBeenCalledWith(sheet);
|
||||
expect(apiRequest).toHaveBeenCalledWith('POST', `/v4/spreadsheets/${sheetName}:batchUpdate`, {
|
||||
requests: [
|
||||
{
|
||||
addSheet: {
|
||||
properties: {
|
||||
title: sheetTitle,
|
||||
tabColor: { red: 10 / 255, green: 165 / 255, blue: 92 / 255 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('should skip creating a sheet if the title already exists', async () => {
|
||||
const items = [{ json: {} }];
|
||||
const existingSheetNames = ['existing-sheet'];
|
||||
const sheetTitle = 'existing-sheet';
|
||||
|
||||
(mockExecuteFunctions as IExecuteFunctions).getInputData = jest.fn().mockReturnValue(items);
|
||||
(mockExecuteFunctions as IExecuteFunctions).getNodeParameter = jest
|
||||
.fn()
|
||||
.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'title') return sheetTitle;
|
||||
if (paramName === 'options') return {};
|
||||
});
|
||||
(getExistingSheetNames as jest.Mock).mockResolvedValue(existingSheetNames);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions as IExecuteFunctions,
|
||||
sheet as GoogleSheet,
|
||||
sheetName,
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(getExistingSheetNames).toHaveBeenCalledWith(sheet);
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { execute } from '../../../v2/actions/sheet/delete.operation';
|
||||
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
|
||||
|
||||
describe('Google Sheet - Delete', () => {
|
||||
let mockExecuteFunctions: Partial<IExecuteFunctions>;
|
||||
let mockSheet: Partial<GoogleSheet>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions = {
|
||||
getInputData: jest.fn().mockReturnValue([{ json: {} }]),
|
||||
getNodeParameter: jest.fn(),
|
||||
helpers: {
|
||||
constructExecutionMetaData: jest.fn((data) => ({ json: data })),
|
||||
},
|
||||
} as unknown as Partial<IExecuteFunctions>;
|
||||
|
||||
mockSheet = {
|
||||
spreadsheetBatchUpdate: jest.fn(),
|
||||
} as Partial<GoogleSheet>;
|
||||
});
|
||||
|
||||
test('should delete a single row', async () => {
|
||||
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
|
||||
if (param === 'toDelete') return 'rows';
|
||||
if (param === 'startIndex') return 2;
|
||||
if (param === 'numberToDelete') return 1;
|
||||
return null;
|
||||
}) as unknown as IExecuteFunctions['getNodeParameter'];
|
||||
|
||||
await execute.call(
|
||||
mockExecuteFunctions as IExecuteFunctions,
|
||||
mockSheet as GoogleSheet,
|
||||
'Sheet1',
|
||||
);
|
||||
|
||||
expect(mockSheet.spreadsheetBatchUpdate).toHaveBeenCalledWith([
|
||||
{
|
||||
deleteDimension: {
|
||||
range: {
|
||||
sheetId: 'Sheet1',
|
||||
dimension: 'ROWS',
|
||||
startIndex: 1, // Adjusted for zero-based index
|
||||
endIndex: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should delete multiple rows', async () => {
|
||||
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
|
||||
if (param === 'toDelete') return 'rows';
|
||||
if (param === 'startIndex') return 3;
|
||||
if (param === 'numberToDelete') return 2;
|
||||
return null;
|
||||
}) as unknown as IExecuteFunctions['getNodeParameter'];
|
||||
|
||||
await execute.call(
|
||||
mockExecuteFunctions as IExecuteFunctions,
|
||||
mockSheet as GoogleSheet,
|
||||
'Sheet1',
|
||||
);
|
||||
|
||||
expect(mockSheet.spreadsheetBatchUpdate).toHaveBeenCalledWith([
|
||||
{
|
||||
deleteDimension: {
|
||||
range: {
|
||||
sheetId: 'Sheet1',
|
||||
dimension: 'ROWS',
|
||||
startIndex: 2,
|
||||
endIndex: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should delete a single column', async () => {
|
||||
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
|
||||
if (param === 'toDelete') return 'columns';
|
||||
if (param === 'startIndex') return 'B';
|
||||
if (param === 'numberToDelete') return 1;
|
||||
return null;
|
||||
}) as unknown as IExecuteFunctions['getNodeParameter'];
|
||||
|
||||
await execute.call(
|
||||
mockExecuteFunctions as IExecuteFunctions,
|
||||
mockSheet as GoogleSheet,
|
||||
'Sheet1',
|
||||
);
|
||||
|
||||
expect(mockSheet.spreadsheetBatchUpdate).toHaveBeenCalledWith([
|
||||
{
|
||||
deleteDimension: {
|
||||
range: {
|
||||
sheetId: 'Sheet1',
|
||||
dimension: 'COLUMNS',
|
||||
startIndex: 1, // 'B' corresponds to index 1 (zero-based)
|
||||
endIndex: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should delete multiple columns', async () => {
|
||||
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
|
||||
if (param === 'toDelete') return 'columns';
|
||||
if (param === 'startIndex') return 'C';
|
||||
if (param === 'numberToDelete') return 3;
|
||||
return null;
|
||||
}) as unknown as IExecuteFunctions['getNodeParameter'];
|
||||
|
||||
await execute.call(
|
||||
mockExecuteFunctions as IExecuteFunctions,
|
||||
mockSheet as GoogleSheet,
|
||||
'Sheet1',
|
||||
);
|
||||
|
||||
expect(mockSheet.spreadsheetBatchUpdate).toHaveBeenCalledWith([
|
||||
{
|
||||
deleteDimension: {
|
||||
range: {
|
||||
sheetId: 'Sheet1',
|
||||
dimension: 'COLUMNS',
|
||||
startIndex: 2, // 'C' corresponds to index 2 (zero-based)
|
||||
endIndex: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should return wrapped success response', async () => {
|
||||
mockExecuteFunctions.getNodeParameter = jest.fn((param: string) => {
|
||||
if (param === 'toDelete') return 'rows';
|
||||
if (param === 'startIndex') return 2;
|
||||
if (param === 'numberToDelete') return 1;
|
||||
return null;
|
||||
}) as unknown as IExecuteFunctions['getNodeParameter'];
|
||||
mockExecuteFunctions.helpers = {
|
||||
constructExecutionMetaData: jest.fn((data) => data),
|
||||
} as unknown as IExecuteFunctions['helpers'];
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions as IExecuteFunctions,
|
||||
mockSheet as GoogleSheet,
|
||||
'Sheet1',
|
||||
);
|
||||
expect(result).toEqual([{ json: { success: true } }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { execute } from '../../../v2/actions/sheet/read.operation';
|
||||
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
|
||||
|
||||
describe('Google Sheet - Read', () => {
|
||||
let mockExecuteFunctions: Partial<IExecuteFunctions>;
|
||||
let mockSheet: Partial<GoogleSheet>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions = {
|
||||
getInputData: jest.fn().mockReturnValue([{ json: {} }]),
|
||||
getNode: jest.fn().mockReturnValue({ typeVersion: 4.5 }),
|
||||
getNodeParameter: jest.fn((param) => {
|
||||
const mockParams: { [key: string]: unknown } = {
|
||||
options: {},
|
||||
'filtersUI.values': [],
|
||||
combineFilters: 'AND',
|
||||
};
|
||||
return mockParams[param];
|
||||
}),
|
||||
} as Partial<IExecuteFunctions>;
|
||||
|
||||
mockSheet = {
|
||||
getData: jest.fn().mockResolvedValue([
|
||||
['Header1', 'Header2'],
|
||||
['Value1', 'Value2'],
|
||||
]),
|
||||
lookupValues: jest.fn().mockResolvedValue([{ Header1: 'Value1', Header2: 'Value2' }]),
|
||||
structureArrayDataByColumn: jest
|
||||
.fn()
|
||||
.mockReturnValue([{ Header1: 'Value1', Header2: 'Value2' }]),
|
||||
};
|
||||
});
|
||||
|
||||
test('should return structured sheet data when no filters are applied', async () => {
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions as IExecuteFunctions,
|
||||
mockSheet as GoogleSheet,
|
||||
'Sheet1',
|
||||
);
|
||||
expect(mockSheet.getData).toHaveBeenCalled();
|
||||
expect(mockSheet.structureArrayDataByColumn).toHaveBeenCalled();
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: { Header1: 'Value1', Header2: 'Value2' },
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should call lookupValues when filters are provided', async () => {
|
||||
mockExecuteFunctions.getNodeParameter = jest.fn((param) => {
|
||||
if (param === 'filtersUI.values') return [{ lookupColumn: 'Header1', lookupValue: 'Value1' }];
|
||||
return '';
|
||||
}) as unknown as IExecuteFunctions['getNodeParameter'];
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions as IExecuteFunctions,
|
||||
mockSheet as GoogleSheet,
|
||||
'Sheet1',
|
||||
);
|
||||
expect(mockSheet.lookupValues).toHaveBeenCalled();
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: { Header1: 'Value1', Header2: 'Value2' },
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should return an empty array when sheet data is empty', async () => {
|
||||
mockSheet.getData = jest.fn().mockResolvedValue([]);
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions as IExecuteFunctions,
|
||||
mockSheet as GoogleSheet,
|
||||
'Sheet1',
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { execute } from '../../../v2/actions/sheet/remove.operation';
|
||||
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
|
||||
import { apiRequest } from '../../../v2/transport';
|
||||
|
||||
jest.mock('../../../v2/transport', () => ({
|
||||
apiRequest: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Google Sheet - Remove', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
const mockExecuteFunctions = {
|
||||
getInputData: jest.fn(),
|
||||
getNodeParameter: jest.fn(),
|
||||
helpers: {
|
||||
constructExecutionMetaData: jest.fn(),
|
||||
},
|
||||
} as unknown as Partial<IExecuteFunctions>;
|
||||
|
||||
const sheet = {} as Partial<GoogleSheet>;
|
||||
const sheetName = 'spreadsheet123||sheet456';
|
||||
|
||||
test('should process a single item', async () => {
|
||||
const items = [{ json: {} }];
|
||||
((mockExecuteFunctions as IExecuteFunctions).getInputData as jest.Mock).mockReturnValue(items);
|
||||
|
||||
const apiResponse = { replies: [{ some: 'data' }], foo: 'bar' };
|
||||
(apiRequest as jest.Mock).mockResolvedValue(apiResponse);
|
||||
|
||||
const constructedData = [{ json: { foo: 'bar', index: 0 } }];
|
||||
(
|
||||
(mockExecuteFunctions as IExecuteFunctions).helpers.constructExecutionMetaData as jest.Mock
|
||||
).mockReturnValue(constructedData);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions as IExecuteFunctions,
|
||||
sheet as GoogleSheet,
|
||||
sheetName,
|
||||
);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(apiRequest).toHaveBeenCalledWith('POST', '/v4/spreadsheets/spreadsheet123:batchUpdate', {
|
||||
requests: [
|
||||
{
|
||||
deleteSheet: {
|
||||
sheetId: 'sheet456',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual(constructedData);
|
||||
});
|
||||
|
||||
test('should process multiple items', async () => {
|
||||
const items = [{ json: {} }, { json: {} }];
|
||||
((mockExecuteFunctions as IExecuteFunctions).getInputData as jest.Mock).mockReturnValue(items);
|
||||
|
||||
const apiResponses = [
|
||||
{ replies: [{ some: 'data1' }], foo: 'bar1' },
|
||||
{ replies: [{ some: 'data2' }], foo: 'bar2' },
|
||||
];
|
||||
(apiRequest as jest.Mock)
|
||||
.mockResolvedValueOnce(apiResponses[0])
|
||||
.mockResolvedValueOnce(apiResponses[1]);
|
||||
|
||||
const constructedDataItem0 = [{ json: { foo: 'bar1', index: 0 } }];
|
||||
const constructedDataItem1 = [{ json: { foo: 'bar2', index: 1 } }];
|
||||
((mockExecuteFunctions as IExecuteFunctions).helpers.constructExecutionMetaData as jest.Mock)
|
||||
.mockReturnValueOnce(constructedDataItem0)
|
||||
.mockReturnValueOnce(constructedDataItem1);
|
||||
|
||||
const result = await execute.call(
|
||||
mockExecuteFunctions as IExecuteFunctions,
|
||||
sheet as GoogleSheet,
|
||||
sheetName,
|
||||
);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(apiRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'POST',
|
||||
'/v4/spreadsheets/spreadsheet123:batchUpdate',
|
||||
{
|
||||
requests: [
|
||||
{
|
||||
deleteSheet: {
|
||||
sheetId: 'sheet456',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(apiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'POST',
|
||||
'/v4/spreadsheets/spreadsheet123:batchUpdate',
|
||||
{
|
||||
requests: [
|
||||
{
|
||||
deleteSheet: {
|
||||
sheetId: 'sheet456',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(result).toEqual([...constructedDataItem0, ...constructedDataItem1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,468 @@
|
||||
import type { MockProxy } from 'jest-mock-extended';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
|
||||
import { execute } from '../../../v2/actions/sheet/update.operation';
|
||||
import type { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
|
||||
|
||||
describe('Google Sheet - Update', () => {
|
||||
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
|
||||
let mockGoogleSheet: MockProxy<GoogleSheet>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
mockGoogleSheet = mock<GoogleSheet>();
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>({ typeVersion: 4.5 }));
|
||||
|
||||
mockGoogleSheet.batchUpdate.mockResolvedValueOnce([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should update by row_number and not insert it as a new column', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: {
|
||||
row_number: 3,
|
||||
name: 'NEW NAME',
|
||||
text: 'NEW TEXT',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
input: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNodeParameter
|
||||
.mockReturnValueOnce('USER_ENTERED') // valueInputMode
|
||||
.mockReturnValueOnce({}); // options
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(['row_number']); // columnsToMatchOn
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('autoMapInputData'); // dataMode
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([
|
||||
['id', 'name', 'text'],
|
||||
['1', 'a', 'a'],
|
||||
['2', 'x', 'x'],
|
||||
['3', 'b', 'b'],
|
||||
]);
|
||||
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
|
||||
|
||||
mockGoogleSheet.prepareDataForUpdatingByRowNumber.mockReturnValueOnce({
|
||||
updateData: [
|
||||
{
|
||||
range: 'Sheet1!B3',
|
||||
values: [['NEW NAME']],
|
||||
},
|
||||
{
|
||||
range: 'Sheet1!C3',
|
||||
values: [['NEW TEXT']],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1');
|
||||
|
||||
expect(mockGoogleSheet.getData).toHaveBeenCalledWith('Sheet1', 'FORMATTED_VALUE');
|
||||
expect(mockGoogleSheet.getColumnValues).toHaveBeenCalledWith({
|
||||
range: 'Sheet1!A:Z',
|
||||
keyIndex: -1,
|
||||
dataStartRowIndex: 1,
|
||||
valueRenderMode: 'UNFORMATTED_VALUE',
|
||||
sheetData: [
|
||||
['id', 'name', 'text'],
|
||||
['1', 'a', 'a'],
|
||||
['2', 'x', 'x'],
|
||||
['3', 'b', 'b'],
|
||||
],
|
||||
});
|
||||
expect(mockGoogleSheet.prepareDataForUpdatingByRowNumber).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
row_number: 3,
|
||||
name: 'NEW NAME',
|
||||
text: 'NEW TEXT',
|
||||
},
|
||||
],
|
||||
'Sheet1!A:Z',
|
||||
[['id', 'name', 'text']],
|
||||
);
|
||||
|
||||
expect(mockGoogleSheet.batchUpdate).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
range: 'Sheet1!B3',
|
||||
values: [['NEW NAME']],
|
||||
},
|
||||
{
|
||||
range: 'Sheet1!C3',
|
||||
values: [['NEW TEXT']],
|
||||
},
|
||||
],
|
||||
'USER_ENTERED',
|
||||
);
|
||||
});
|
||||
|
||||
it('should update rows by column values with special character', async () => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: {
|
||||
row_number: 3,
|
||||
name: '** δ$% " []',
|
||||
text: 'δ$% " []',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
input: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: string | object } = {
|
||||
options: {},
|
||||
'options.cellFormat': 'USER_ENTERED',
|
||||
'columns.matchingColumns': ['row_number'],
|
||||
'columns.value': {
|
||||
'ha []': 'kayyyy$',
|
||||
macarena: 'baile',
|
||||
'Real.1': 't&c',
|
||||
'21 "': 'Σ',
|
||||
},
|
||||
dataMode: 'autoMapInputData',
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([
|
||||
['Real.1', '21 "', 'dfd', 'ha []', 'macarena'],
|
||||
['aye.2', '"book"', 'ee', 'dd', 'dance'],
|
||||
['t&c', 'Σ', 'baz', 'kayyyy$', 'baile'],
|
||||
['fudge.2', '9080', 'live', 'dog', 'brazil'],
|
||||
]);
|
||||
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
|
||||
|
||||
mockGoogleSheet.prepareDataForUpdatingByRowNumber.mockReturnValueOnce({
|
||||
updateData: [
|
||||
{
|
||||
range: 'Sheet1!B3',
|
||||
values: [['** δ$% " []']],
|
||||
},
|
||||
{
|
||||
range: 'Sheet1!C3',
|
||||
values: [['δ$% " []']],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1');
|
||||
|
||||
expect(mockGoogleSheet.getData).toHaveBeenCalledWith('Sheet1', 'FORMATTED_VALUE');
|
||||
|
||||
expect(mockGoogleSheet.getColumnValues).toHaveBeenCalledWith({
|
||||
range: 'Sheet1!A:Z',
|
||||
keyIndex: -1,
|
||||
dataStartRowIndex: 1,
|
||||
valueRenderMode: 'UNFORMATTED_VALUE',
|
||||
sheetData: [
|
||||
['Real.1', '21 "', 'dfd', 'ha []', 'macarena'],
|
||||
['aye.2', '"book"', 'ee', 'dd', 'dance'],
|
||||
['t&c', 'Σ', 'baz', 'kayyyy$', 'baile'],
|
||||
['fudge.2', '9080', 'live', 'dog', 'brazil'],
|
||||
],
|
||||
});
|
||||
|
||||
expect(mockGoogleSheet.prepareDataForUpdatingByRowNumber).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
'21 "': 'Σ',
|
||||
'Real.1': 't&c',
|
||||
'ha []': 'kayyyy$',
|
||||
macarena: 'baile',
|
||||
},
|
||||
],
|
||||
'Sheet1!A:Z',
|
||||
[['Real.1', '21 "', 'dfd', 'ha []', 'macarena']],
|
||||
);
|
||||
|
||||
expect(mockGoogleSheet.batchUpdate).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
range: 'Sheet1!B3',
|
||||
values: [['** δ$% " []']],
|
||||
},
|
||||
{
|
||||
range: 'Sheet1!C3',
|
||||
values: [['δ$% " []']],
|
||||
},
|
||||
],
|
||||
'USER_ENTERED',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Google Sheet - Update 4.6', () => {
|
||||
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
|
||||
let mockGoogleSheet: MockProxy<GoogleSheet>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
mockGoogleSheet = mock<GoogleSheet>();
|
||||
|
||||
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>({ typeVersion: 4.6 }));
|
||||
|
||||
mockGoogleSheet.batchUpdate.mockResolvedValueOnce([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('row_number input error', () => {
|
||||
it.each([{ rowNumber: undefined }])(
|
||||
'displays a helpful error message when row_number is $rowNumber',
|
||||
async ({ rowNumber }) => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: {
|
||||
row_number: rowNumber,
|
||||
name: 'name',
|
||||
text: 'txt',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
input: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: string | object } = {
|
||||
options: {},
|
||||
'options.cellFormat': 'USER_ENTERED',
|
||||
'columns.matchingColumns': ['row_number'],
|
||||
'columns.value': {
|
||||
row_number: rowNumber, // TODO: Test for undefined
|
||||
},
|
||||
dataMode: 'defineBelow',
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([['macarena'], ['boomboom']]);
|
||||
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1')).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
message: 'row_number is null or undefined',
|
||||
description:
|
||||
"Since it's being used to determine the row to update, it cannot be null or undefined",
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('non-row_number undefined', () => {
|
||||
it.each([{ nonRowNumber: undefined }])(
|
||||
'displays a helpful error message when row_number is $rowNumber',
|
||||
async ({ nonRowNumber }) => {
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: {
|
||||
row_number: 2,
|
||||
nonRowNumber: 'name',
|
||||
text: 'txt',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
input: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: string | object } = {
|
||||
options: {},
|
||||
'options.cellFormat': 'USER_ENTERED',
|
||||
'columns.matchingColumns': ['nonRowNumber'],
|
||||
'columns.value': {
|
||||
nonRowNumber,
|
||||
},
|
||||
dataMode: 'defineBelow',
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([['macarena'], ['boomboom']]);
|
||||
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce([]);
|
||||
|
||||
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
|
||||
updateData: [],
|
||||
appendData: [],
|
||||
});
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1');
|
||||
|
||||
expect(mockExecuteFunctions.addExecutionHints).toHaveBeenCalledWith({
|
||||
message: 'Warning: The value of column to match is null or undefined',
|
||||
location: 'outputPane',
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Google Sheet - Update v4.6 vs v4.7 Behavior', () => {
|
||||
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
|
||||
let mockGoogleSheet: MockProxy<GoogleSheet>;
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('v4.6: empty string in UI gets filtered out, field not sent to backend', async () => {
|
||||
mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
mockGoogleSheet = mock<GoogleSheet>();
|
||||
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>({ typeVersion: 4.6 }));
|
||||
mockGoogleSheet.batchUpdate.mockResolvedValueOnce([]);
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: {},
|
||||
pairedItem: { item: 0, input: undefined },
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: string | object } = {
|
||||
options: {},
|
||||
'options.cellFormat': 'USER_ENTERED',
|
||||
'columns.matchingColumns': ['id'],
|
||||
'columns.mappingMode': 'defineBelow',
|
||||
'columns.value': {
|
||||
id: 1,
|
||||
name: 'John',
|
||||
// email field is NOT present here because user typed '' in UI
|
||||
// and v4.6 frontend filtered it out (allowEmptyStrings: false)
|
||||
},
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([
|
||||
['id', 'name', 'email'],
|
||||
['1', 'Old Name', 'old@email.com'],
|
||||
]);
|
||||
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce(['1']);
|
||||
|
||||
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
|
||||
updateData: [
|
||||
{
|
||||
range: 'Sheet1!B2',
|
||||
values: [['John']],
|
||||
},
|
||||
// No update for email column - it keeps its old value
|
||||
],
|
||||
appendData: [],
|
||||
});
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1');
|
||||
|
||||
// v4.6: Only name field is updated, email is not included in the update
|
||||
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith({
|
||||
inputData: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'John',
|
||||
// email is NOT in the inputData, so cell keeps old value
|
||||
},
|
||||
],
|
||||
indexKey: 'id',
|
||||
range: 'Sheet1!A:Z',
|
||||
keyRowIndex: 0,
|
||||
dataStartRowIndex: 1,
|
||||
valueRenderMode: 'UNFORMATTED_VALUE',
|
||||
columnNamesList: [['id', 'name', 'email']],
|
||||
columnValuesList: ['1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('v4.7: empty string in UI is preserved and sent to backend to clear cell', async () => {
|
||||
mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
mockGoogleSheet = mock<GoogleSheet>();
|
||||
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>({ typeVersion: 4.7 }));
|
||||
mockGoogleSheet.batchUpdate.mockResolvedValueOnce([]);
|
||||
|
||||
mockExecuteFunctions.getInputData.mockReturnValueOnce([
|
||||
{
|
||||
json: {},
|
||||
pairedItem: { item: 0, input: undefined },
|
||||
},
|
||||
]);
|
||||
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: string | object } = {
|
||||
options: {},
|
||||
'options.cellFormat': 'USER_ENTERED',
|
||||
'columns.matchingColumns': ['id'],
|
||||
'columns.mappingMode': 'defineBelow',
|
||||
'columns.value': {
|
||||
id: 1,
|
||||
name: 'John',
|
||||
email: '', // Empty string is preserved in v4.7 (allowEmptyStrings: true)
|
||||
},
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
mockGoogleSheet.getData.mockResolvedValueOnce([
|
||||
['id', 'name', 'email'],
|
||||
['1', 'Old Name', 'old@email.com'],
|
||||
]);
|
||||
|
||||
mockGoogleSheet.getColumnValues.mockResolvedValueOnce(['1']);
|
||||
|
||||
mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({
|
||||
updateData: [
|
||||
{
|
||||
range: 'Sheet1!B2',
|
||||
values: [['John']],
|
||||
},
|
||||
{
|
||||
range: 'Sheet1!C2',
|
||||
values: [['']],
|
||||
},
|
||||
],
|
||||
appendData: [],
|
||||
});
|
||||
|
||||
await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1');
|
||||
|
||||
// v4.7: Both name and email fields are updated, email is cleared with empty string
|
||||
expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith({
|
||||
inputData: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'John',
|
||||
email: '', // Empty string is preserved and will clear the cell
|
||||
},
|
||||
],
|
||||
indexKey: 'id',
|
||||
range: 'Sheet1!A:Z',
|
||||
keyRowIndex: 0,
|
||||
dataStartRowIndex: 1,
|
||||
valueRenderMode: 'UNFORMATTED_VALUE',
|
||||
columnNamesList: [['id', 'name', 'email']],
|
||||
columnValuesList: ['1'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { IExecuteFunctions, ILoadOptionsFunctions } from 'n8n-workflow';
|
||||
|
||||
import { getGoogleAccessToken } from '../../../../GenericFunctions';
|
||||
import { apiRequest, apiRequestAllItems } from '../../../v2/transport';
|
||||
|
||||
jest.mock('../../../../GenericFunctions', () => ({
|
||||
getGoogleAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Google Sheets Transport', () => {
|
||||
let mockExecuteFunction: IExecuteFunctions;
|
||||
let mockLoadOptionsFunction: ILoadOptionsFunctions;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecuteFunction = {
|
||||
getNode: jest.fn(),
|
||||
getNodeParameter: jest.fn(),
|
||||
getCredentials: jest.fn(),
|
||||
helpers: {
|
||||
request: jest.fn(),
|
||||
requestOAuth2: jest.fn(),
|
||||
},
|
||||
} as unknown as IExecuteFunctions;
|
||||
|
||||
mockLoadOptionsFunction = {
|
||||
...mockExecuteFunction,
|
||||
} as unknown as ILoadOptionsFunctions;
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
const mockAccessToken = 'mock-access-token';
|
||||
|
||||
describe('apiRequest', () => {
|
||||
it('should make successful request with service account authentication', async () => {
|
||||
const method = 'GET';
|
||||
const resource = '/v4/spreadsheets';
|
||||
const mockResponse = { data: 'test' };
|
||||
|
||||
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue('serviceAccount');
|
||||
mockExecuteFunction.getCredentials = jest.fn().mockResolvedValue({
|
||||
email: 'test@test.com',
|
||||
privateKey: 'private-key',
|
||||
});
|
||||
(getGoogleAccessToken as jest.Mock).mockResolvedValue({ access_token: mockAccessToken });
|
||||
mockExecuteFunction.helpers.request = jest.fn().mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await apiRequest.call(mockExecuteFunction, method, resource);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(mockExecuteFunction.helpers.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: `Bearer ${mockAccessToken}`,
|
||||
}),
|
||||
method,
|
||||
uri: `https://sheets.googleapis.com${resource}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should make successful request with OAuth2 authentication', async () => {
|
||||
const method = 'GET';
|
||||
const resource = '/v4/spreadsheets';
|
||||
const mockResponse = { data: 'test' };
|
||||
|
||||
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue('oAuth2');
|
||||
mockExecuteFunction.helpers.requestOAuth2 = jest.fn().mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await apiRequest.call(mockExecuteFunction, method, resource);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(mockExecuteFunction.helpers.requestOAuth2).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle custom headers and query parameters', async () => {
|
||||
const method = 'GET';
|
||||
const resource = '/v4/spreadsheets';
|
||||
const headers = { 'Custom-Header': 'value' };
|
||||
const qs = { param: 'value' };
|
||||
|
||||
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue('serviceAccount');
|
||||
mockExecuteFunction.getCredentials = jest.fn().mockResolvedValue({});
|
||||
(getGoogleAccessToken as jest.Mock).mockResolvedValue({ access_token: 'token' });
|
||||
|
||||
await apiRequest.call(mockExecuteFunction, method, resource, {}, qs, undefined, headers);
|
||||
|
||||
expect(mockExecuteFunction.helpers.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining(headers),
|
||||
qs,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle PERMISSION_DENIED error with custom description', async () => {
|
||||
const method = 'GET';
|
||||
const resource = '/v4/spreadsheets';
|
||||
const error = new Error('PERMISSION_DENIED');
|
||||
error.message = 'PERMISSION_DENIED Some error';
|
||||
|
||||
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue('serviceAccount');
|
||||
mockExecuteFunction.getCredentials = jest.fn().mockResolvedValue({});
|
||||
mockExecuteFunction.helpers.request = jest.fn().mockRejectedValue(error);
|
||||
|
||||
await expect(apiRequest.call(mockExecuteFunction, method, resource)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle SSL certificate errors', async () => {
|
||||
const method = 'GET';
|
||||
const resource = '/v4/spreadsheets';
|
||||
const error = new Error('ERR_OSSL_PEM_NO_START_LINE');
|
||||
|
||||
mockExecuteFunction.getNodeParameter = jest.fn().mockReturnValue('serviceAccount');
|
||||
mockExecuteFunction.getCredentials = jest.fn().mockResolvedValue({});
|
||||
mockExecuteFunction.helpers.request = jest.fn().mockRejectedValue(error);
|
||||
|
||||
await expect(apiRequest.call(mockExecuteFunction, method, resource)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiRequestAllItems', () => {
|
||||
it('should fetch all pages of results', async () => {
|
||||
const propertyName = 'items';
|
||||
const method = 'GET';
|
||||
const endpoint = '/v4/spreadsheets';
|
||||
const firstPage = {
|
||||
items: [{ id: 1 }, { id: 2 }],
|
||||
nextPageToken: 'token1',
|
||||
};
|
||||
const secondPage = {
|
||||
items: [{ id: 3 }, { id: 4 }],
|
||||
nextPageToken: undefined,
|
||||
};
|
||||
|
||||
mockLoadOptionsFunction.getNodeParameter = jest.fn().mockReturnValue('serviceAccount');
|
||||
mockLoadOptionsFunction.getCredentials = jest.fn().mockResolvedValue({});
|
||||
|
||||
(getGoogleAccessToken as jest.Mock).mockResolvedValue({ access_token: mockAccessToken });
|
||||
mockExecuteFunction.helpers.request = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(firstPage)
|
||||
.mockResolvedValueOnce(secondPage);
|
||||
|
||||
const result = await apiRequestAllItems.call(
|
||||
mockLoadOptionsFunction,
|
||||
propertyName,
|
||||
method,
|
||||
endpoint,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]);
|
||||
});
|
||||
|
||||
it('should handle empty response', async () => {
|
||||
const propertyName = 'items';
|
||||
const method = 'GET';
|
||||
const endpoint = '/v4/spreadsheets';
|
||||
const emptyResponse = {
|
||||
items: [],
|
||||
};
|
||||
|
||||
mockLoadOptionsFunction.getNodeParameter = jest.fn().mockReturnValue('serviceAccount');
|
||||
mockLoadOptionsFunction.getCredentials = jest.fn().mockResolvedValue({});
|
||||
|
||||
(getGoogleAccessToken as jest.Mock).mockResolvedValue({ access_token: mockAccessToken });
|
||||
mockExecuteFunction.helpers.request = jest.fn().mockResolvedValue(emptyResponse);
|
||||
|
||||
const result = await apiRequestAllItems.call(
|
||||
mockLoadOptionsFunction,
|
||||
propertyName,
|
||||
method,
|
||||
endpoint,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,735 @@
|
||||
import {
|
||||
NodeOperationError,
|
||||
type IExecuteFunctions,
|
||||
type INode,
|
||||
type ResourceMapperField,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { GOOGLE_SHEETS_SHEET_URL_REGEX } from '../../../../constants';
|
||||
import { GoogleSheet } from '../../../v2/helpers/GoogleSheet';
|
||||
import {
|
||||
addRowNumber,
|
||||
autoMapInputData,
|
||||
checkForSchemaChanges,
|
||||
getColumnName,
|
||||
getColumnNumber,
|
||||
getExistingSheetNames,
|
||||
getRangeString,
|
||||
getSheetId,
|
||||
getSpreadsheetId,
|
||||
hexToRgb,
|
||||
mapFields,
|
||||
prepareSheetData,
|
||||
removeEmptyColumns,
|
||||
removeEmptyRows,
|
||||
trimLeadingEmptyRows,
|
||||
trimToFirstEmptyRow,
|
||||
} from '../../../v2/helpers/GoogleSheets.utils';
|
||||
|
||||
describe('Test Google Sheets, addRowNumber', () => {
|
||||
it('should add row nomber', () => {
|
||||
const data = [
|
||||
['id', 'col1', 'col2', 'col3'],
|
||||
[0, 'A', 'B', 'C'],
|
||||
[1, 'a', 'b', 'c'],
|
||||
[2, 'd', 'e', 'f'],
|
||||
[3, 'g', 'h', 'i'],
|
||||
];
|
||||
const result = addRowNumber(data, 0);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([
|
||||
['row_number', 'id', 'col1', 'col2', 'col3'],
|
||||
[2, 0, 'A', 'B', 'C'],
|
||||
[3, 1, 'a', 'b', 'c'],
|
||||
[4, 2, 'd', 'e', 'f'],
|
||||
[5, 3, 'g', 'h', 'i'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Google Sheets, trimToFirstEmptyRow', () => {
|
||||
it('should trimToFirstEmptyRow without row numbers', () => {
|
||||
const data = [
|
||||
['id', 'col1', 'col2', 'col3'],
|
||||
[0, 'A', 'B', 'C'],
|
||||
['', '', '', ''],
|
||||
[2, 'd', 'e', 'f'],
|
||||
[3, 'g', 'h', 'i'],
|
||||
];
|
||||
|
||||
const result = trimToFirstEmptyRow(data, false);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([
|
||||
['id', 'col1', 'col2', 'col3'],
|
||||
[0, 'A', 'B', 'C'],
|
||||
]);
|
||||
});
|
||||
it('should trimToFirstEmptyRow with row numbers', () => {
|
||||
const data = [
|
||||
['row_number', 'id', 'col1', 'col2', 'col3'],
|
||||
[2, 0, 'A', 'B', 'C'],
|
||||
[3, 1, 'a', 'b', 'c'],
|
||||
[4, '', '', '', ''],
|
||||
[5, 3, 'g', 'h', 'i'],
|
||||
];
|
||||
|
||||
const result = trimToFirstEmptyRow(data);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([
|
||||
['row_number', 'id', 'col1', 'col2', 'col3'],
|
||||
[2, 0, 'A', 'B', 'C'],
|
||||
[3, 1, 'a', 'b', 'c'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Google Sheets, removeEmptyRows', () => {
|
||||
it('should removeEmptyRows without row numbers', () => {
|
||||
const data = [
|
||||
['id', 'col1', 'col2', 'col3'],
|
||||
[0, 'A', 'B', 'C'],
|
||||
['', '', '', ''],
|
||||
[2, 'd', 'e', 'f'],
|
||||
['', '', '', ''],
|
||||
];
|
||||
|
||||
const result = removeEmptyRows(data, false);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([
|
||||
['id', 'col1', 'col2', 'col3'],
|
||||
[0, 'A', 'B', 'C'],
|
||||
[2, 'd', 'e', 'f'],
|
||||
]);
|
||||
});
|
||||
it('should removeEmptyRows with row numbers', () => {
|
||||
const data = [
|
||||
['row_number', 'id', 'col1', 'col2', 'col3'],
|
||||
[2, 0, 'A', 'B', 'C'],
|
||||
[3, 1, 'a', 'b', 'c'],
|
||||
[4, '', '', '', ''],
|
||||
[5, 3, 'g', 'h', 'i'],
|
||||
];
|
||||
|
||||
const result = removeEmptyRows(data);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([
|
||||
['row_number', 'id', 'col1', 'col2', 'col3'],
|
||||
[2, 0, 'A', 'B', 'C'],
|
||||
[3, 1, 'a', 'b', 'c'],
|
||||
[5, 3, 'g', 'h', 'i'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Google Sheets, trimLeadingEmptyRows', () => {
|
||||
it('should trimLeadingEmptyRows without row numbers', () => {
|
||||
const data = [
|
||||
['', '', '', ''],
|
||||
['', '', '', ''],
|
||||
[2, 'd', 'e', 'f'],
|
||||
['', '', '', ''],
|
||||
];
|
||||
|
||||
const result = trimLeadingEmptyRows(data, false);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([
|
||||
[2, 'd', 'e', 'f'],
|
||||
['', '', '', ''],
|
||||
]);
|
||||
});
|
||||
it('should trimLeadingEmptyRows with row numbers', () => {
|
||||
const data = [
|
||||
[1, '', '', '', ''],
|
||||
['row_number', 'id', 'col1', 'col2', 'col3'],
|
||||
[2, 0, 'A', 'B', 'C'],
|
||||
[3, 1, 'a', 'b', 'c'],
|
||||
[5, 3, 'g', 'h', 'i'],
|
||||
];
|
||||
|
||||
const result = trimLeadingEmptyRows(data, true);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([
|
||||
['row_number', 'id', 'col1', 'col2', 'col3'],
|
||||
[2, 0, 'A', 'B', 'C'],
|
||||
[3, 1, 'a', 'b', 'c'],
|
||||
[5, 3, 'g', 'h', 'i'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Google Sheets, removeEmptyColumns', () => {
|
||||
it('should removeEmptyColumns without row numbers', () => {
|
||||
const data = [
|
||||
['id', 'col1', '', 'col3'],
|
||||
[0, 'A', '', 'C'],
|
||||
[1, 'a', '', 'c'],
|
||||
[2, 'd', '', 'f'],
|
||||
[3, 'g', '', 'i'],
|
||||
];
|
||||
|
||||
const result = removeEmptyColumns(data);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([
|
||||
['id', 'col1', 'col3'],
|
||||
[0, 'A', 'C'],
|
||||
[1, 'a', 'c'],
|
||||
[2, 'd', 'f'],
|
||||
[3, 'g', 'i'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Google Sheets, prepareSheetData', () => {
|
||||
it('should prepareSheetData without row numbers', () => {
|
||||
const data = [
|
||||
['id', 'col1', 'col2', 'col3'],
|
||||
[1, 'A', 'B', 'C'],
|
||||
['', '', '', ''],
|
||||
[2, 'd', 'e', 'f'],
|
||||
['', '', '', ''],
|
||||
];
|
||||
|
||||
const result = prepareSheetData(data, { rangeDefinition: 'detectAutomatically' }, true);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual({
|
||||
data: [
|
||||
['row_number', 'id', 'col1', 'col2', 'col3'],
|
||||
[2, 1, 'A', 'B', 'C'],
|
||||
[4, 2, 'd', 'e', 'f'],
|
||||
],
|
||||
firstDataRow: 1,
|
||||
headerRow: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Google Sheets, autoMapInputData', () => {
|
||||
it('should autoMapInputData', async () => {
|
||||
const node: INode = {
|
||||
id: '1',
|
||||
name: 'Postgres node',
|
||||
typeVersion: 2,
|
||||
type: 'n8n-nodes-base.postgres',
|
||||
position: [60, 760],
|
||||
parameters: {
|
||||
operation: 'executeQuery',
|
||||
},
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
json: {
|
||||
id: 1,
|
||||
name: 'Jon',
|
||||
data: 'A',
|
||||
},
|
||||
},
|
||||
{
|
||||
json: {
|
||||
id: 2,
|
||||
name: 'Sam',
|
||||
data: 'B',
|
||||
},
|
||||
},
|
||||
{
|
||||
json: {
|
||||
id: 3,
|
||||
name: 'Ron',
|
||||
data: 'C',
|
||||
info: 'some info',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const fakeExecuteFunction = {
|
||||
getNode() {
|
||||
return node;
|
||||
},
|
||||
} as unknown as IExecuteFunctions;
|
||||
|
||||
const getData = (GoogleSheet.prototype.getData = jest.fn().mockResolvedValue([[]]));
|
||||
|
||||
const updateRows = (GoogleSheet.prototype.updateRows = jest.fn().mockResolvedValue(true));
|
||||
|
||||
const googleSheet = new GoogleSheet('spreadsheetId', fakeExecuteFunction);
|
||||
|
||||
const result = await autoMapInputData.call(
|
||||
fakeExecuteFunction,
|
||||
'foo 1',
|
||||
googleSheet,
|
||||
items,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(getData).toHaveBeenCalledTimes(1);
|
||||
expect(getData).toHaveBeenCalledWith('foo 1!1:1', 'FORMATTED_VALUE');
|
||||
|
||||
expect(updateRows).toHaveBeenCalledTimes(2);
|
||||
expect(updateRows).toHaveBeenCalledWith('foo 1', [['id', 'name', 'data']], 'RAW', 1);
|
||||
expect(updateRows).toHaveBeenCalledWith('foo 1', [['id', 'name', 'data', 'info']], 'RAW', 1);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Jon',
|
||||
data: 'A',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Sam',
|
||||
data: 'B',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Ron',
|
||||
data: 'C',
|
||||
info: 'some info',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Google Sheets, lookupValues', () => {
|
||||
const inputData = [
|
||||
['row_number', 'id', 'num', 'text'],
|
||||
[2, 1, '111', 'bar'],
|
||||
[3, 3, 1, 'bar'],
|
||||
[4, 4, 1, 'baz'],
|
||||
[5, 5, 1, 'baz'],
|
||||
[6, 6, 66, 'foo'],
|
||||
[7, 7, 77, 'foo'],
|
||||
] as string[][];
|
||||
|
||||
it('should return rows by combining filters by OR', async () => {
|
||||
const fakeExecuteFunction = {
|
||||
getNode() {
|
||||
return {};
|
||||
},
|
||||
} as unknown as IExecuteFunctions;
|
||||
|
||||
const googleSheet = new GoogleSheet('spreadsheetId', fakeExecuteFunction);
|
||||
|
||||
const result = await googleSheet.lookupValues({
|
||||
inputData,
|
||||
keyRowIndex: 0,
|
||||
dataStartRowIndex: 1,
|
||||
lookupValues: [
|
||||
{
|
||||
lookupColumn: 'num',
|
||||
lookupValue: '1',
|
||||
},
|
||||
{
|
||||
lookupColumn: 'text',
|
||||
lookupValue: 'foo',
|
||||
},
|
||||
],
|
||||
returnAllMatches: true,
|
||||
combineFilters: 'OR',
|
||||
nodeVersion: 4.5,
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([
|
||||
{
|
||||
row_number: 3,
|
||||
id: 3,
|
||||
num: 1,
|
||||
text: 'bar',
|
||||
},
|
||||
{
|
||||
row_number: 4,
|
||||
id: 4,
|
||||
num: 1,
|
||||
text: 'baz',
|
||||
},
|
||||
{
|
||||
row_number: 5,
|
||||
id: 5,
|
||||
num: 1,
|
||||
text: 'baz',
|
||||
},
|
||||
{
|
||||
row_number: 6,
|
||||
id: 6,
|
||||
num: 66,
|
||||
text: 'foo',
|
||||
},
|
||||
{
|
||||
row_number: 7,
|
||||
id: 7,
|
||||
num: 77,
|
||||
text: 'foo',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return rows by combining filters by AND', async () => {
|
||||
const fakeExecuteFunction = {
|
||||
getNode() {
|
||||
return {};
|
||||
},
|
||||
} as unknown as IExecuteFunctions;
|
||||
|
||||
const googleSheet = new GoogleSheet('spreadsheetId', fakeExecuteFunction);
|
||||
|
||||
const result = await googleSheet.lookupValues({
|
||||
inputData,
|
||||
keyRowIndex: 0,
|
||||
dataStartRowIndex: 1,
|
||||
lookupValues: [
|
||||
{
|
||||
lookupColumn: 'num',
|
||||
lookupValue: '1',
|
||||
},
|
||||
{
|
||||
lookupColumn: 'text',
|
||||
lookupValue: 'baz',
|
||||
},
|
||||
],
|
||||
returnAllMatches: true,
|
||||
combineFilters: 'AND',
|
||||
nodeVersion: 4.5,
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([
|
||||
{
|
||||
row_number: 4,
|
||||
id: 4,
|
||||
num: 1,
|
||||
text: 'baz',
|
||||
},
|
||||
{
|
||||
row_number: 5,
|
||||
id: 5,
|
||||
num: 1,
|
||||
text: 'baz',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Google Sheets, checkForSchemaChanges', () => {
|
||||
it('should not to throw error', async () => {
|
||||
const node: INode = {
|
||||
id: '1',
|
||||
name: 'Google Sheets',
|
||||
typeVersion: 4.4,
|
||||
type: 'n8n-nodes-base.googleSheets',
|
||||
position: [60, 760],
|
||||
parameters: {
|
||||
operation: 'append',
|
||||
},
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
checkForSchemaChanges(node, ['id', 'name', 'data'], [
|
||||
{ id: 'id' },
|
||||
{ id: 'name' },
|
||||
{ id: 'data' },
|
||||
] as ResourceMapperField[]),
|
||||
).not.toThrow();
|
||||
});
|
||||
it('should throw error when columns were renamed', async () => {
|
||||
const node: INode = {
|
||||
id: '1',
|
||||
name: 'Google Sheets',
|
||||
typeVersion: 4.4,
|
||||
type: 'n8n-nodes-base.googleSheets',
|
||||
position: [60, 760],
|
||||
parameters: {
|
||||
operation: 'append',
|
||||
},
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
checkForSchemaChanges(node, ['id', 'name', 'data'], [
|
||||
{ id: 'id' },
|
||||
{ id: 'name' },
|
||||
{ id: 'text' },
|
||||
] as ResourceMapperField[]),
|
||||
).toThrow("Column names were updated after the node's setup");
|
||||
});
|
||||
|
||||
it('should filter out empty columns without throwing an error', async () => {
|
||||
const node: INode = {
|
||||
id: '1',
|
||||
name: 'Google Sheets',
|
||||
typeVersion: 4.4,
|
||||
type: 'n8n-nodes-base.googleSheets',
|
||||
position: [60, 760],
|
||||
parameters: {
|
||||
operation: 'append',
|
||||
},
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
checkForSchemaChanges(node, ['', '', 'id', 'name', 'data'], [
|
||||
{ id: 'id' },
|
||||
{ id: 'name' },
|
||||
{ id: 'data' },
|
||||
] as ResourceMapperField[]),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Google Sheets, getSpreadsheetId', () => {
|
||||
let mockNode: INode;
|
||||
|
||||
beforeEach(() => {
|
||||
mockNode = { name: 'Google Sheets' } as INode;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should throw an error if value is empty', () => {
|
||||
expect(() => getSpreadsheetId(mockNode, 'url', '')).toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should return the ID from a valid URL', () => {
|
||||
const url =
|
||||
'https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit#gid=0';
|
||||
const result = getSpreadsheetId(mockNode, 'url', url);
|
||||
expect(result).toBe('1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms');
|
||||
});
|
||||
|
||||
it('should return an empty string for an invalid URL', () => {
|
||||
const url = 'https://docs.google.com/spreadsheets/d/';
|
||||
const result = getSpreadsheetId(mockNode, 'url', url);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return the value for documentIdType byId or byList', () => {
|
||||
const value = '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms';
|
||||
expect(getSpreadsheetId(mockNode, 'id', value)).toBe(value);
|
||||
expect(getSpreadsheetId(mockNode, 'list', value)).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Google Sheets, Google Sheets Sheet URL Regex', () => {
|
||||
const regex = new RegExp(GOOGLE_SHEETS_SHEET_URL_REGEX);
|
||||
|
||||
it('should match a valid Google Sheets URL', () => {
|
||||
const urls = [
|
||||
'https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit#gid=0',
|
||||
'https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit#gid=123456',
|
||||
'https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit?gid=654321#gid=654321',
|
||||
];
|
||||
for (const url of urls) {
|
||||
expect(regex.test(url)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not match an invalid Google Sheets URL', () => {
|
||||
const url = 'https://docs.google.com/spreadsheets/d/';
|
||||
expect(regex.test(url)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match a URL that does not match the pattern', () => {
|
||||
const url =
|
||||
'https://example.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit#gid=0';
|
||||
expect(regex.test(url)).toBe(false);
|
||||
});
|
||||
|
||||
it('should extract the gid from a valid Google Sheets URL', () => {
|
||||
const urls = [
|
||||
'https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit#gid=12345',
|
||||
'https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit?gid=12345#gid=12345',
|
||||
];
|
||||
for (const url of urls) {
|
||||
const match = url.match(regex);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match?.[1]).toBe('12345');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Google Sheets, getColumnNumber', () => {
|
||||
it('should return the correct number for single-letter columns', () => {
|
||||
expect(getColumnNumber('A')).toBe(1);
|
||||
expect(getColumnNumber('Z')).toBe(26);
|
||||
});
|
||||
|
||||
it('should return the correct number for multi-letter columns', () => {
|
||||
expect(getColumnNumber('AA')).toBe(27);
|
||||
expect(getColumnNumber('AZ')).toBe(52);
|
||||
expect(getColumnNumber('BA')).toBe(53);
|
||||
expect(getColumnNumber('ZZ')).toBe(702);
|
||||
expect(getColumnNumber('AAA')).toBe(703);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Google Sheets, hexToRgb', () => {
|
||||
it('should correctly convert a full hex code to RGB', () => {
|
||||
expect(hexToRgb('#0033FF')).toEqual({ red: 0, green: 51, blue: 255 });
|
||||
expect(hexToRgb('#FF5733')).toEqual({ red: 255, green: 87, blue: 51 });
|
||||
});
|
||||
|
||||
it('should correctly convert a shorthand hex code to RGB', () => {
|
||||
expect(hexToRgb('#03F')).toEqual({ red: 0, green: 51, blue: 255 });
|
||||
expect(hexToRgb('#F00')).toEqual({ red: 255, green: 0, blue: 0 });
|
||||
});
|
||||
|
||||
it('should return null for invalid hex codes', () => {
|
||||
expect(hexToRgb('#XYZ123')).toBeNull(); // Invalid characters
|
||||
expect(hexToRgb('#12345')).toBeNull(); // Incorrect length
|
||||
expect(hexToRgb('')).toBeNull(); // Empty input
|
||||
expect(hexToRgb('#')).toBeNull(); // Just a hash
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Google Sheets, getRangeString', () => {
|
||||
it('should return the range in A1 notation when "specifyRangeA1" is set', () => {
|
||||
const result = getRangeString('Sheet1', { rangeDefinition: 'specifyRangeA1', range: 'A1:B2' });
|
||||
expect(result).toBe('Sheet1!A1:B2');
|
||||
});
|
||||
|
||||
it('should return only the sheet name if no range is specified', () => {
|
||||
const result = getRangeString('Sheet1', { rangeDefinition: 'specifyRangeA1', range: '' });
|
||||
expect(result).toBe('Sheet1');
|
||||
});
|
||||
|
||||
it('should return only the sheet name if rangeDefinition is not "specifyRangeA1"', () => {
|
||||
const result = getRangeString('Sheet1', { rangeDefinition: 'detectAutomatically' });
|
||||
expect(result).toBe('Sheet1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Google Sheets, getExistingSheetNames', () => {
|
||||
const mockGoogleSheetInstance: Partial<GoogleSheet> = {
|
||||
spreadsheetGetSheets: jest.fn(),
|
||||
};
|
||||
it('should return an array of sheet names', async () => {
|
||||
mockGoogleSheetInstance.spreadsheetGetSheets = jest.fn().mockResolvedValue({
|
||||
sheets: [{ properties: { title: 'Sheet1' } }, { properties: { title: 'Sheet2' } }],
|
||||
});
|
||||
const result = await getExistingSheetNames(mockGoogleSheetInstance as GoogleSheet);
|
||||
expect(result).toEqual(['Sheet1', 'Sheet2']);
|
||||
});
|
||||
|
||||
it('should return an empty array if no sheets are present', async () => {
|
||||
mockGoogleSheetInstance.spreadsheetGetSheets = jest.fn().mockResolvedValue({ sheets: [] });
|
||||
const result = await getExistingSheetNames(mockGoogleSheetInstance as GoogleSheet);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle a case where sheets are undefined', async () => {
|
||||
mockGoogleSheetInstance.spreadsheetGetSheets = jest.fn().mockResolvedValue({});
|
||||
const result = await getExistingSheetNames(mockGoogleSheetInstance as GoogleSheet);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Google Sheets, mapFields', () => {
|
||||
const fakeExecuteFunction: Partial<IExecuteFunctions> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
fakeExecuteFunction.getNode = jest.fn();
|
||||
fakeExecuteFunction.getNodeParameter = jest.fn();
|
||||
});
|
||||
|
||||
it('should map fields for node version < 4', () => {
|
||||
fakeExecuteFunction.getNode = jest.fn().mockReturnValue({ typeVersion: 3 });
|
||||
fakeExecuteFunction.getNodeParameter = jest.fn().mockImplementation((_, i) => [
|
||||
{ fieldId: 'field1', fieldValue: `value${i}` },
|
||||
{ fieldId: 'field2', fieldValue: `value${i * 2}` },
|
||||
]);
|
||||
|
||||
const result = mapFields.call(fakeExecuteFunction as IExecuteFunctions, 2);
|
||||
expect(result).toEqual([
|
||||
{ field1: 'value0', field2: 'value0' },
|
||||
{ field1: 'value1', field2: 'value2' },
|
||||
]);
|
||||
expect(fakeExecuteFunction.getNodeParameter).toHaveBeenCalledTimes(2);
|
||||
expect(fakeExecuteFunction.getNodeParameter).toHaveBeenCalledWith(
|
||||
'fieldsUi.fieldValues',
|
||||
0,
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('should map columns for node version >= 4', () => {
|
||||
fakeExecuteFunction.getNode = jest.fn().mockReturnValue({ typeVersion: 4 });
|
||||
fakeExecuteFunction.getNodeParameter = jest.fn().mockImplementation((_, i) => ({
|
||||
column1: `value${i}`,
|
||||
column2: `value${i * 2}`,
|
||||
}));
|
||||
|
||||
const result = mapFields.call(fakeExecuteFunction as IExecuteFunctions, 2);
|
||||
expect(result).toEqual([
|
||||
{ column1: 'value0', column2: 'value0' },
|
||||
{ column1: 'value1', column2: 'value2' },
|
||||
]);
|
||||
expect(fakeExecuteFunction.getNodeParameter).toHaveBeenCalledTimes(2);
|
||||
expect(fakeExecuteFunction.getNodeParameter).toHaveBeenCalledWith('columns.value', 0);
|
||||
});
|
||||
|
||||
it('should throw an error if no values are added in version >= 4', () => {
|
||||
fakeExecuteFunction.getNode = jest.fn().mockReturnValue({ typeVersion: 4 });
|
||||
fakeExecuteFunction.getNodeParameter = jest.fn().mockReturnValue({});
|
||||
|
||||
expect(() => mapFields.call(fakeExecuteFunction as IExecuteFunctions, 1)).toThrow(
|
||||
"At least one value has to be added under 'Values to Send'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an empty array when inputSize is 0', () => {
|
||||
const result = mapFields.call(fakeExecuteFunction as IExecuteFunctions, 0);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Google Sheets, getSheetId', () => {
|
||||
it('should return 0 when value is "gid=0"', () => {
|
||||
expect(getSheetId('gid=0')).toBe(0);
|
||||
});
|
||||
|
||||
it('should return a parsed integer when value is a numeric string', () => {
|
||||
expect(getSheetId('123')).toBe(123);
|
||||
expect(getSheetId('456')).toBe(456);
|
||||
});
|
||||
|
||||
it('should return NaN for non-numeric strings', () => {
|
||||
expect(getSheetId('abc')).toBeNaN();
|
||||
expect(getSheetId('gid=abc')).toBeNaN();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test Google Sheets, getColumnName', () => {
|
||||
it('should return "A" for column number 1', () => {
|
||||
expect(getColumnName(1)).toBe('A');
|
||||
});
|
||||
|
||||
it('should return "Z" for column number 26', () => {
|
||||
expect(getColumnName(26)).toBe('Z');
|
||||
});
|
||||
|
||||
it('should return "AA" for column number 27', () => {
|
||||
expect(getColumnName(27)).toBe('AA');
|
||||
});
|
||||
|
||||
it('should return "AZ" for column number 52', () => {
|
||||
expect(getColumnName(52)).toBe('AZ');
|
||||
});
|
||||
|
||||
it('should return "BA" for column number 53', () => {
|
||||
expect(getColumnName(53)).toBe('BA');
|
||||
});
|
||||
|
||||
it('should return "ZZ" for column number 702', () => {
|
||||
expect(getColumnName(702)).toBe('ZZ');
|
||||
});
|
||||
|
||||
it('should return "AAA" for column number 703', () => {
|
||||
expect(getColumnName(703)).toBe('AAA');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
IHttpRequestMethods,
|
||||
ILoadOptionsFunctions,
|
||||
IRequestOptions,
|
||||
JsonObject,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import { getGoogleAccessToken } from '../../GenericFunctions';
|
||||
|
||||
export interface IGoogleAuthCredentials {
|
||||
delegatedEmail?: string;
|
||||
email: string;
|
||||
inpersonate: boolean;
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
export async function googleApiRequest(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
resource: string,
|
||||
|
||||
body: any = {},
|
||||
qs: IDataObject = {},
|
||||
uri?: string,
|
||||
headers: IDataObject = {},
|
||||
): Promise<any> {
|
||||
const authenticationMethod = this.getNodeParameter(
|
||||
'authentication',
|
||||
0,
|
||||
'serviceAccount',
|
||||
) as string;
|
||||
const options: IRequestOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method,
|
||||
body,
|
||||
qs,
|
||||
uri: uri || `https://sheets.googleapis.com${resource}`,
|
||||
json: true,
|
||||
};
|
||||
try {
|
||||
if (Object.keys(headers).length !== 0) {
|
||||
options.headers = Object.assign({}, options.headers, headers);
|
||||
}
|
||||
if (Object.keys(body as IDataObject).length === 0) {
|
||||
delete options.body;
|
||||
}
|
||||
|
||||
if (authenticationMethod === 'serviceAccount') {
|
||||
const credentials = await this.getCredentials('googleApi');
|
||||
|
||||
const { access_token } = await getGoogleAccessToken.call(this, credentials, 'sheetV1');
|
||||
|
||||
options.headers!.Authorization = `Bearer ${access_token}`;
|
||||
return await this.helpers.request(options);
|
||||
} else {
|
||||
return await this.helpers.requestOAuth2.call(this, 'googleSheetsOAuth2Api', options);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'ERR_OSSL_PEM_NO_START_LINE') {
|
||||
error.statusCode = '401';
|
||||
}
|
||||
|
||||
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||
}
|
||||
}
|
||||
|
||||
export async function googleApiRequestAllItems(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
propertyName: string,
|
||||
method: IHttpRequestMethods,
|
||||
endpoint: string,
|
||||
|
||||
body: any = {},
|
||||
query: IDataObject = {},
|
||||
): Promise<any> {
|
||||
const returnData: IDataObject[] = [];
|
||||
|
||||
let responseData;
|
||||
query.maxResults = 100;
|
||||
|
||||
do {
|
||||
responseData = await googleApiRequest.call(this, method, endpoint, body, query);
|
||||
query.pageToken = responseData.nextPageToken;
|
||||
returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]);
|
||||
} while (responseData.nextPageToken !== undefined && responseData.nextPageToken !== '');
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
// Hex to RGB
|
||||
export function hexToRgb(hex: string) {
|
||||
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
|
||||
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
|
||||
hex = hex.replace(shorthandRegex, (_, r, g, b) => {
|
||||
return r + r + g + g + b + b;
|
||||
});
|
||||
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
red: parseInt(result[1], 16),
|
||||
green: parseInt(result[2], 16),
|
||||
blue: parseInt(result[3], 16),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
import get from 'lodash/get';
|
||||
import type { IExecuteFunctions, ILoadOptionsFunctions, IDataObject } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import { utils as xlsxUtils } from 'xlsx';
|
||||
|
||||
import { googleApiRequest } from './GenericFunctions';
|
||||
|
||||
export interface ISheetOptions {
|
||||
scope: string[];
|
||||
}
|
||||
|
||||
export interface IGoogleAuthCredentials {
|
||||
email: string;
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
export interface ISheetUpdateData {
|
||||
range: string;
|
||||
values: string[][];
|
||||
}
|
||||
|
||||
export interface ILookupValues {
|
||||
lookupColumn: string;
|
||||
lookupValue: string;
|
||||
}
|
||||
|
||||
export interface IToDeleteRange {
|
||||
amount: number;
|
||||
startIndex: number;
|
||||
sheetId: number;
|
||||
}
|
||||
|
||||
export interface IToDelete {
|
||||
[key: string]: IToDeleteRange[] | undefined;
|
||||
columns?: IToDeleteRange[];
|
||||
rows?: IToDeleteRange[];
|
||||
}
|
||||
|
||||
export type ValueInputOption = 'RAW' | 'USER_ENTERED';
|
||||
|
||||
export type ValueRenderOption = 'FORMATTED_VALUE' | 'FORMULA' | 'UNFORMATTED_VALUE';
|
||||
|
||||
export class GoogleSheet {
|
||||
id: string;
|
||||
|
||||
executeFunctions: IExecuteFunctions | ILoadOptionsFunctions;
|
||||
|
||||
constructor(
|
||||
spreadsheetId: string,
|
||||
executeFunctions: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
options?: ISheetOptions,
|
||||
) {
|
||||
// options = <SheetOptions>options || {};
|
||||
if (!options) {
|
||||
options = {} as ISheetOptions;
|
||||
}
|
||||
|
||||
this.executeFunctions = executeFunctions;
|
||||
this.id = spreadsheetId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the range that also none latin character work
|
||||
*
|
||||
*/
|
||||
encodeRange(range: string): string {
|
||||
if (range.includes('!')) {
|
||||
const [sheet, ranges] = range.split('!');
|
||||
range = `${encodeURIComponent(sheet)}!${ranges}`;
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears values from a sheet
|
||||
*
|
||||
*/
|
||||
async clearData(range: string): Promise<object> {
|
||||
const body = {
|
||||
spreadsheetId: this.id,
|
||||
range,
|
||||
};
|
||||
|
||||
const response = await googleApiRequest.call(
|
||||
this.executeFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${this.id}/values/${range}:clear`,
|
||||
body,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell values
|
||||
*/
|
||||
async getData(
|
||||
range: string,
|
||||
valueRenderMode: ValueRenderOption,
|
||||
): Promise<string[][] | undefined> {
|
||||
const query = {
|
||||
valueRenderOption: valueRenderMode,
|
||||
};
|
||||
|
||||
const response = await googleApiRequest.call(
|
||||
this.executeFunctions,
|
||||
'GET',
|
||||
`/v4/spreadsheets/${this.id}/values/${range}`,
|
||||
{},
|
||||
query,
|
||||
);
|
||||
|
||||
return response.values as string[][] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the sheets in a Spreadsheet
|
||||
*/
|
||||
async spreadsheetGetSheets() {
|
||||
const query = {
|
||||
fields: 'sheets.properties',
|
||||
};
|
||||
|
||||
const response = await googleApiRequest.call(
|
||||
this.executeFunctions,
|
||||
'GET',
|
||||
`/v4/spreadsheets/${this.id}`,
|
||||
{},
|
||||
query,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets values in one or more ranges of a spreadsheet.
|
||||
*/
|
||||
async spreadsheetBatchUpdate(requests: IDataObject[]) {
|
||||
const body = {
|
||||
requests,
|
||||
};
|
||||
|
||||
const response = await googleApiRequest.call(
|
||||
this.executeFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${this.id}:batchUpdate`,
|
||||
body,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the cell values
|
||||
*/
|
||||
async batchUpdate(updateData: ISheetUpdateData[], valueInputMode: ValueInputOption) {
|
||||
const body = {
|
||||
data: updateData,
|
||||
valueInputOption: valueInputMode,
|
||||
};
|
||||
|
||||
const response = await googleApiRequest.call(
|
||||
this.executeFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${this.id}/values:batchUpdate`,
|
||||
body,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the cell values
|
||||
*/
|
||||
async setData(range: string, data: string[][], valueInputMode: ValueInputOption) {
|
||||
const body = {
|
||||
valueInputOption: valueInputMode,
|
||||
values: data,
|
||||
};
|
||||
|
||||
const response = await googleApiRequest.call(
|
||||
this.executeFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${this.id}/values/${range}`,
|
||||
body,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the cell values
|
||||
*/
|
||||
async appendData(range: string, data: string[][], valueInputMode: ValueInputOption) {
|
||||
const body = {
|
||||
range: decodeURIComponent(range),
|
||||
values: data,
|
||||
};
|
||||
|
||||
const query = {
|
||||
valueInputOption: valueInputMode,
|
||||
};
|
||||
|
||||
const response = await googleApiRequest.call(
|
||||
this.executeFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${this.id}/values/${range}:append`,
|
||||
body,
|
||||
query,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the given sheet data in a structured way
|
||||
*/
|
||||
structureData(
|
||||
inputData: string[][],
|
||||
startRow: number,
|
||||
keys: string[],
|
||||
addEmpty?: boolean,
|
||||
): IDataObject[] {
|
||||
const returnData = [];
|
||||
|
||||
let tempEntry: IDataObject, rowIndex: number, columnIndex: number, key: string;
|
||||
|
||||
for (rowIndex = startRow; rowIndex < inputData.length; rowIndex++) {
|
||||
tempEntry = {};
|
||||
for (columnIndex = 0; columnIndex < inputData[rowIndex].length; columnIndex++) {
|
||||
key = keys[columnIndex];
|
||||
if (key) {
|
||||
// Only add the data for which a key was given and ignore all others
|
||||
tempEntry[key] = inputData[rowIndex][columnIndex];
|
||||
}
|
||||
}
|
||||
if (Object.keys(tempEntry).length || addEmpty === true) {
|
||||
// Only add the entry if data got found to not have empty ones
|
||||
returnData.push(tempEntry);
|
||||
}
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the given sheet data in a structured way using
|
||||
* the startRow as the one with the name of the key
|
||||
*/
|
||||
structureArrayDataByColumn(
|
||||
inputData: string[][],
|
||||
keyRow: number,
|
||||
dataStartRow: number,
|
||||
): IDataObject[] {
|
||||
const keys: string[] = [];
|
||||
|
||||
if (keyRow < 0 || dataStartRow < keyRow || keyRow >= inputData.length) {
|
||||
// The key row does not exist so it is not possible to structure data
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create the keys array
|
||||
for (let columnIndex = 0; columnIndex < inputData[keyRow].length; columnIndex++) {
|
||||
keys.push(inputData[keyRow][columnIndex]);
|
||||
}
|
||||
|
||||
return this.structureData(inputData, dataStartRow, keys);
|
||||
}
|
||||
|
||||
async appendSheetData(
|
||||
inputData: IDataObject[],
|
||||
range: string,
|
||||
keyRowIndex: number,
|
||||
valueInputMode: ValueInputOption,
|
||||
usePathForKeyRow: boolean,
|
||||
): Promise<string[][]> {
|
||||
const data = await this.convertStructuredDataToArray(
|
||||
inputData,
|
||||
range,
|
||||
keyRowIndex,
|
||||
usePathForKeyRow,
|
||||
);
|
||||
return await this.appendData(range, data, valueInputMode);
|
||||
}
|
||||
|
||||
getColumnWithOffset(startColumn: string, offset: number): string {
|
||||
const columnIndex = xlsxUtils.decode_col(startColumn) + offset;
|
||||
return xlsxUtils.encode_col(columnIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates data in a sheet
|
||||
*
|
||||
* @param {IDataObject[]} inputData Data to update Sheet with
|
||||
* @param {string} indexKey The name of the key which gets used to know which rows to update
|
||||
* @param {string} range The range to look for data
|
||||
* @param {number} keyRowIndex Index of the row which contains the keys
|
||||
* @param {number} dataStartRowIndex Index of the first row which contains data
|
||||
*/
|
||||
async updateSheetData(
|
||||
inputData: IDataObject[],
|
||||
indexKey: string,
|
||||
range: string,
|
||||
keyRowIndex: number,
|
||||
dataStartRowIndex: number,
|
||||
valueInputMode: ValueInputOption,
|
||||
valueRenderMode: ValueRenderOption,
|
||||
upsert = false,
|
||||
): Promise<string[][]> {
|
||||
// Get current data in Google Sheet
|
||||
let rangeFull: string;
|
||||
let sheet: string | undefined = undefined;
|
||||
if (range.includes('!')) {
|
||||
[sheet, rangeFull] = range.split('!');
|
||||
} else {
|
||||
rangeFull = range;
|
||||
}
|
||||
const [rangeStart, rangeEnd] = rangeFull.split(':');
|
||||
|
||||
const rangeStartSplit = rangeStart.match(/([a-zA-Z]{1,10})([0-9]{0,10})/);
|
||||
const rangeEndSplit = rangeEnd.match(/([a-zA-Z]{1,10})([0-9]{0,10})/);
|
||||
|
||||
if (
|
||||
rangeStartSplit === null ||
|
||||
rangeStartSplit.length !== 3 ||
|
||||
rangeEndSplit === null ||
|
||||
rangeEndSplit.length !== 3
|
||||
) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
`The range "${range}" is not valid.`,
|
||||
);
|
||||
}
|
||||
|
||||
const keyRowRange = `${sheet ? sheet + '!' : ''}${rangeStartSplit[1]}${keyRowIndex + 1}:${
|
||||
rangeEndSplit[1]
|
||||
}${keyRowIndex + 1}`;
|
||||
|
||||
const sheetDatakeyRow = await this.getData(this.encodeRange(keyRowRange), valueRenderMode);
|
||||
|
||||
if (sheetDatakeyRow === undefined) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
'Could not retrieve the key row!',
|
||||
);
|
||||
}
|
||||
|
||||
const keyColumnOrder = sheetDatakeyRow[0];
|
||||
|
||||
const keyIndex = keyColumnOrder.indexOf(indexKey);
|
||||
|
||||
if (keyIndex === -1) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
`Could not find column for key "${indexKey}"!`,
|
||||
);
|
||||
}
|
||||
|
||||
const startRowIndex = rangeStartSplit[2] || dataStartRowIndex;
|
||||
const endRowIndex = rangeEndSplit[2] || '';
|
||||
|
||||
const keyColumn = this.getColumnWithOffset(rangeStartSplit[1], keyIndex);
|
||||
const keyColumnRange = `${
|
||||
sheet ? sheet + '!' : ''
|
||||
}${keyColumn}${startRowIndex}:${keyColumn}${endRowIndex}`;
|
||||
|
||||
const sheetDataKeyColumn = await this.getData(
|
||||
this.encodeRange(keyColumnRange),
|
||||
valueRenderMode,
|
||||
);
|
||||
|
||||
if (sheetDataKeyColumn === undefined) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
'Could not retrieve the key column!',
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: The data till here can be cached optionally. Maybe add an option which can
|
||||
// can be activated if it is used in a loop and nothing else updates the data.
|
||||
|
||||
// Remove the first row which contains the key
|
||||
sheetDataKeyColumn.shift();
|
||||
|
||||
// Create an Array which all the key-values of the Google Sheet
|
||||
const keyColumnIndexLookup = sheetDataKeyColumn.map((rowContent) => rowContent[0]);
|
||||
|
||||
const updateData: ISheetUpdateData[] = [];
|
||||
let itemKey: string | number | undefined | null;
|
||||
let propertyName: string;
|
||||
let itemKeyIndex: number;
|
||||
let updateRowIndex: number;
|
||||
let updateColumnName: string;
|
||||
for (const inputItem of inputData) {
|
||||
itemKey = inputItem[indexKey] as string;
|
||||
// if ([undefined, null].includes(inputItem[indexKey] as string | undefined | null)) {
|
||||
if (itemKey === undefined || itemKey === null) {
|
||||
// Item does not have the indexKey so we can ignore it or append it if upsert true
|
||||
if (upsert) {
|
||||
await this.appendSheetData(
|
||||
[inputItem],
|
||||
this.encodeRange(range),
|
||||
keyRowIndex,
|
||||
valueInputMode,
|
||||
false,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Item does have the key so check if it exists in Sheet
|
||||
itemKeyIndex = keyColumnIndexLookup.indexOf(itemKey);
|
||||
if (itemKeyIndex === -1) {
|
||||
// Key does not exist in the Sheet so it can not be updated so skip it or append it if upsert true
|
||||
if (upsert) {
|
||||
await this.appendSheetData(
|
||||
[inputItem],
|
||||
this.encodeRange(range),
|
||||
keyRowIndex,
|
||||
valueInputMode,
|
||||
false,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the row index in which the data should be updated
|
||||
updateRowIndex = keyColumnIndexLookup.indexOf(itemKey) + dataStartRowIndex + 1;
|
||||
|
||||
// Check all the properties in the sheet and check which ones exist on the
|
||||
// item and should be updated
|
||||
for (propertyName of keyColumnOrder) {
|
||||
if (propertyName === indexKey) {
|
||||
// Ignore the key itself as that does not get changed it gets
|
||||
// only used to find the correct row to update
|
||||
continue;
|
||||
}
|
||||
if (inputItem[propertyName] === undefined || inputItem[propertyName] === null) {
|
||||
// Property does not exist so skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
// Property exists so add it to the data to update
|
||||
|
||||
// Get the column name in which the property data can be found
|
||||
updateColumnName = this.getColumnWithOffset(
|
||||
rangeStartSplit[1],
|
||||
keyColumnOrder.indexOf(propertyName),
|
||||
);
|
||||
|
||||
updateData.push({
|
||||
range: `${sheet ? sheet + '!' : ''}${updateColumnName}${updateRowIndex}`,
|
||||
values: [[inputItem[propertyName] as string]],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await this.batchUpdate(updateData, valueInputMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks for a specific value in a column and if it gets found it returns the whole row
|
||||
*
|
||||
* @param {string[][]} inputData Data to check for lookup value in
|
||||
* @param {number} keyRowIndex Index of the row which contains the keys
|
||||
* @param {number} dataStartRowIndex Index of the first row which contains data
|
||||
* @param {ILookupValues[]} lookupValues The lookup values which decide what data to return
|
||||
* @param {boolean} [returnAllMatches] Returns all the found matches instead of only the first one
|
||||
*/
|
||||
async lookupValues(
|
||||
inputData: string[][],
|
||||
keyRowIndex: number,
|
||||
dataStartRowIndex: number,
|
||||
lookupValues: ILookupValues[],
|
||||
returnAllMatches?: boolean,
|
||||
): Promise<IDataObject[]> {
|
||||
const keys: string[] = [];
|
||||
|
||||
if (keyRowIndex < 0 || dataStartRowIndex < keyRowIndex || keyRowIndex >= inputData.length) {
|
||||
// The key row does not exist so it is not possible to look up the data
|
||||
throw new NodeOperationError(this.executeFunctions.getNode(), 'The key row does not exist!');
|
||||
}
|
||||
|
||||
// Create the keys array
|
||||
for (let columnIndex = 0; columnIndex < inputData[keyRowIndex].length; columnIndex++) {
|
||||
keys.push(inputData[keyRowIndex][columnIndex]);
|
||||
}
|
||||
|
||||
const returnData = [inputData[keyRowIndex]];
|
||||
|
||||
// Standardise values array, if rows is [[]], map it to [['']] (Keep the columns into consideration)
|
||||
for (let rowIndex = 0; rowIndex < inputData?.length; rowIndex++) {
|
||||
if (inputData[rowIndex].length === 0) {
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
inputData[rowIndex][i] = '';
|
||||
}
|
||||
} else if (inputData[rowIndex].length < keys.length) {
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
if (inputData[rowIndex][i] === undefined) {
|
||||
inputData[rowIndex].push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Loop over all the lookup values and try to find a row to return
|
||||
let rowIndex: number;
|
||||
let returnColumnIndex: number;
|
||||
|
||||
lookupLoop: for (const lookupValue of lookupValues) {
|
||||
returnColumnIndex = keys.indexOf(lookupValue.lookupColumn);
|
||||
|
||||
if (returnColumnIndex === -1) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
`The column "${lookupValue.lookupColumn}" could not be found!`,
|
||||
);
|
||||
}
|
||||
|
||||
// Loop over all the items and find the one with the matching value
|
||||
for (rowIndex = dataStartRowIndex; rowIndex < inputData.length; rowIndex++) {
|
||||
if (
|
||||
inputData[rowIndex][returnColumnIndex]?.toString() === lookupValue.lookupValue.toString()
|
||||
) {
|
||||
returnData.push(inputData[rowIndex]);
|
||||
|
||||
if (returnAllMatches !== true) {
|
||||
continue lookupLoop;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If value could not be found add an empty one that the order of
|
||||
// the returned items stays the same
|
||||
if (returnAllMatches !== true) {
|
||||
returnData.push([]);
|
||||
}
|
||||
}
|
||||
|
||||
return this.structureData(returnData, 1, keys, true);
|
||||
}
|
||||
|
||||
async convertStructuredDataToArray(
|
||||
inputData: IDataObject[],
|
||||
range: string,
|
||||
keyRowIndex: number,
|
||||
usePathForKeyRow: boolean,
|
||||
): Promise<string[][]> {
|
||||
let sheet: string | undefined = undefined;
|
||||
if (range.includes('!')) {
|
||||
[sheet, range] = range.split('!');
|
||||
}
|
||||
const [startColumn, endColumn] = range.split(':');
|
||||
|
||||
let getRange = `${startColumn}${keyRowIndex + 1}:${endColumn}${keyRowIndex + 1}`;
|
||||
|
||||
if (sheet !== undefined) {
|
||||
getRange = `${sheet}!${getRange}`;
|
||||
}
|
||||
|
||||
const keyColumnData = await this.getData(getRange, 'UNFORMATTED_VALUE');
|
||||
|
||||
if (keyColumnData === undefined) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
'Could not retrieve the column data!',
|
||||
);
|
||||
}
|
||||
|
||||
const keyColumnOrder = keyColumnData[0];
|
||||
|
||||
const setData: string[][] = [];
|
||||
|
||||
let rowData: string[] = [];
|
||||
inputData.forEach((item) => {
|
||||
rowData = [];
|
||||
keyColumnOrder.forEach((key) => {
|
||||
const value = get(item, key) as string;
|
||||
if (usePathForKeyRow && value !== undefined && value !== null) {
|
||||
//match by key path
|
||||
rowData.push(value.toString());
|
||||
} else if (
|
||||
!usePathForKeyRow &&
|
||||
item.hasOwnProperty(key) &&
|
||||
item[key] !== null &&
|
||||
item[key] !== undefined
|
||||
) {
|
||||
//match by exact key name
|
||||
rowData.push(item[key].toString());
|
||||
} else {
|
||||
rowData.push('');
|
||||
}
|
||||
});
|
||||
setData.push(rowData);
|
||||
});
|
||||
|
||||
return setData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
ICredentialsDecrypted,
|
||||
ICredentialTestFunctions,
|
||||
IDataObject,
|
||||
ILoadOptionsFunctions,
|
||||
INodeCredentialTestResult,
|
||||
INodeExecutionData,
|
||||
INodePropertyOptions,
|
||||
INodeType,
|
||||
INodeTypeBaseDescription,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { googleApiRequest, hexToRgb } from './GenericFunctions';
|
||||
import type {
|
||||
ILookupValues,
|
||||
ISheetUpdateData,
|
||||
IToDelete,
|
||||
ValueInputOption,
|
||||
ValueRenderOption,
|
||||
} from './GoogleSheet';
|
||||
import { GoogleSheet } from './GoogleSheet';
|
||||
import { versionDescription } from './versionDescription';
|
||||
import { generatePairedItemData } from '../../../../utils/utilities';
|
||||
import { getGoogleAccessToken } from '../../GenericFunctions';
|
||||
|
||||
export class GoogleSheetsV1 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
...versionDescription,
|
||||
};
|
||||
}
|
||||
|
||||
methods = {
|
||||
loadOptions: {
|
||||
// Get all the sheets in a Spreadsheet
|
||||
async getSheets(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
|
||||
const spreadsheetId = this.getCurrentNodeParameter('sheetId') as string;
|
||||
|
||||
const sheet = new GoogleSheet(spreadsheetId, this);
|
||||
const responseData = await sheet.spreadsheetGetSheets();
|
||||
|
||||
if (responseData === undefined) {
|
||||
throw new NodeOperationError(this.getNode(), 'No data got returned');
|
||||
}
|
||||
|
||||
const returnData: INodePropertyOptions[] = [];
|
||||
for (const entry of responseData.sheets!) {
|
||||
if (entry.properties!.sheetType !== 'GRID') {
|
||||
continue;
|
||||
}
|
||||
|
||||
returnData.push({
|
||||
name: entry.properties!.title as string,
|
||||
value: entry.properties!.sheetId as unknown as string,
|
||||
});
|
||||
}
|
||||
|
||||
return returnData;
|
||||
},
|
||||
},
|
||||
credentialTest: {
|
||||
async googleApiCredentialTest(
|
||||
this: ICredentialTestFunctions,
|
||||
credential: ICredentialsDecrypted,
|
||||
): Promise<INodeCredentialTestResult> {
|
||||
try {
|
||||
const tokenRequest = await getGoogleAccessToken.call(this, credential.data!, 'sheetV1');
|
||||
|
||||
if (!tokenRequest.access_token) {
|
||||
return {
|
||||
status: 'Error',
|
||||
message: 'Could not generate a token from your private key.',
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 'Error',
|
||||
message: `Private key validation failed: ${err.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'OK',
|
||||
message: 'Connection successful!',
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
const resource = this.getNodeParameter('resource', 0);
|
||||
|
||||
if (resource === 'sheet') {
|
||||
const spreadsheetId = this.getNodeParameter('sheetId', 0) as string;
|
||||
|
||||
const sheet = new GoogleSheet(spreadsheetId, this);
|
||||
|
||||
let range = '';
|
||||
if (!['create', 'delete', 'remove'].includes(operation)) {
|
||||
range = this.getNodeParameter('range', 0) as string;
|
||||
}
|
||||
|
||||
const options = this.getNodeParameter('options', 0, {});
|
||||
|
||||
const valueInputMode = (options.valueInputMode || 'RAW') as ValueInputOption;
|
||||
const valueRenderMode = (options.valueRenderMode || 'UNFORMATTED_VALUE') as ValueRenderOption;
|
||||
|
||||
if (operation === 'append') {
|
||||
// ----------------------------------
|
||||
// append
|
||||
// ----------------------------------
|
||||
try {
|
||||
const keyRow = parseInt(this.getNodeParameter('keyRow', 0) as string, 10);
|
||||
|
||||
const items = this.getInputData();
|
||||
|
||||
const setData: IDataObject[] = [];
|
||||
items.forEach((item) => {
|
||||
setData.push(item.json);
|
||||
});
|
||||
|
||||
const usePathForKeyRow = (options.usePathForKeyRow || false) as boolean;
|
||||
|
||||
// Convert data into array format
|
||||
await sheet.appendSheetData(
|
||||
setData,
|
||||
sheet.encodeRange(range),
|
||||
keyRow,
|
||||
valueInputMode,
|
||||
usePathForKeyRow,
|
||||
);
|
||||
|
||||
// TODO: Should add this data somewhere
|
||||
// TODO: Should have something like add metadata which does not get passed through
|
||||
|
||||
return [items];
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
return [[{ json: { error: error.message } }]];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else if (operation === 'clear') {
|
||||
// ----------------------------------
|
||||
// clear
|
||||
// ----------------------------------
|
||||
try {
|
||||
await sheet.clearData(sheet.encodeRange(range));
|
||||
|
||||
const items = this.getInputData();
|
||||
return [items];
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
return [[{ json: { error: error.message } }]];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else if (operation === 'create') {
|
||||
const returnData: IDataObject[] = [];
|
||||
|
||||
let responseData;
|
||||
for (let i = 0; i < this.getInputData().length; i++) {
|
||||
try {
|
||||
const sheetId = this.getNodeParameter('sheetId', i) as string;
|
||||
const iterationOptions = this.getNodeParameter('options', i, {});
|
||||
const simple = this.getNodeParameter('simple', 0) as boolean;
|
||||
const properties = { ...iterationOptions };
|
||||
|
||||
if (iterationOptions.tabColor) {
|
||||
const { red, green, blue } = hexToRgb(iterationOptions.tabColor as string)!;
|
||||
properties.tabColor = { red: red / 255, green: green / 255, blue: blue / 255 };
|
||||
}
|
||||
|
||||
const requests = [
|
||||
{
|
||||
addSheet: {
|
||||
properties,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
responseData = await googleApiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${sheetId}:batchUpdate`,
|
||||
{ requests },
|
||||
);
|
||||
|
||||
if (simple) {
|
||||
Object.assign(responseData, responseData.replies[0].addSheet.properties);
|
||||
delete responseData.replies;
|
||||
}
|
||||
returnData.push(responseData as IDataObject);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ error: error.message });
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return [this.helpers.returnJsonArray(returnData)];
|
||||
} else if (operation === 'delete') {
|
||||
// ----------------------------------
|
||||
// delete
|
||||
// ----------------------------------
|
||||
try {
|
||||
const requests: IDataObject[] = [];
|
||||
|
||||
const toDelete = this.getNodeParameter('toDelete', 0) as IToDelete;
|
||||
|
||||
const deletePropertyToDimensions: IDataObject = {
|
||||
columns: 'COLUMNS',
|
||||
rows: 'ROWS',
|
||||
};
|
||||
|
||||
for (const propertyName of Object.keys(deletePropertyToDimensions)) {
|
||||
if (toDelete[propertyName] !== undefined) {
|
||||
toDelete[propertyName].forEach((entry) => {
|
||||
requests.push({
|
||||
deleteDimension: {
|
||||
range: {
|
||||
sheetId: entry.sheetId,
|
||||
dimension: deletePropertyToDimensions[propertyName] as string,
|
||||
startIndex: entry.startIndex,
|
||||
endIndex:
|
||||
parseInt(entry.startIndex.toString(), 10) +
|
||||
parseInt(entry.amount.toString(), 10),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await sheet.spreadsheetBatchUpdate(requests);
|
||||
|
||||
const items = this.getInputData();
|
||||
return [items];
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
return [[{ json: { error: error.message } }]];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else if (operation === 'lookup') {
|
||||
// ----------------------------------
|
||||
// lookup
|
||||
// ----------------------------------
|
||||
try {
|
||||
const sheetData = await sheet.getData(sheet.encodeRange(range), valueRenderMode);
|
||||
|
||||
if (sheetData === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dataStartRow = parseInt(this.getNodeParameter('dataStartRow', 0) as string, 10);
|
||||
const keyRow = parseInt(this.getNodeParameter('keyRow', 0) as string, 10);
|
||||
|
||||
const items = this.getInputData();
|
||||
|
||||
const lookupValues: ILookupValues[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
lookupValues.push({
|
||||
lookupColumn: this.getNodeParameter('lookupColumn', i) as string,
|
||||
lookupValue: this.getNodeParameter('lookupValue', i) as string,
|
||||
});
|
||||
}
|
||||
|
||||
let returnData = await sheet.lookupValues(
|
||||
sheetData,
|
||||
keyRow,
|
||||
dataStartRow,
|
||||
lookupValues,
|
||||
options.returnAllMatches as boolean | undefined,
|
||||
);
|
||||
|
||||
if (returnData.length === 0 && options.continue && options.returnAllMatches) {
|
||||
returnData = [{}];
|
||||
} else if (
|
||||
returnData.length === 1 &&
|
||||
Object.keys(returnData[0]).length === 0 &&
|
||||
!options.continue &&
|
||||
!options.returnAllMatches
|
||||
) {
|
||||
returnData = [];
|
||||
}
|
||||
|
||||
const pairedItem = generatePairedItemData(items.length);
|
||||
|
||||
const lookupOutput = returnData.map((item) => {
|
||||
return {
|
||||
json: item,
|
||||
pairedItem,
|
||||
};
|
||||
});
|
||||
|
||||
return [lookupOutput];
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
return [this.helpers.returnJsonArray({ error: error.message })];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else if (operation === 'read') {
|
||||
// ----------------------------------
|
||||
// read
|
||||
// ----------------------------------
|
||||
try {
|
||||
const rawData = this.getNodeParameter('rawData', 0);
|
||||
|
||||
const sheetData = await sheet.getData(sheet.encodeRange(range), valueRenderMode);
|
||||
|
||||
let returnData: IDataObject[];
|
||||
if (!sheetData) {
|
||||
returnData = [];
|
||||
} else if (rawData) {
|
||||
const dataProperty = this.getNodeParameter('dataProperty', 0) as string;
|
||||
returnData = [
|
||||
{
|
||||
[dataProperty]: sheetData,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
const dataStartRow = parseInt(this.getNodeParameter('dataStartRow', 0) as string, 10);
|
||||
const keyRow = parseInt(this.getNodeParameter('keyRow', 0) as string, 10);
|
||||
|
||||
returnData = sheet.structureArrayDataByColumn(sheetData, keyRow, dataStartRow);
|
||||
}
|
||||
|
||||
if (returnData.length === 0 && options.continue) {
|
||||
returnData = [{}];
|
||||
}
|
||||
|
||||
return [this.helpers.returnJsonArray(returnData)];
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
return [this.helpers.returnJsonArray({ error: error.message })];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else if (operation === 'remove') {
|
||||
const returnData: IDataObject[] = [];
|
||||
|
||||
let responseData;
|
||||
for (let i = 0; i < this.getInputData().length; i++) {
|
||||
try {
|
||||
const id = this.getNodeParameter('id', i) as string;
|
||||
const sheetId = this.getNodeParameter('sheetId', i) as string;
|
||||
|
||||
const requests = [
|
||||
{
|
||||
deleteSheet: {
|
||||
sheetId: id,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
responseData = await googleApiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${sheetId}:batchUpdate`,
|
||||
{ requests },
|
||||
);
|
||||
delete responseData.replies;
|
||||
returnData.push(responseData as IDataObject);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ error: error.message });
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return [this.helpers.returnJsonArray(returnData)];
|
||||
} else if (operation === 'update' || operation === 'upsert') {
|
||||
// ----------------------------------
|
||||
// update/upsert
|
||||
// ----------------------------------
|
||||
const upsert = operation === 'upsert' ? true : false;
|
||||
try {
|
||||
const rawData = this.getNodeParameter('rawData', 0);
|
||||
|
||||
const items = this.getInputData();
|
||||
|
||||
if (rawData) {
|
||||
const dataProperty = this.getNodeParameter('dataProperty', 0) as string;
|
||||
|
||||
const updateData: ISheetUpdateData[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
updateData.push({
|
||||
range,
|
||||
values: items[i].json[dataProperty] as string[][],
|
||||
});
|
||||
}
|
||||
|
||||
await sheet.batchUpdate(updateData, valueInputMode);
|
||||
} else {
|
||||
const keyName = this.getNodeParameter('key', 0) as string;
|
||||
const keyRow = parseInt(this.getNodeParameter('keyRow', 0) as string, 10);
|
||||
const dataStartRow = parseInt(this.getNodeParameter('dataStartRow', 0) as string, 10);
|
||||
|
||||
const setData: IDataObject[] = [];
|
||||
items.forEach((item) => {
|
||||
setData.push(item.json);
|
||||
});
|
||||
|
||||
await sheet.updateSheetData(
|
||||
setData,
|
||||
keyName,
|
||||
range,
|
||||
keyRow,
|
||||
dataStartRow,
|
||||
valueInputMode,
|
||||
valueRenderMode,
|
||||
upsert,
|
||||
);
|
||||
}
|
||||
// TODO: Should add this data somewhere
|
||||
// TODO: Should have something like add metadata which does not get passed through
|
||||
|
||||
return [items];
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
return [[{ json: { error: error.message } }]];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resource === 'spreadsheet') {
|
||||
const returnData: IDataObject[] = [];
|
||||
|
||||
let responseData;
|
||||
|
||||
if (operation === 'create') {
|
||||
// ----------------------------------
|
||||
// create
|
||||
// ----------------------------------
|
||||
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/create
|
||||
|
||||
for (let i = 0; i < this.getInputData().length; i++) {
|
||||
try {
|
||||
const title = this.getNodeParameter('title', i) as string;
|
||||
const sheetsUi = this.getNodeParameter('sheetsUi', i, {}) as IDataObject;
|
||||
|
||||
const body = {
|
||||
properties: {
|
||||
title,
|
||||
autoRecalc: undefined as undefined | string,
|
||||
locale: undefined as undefined | string,
|
||||
},
|
||||
sheets: [] as IDataObject[],
|
||||
};
|
||||
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
if (Object.keys(sheetsUi).length) {
|
||||
const data = [];
|
||||
const sheets = sheetsUi.sheetValues as IDataObject[];
|
||||
for (const sheet of sheets) {
|
||||
const properties = sheet.propertiesUi as IDataObject;
|
||||
if (properties) {
|
||||
data.push({ properties });
|
||||
}
|
||||
}
|
||||
body.sheets = data;
|
||||
}
|
||||
|
||||
body.properties.autoRecalc = options.autoRecalc
|
||||
? (options.autoRecalc as string)
|
||||
: undefined;
|
||||
body.properties.locale = options.locale ? (options.locale as string) : undefined;
|
||||
|
||||
responseData = await googleApiRequest.call(this, 'POST', '/v4/spreadsheets', body);
|
||||
|
||||
returnData.push(responseData as IDataObject);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ error: error.message });
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [this.helpers.returnJsonArray(returnData)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,910 @@
|
||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { oldVersionNotice } from '@utils/descriptions';
|
||||
|
||||
export const versionDescription: INodeTypeDescription = {
|
||||
displayName: 'Google Sheets ',
|
||||
name: 'googleSheets',
|
||||
icon: 'file:googleSheets.svg',
|
||||
group: ['input', 'output'],
|
||||
version: [1, 2],
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Read, update and write data to Google Sheets',
|
||||
defaults: {
|
||||
name: 'Google Sheets',
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'googleApi',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['serviceAccount'],
|
||||
},
|
||||
},
|
||||
testedBy: 'googleApiCredentialTest',
|
||||
},
|
||||
{
|
||||
name: 'googleSheetsOAuth2Api',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['oAuth2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
oldVersionNotice,
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Service Account',
|
||||
value: 'serviceAccount',
|
||||
},
|
||||
{
|
||||
name: 'OAuth2',
|
||||
value: 'oAuth2',
|
||||
},
|
||||
],
|
||||
default: 'serviceAccount',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'OAuth2 (recommended)',
|
||||
value: 'oAuth2',
|
||||
},
|
||||
{
|
||||
name: 'Service Account',
|
||||
value: 'serviceAccount',
|
||||
},
|
||||
],
|
||||
default: 'oAuth2',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [2],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Spreadsheet',
|
||||
value: 'spreadsheet',
|
||||
},
|
||||
{
|
||||
name: 'Sheet',
|
||||
value: 'sheet',
|
||||
},
|
||||
],
|
||||
default: 'sheet',
|
||||
},
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Append',
|
||||
value: 'append',
|
||||
description: 'Append data to a sheet',
|
||||
action: 'Append data to a sheet',
|
||||
},
|
||||
{
|
||||
name: 'Clear',
|
||||
value: 'clear',
|
||||
description: 'Clear data from a sheet',
|
||||
action: 'Clear a sheet',
|
||||
},
|
||||
{
|
||||
name: 'Create',
|
||||
value: 'create',
|
||||
description: 'Create a new sheet',
|
||||
action: 'Create a sheet',
|
||||
},
|
||||
{
|
||||
name: 'Create or Update',
|
||||
value: 'upsert',
|
||||
description:
|
||||
'Create a new record, or update the current one if it already exists (upsert)',
|
||||
action: 'Create or update a sheet',
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
value: 'delete',
|
||||
description: 'Delete columns and rows from a sheet',
|
||||
action: 'Delete a sheet',
|
||||
},
|
||||
{
|
||||
name: 'Lookup',
|
||||
value: 'lookup',
|
||||
description: 'Look up a specific column value and return the matching row',
|
||||
action: 'Look up a column value in a sheet',
|
||||
},
|
||||
{
|
||||
name: 'Read',
|
||||
value: 'read',
|
||||
description: 'Read data from a sheet',
|
||||
action: 'Read a sheet',
|
||||
},
|
||||
{
|
||||
name: 'Remove',
|
||||
value: 'remove',
|
||||
description: 'Remove a sheet',
|
||||
action: 'Remove a sheet',
|
||||
},
|
||||
{
|
||||
name: 'Update',
|
||||
value: 'update',
|
||||
description: 'Update rows in a sheet',
|
||||
action: 'Update a sheet',
|
||||
},
|
||||
],
|
||||
default: 'read',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// All
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Spreadsheet ID',
|
||||
name: 'sheetId',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
description:
|
||||
'The ID of the Google Spreadsheet. Found as part of the sheet URL https://docs.google.com/spreadsheets/d/{ID}/.',
|
||||
},
|
||||
{
|
||||
displayName: 'Range',
|
||||
name: 'range',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
},
|
||||
hide: {
|
||||
operation: ['create', 'delete', 'remove'],
|
||||
},
|
||||
},
|
||||
default: 'A:F',
|
||||
required: true,
|
||||
description:
|
||||
'The table range to read from or to append data to. See the Google <a href="https://developers.google.com/sheets/api/guides/values#writing">documentation</a> for the details. If it contains multiple sheets it can also be added like this: "MySheet!A:F"',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// Delete
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'To Delete',
|
||||
name: 'toDelete',
|
||||
placeholder: 'Add Columns/Rows to delete',
|
||||
description: 'Deletes columns and rows from a sheet',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['delete'],
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Sheet Name or ID',
|
||||
name: 'sheetId',
|
||||
type: 'options',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getSheets',
|
||||
},
|
||||
options: [],
|
||||
default: '',
|
||||
required: true,
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
},
|
||||
{
|
||||
displayName: 'Start Index',
|
||||
name: 'startIndex',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
},
|
||||
default: 0,
|
||||
description: 'The start index (0 based and inclusive) of column to delete',
|
||||
},
|
||||
{
|
||||
displayName: 'Amount',
|
||||
name: 'amount',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
description: 'Number of columns to delete',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Rows',
|
||||
name: 'rows',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Sheet Name or ID',
|
||||
name: 'sheetId',
|
||||
type: 'options',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getSheets',
|
||||
},
|
||||
options: [],
|
||||
default: '',
|
||||
required: true,
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
},
|
||||
{
|
||||
displayName: 'Start Index',
|
||||
name: 'startIndex',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
},
|
||||
default: 0,
|
||||
description: 'The start index (0 based and inclusive) of row to delete',
|
||||
},
|
||||
{
|
||||
displayName: 'Amount',
|
||||
name: 'amount',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
description: 'Number of rows to delete',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// Read
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'RAW Data',
|
||||
name: 'rawData',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['read'],
|
||||
},
|
||||
},
|
||||
default: false,
|
||||
description:
|
||||
'Whether the data should be returned RAW instead of parsed into keys according to their header',
|
||||
},
|
||||
{
|
||||
displayName: 'Data Property',
|
||||
name: 'dataProperty',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['read'],
|
||||
rawData: [true],
|
||||
},
|
||||
},
|
||||
description: 'The name of the property into which to write the RAW data',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// Update
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'RAW Data',
|
||||
name: 'rawData',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['update', 'upsert'],
|
||||
},
|
||||
},
|
||||
default: false,
|
||||
description: 'Whether the data supplied is RAW instead of parsed into keys',
|
||||
},
|
||||
{
|
||||
displayName: 'Data Property',
|
||||
name: 'dataProperty',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['update', 'upsert'],
|
||||
rawData: [true],
|
||||
},
|
||||
},
|
||||
description: 'The name of the property from which to read the RAW data',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// Read & Update & lookupColumn
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Data Start Row',
|
||||
name: 'dataStartRow',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
},
|
||||
hide: {
|
||||
operation: ['append', 'create', 'clear', 'delete', 'remove'],
|
||||
rawData: [true],
|
||||
},
|
||||
},
|
||||
description:
|
||||
'Index of the first row which contains the actual data and not the keys. Starts with 0.',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// Mixed
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Key Row',
|
||||
name: 'keyRow',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
},
|
||||
hide: {
|
||||
operation: ['clear', 'create', 'delete', 'remove'],
|
||||
rawData: [true],
|
||||
},
|
||||
},
|
||||
default: 0,
|
||||
description:
|
||||
'Index of the row which contains the keys. Starts at 0. The incoming node data is matched to the keys for assignment. The matching is case sensitive.',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// lookup
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Lookup Column',
|
||||
name: 'lookupColumn',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'Email',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['lookup'],
|
||||
},
|
||||
},
|
||||
description: 'The name of the column in which to look for value',
|
||||
},
|
||||
{
|
||||
displayName: 'Lookup Value',
|
||||
name: 'lookupValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'frank@example.com',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['lookup'],
|
||||
},
|
||||
},
|
||||
description: 'The value to look for in column',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// Update
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Key',
|
||||
name: 'key',
|
||||
type: 'string',
|
||||
default: 'id',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['update', 'upsert'],
|
||||
rawData: [false],
|
||||
},
|
||||
},
|
||||
description: 'The name of the key to identify which data should be updated in the sheet',
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['append', 'lookup', 'read', 'update', 'upsert'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Continue If Empty',
|
||||
name: 'continue',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operation': ['lookup', 'read'],
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
|
||||
description:
|
||||
'By default, the workflow stops executing if the lookup/read does not return values',
|
||||
},
|
||||
{
|
||||
displayName: 'Return All Matches',
|
||||
name: 'returnAllMatches',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operation': ['lookup'],
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
|
||||
description:
|
||||
'By default only the first result gets returned. If options gets set all found matches get returned.',
|
||||
},
|
||||
{
|
||||
displayName: 'Use Header Names as JSON Paths',
|
||||
name: 'usePathForKeyRow',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operation': ['append'],
|
||||
},
|
||||
},
|
||||
description:
|
||||
'Whether you want to match the headers as path, for example, the row header "category.name" will match the "category" object and get the field "name" from it. By default "category.name" will match with the field with exact name, not nested object.',
|
||||
},
|
||||
{
|
||||
displayName: 'Value Input Mode',
|
||||
name: 'valueInputMode',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operation': ['append', 'update', 'upsert'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'RAW',
|
||||
value: 'RAW',
|
||||
description: 'The values will not be parsed and will be stored as-is',
|
||||
},
|
||||
{
|
||||
name: 'User Entered',
|
||||
value: 'USER_ENTERED',
|
||||
description:
|
||||
'The values will be parsed as if the user typed them into the UI. Numbers will stay as numbers, but strings may be converted to numbers, dates, etc. following the same rules that are applied when entering text into a cell via the Google Sheets UI.',
|
||||
},
|
||||
],
|
||||
default: 'RAW',
|
||||
description: 'Determines how data should be interpreted',
|
||||
},
|
||||
{
|
||||
displayName: 'Value Render Mode',
|
||||
name: 'valueRenderMode',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operation': ['lookup', 'read'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Formatted Value',
|
||||
value: 'FORMATTED_VALUE',
|
||||
description:
|
||||
"Values will be calculated & formatted in the reply according to the cell's formatting.Formatting is based on the spreadsheet's locale, not the requesting user's locale.For example, if A1 is 1.23 and A2 is =A1 and formatted as currency, then A2 would return \"$1.23\"",
|
||||
},
|
||||
{
|
||||
name: 'Formula',
|
||||
value: 'FORMULA',
|
||||
description:
|
||||
'Values will not be calculated. The reply will include the formulas. For example, if A1 is 1.23 and A2 is =A1 and formatted as currency, then A2 would return "=A1".',
|
||||
},
|
||||
{
|
||||
name: 'Unformatted Value',
|
||||
value: 'UNFORMATTED_VALUE',
|
||||
description:
|
||||
'Values will be calculated, but not formatted in the reply. For example, if A1 is 1.23 and A2 is =A1 and formatted as currency, then A2 would return the number 1.23.',
|
||||
},
|
||||
],
|
||||
default: 'UNFORMATTED_VALUE',
|
||||
description: 'Determines how values should be rendered in the output',
|
||||
},
|
||||
{
|
||||
displayName: 'Value Render Mode',
|
||||
name: 'valueRenderMode',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operation': ['update', 'upsert'],
|
||||
'/rawData': [false],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Formatted Value',
|
||||
value: 'FORMATTED_VALUE',
|
||||
description:
|
||||
"Values will be calculated & formatted in the reply according to the cell's formatting.Formatting is based on the spreadsheet's locale, not the requesting user's locale. For example, if A1 is 1.23 and A2 is =A1 and formatted as currency, then A2 would return \"$1.23\".",
|
||||
},
|
||||
{
|
||||
name: 'Formula',
|
||||
value: 'FORMULA',
|
||||
description:
|
||||
'Values will not be calculated. The reply will include the formulas. For example, if A1 is 1.23 and A2 is =A1 and formatted as currency, then A2 would return "=A1".',
|
||||
},
|
||||
{
|
||||
name: 'Unformatted Value',
|
||||
value: 'UNFORMATTED_VALUE',
|
||||
description:
|
||||
'Values will be calculated, but not formatted in the reply. For example, if A1 is 1.23 and A2 is =A1 and formatted as currency, then A2 would return the number 1.23.',
|
||||
},
|
||||
],
|
||||
default: 'UNFORMATTED_VALUE',
|
||||
description: 'Determines how values should be rendered in the output',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['spreadsheet'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Create',
|
||||
value: 'create',
|
||||
description: 'Create a spreadsheet',
|
||||
action: 'Create a spreadsheet',
|
||||
},
|
||||
],
|
||||
default: 'create',
|
||||
},
|
||||
// ----------------------------------
|
||||
// spreadsheet:create
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Title',
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['spreadsheet'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
description: 'The title of the spreadsheet',
|
||||
},
|
||||
{
|
||||
displayName: 'Sheets',
|
||||
name: 'sheetsUi',
|
||||
placeholder: 'Add Sheet',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['spreadsheet'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'sheetValues',
|
||||
displayName: 'Sheet',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Sheet Properties',
|
||||
name: 'propertiesUi',
|
||||
placeholder: 'Add Property',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Hidden',
|
||||
name: 'hidden',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether the Sheet should be hidden in the UI',
|
||||
},
|
||||
{
|
||||
displayName: 'Title',
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Title of the property to create',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['spreadsheet'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Locale',
|
||||
name: 'locale',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'en_US',
|
||||
description: `The locale of the spreadsheet in one of the following formats:
|
||||
<ul>
|
||||
<li>en (639-1)</li>
|
||||
<li>fil (639-2 if no 639-1 format exists)</li>
|
||||
<li>en_US (combination of ISO language an country)</li>
|
||||
<ul>`,
|
||||
},
|
||||
{
|
||||
displayName: 'Recalculation Interval',
|
||||
name: 'autoRecalc',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Default',
|
||||
value: '',
|
||||
description: 'Default value',
|
||||
},
|
||||
{
|
||||
name: 'On Change',
|
||||
value: 'ON_CHANGE',
|
||||
description: 'Volatile functions are updated on every change',
|
||||
},
|
||||
{
|
||||
name: 'Minute',
|
||||
value: 'MINUTE',
|
||||
description: 'Volatile functions are updated on every change and every minute',
|
||||
},
|
||||
{
|
||||
name: 'Hour',
|
||||
value: 'HOUR',
|
||||
description: 'Volatile functions are updated on every change and hourly',
|
||||
},
|
||||
],
|
||||
default: '',
|
||||
description: 'Cell recalculation interval options',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// sheet:create
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Simplify',
|
||||
name: 'simple',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
description: 'Whether to return a simplified version of the response instead of the raw data',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Grid Properties',
|
||||
name: 'gridProperties',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Property',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Column Count',
|
||||
name: 'columnCount',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'The number of columns in the grid',
|
||||
},
|
||||
{
|
||||
displayName: 'Column Group Control After',
|
||||
name: 'columnGroupControlAfter',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether the column grouping control toggle is shown after the group',
|
||||
},
|
||||
{
|
||||
displayName: 'Frozen Column Count',
|
||||
name: 'frozenColumnCount',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'The number of columns that are frozen in the grid',
|
||||
},
|
||||
{
|
||||
displayName: 'Frozen Row Count',
|
||||
name: 'frozenRowCount',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'The number of rows that are frozen in the grid',
|
||||
},
|
||||
{
|
||||
displayName: 'Hide Gridlines',
|
||||
name: 'hideGridlines',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: "Whether the grid isn't showing gridlines in the UI",
|
||||
},
|
||||
{
|
||||
displayName: 'Row Count',
|
||||
name: 'rowCount',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'The number of rows in the grid',
|
||||
},
|
||||
{
|
||||
displayName: 'Row Group Control After',
|
||||
name: 'rowGroupControlAfter',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether the row grouping control toggle is shown after the group',
|
||||
},
|
||||
],
|
||||
description: 'The type of the sheet',
|
||||
},
|
||||
{
|
||||
displayName: 'Hidden',
|
||||
name: 'hidden',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: "Whether the sheet is hidden in the UI, false if it's visible",
|
||||
},
|
||||
{
|
||||
displayName: 'Right To Left',
|
||||
name: 'rightToLeft',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether the sheet is an RTL sheet instead of an LTR sheet',
|
||||
},
|
||||
{
|
||||
displayName: 'Sheet ID',
|
||||
name: 'sheetId',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description:
|
||||
'The ID of the sheet. Must be non-negative. This field cannot be changed once set.',
|
||||
},
|
||||
{
|
||||
displayName: 'Sheet Index',
|
||||
name: 'index',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'The index of the sheet within the spreadsheet',
|
||||
},
|
||||
{
|
||||
displayName: 'Tab Color',
|
||||
name: 'tabColor',
|
||||
type: 'color',
|
||||
default: '0aa55c',
|
||||
description: 'The color of the tab in the UI',
|
||||
},
|
||||
{
|
||||
displayName: 'Title',
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'The Sheet name',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// sheet:remove
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Sheet ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['remove'],
|
||||
},
|
||||
},
|
||||
description: 'The ID of the sheet to delete',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeType,
|
||||
INodeTypeBaseDescription,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { router } from './actions/router';
|
||||
import { versionDescription } from './actions/versionDescription';
|
||||
import { credentialTest, listSearch, loadOptions, resourceMapping } from './methods';
|
||||
|
||||
export class GoogleSheetsV2 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
...versionDescription,
|
||||
};
|
||||
}
|
||||
|
||||
methods = {
|
||||
loadOptions,
|
||||
credentialTest,
|
||||
listSearch,
|
||||
resourceMapping,
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions) {
|
||||
return await router.call(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { type IExecuteFunctions, type IDataObject, type INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import * as sheet from './sheet/Sheet.resource';
|
||||
import * as spreadsheet from './spreadsheet/SpreadSheet.resource';
|
||||
import { GoogleSheet } from '../helpers/GoogleSheet';
|
||||
import type { GoogleSheets, ResourceLocator } from '../helpers/GoogleSheets.types';
|
||||
import { getSpreadsheetId } from '../helpers/GoogleSheets.utils';
|
||||
|
||||
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
let operationResult: INodeExecutionData[] = [];
|
||||
|
||||
try {
|
||||
const resource = this.getNodeParameter('resource', 0);
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
|
||||
const googleSheets = {
|
||||
resource,
|
||||
operation,
|
||||
} as GoogleSheets;
|
||||
|
||||
let results: INodeExecutionData[] | undefined;
|
||||
if (googleSheets.resource === 'sheet') {
|
||||
const { mode, value } = this.getNodeParameter('documentId', 0) as IDataObject;
|
||||
const spreadsheetId = getSpreadsheetId(
|
||||
this.getNode(),
|
||||
mode as ResourceLocator,
|
||||
value as string,
|
||||
);
|
||||
|
||||
const googleSheet = new GoogleSheet(spreadsheetId, this);
|
||||
|
||||
let sheetId = '';
|
||||
let sheetName = '';
|
||||
|
||||
if (operation !== 'create') {
|
||||
const sheetWithinDocument = this.getNodeParameter('sheetName', 0, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
const { mode: sheetMode } = this.getNodeParameter('sheetName', 0) as {
|
||||
mode: ResourceLocator;
|
||||
};
|
||||
|
||||
const result = await googleSheet.spreadsheetGetSheet(
|
||||
this.getNode(),
|
||||
sheetMode,
|
||||
sheetWithinDocument,
|
||||
);
|
||||
sheetId = result.sheetId.toString();
|
||||
sheetName = result.title;
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case 'create':
|
||||
sheetName = spreadsheetId;
|
||||
break;
|
||||
case 'delete':
|
||||
sheetName = sheetId;
|
||||
break;
|
||||
case 'remove':
|
||||
sheetName = `${spreadsheetId}||${sheetId}`;
|
||||
break;
|
||||
}
|
||||
|
||||
results = await sheet[googleSheets.operation].execute.call(
|
||||
this,
|
||||
googleSheet,
|
||||
sheetName,
|
||||
sheetId,
|
||||
);
|
||||
} else if (googleSheets.resource === 'spreadsheet') {
|
||||
results = await spreadsheet[googleSheets.operation].execute.call(this);
|
||||
}
|
||||
if (results?.length) {
|
||||
operationResult = operationResult.concat(results);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
operationResult.push({ json: this.getInputData(0)[0].json, error });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return [operationResult];
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as append from './append.operation';
|
||||
import * as appendOrUpdate from './appendOrUpdate.operation';
|
||||
import * as clear from './clear.operation';
|
||||
import * as create from './create.operation';
|
||||
import * as del from './delete.operation';
|
||||
import * as read from './read.operation';
|
||||
import * as remove from './remove.operation';
|
||||
import * as update from './update.operation';
|
||||
import { GOOGLE_DRIVE_FILE_URL_REGEX, GOOGLE_SHEETS_SHEET_URL_REGEX } from '../../../../constants';
|
||||
|
||||
export { append, appendOrUpdate, clear, create, del as delete, read, remove, update };
|
||||
|
||||
export const descriptions: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Append or Update Row',
|
||||
value: 'appendOrUpdate',
|
||||
description: 'Append a new row or update an existing one (upsert)',
|
||||
action: 'Append or update row in sheet',
|
||||
},
|
||||
{
|
||||
name: 'Append Row',
|
||||
value: 'append',
|
||||
description: 'Create a new row in a sheet',
|
||||
action: 'Append row in sheet',
|
||||
},
|
||||
{
|
||||
name: 'Clear',
|
||||
value: 'clear',
|
||||
description: 'Delete all the contents or a part of a sheet',
|
||||
action: 'Clear sheet',
|
||||
},
|
||||
{
|
||||
name: 'Create',
|
||||
value: 'create',
|
||||
description: 'Create a new sheet',
|
||||
action: 'Create sheet',
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
value: 'remove',
|
||||
description: 'Permanently delete a sheet',
|
||||
action: 'Delete sheet',
|
||||
},
|
||||
{
|
||||
name: 'Delete Rows or Columns',
|
||||
value: 'delete',
|
||||
description: 'Delete columns or rows from a sheet',
|
||||
action: 'Delete rows or columns from sheet',
|
||||
},
|
||||
{
|
||||
name: 'Get Row(s)',
|
||||
value: 'read',
|
||||
description: 'Retrieve one or more rows from a sheet',
|
||||
action: 'Get row(s) in sheet',
|
||||
},
|
||||
{
|
||||
name: 'Update Row',
|
||||
value: 'update',
|
||||
description: 'Update an existing row in a sheet',
|
||||
action: 'Update row in sheet',
|
||||
},
|
||||
],
|
||||
default: 'read',
|
||||
},
|
||||
{
|
||||
displayName: 'Document',
|
||||
name: 'documentId',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
builderHint: { message: "Default to mode: 'list' which is easier for users to set up" },
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'spreadSheetsSearch',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: GOOGLE_DRIVE_FILE_URL_REGEX,
|
||||
},
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: GOOGLE_DRIVE_FILE_URL_REGEX,
|
||||
errorMessage: 'Not a valid Google Drive File URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '[a-zA-Z0-9\\-_]{2,}',
|
||||
errorMessage: 'Not a valid Google Drive File ID',
|
||||
},
|
||||
},
|
||||
],
|
||||
url: '=https://docs.google.com/spreadsheets/d/{{$value}}/edit',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Sheet',
|
||||
name: 'sheetName',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
// default: '', //empty string set to progresivly reveal fields
|
||||
required: true,
|
||||
builderHint: { message: "Default to mode: 'list' which is easier for users to set up" },
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['documentId.value'],
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'sheetsSearch',
|
||||
searchable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: GOOGLE_SHEETS_SHEET_URL_REGEX,
|
||||
},
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: GOOGLE_SHEETS_SHEET_URL_REGEX,
|
||||
errorMessage: 'Not a valid Sheet URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '((gid=)?[0-9]{1,})',
|
||||
errorMessage: 'Not a valid Sheet ID',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'By Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
placeholder: 'Sheet1',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['append', 'appendOrUpdate', 'clear', 'delete', 'read', 'remove', 'update'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...append.description,
|
||||
...clear.description,
|
||||
...create.description,
|
||||
...del.description,
|
||||
...read.description,
|
||||
...update.description,
|
||||
...appendOrUpdate.description,
|
||||
];
|
||||
@@ -0,0 +1,305 @@
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type IDataObject,
|
||||
type INodeExecutionData,
|
||||
NodeOperationError,
|
||||
type ResourceMapperField,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { cellFormat, handlingExtraData, useAppendOption } from './commonDescription';
|
||||
import type { GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import type { SheetProperties, ValueInputOption } from '../../helpers/GoogleSheets.types';
|
||||
import {
|
||||
autoMapInputData,
|
||||
cellFormatDefault,
|
||||
checkForSchemaChanges,
|
||||
mapFields,
|
||||
untilSheetSelected,
|
||||
} from '../../helpers/GoogleSheets.utils';
|
||||
|
||||
export const description: SheetProperties = [
|
||||
{
|
||||
displayName: 'Data Mode',
|
||||
name: 'dataMode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto-Map Input Data to Columns',
|
||||
value: 'autoMapInputData',
|
||||
description: 'Use when node input properties match destination column names',
|
||||
},
|
||||
{
|
||||
name: 'Map Each Column Below',
|
||||
value: 'defineBelow',
|
||||
description: 'Set the value for each destination column',
|
||||
},
|
||||
{
|
||||
name: 'Nothing',
|
||||
value: 'nothing',
|
||||
description: 'Do not send anything',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['append'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: 'defineBelow',
|
||||
description: 'Whether to insert the input data this node receives in the new row',
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
"In this mode, make sure the incoming data is named the same as the columns in your Sheet. (Use an 'Edit Fields' node before this node to change it if required.)",
|
||||
name: 'autoMapNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['append'],
|
||||
dataMode: ['autoMapInputData'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Fields to Send',
|
||||
name: 'fieldsUi',
|
||||
placeholder: 'Add Field',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValueButtonText: 'Add Field to Send',
|
||||
multipleValues: true,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['append'],
|
||||
dataMode: ['defineBelow'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Field',
|
||||
name: 'fieldValues',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Field Name or ID',
|
||||
name: 'fieldId',
|
||||
type: 'options',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
loadOptionsMethod: 'getSheetHeaderRowAndSkipEmpty',
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Field Value',
|
||||
name: 'fieldValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'getMappingColumns',
|
||||
mode: 'add',
|
||||
fieldWords: {
|
||||
singular: 'column',
|
||||
plural: 'columns',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: false,
|
||||
},
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['append'],
|
||||
'@version': [{ _cnd: { gte: 4 } }],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['append'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
options: [
|
||||
cellFormat,
|
||||
{
|
||||
displayName: 'Data Location on Sheet',
|
||||
name: 'locationDefine',
|
||||
type: 'fixedCollection',
|
||||
placeholder: 'Select Range',
|
||||
default: { values: {} },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Header Row',
|
||||
name: 'headerRow',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
description:
|
||||
'Index of the row which contains the keys. Starts at 1. The incoming node data is matched to the keys for assignment. The matching is case sensitive.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
handlingExtraData,
|
||||
{
|
||||
...handlingExtraData,
|
||||
displayOptions: { show: { '/columns.mappingMode': ['autoMapInputData'] } },
|
||||
},
|
||||
useAppendOption,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
sheet: GoogleSheet,
|
||||
range: string,
|
||||
sheetId: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const items = this.getInputData();
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
let dataMode =
|
||||
nodeVersion < 4
|
||||
? (this.getNodeParameter('dataMode', 0) as string)
|
||||
: (this.getNodeParameter('columns.mappingMode', 0) as string);
|
||||
|
||||
if (!items.length || dataMode === 'nothing') return [];
|
||||
|
||||
const options = this.getNodeParameter('options', 0, {});
|
||||
const locationDefine = (options.locationDefine as IDataObject)?.values as IDataObject;
|
||||
|
||||
let keyRowIndex = 1;
|
||||
if (locationDefine?.headerRow) {
|
||||
keyRowIndex = locationDefine.headerRow as number;
|
||||
}
|
||||
|
||||
const sheetData = await sheet.getData(range, 'FORMATTED_VALUE');
|
||||
|
||||
if (!sheetData?.length) {
|
||||
dataMode = 'autoMapInputData';
|
||||
}
|
||||
|
||||
if (nodeVersion >= 4.4 && dataMode !== 'autoMapInputData') {
|
||||
//not possible to refresh columns when mode is autoMapInputData
|
||||
if (sheetData?.[keyRowIndex - 1] === undefined) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Could not retrieve the column names from row ${keyRowIndex}`,
|
||||
);
|
||||
}
|
||||
|
||||
const schema = this.getNodeParameter('columns.schema', 0) as ResourceMapperField[];
|
||||
checkForSchemaChanges(this.getNode(), sheetData[keyRowIndex - 1], schema);
|
||||
}
|
||||
|
||||
let inputData: IDataObject[] = [];
|
||||
|
||||
if (dataMode === 'autoMapInputData') {
|
||||
inputData = await autoMapInputData.call(this, range, sheet, items, options);
|
||||
} else {
|
||||
inputData = mapFields.call(this, items.length);
|
||||
}
|
||||
|
||||
if (inputData.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const valueInputMode = (options.cellFormat as ValueInputOption) || cellFormatDefault(nodeVersion);
|
||||
const useAppend = options.useAppend as boolean;
|
||||
|
||||
if (options.useAppend) {
|
||||
await sheet.appendSheetData({
|
||||
inputData,
|
||||
range,
|
||||
keyRowIndex,
|
||||
valueInputMode,
|
||||
useAppend,
|
||||
});
|
||||
} else {
|
||||
//if no trailing empty row exists in the sheet update operation will fail
|
||||
await sheet.appendEmptyRowsOrColumns(sheetId, 1, 0);
|
||||
|
||||
// if sheetData is undefined it means that the sheet was empty
|
||||
// we did add row with column names in the first row (autoMapInputData)
|
||||
// to account for that length has to be 1 and we append data in the next row
|
||||
const lastRow = (sheetData ?? [{}]).length + 1;
|
||||
|
||||
await sheet.appendSheetData({
|
||||
inputData,
|
||||
range,
|
||||
keyRowIndex,
|
||||
valueInputMode,
|
||||
lastRow,
|
||||
});
|
||||
}
|
||||
|
||||
if (nodeVersion < 4 || dataMode === 'autoMapInputData') {
|
||||
return items.map((item, index) => {
|
||||
item.pairedItem = { item: index };
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
for (const [index, entry] of inputData.entries()) {
|
||||
returnData.push({
|
||||
json: entry,
|
||||
pairedItem: { item: index },
|
||||
});
|
||||
}
|
||||
return returnData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
ResourceMapperField,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
cellFormat,
|
||||
handlingExtraData,
|
||||
locationDefine,
|
||||
useAppendOption,
|
||||
} from './commonDescription';
|
||||
import type { GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import {
|
||||
ROW_NUMBER,
|
||||
type ISheetUpdateData,
|
||||
type SheetProperties,
|
||||
type ValueInputOption,
|
||||
type ValueRenderOption,
|
||||
} from '../../helpers/GoogleSheets.types';
|
||||
import {
|
||||
cellFormatDefault,
|
||||
checkForSchemaChanges,
|
||||
untilSheetSelected,
|
||||
} from '../../helpers/GoogleSheets.utils';
|
||||
|
||||
export const description: SheetProperties = [
|
||||
{
|
||||
displayName: 'Data Mode',
|
||||
name: 'dataMode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto-Map Input Data to Columns',
|
||||
value: 'autoMapInputData',
|
||||
description: 'Use when node input properties match destination column names',
|
||||
},
|
||||
{
|
||||
name: 'Map Each Column Below',
|
||||
value: 'defineBelow',
|
||||
description: 'Set the value for each destination column',
|
||||
},
|
||||
{
|
||||
name: 'Nothing',
|
||||
value: 'nothing',
|
||||
description: 'Do not send anything',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['appendOrUpdate'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: 'defineBelow',
|
||||
description: 'Whether to insert the input data this node receives in the new row',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased, n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column to match on',
|
||||
name: 'columnToMatchOn',
|
||||
type: 'options',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
loadOptionsMethod: 'getSheetHeaderRowAndSkipEmpty',
|
||||
},
|
||||
default: '',
|
||||
hint: "Used to find the correct row to update. Doesn't get changed.",
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['appendOrUpdate'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Value of Column to Match On',
|
||||
name: 'valueToMatchOn',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['appendOrUpdate'],
|
||||
dataMode: ['defineBelow'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Values to Send',
|
||||
name: 'fieldsUi',
|
||||
placeholder: 'Add Field',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['appendOrUpdate'],
|
||||
dataMode: ['defineBelow'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Field',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column',
|
||||
name: 'column',
|
||||
type: 'options',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value', 'columnToMatchOn'],
|
||||
loadOptionsMethod: 'getSheetHeaderRowAndAddColumn',
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Column Name',
|
||||
name: 'columnName',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
column: ['newColumn'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'fieldValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'getMappingColumns',
|
||||
mode: 'upsert',
|
||||
fieldWords: {
|
||||
singular: 'column',
|
||||
plural: 'columns',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: false,
|
||||
allowEmptyValues: true,
|
||||
},
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['appendOrUpdate'],
|
||||
'@version': [{ _cnd: { gte: 4.7 } }],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'getMappingColumns',
|
||||
mode: 'upsert',
|
||||
fieldWords: {
|
||||
singular: 'column',
|
||||
plural: 'columns',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: false,
|
||||
},
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['appendOrUpdate'],
|
||||
'@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['appendOrUpdate'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
options: [
|
||||
cellFormat,
|
||||
locationDefine,
|
||||
handlingExtraData,
|
||||
{
|
||||
...handlingExtraData,
|
||||
displayOptions: { show: { '/columns.mappingMode': ['autoMapInputData'] } },
|
||||
},
|
||||
useAppendOption,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
sheet: GoogleSheet,
|
||||
sheetName: string,
|
||||
sheetId: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const items = this.getInputData();
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
|
||||
const range = `${sheetName}!A:Z`;
|
||||
|
||||
const valueInputMode = this.getNodeParameter(
|
||||
'options.cellFormat',
|
||||
0,
|
||||
cellFormatDefault(nodeVersion),
|
||||
) as ValueInputOption;
|
||||
|
||||
const options = this.getNodeParameter('options', 0, {});
|
||||
|
||||
const valueRenderMode = (options.valueRenderMode || 'UNFORMATTED_VALUE') as ValueRenderOption;
|
||||
|
||||
const locationDefineOption = (options.locationDefine as IDataObject)?.values as IDataObject;
|
||||
|
||||
let keyRowIndex = 0;
|
||||
let dataStartRowIndex = 1;
|
||||
|
||||
if (locationDefineOption) {
|
||||
if (locationDefineOption.headerRow) {
|
||||
keyRowIndex = parseInt(locationDefineOption.headerRow as string, 10) - 1;
|
||||
}
|
||||
if (locationDefineOption.firstDataRow) {
|
||||
dataStartRowIndex = parseInt(locationDefineOption.firstDataRow as string, 10) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
let dataMode =
|
||||
nodeVersion < 4
|
||||
? (this.getNodeParameter('dataMode', 0) as string)
|
||||
: (this.getNodeParameter('columns.mappingMode', 0) as string);
|
||||
|
||||
let columnNames: string[] = [];
|
||||
|
||||
const sheetData = (await sheet.getData(sheetName, 'FORMATTED_VALUE')) ?? [];
|
||||
|
||||
if (!sheetData[keyRowIndex] && dataMode !== 'autoMapInputData') {
|
||||
if (!sheetData.length) {
|
||||
dataMode = 'autoMapInputData';
|
||||
} else {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Could not retrieve the column names from row ${keyRowIndex + 1}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
columnNames = sheetData[keyRowIndex] ?? [];
|
||||
|
||||
if (nodeVersion >= 4.4) {
|
||||
const schema = this.getNodeParameter('columns.schema', 0) as ResourceMapperField[];
|
||||
checkForSchemaChanges(this.getNode(), columnNames, schema);
|
||||
}
|
||||
|
||||
const newColumns = new Set<string>();
|
||||
|
||||
const columnsToMatchOn: string[] =
|
||||
nodeVersion < 4
|
||||
? [this.getNodeParameter('columnToMatchOn', 0) as string]
|
||||
: (this.getNodeParameter('columns.matchingColumns', 0) as string[]);
|
||||
|
||||
// TODO: Add support for multiple columns to match on in the next overhaul
|
||||
const keyIndex = columnNames.indexOf(columnsToMatchOn[0]);
|
||||
|
||||
const columnValuesList = await sheet.getColumnValues({
|
||||
range,
|
||||
keyIndex,
|
||||
dataStartRowIndex,
|
||||
valueRenderMode,
|
||||
sheetData,
|
||||
});
|
||||
|
||||
const updateData: ISheetUpdateData[] = [];
|
||||
const appendData: IDataObject[] = [];
|
||||
|
||||
const errorOnUnexpectedColumn = (key: string, i: number) => {
|
||||
if (!columnNames.includes(key)) {
|
||||
throw new NodeOperationError(this.getNode(), 'Unexpected fields in node input', {
|
||||
itemIndex: i,
|
||||
description: `The input field '${key}' doesn't match any column in the Sheet. You can ignore this by changing the 'Handling extra data' field, which you can find under 'Options'.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addNewColumn = (key: string) => {
|
||||
if (!columnNames.includes(key) && key !== ROW_NUMBER) {
|
||||
newColumns.add(key);
|
||||
}
|
||||
};
|
||||
|
||||
const mappedValues: IDataObject[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (dataMode === 'nothing') continue;
|
||||
|
||||
const inputData: IDataObject[] = [];
|
||||
|
||||
if (dataMode === 'autoMapInputData') {
|
||||
const handlingExtraDataOption = (options.handlingExtraData as string) || 'insertInNewColumn';
|
||||
if (handlingExtraDataOption === 'ignoreIt') {
|
||||
inputData.push(items[i].json);
|
||||
}
|
||||
if (handlingExtraDataOption === 'error') {
|
||||
Object.keys(items[i].json).forEach((key) => errorOnUnexpectedColumn(key, i));
|
||||
inputData.push(items[i].json);
|
||||
}
|
||||
if (handlingExtraDataOption === 'insertInNewColumn') {
|
||||
Object.keys(items[i].json).forEach(addNewColumn);
|
||||
inputData.push(items[i].json);
|
||||
}
|
||||
} else {
|
||||
const valueToMatchOn =
|
||||
nodeVersion < 4
|
||||
? (this.getNodeParameter('valueToMatchOn', i, '') as string)
|
||||
: (this.getNodeParameter(`columns.value[${columnsToMatchOn[0]}]`, i, '') as string);
|
||||
|
||||
if (valueToMatchOn === '') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"The 'Column to Match On' parameter is required",
|
||||
{
|
||||
itemIndex: i,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (nodeVersion < 4) {
|
||||
const valuesToSend = this.getNodeParameter('fieldsUi.values', i, []) as IDataObject[];
|
||||
if (!valuesToSend?.length) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"At least one value has to be added under 'Values to Send'",
|
||||
);
|
||||
}
|
||||
|
||||
const fields = valuesToSend.reduce((acc, entry) => {
|
||||
if (entry.column === 'newColumn') {
|
||||
const columnName = entry.columnName as string;
|
||||
if (!columnNames.includes(columnName)) {
|
||||
newColumns.add(columnName);
|
||||
}
|
||||
acc[columnName] = entry.fieldValue as string;
|
||||
} else {
|
||||
acc[entry.column as string] = entry.fieldValue as string;
|
||||
}
|
||||
return acc;
|
||||
}, {} as IDataObject);
|
||||
fields[columnsToMatchOn[0]] = valueToMatchOn;
|
||||
inputData.push(fields);
|
||||
} else {
|
||||
const mappingValues = this.getNodeParameter('columns.value', i) as IDataObject;
|
||||
if (Object.keys(mappingValues).length === 0) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"At least one value has to be added under 'Values to Send'",
|
||||
);
|
||||
}
|
||||
// Setting empty values to empty string so that they are not ignored by the API
|
||||
Object.keys(mappingValues).forEach((key) => {
|
||||
if (mappingValues[key] === undefined || mappingValues[key] === null) {
|
||||
mappingValues[key] = '';
|
||||
}
|
||||
});
|
||||
inputData.push(mappingValues);
|
||||
mappedValues.push(mappingValues);
|
||||
}
|
||||
}
|
||||
|
||||
if (newColumns.size) {
|
||||
const newColumnNames = columnNames.concat([...newColumns]);
|
||||
await sheet.updateRows(
|
||||
sheetName,
|
||||
[newColumnNames],
|
||||
(options.cellFormat as ValueInputOption) || cellFormatDefault(nodeVersion),
|
||||
keyRowIndex + 1,
|
||||
);
|
||||
columnNames = newColumnNames;
|
||||
sheetData[keyRowIndex] = newColumnNames;
|
||||
newColumns.clear();
|
||||
}
|
||||
|
||||
const indexKey = columnsToMatchOn[0];
|
||||
|
||||
const preparedData = await sheet.prepareDataForUpdateOrUpsert({
|
||||
inputData,
|
||||
indexKey,
|
||||
range,
|
||||
keyRowIndex,
|
||||
dataStartRowIndex,
|
||||
valueRenderMode,
|
||||
upsert: true,
|
||||
columnNamesList: [columnNames.concat([...newColumns])],
|
||||
columnValuesList,
|
||||
});
|
||||
|
||||
updateData.push(...preparedData.updateData);
|
||||
appendData.push(...preparedData.appendData);
|
||||
}
|
||||
|
||||
const columnNamesList = [columnNames.concat([...newColumns])];
|
||||
|
||||
if (updateData.length) {
|
||||
await sheet.batchUpdate(updateData, valueInputMode);
|
||||
}
|
||||
if (appendData.length) {
|
||||
const lastRow = sheetData.length + 1;
|
||||
const useAppend = options.useAppend as boolean;
|
||||
|
||||
if (options.useAppend) {
|
||||
await sheet.appendSheetData({
|
||||
inputData: appendData,
|
||||
range,
|
||||
keyRowIndex: keyRowIndex + 1,
|
||||
valueInputMode,
|
||||
columnNamesList,
|
||||
lastRow,
|
||||
useAppend,
|
||||
});
|
||||
} else {
|
||||
await sheet.appendEmptyRowsOrColumns(sheetId, 1, 0);
|
||||
await sheet.appendSheetData({
|
||||
inputData: appendData,
|
||||
range,
|
||||
keyRowIndex: keyRowIndex + 1,
|
||||
valueInputMode,
|
||||
columnNamesList,
|
||||
lastRow,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (nodeVersion < 4 || dataMode === 'autoMapInputData') {
|
||||
return items.map((item, index) => {
|
||||
item.pairedItem = { item: index };
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
for (const [index, entry] of mappedValues.entries()) {
|
||||
returnData.push({
|
||||
json: entry,
|
||||
pairedItem: { item: index },
|
||||
});
|
||||
}
|
||||
return returnData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import type { GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import type { SheetProperties } from '../../helpers/GoogleSheets.types';
|
||||
import {
|
||||
getColumnName,
|
||||
getColumnNumber,
|
||||
untilSheetSelected,
|
||||
} from '../../helpers/GoogleSheets.utils';
|
||||
|
||||
export const description: SheetProperties = [
|
||||
{
|
||||
displayName: 'Clear',
|
||||
name: 'clear',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Whole Sheet',
|
||||
value: 'wholeSheet',
|
||||
},
|
||||
{
|
||||
name: 'Specific Rows',
|
||||
value: 'specificRows',
|
||||
},
|
||||
{
|
||||
name: 'Specific Columns',
|
||||
value: 'specificColumns',
|
||||
},
|
||||
{
|
||||
name: 'Specific Range',
|
||||
value: 'specificRange',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['clear'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: 'wholeSheet',
|
||||
description: 'What to clear',
|
||||
},
|
||||
{
|
||||
displayName: 'Keep First Row',
|
||||
name: 'keepFirstRow',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['clear'],
|
||||
clear: ['wholeSheet'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
displayName: 'Start Row Number',
|
||||
name: 'startIndex',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
description: 'The row number to delete from, The first row is 1',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['clear'],
|
||||
clear: ['specificRows'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Number of Rows to Delete',
|
||||
name: 'rowsToDelete',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['clear'],
|
||||
clear: ['specificRows'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Start Column',
|
||||
name: 'startIndex',
|
||||
type: 'string',
|
||||
default: 'A',
|
||||
description: 'The column to delete',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['clear'],
|
||||
clear: ['specificColumns'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Could this be better as "end column"?
|
||||
displayName: 'Number of Columns to Delete',
|
||||
name: 'columnsToDelete',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['clear'],
|
||||
clear: ['specificColumns'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Range',
|
||||
name: 'range',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['clear'],
|
||||
clear: ['specificRange'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: 'A:F',
|
||||
required: true,
|
||||
description:
|
||||
'The table range to read from or to append data to. See the Google <a href="https://developers.google.com/sheets/api/guides/values#writing">documentation</a> for the details. If it contains multiple sheets it can also be added like this: "MySheet!A:F"',
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
sheet: GoogleSheet,
|
||||
sheetName: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const items = this.getInputData();
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const clearType = this.getNodeParameter('clear', i) as string;
|
||||
const keepFirstRow = this.getNodeParameter('keepFirstRow', i, false) as boolean;
|
||||
let range = '';
|
||||
|
||||
if (clearType === 'specificRows') {
|
||||
const startIndex = this.getNodeParameter('startIndex', i) as number;
|
||||
const rowsToDelete = this.getNodeParameter('rowsToDelete', i) as number;
|
||||
const endIndex = rowsToDelete === 1 ? startIndex : startIndex + rowsToDelete - 1;
|
||||
|
||||
range = `${sheetName}!${startIndex}:${endIndex}`;
|
||||
}
|
||||
|
||||
if (clearType === 'specificColumns') {
|
||||
const startIndex = this.getNodeParameter('startIndex', i) as string;
|
||||
const columnsToDelete = this.getNodeParameter('columnsToDelete', i) as number;
|
||||
const columnNumber = getColumnNumber(startIndex);
|
||||
const endIndex = columnsToDelete === 1 ? columnNumber : columnNumber + columnsToDelete - 1;
|
||||
|
||||
range = `${sheetName}!${startIndex}:${getColumnName(endIndex)}`;
|
||||
}
|
||||
|
||||
if (clearType === 'specificRange') {
|
||||
const rangeField = this.getNodeParameter('range', i) as string;
|
||||
const region = rangeField.includes('!') ? rangeField.split('!')[1] || '' : rangeField;
|
||||
|
||||
range = `${sheetName}!${region}`;
|
||||
}
|
||||
|
||||
if (clearType === 'wholeSheet') {
|
||||
range = sheetName;
|
||||
}
|
||||
|
||||
if (keepFirstRow) {
|
||||
const firstRow = await sheet.getData(`${range}!1:1`, 'FORMATTED_VALUE');
|
||||
await sheet.clearData(range);
|
||||
await sheet.updateRows(range, firstRow as string[][], 'RAW', 1);
|
||||
} else {
|
||||
await sheet.clearData(range);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const dataLocationOnSheet: INodeProperties = {
|
||||
displayName: 'Data Location on Sheet',
|
||||
name: 'dataLocationOnSheet',
|
||||
type: 'fixedCollection',
|
||||
placeholder: 'Select Range',
|
||||
default: { values: { rangeDefinition: 'detectAutomatically' } },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Range Definition',
|
||||
name: 'rangeDefinition',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Detect Automatically',
|
||||
value: 'detectAutomatically',
|
||||
description: 'Automatically detect the data range',
|
||||
},
|
||||
{
|
||||
name: 'Specify Range (A1 Notation)',
|
||||
value: 'specifyRangeA1',
|
||||
description: 'Manually specify the data range',
|
||||
},
|
||||
{
|
||||
name: 'Specify Range (Rows)',
|
||||
value: 'specifyRange',
|
||||
description: 'Manually specify the data range',
|
||||
},
|
||||
],
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Read Rows Until',
|
||||
name: 'readRowsUntil',
|
||||
type: 'options',
|
||||
default: 'lastRowInSheet',
|
||||
options: [
|
||||
{
|
||||
name: 'First Empty Row',
|
||||
value: 'firstEmptyRow',
|
||||
},
|
||||
{
|
||||
name: 'Last Row In Sheet',
|
||||
value: 'lastRowInSheet',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
rangeDefinition: ['detectAutomatically'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Header Row',
|
||||
name: 'headerRow',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
description: "Index is relative to the set 'Range', first row index is 1",
|
||||
hint: 'Index of the row which contains the column names',
|
||||
displayOptions: {
|
||||
show: {
|
||||
rangeDefinition: ['specifyRange'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'First Data Row',
|
||||
name: 'firstDataRow',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 2,
|
||||
description: "Index is relative to the set 'Range', first row index is 1",
|
||||
hint: 'Index of first row which contains the actual data',
|
||||
displayOptions: {
|
||||
show: {
|
||||
rangeDefinition: ['specifyRange'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Range',
|
||||
name: 'range',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'A:Z',
|
||||
description:
|
||||
'The table range to read from or to append data to. See the Google <a href="https://developers.google.com/sheets/api/guides/values#writing">documentation</a> for the details.',
|
||||
hint: 'You can specify both the rows and the columns, e.g. C4:E7',
|
||||
displayOptions: {
|
||||
show: {
|
||||
rangeDefinition: ['specifyRangeA1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const locationDefine: INodeProperties = {
|
||||
displayName: 'Data Location on Sheet',
|
||||
name: 'locationDefine',
|
||||
type: 'fixedCollection',
|
||||
placeholder: 'Select Range',
|
||||
default: { values: {} },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Header Row',
|
||||
name: 'headerRow',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
description: "Index is relative to the set 'Range', first row index is 1",
|
||||
hint: 'Index of the row which contains the column names',
|
||||
},
|
||||
{
|
||||
displayName: 'First Data Row',
|
||||
name: 'firstDataRow',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 2,
|
||||
description: "Index is relative to the set 'Range', first row index is 1",
|
||||
hint: 'Index of first row which contains the actual data',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const outputFormatting: INodeProperties = {
|
||||
displayName: 'Output Formatting',
|
||||
name: 'outputFormatting',
|
||||
type: 'fixedCollection',
|
||||
placeholder: 'Add Formatting',
|
||||
default: { values: { general: 'UNFORMATTED_VALUE', date: 'FORMATTED_STRING' } },
|
||||
options: [
|
||||
{
|
||||
displayName: 'Values',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'General Formatting',
|
||||
name: 'general',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'Values (unformatted)',
|
||||
value: 'UNFORMATTED_VALUE',
|
||||
description:
|
||||
'Numbers stay as numbers, but any currency signs or special formatting is lost',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'Values (formatted)',
|
||||
value: 'FORMATTED_VALUE',
|
||||
description:
|
||||
'Numbers are turned to text, and displayed as in Google Sheets (e.g. with commas or currency signs)',
|
||||
},
|
||||
{
|
||||
name: 'Formulas',
|
||||
value: 'FORMULA',
|
||||
},
|
||||
],
|
||||
default: '',
|
||||
description: 'Determines how values should be rendered in the output',
|
||||
},
|
||||
{
|
||||
displayName: 'Date Formatting',
|
||||
name: 'date',
|
||||
type: 'options',
|
||||
default: '',
|
||||
options: [
|
||||
{
|
||||
name: 'Formatted Text',
|
||||
value: 'FORMATTED_STRING',
|
||||
description: "As displayed in Google Sheets, e.g. '01/01/2022'",
|
||||
},
|
||||
{
|
||||
name: 'Serial Number',
|
||||
value: 'SERIAL_NUMBER',
|
||||
description: 'A number representing the number of days since Dec 30, 1899',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const cellFormat: INodeProperties = {
|
||||
displayName: 'Cell Format',
|
||||
name: 'cellFormat',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'Let Google Sheets format',
|
||||
value: 'USER_ENTERED',
|
||||
description: 'Cells are styled as if you typed the values into Google Sheets directly',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'Let n8n format',
|
||||
value: 'RAW',
|
||||
description: 'Cells have the same types as the input data',
|
||||
},
|
||||
],
|
||||
default: 'USER_ENTERED',
|
||||
description: 'Determines how data should be interpreted',
|
||||
};
|
||||
|
||||
export const handlingExtraData: INodeProperties = {
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
displayName: 'Handling extra fields in input',
|
||||
name: 'handlingExtraData',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Insert in New Column(s)',
|
||||
value: 'insertInNewColumn',
|
||||
description: 'Create a new column for extra data',
|
||||
},
|
||||
{
|
||||
name: 'Ignore Them',
|
||||
value: 'ignoreIt',
|
||||
description: 'Ignore extra data',
|
||||
},
|
||||
{
|
||||
name: 'Error',
|
||||
value: 'error',
|
||||
description: 'Throw an error',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/dataMode': ['autoMapInputData'],
|
||||
},
|
||||
},
|
||||
default: 'insertInNewColumn',
|
||||
description: "What do to with fields that don't match any columns in the Google Sheet",
|
||||
};
|
||||
|
||||
export const useAppendOption: INodeProperties = {
|
||||
displayName: 'Minimise API Calls',
|
||||
name: 'useAppend',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
hint: 'Use if your sheet has no gaps between rows or columns',
|
||||
description:
|
||||
'Whether to use append instead of update(default), this is more efficient but in some cases data might be misaligned',
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { wrapData } from '../../../../../../utils/utilities';
|
||||
import type { GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import type { SheetProperties } from '../../helpers/GoogleSheets.types';
|
||||
import { getExistingSheetNames, hexToRgb } from '../../helpers/GoogleSheets.utils';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: SheetProperties = [
|
||||
{
|
||||
displayName: 'Title',
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: 'n8n-sheet',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
description: 'The name of the sheet',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Hidden',
|
||||
name: 'hidden',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: "Whether the sheet is hidden in the UI, false if it's visible",
|
||||
},
|
||||
{
|
||||
displayName: 'Right To Left',
|
||||
name: 'rightToLeft',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether the sheet is an RTL sheet instead of an LTR sheet',
|
||||
},
|
||||
{
|
||||
displayName: 'Sheet ID',
|
||||
name: 'sheetId',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description:
|
||||
'The ID of the sheet. Must be non-negative. This field cannot be changed once set.',
|
||||
},
|
||||
{
|
||||
displayName: 'Sheet Index',
|
||||
name: 'index',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'The index of the sheet within the spreadsheet',
|
||||
},
|
||||
{
|
||||
displayName: 'Tab Color',
|
||||
name: 'tabColor',
|
||||
type: 'color',
|
||||
default: '0aa55c',
|
||||
description: 'The color of the tab in the UI',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
sheet: GoogleSheet,
|
||||
sheetName: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
let responseData;
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
const items = this.getInputData();
|
||||
|
||||
const existingSheetNames = await getExistingSheetNames(sheet);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const sheetTitle = this.getNodeParameter('title', i, {}) as string;
|
||||
|
||||
if (existingSheetNames.includes(sheetTitle)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
const properties = { ...options };
|
||||
properties.title = sheetTitle;
|
||||
|
||||
if (options.tabColor) {
|
||||
const { red, green, blue } = hexToRgb(options.tabColor as string)!;
|
||||
properties.tabColor = { red: red / 255, green: green / 255, blue: blue / 255 };
|
||||
}
|
||||
|
||||
const requests = [
|
||||
{
|
||||
addSheet: {
|
||||
properties,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
responseData = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${sheetName}:batchUpdate`,
|
||||
{ requests },
|
||||
);
|
||||
|
||||
// simplify response
|
||||
Object.assign(responseData, responseData.replies[0].addSheet.properties);
|
||||
delete responseData.replies;
|
||||
|
||||
existingSheetNames.push(sheetTitle);
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
wrapData(responseData as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
returnData.push(...executionData);
|
||||
}
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { generatePairedItemData, wrapData } from '../../../../../../utils/utilities';
|
||||
import type { GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import type { SheetProperties } from '../../helpers/GoogleSheets.types';
|
||||
import { getColumnNumber, untilSheetSelected } from '../../helpers/GoogleSheets.utils';
|
||||
|
||||
export const description: SheetProperties = [
|
||||
{
|
||||
displayName: 'To Delete',
|
||||
name: 'toDelete',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Rows',
|
||||
value: 'rows',
|
||||
description: 'Rows to delete',
|
||||
},
|
||||
{
|
||||
name: 'Columns',
|
||||
value: 'columns',
|
||||
description: 'Columns to delete',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['delete'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: 'rows',
|
||||
description: 'What to delete',
|
||||
},
|
||||
{
|
||||
displayName: 'Start Row Number',
|
||||
name: 'startIndex',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 2,
|
||||
description: 'The row number to delete from, The first row is 2',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['delete'],
|
||||
toDelete: ['rows'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Number of Rows to Delete',
|
||||
name: 'numberToDelete',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['delete'],
|
||||
toDelete: ['rows'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Start Column',
|
||||
name: 'startIndex',
|
||||
type: 'string',
|
||||
default: 'A',
|
||||
description: 'The column to delete',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['delete'],
|
||||
toDelete: ['columns'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Number of Columns to Delete',
|
||||
name: 'numberToDelete',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
default: 1,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['delete'],
|
||||
toDelete: ['columns'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
sheet: GoogleSheet,
|
||||
sheetName: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const items = this.getInputData();
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const requests: IDataObject[] = [];
|
||||
let startIndex, endIndex, numberToDelete;
|
||||
const deleteType = this.getNodeParameter('toDelete', i) as string;
|
||||
|
||||
if (deleteType === 'rows') {
|
||||
startIndex = this.getNodeParameter('startIndex', i) as number;
|
||||
// We start from 1 now...
|
||||
startIndex--;
|
||||
numberToDelete = this.getNodeParameter('numberToDelete', i) as number;
|
||||
if (numberToDelete === 1) {
|
||||
endIndex = startIndex + 1;
|
||||
} else {
|
||||
endIndex = startIndex + numberToDelete;
|
||||
}
|
||||
requests.push({
|
||||
deleteDimension: {
|
||||
range: {
|
||||
sheetId: sheetName,
|
||||
dimension: 'ROWS',
|
||||
startIndex,
|
||||
endIndex,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else if (deleteType === 'columns') {
|
||||
startIndex = this.getNodeParameter('startIndex', i) as string;
|
||||
numberToDelete = this.getNodeParameter('numberToDelete', i) as number;
|
||||
startIndex = getColumnNumber(startIndex) - 1;
|
||||
if (numberToDelete === 1) {
|
||||
endIndex = startIndex + 1;
|
||||
} else {
|
||||
endIndex = startIndex + numberToDelete;
|
||||
}
|
||||
requests.push({
|
||||
deleteDimension: {
|
||||
range: {
|
||||
sheetId: sheetName,
|
||||
dimension: 'COLUMNS',
|
||||
startIndex,
|
||||
endIndex,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
await sheet.spreadsheetBatchUpdate(requests);
|
||||
}
|
||||
|
||||
const itemData = generatePairedItemData(this.getInputData().length);
|
||||
const returnData = this.helpers.constructExecutionMetaData(wrapData({ success: true }), {
|
||||
itemData,
|
||||
});
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import { dataLocationOnSheet, outputFormatting } from './commonDescription';
|
||||
import type { GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import type { SheetProperties } from '../../helpers/GoogleSheets.types';
|
||||
import { untilSheetSelected } from '../../helpers/GoogleSheets.utils';
|
||||
import { readSheet } from '../utils/readOperation';
|
||||
|
||||
const combineFiltersOptions: INodeProperties = {
|
||||
displayName: 'Combine Filters',
|
||||
name: 'combineFilters',
|
||||
type: 'options',
|
||||
description:
|
||||
'How to combine the conditions defined in "Filters": AND requires all conditions to be true, OR requires at least one condition to be true',
|
||||
options: [
|
||||
{
|
||||
name: 'AND',
|
||||
value: 'AND',
|
||||
description: 'Only rows that meet all the conditions are selected',
|
||||
},
|
||||
{
|
||||
name: 'OR',
|
||||
value: 'OR',
|
||||
description: 'Rows that meet at least one condition are selected',
|
||||
},
|
||||
],
|
||||
default: 'AND',
|
||||
};
|
||||
|
||||
export const readFilter: INodeProperties = {
|
||||
displayName: 'Filters',
|
||||
name: 'filtersUI',
|
||||
placeholder: 'Add Filter',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValueButtonText: 'Add Filter',
|
||||
multipleValues: true,
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Filter',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column',
|
||||
name: 'lookupColumn',
|
||||
type: 'options',
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
loadOptionsMethod: 'getSheetHeaderRowWithGeneratedColumnNames',
|
||||
},
|
||||
default: '',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'lookupValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
hint: 'The column must have this value to be matched',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const description: SheetProperties = [
|
||||
{
|
||||
...readFilter,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['read'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...combineFiltersOptions,
|
||||
default: 'OR',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { lt: 4.3 } }],
|
||||
resource: ['sheet'],
|
||||
operation: ['read'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...combineFiltersOptions,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 4.3 } }],
|
||||
resource: ['sheet'],
|
||||
operation: ['read'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['read'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
options: [
|
||||
dataLocationOnSheet,
|
||||
outputFormatting,
|
||||
{
|
||||
displayName: 'Return only First Matching Row',
|
||||
name: 'returnFirstMatch',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to select the first row of the sheet or the first matching row (if filters are set)',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 4.5 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'When Filter Has Multiple Matches',
|
||||
name: 'returnAllMatches',
|
||||
type: 'options',
|
||||
default: 'returnFirstMatch',
|
||||
options: [
|
||||
{
|
||||
name: 'Return First Match',
|
||||
value: 'returnFirstMatch',
|
||||
description: 'Return only the first match',
|
||||
},
|
||||
{
|
||||
name: 'Return All Matches',
|
||||
value: 'returnAllMatches',
|
||||
description: 'Return all values that match',
|
||||
},
|
||||
],
|
||||
description:
|
||||
'By default only the first result gets returned, Set to "Return All Matches" to get multiple matches',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { lt: 4.5 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
sheet: GoogleSheet,
|
||||
sheetName: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const items = this.getInputData();
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
let length = 1;
|
||||
|
||||
if (nodeVersion > 4.1) {
|
||||
length = items.length;
|
||||
}
|
||||
|
||||
let returnData: INodeExecutionData[] = [];
|
||||
|
||||
for (let itemIndex = 0; itemIndex < length; itemIndex++) {
|
||||
returnData = await readSheet.call(
|
||||
this,
|
||||
sheet,
|
||||
sheetName,
|
||||
itemIndex,
|
||||
returnData,
|
||||
nodeVersion,
|
||||
items,
|
||||
);
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { wrapData } from '../../../../../../utils/utilities';
|
||||
import type { GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
_sheet: GoogleSheet,
|
||||
sheetName: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
const items = this.getInputData();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const [spreadsheetId, sheetWithinDocument] = sheetName.split('||');
|
||||
const requests = [
|
||||
{
|
||||
deleteSheet: {
|
||||
sheetId: sheetWithinDocument,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const responseData = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${spreadsheetId}:batchUpdate`,
|
||||
{ requests },
|
||||
);
|
||||
delete responseData.replies;
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
wrapData(responseData as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
returnData.push(...executionData);
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
import { NodeOperationError, UserError } from 'n8n-workflow';
|
||||
|
||||
import { cellFormat, handlingExtraData, locationDefine } from './commonDescription';
|
||||
import type { GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import {
|
||||
ROW_NUMBER,
|
||||
type ISheetUpdateData,
|
||||
type SheetProperties,
|
||||
type ValueInputOption,
|
||||
type ValueRenderOption,
|
||||
} from '../../helpers/GoogleSheets.types';
|
||||
import { cellFormatDefault, untilSheetSelected } from '../../helpers/GoogleSheets.utils';
|
||||
|
||||
export const description: SheetProperties = [
|
||||
{
|
||||
displayName: 'Data Mode',
|
||||
name: 'dataMode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Auto-Map Input Data to Columns',
|
||||
value: 'autoMapInputData',
|
||||
description: 'Use when node input properties match destination column names',
|
||||
},
|
||||
{
|
||||
name: 'Map Each Column Below',
|
||||
value: 'defineBelow',
|
||||
description: 'Set the value for each destination column',
|
||||
},
|
||||
{
|
||||
name: 'Nothing',
|
||||
value: 'nothing',
|
||||
description: 'Do not send anything',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['update'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: 'defineBelow',
|
||||
description: 'Whether to insert the input data this node receives in the new row',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased, n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column to match on',
|
||||
name: 'columnToMatchOn',
|
||||
type: 'options',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
loadOptionsMethod: 'getSheetHeaderRowAndSkipEmpty',
|
||||
},
|
||||
default: '',
|
||||
hint: "Used to find the correct row to update. Doesn't get changed.",
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['update'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Value of Column to Match On',
|
||||
name: 'valueToMatchOn',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['update'],
|
||||
dataMode: ['defineBelow'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Values to Send',
|
||||
name: 'fieldsUi',
|
||||
placeholder: 'Add Field',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['update'],
|
||||
dataMode: ['defineBelow'],
|
||||
'@version': [3],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Field',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Column',
|
||||
name: 'column',
|
||||
type: 'options',
|
||||
description:
|
||||
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value', 'columnToMatchOn'],
|
||||
loadOptionsMethod: 'getSheetHeaderRowAndAddColumn',
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Column Name',
|
||||
name: 'columnName',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
column: ['newColumn'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'fieldValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'getMappingColumns',
|
||||
mode: 'update',
|
||||
fieldWords: {
|
||||
singular: 'column',
|
||||
plural: 'columns',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: false,
|
||||
allowEmptyValues: true,
|
||||
},
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['update'],
|
||||
'@version': [{ _cnd: { gte: 4.7 } }],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['sheetName.value'],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'getMappingColumns',
|
||||
mode: 'update',
|
||||
fieldWords: {
|
||||
singular: 'column',
|
||||
plural: 'columns',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: false,
|
||||
},
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['update'],
|
||||
'@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['sheet'],
|
||||
operation: ['update'],
|
||||
},
|
||||
hide: {
|
||||
...untilSheetSelected,
|
||||
},
|
||||
},
|
||||
options: [
|
||||
cellFormat,
|
||||
locationDefine,
|
||||
handlingExtraData,
|
||||
{
|
||||
...handlingExtraData,
|
||||
displayOptions: { show: { '/columns.mappingMode': ['autoMapInputData'] } },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
sheet: GoogleSheet,
|
||||
sheetName: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const items = this.getInputData();
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
|
||||
const range = `${sheetName}!A:Z`;
|
||||
|
||||
const valueInputMode = this.getNodeParameter(
|
||||
'options.cellFormat',
|
||||
0,
|
||||
cellFormatDefault(nodeVersion),
|
||||
) as ValueInputOption;
|
||||
|
||||
const options = this.getNodeParameter('options', 0, {});
|
||||
|
||||
const valueRenderMode = (options.valueRenderMode || 'UNFORMATTED_VALUE') as ValueRenderOption;
|
||||
|
||||
const locationDefineOptions = (options.locationDefine as IDataObject)?.values as IDataObject;
|
||||
|
||||
let keyRowIndex = 0;
|
||||
let dataStartRowIndex = 1;
|
||||
|
||||
if (locationDefineOptions) {
|
||||
if (locationDefineOptions.headerRow) {
|
||||
keyRowIndex = parseInt(locationDefineOptions.headerRow as string, 10) - 1;
|
||||
}
|
||||
if (locationDefineOptions.firstDataRow) {
|
||||
dataStartRowIndex = parseInt(locationDefineOptions.firstDataRow as string, 10) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
let columnNames: string[] = [];
|
||||
|
||||
const sheetData = await sheet.getData(sheetName, 'FORMATTED_VALUE');
|
||||
|
||||
if (sheetData?.[keyRowIndex] === undefined) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Could not retrieve the column names from row ${keyRowIndex + 1}`,
|
||||
);
|
||||
}
|
||||
|
||||
columnNames = sheetData[keyRowIndex];
|
||||
|
||||
const newColumns = new Set<string>();
|
||||
|
||||
const columnsToMatchOn: string[] =
|
||||
nodeVersion < 4
|
||||
? [this.getNodeParameter('columnToMatchOn', 0) as string]
|
||||
: (this.getNodeParameter('columns.matchingColumns', 0) as string[]);
|
||||
|
||||
const dataMode =
|
||||
nodeVersion < 4
|
||||
? (this.getNodeParameter('dataMode', 0) as string)
|
||||
: (this.getNodeParameter('columns.mappingMode', 0) as string);
|
||||
|
||||
// TODO: Add support for multiple columns to match on in the next overhaul
|
||||
const keyIndex = columnNames.indexOf(columnsToMatchOn[0]);
|
||||
|
||||
//not used when updating row
|
||||
const columnValuesList = await sheet.getColumnValues({
|
||||
range,
|
||||
keyIndex,
|
||||
dataStartRowIndex,
|
||||
valueRenderMode,
|
||||
sheetData,
|
||||
});
|
||||
|
||||
const updateData: ISheetUpdateData[] = [];
|
||||
|
||||
const mappedValues: IDataObject[] = [];
|
||||
|
||||
const errorOnUnexpectedColumn = (key: string, i: number) => {
|
||||
if (!columnNames.includes(key)) {
|
||||
throw new NodeOperationError(this.getNode(), 'Unexpected fields in node input', {
|
||||
itemIndex: i,
|
||||
description: `The input field '${key}' doesn't match any column in the Sheet. You can ignore this by changing the 'Handling extra data' field, which you can find under 'Options'.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addNewColumn = (key: string) => {
|
||||
if (!columnNames.includes(key) && key !== ROW_NUMBER) {
|
||||
newColumns.add(key);
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (dataMode === 'nothing') continue;
|
||||
|
||||
const inputData: IDataObject[] = [];
|
||||
|
||||
if (dataMode === 'autoMapInputData') {
|
||||
const handlingExtraDataOption = (options.handlingExtraData as string) || 'insertInNewColumn';
|
||||
if (handlingExtraDataOption === 'ignoreIt') {
|
||||
inputData.push(items[i].json);
|
||||
}
|
||||
if (handlingExtraDataOption === 'error') {
|
||||
Object.keys(items[i].json).forEach((key) => errorOnUnexpectedColumn(key, i));
|
||||
inputData.push(items[i].json);
|
||||
}
|
||||
if (handlingExtraDataOption === 'insertInNewColumn') {
|
||||
Object.keys(items[i].json).forEach(addNewColumn);
|
||||
inputData.push(items[i].json);
|
||||
}
|
||||
} else {
|
||||
const valueToMatchOn =
|
||||
nodeVersion < 4
|
||||
? (this.getNodeParameter('valueToMatchOn', i, '') as string)
|
||||
: (this.getNodeParameter(`columns.value["${columnsToMatchOn[0]}"]`, i, '') as string);
|
||||
|
||||
if (valueToMatchOn === '') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"The 'Column to Match On' parameter is required",
|
||||
{
|
||||
itemIndex: i,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (nodeVersion < 4) {
|
||||
const valuesToSend = this.getNodeParameter('fieldsUi.values', i, []) as IDataObject[];
|
||||
if (!valuesToSend?.length) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"At least one value has to be added under 'Values to Send'",
|
||||
);
|
||||
}
|
||||
|
||||
const fields = valuesToSend.reduce((acc, entry) => {
|
||||
if (entry.column === 'newColumn') {
|
||||
const columnName = entry.columnName as string;
|
||||
|
||||
if (!columnNames.includes(columnName)) {
|
||||
newColumns.add(columnName);
|
||||
}
|
||||
|
||||
acc[columnName] = entry.fieldValue as string;
|
||||
} else {
|
||||
acc[entry.column as string] = entry.fieldValue as string;
|
||||
}
|
||||
return acc;
|
||||
}, {} as IDataObject);
|
||||
|
||||
fields[columnsToMatchOn[0]] = valueToMatchOn;
|
||||
|
||||
inputData.push(fields);
|
||||
} else {
|
||||
const mappingValues = this.getNodeParameter('columns.value', i) as IDataObject;
|
||||
if (Object.keys(mappingValues).length === 0) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"At least one value has to be added under 'Values to Send'",
|
||||
);
|
||||
}
|
||||
// Setting empty values to empty string so that they are not ignored by the API
|
||||
Object.keys(mappingValues).forEach((key) => {
|
||||
// null and undefined values are mapped to undefined
|
||||
if (key === 'row_number' && mappingValues[key] === undefined && nodeVersion >= 4.6) {
|
||||
throw new UserError('row_number is null or undefined', {
|
||||
description:
|
||||
"Since it's being used to determine the row to update, it cannot be null or undefined",
|
||||
});
|
||||
}
|
||||
|
||||
// null and undefined values are mapped to undefined
|
||||
if (mappingValues[key] === undefined) {
|
||||
this.addExecutionHints({
|
||||
message: 'Warning: The value of column to match is null or undefined',
|
||||
location: 'outputPane',
|
||||
});
|
||||
}
|
||||
|
||||
if (mappingValues[key] === undefined || mappingValues[key] === null) {
|
||||
mappingValues[key] = '';
|
||||
}
|
||||
});
|
||||
inputData.push(mappingValues);
|
||||
mappedValues.push(mappingValues);
|
||||
}
|
||||
}
|
||||
|
||||
if (newColumns.size) {
|
||||
const newColumnNames = columnNames.concat([...newColumns]);
|
||||
await sheet.updateRows(
|
||||
sheetName,
|
||||
[newColumnNames],
|
||||
(options.cellFormat as ValueInputOption) || cellFormatDefault(nodeVersion),
|
||||
keyRowIndex + 1,
|
||||
);
|
||||
columnNames = newColumnNames;
|
||||
newColumns.clear();
|
||||
}
|
||||
|
||||
let preparedData;
|
||||
const columnNamesList = [columnNames.concat([...newColumns])];
|
||||
|
||||
if (columnsToMatchOn[0] === 'row_number') {
|
||||
preparedData = sheet.prepareDataForUpdatingByRowNumber(inputData, range, columnNamesList);
|
||||
} else {
|
||||
const indexKey = columnsToMatchOn[0];
|
||||
|
||||
preparedData = await sheet.prepareDataForUpdateOrUpsert({
|
||||
inputData,
|
||||
indexKey,
|
||||
range,
|
||||
keyRowIndex,
|
||||
dataStartRowIndex,
|
||||
valueRenderMode,
|
||||
columnNamesList,
|
||||
columnValuesList,
|
||||
});
|
||||
}
|
||||
|
||||
updateData.push(...preparedData.updateData);
|
||||
}
|
||||
|
||||
if (updateData.length) {
|
||||
await sheet.batchUpdate(updateData, valueInputMode);
|
||||
}
|
||||
|
||||
if (nodeVersion < 4 || dataMode === 'autoMapInputData') {
|
||||
return items.map((item, index) => {
|
||||
item.pairedItem = { item: index };
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
if (!updateData.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
for (const [index, entry] of mappedValues.entries()) {
|
||||
returnData.push({
|
||||
json: entry,
|
||||
pairedItem: { item: index },
|
||||
});
|
||||
}
|
||||
return returnData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as create from './create.operation';
|
||||
import * as deleteSpreadsheet from './delete.operation';
|
||||
|
||||
export { create, deleteSpreadsheet };
|
||||
|
||||
export const descriptions: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['spreadsheet'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Create',
|
||||
value: 'create',
|
||||
description: 'Create a spreadsheet',
|
||||
action: 'Create spreadsheet',
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
value: 'deleteSpreadsheet',
|
||||
description: 'Delete a spreadsheet',
|
||||
action: 'Delete spreadsheet',
|
||||
},
|
||||
],
|
||||
default: 'create',
|
||||
},
|
||||
...create.description,
|
||||
...deleteSpreadsheet.description,
|
||||
];
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { wrapData } from '../../../../../../utils/utilities';
|
||||
import type { SpreadSheetProperties } from '../../helpers/GoogleSheets.types';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: SpreadSheetProperties = [
|
||||
{
|
||||
displayName: 'Title',
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['spreadsheet'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
description: 'The title of the spreadsheet',
|
||||
},
|
||||
{
|
||||
displayName: 'Sheets',
|
||||
name: 'sheetsUi',
|
||||
placeholder: 'Add Sheet',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
name: 'sheetValues',
|
||||
displayName: 'Sheet',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Title',
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Title of the property to create',
|
||||
},
|
||||
{
|
||||
displayName: 'Hidden',
|
||||
name: 'hidden',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether the Sheet should be hidden in the UI',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['spreadsheet'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['spreadsheet'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Locale',
|
||||
name: 'locale',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'en_US',
|
||||
description: `The locale of the spreadsheet in one of the following formats:
|
||||
<ul>
|
||||
<li>en (639-1)</li>
|
||||
<li>fil (639-2 if no 639-1 format exists)</li>
|
||||
<li>en_US (combination of ISO language an country)</li>
|
||||
<ul>`,
|
||||
},
|
||||
{
|
||||
displayName: 'Recalculation Interval',
|
||||
name: 'autoRecalc',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Default',
|
||||
value: '',
|
||||
description: 'Default value',
|
||||
},
|
||||
{
|
||||
name: 'On Change',
|
||||
value: 'ON_CHANGE',
|
||||
description: 'Volatile functions are updated on every change',
|
||||
},
|
||||
{
|
||||
name: 'Minute',
|
||||
value: 'MINUTE',
|
||||
description: 'Volatile functions are updated on every change and every minute',
|
||||
},
|
||||
{
|
||||
name: 'Hour',
|
||||
value: 'HOUR',
|
||||
description: 'Volatile functions are updated on every change and hourly',
|
||||
},
|
||||
],
|
||||
default: '',
|
||||
description: 'Cell recalculation interval options',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[]> {
|
||||
const items = this.getInputData();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const title = this.getNodeParameter('title', i) as string;
|
||||
const sheetsUi = this.getNodeParameter('sheetsUi', i, {}) as IDataObject;
|
||||
|
||||
const body = {
|
||||
properties: {
|
||||
title,
|
||||
autoRecalc: undefined as undefined | string,
|
||||
locale: undefined as undefined | string,
|
||||
},
|
||||
sheets: [] as IDataObject[],
|
||||
};
|
||||
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
if (Object.keys(sheetsUi).length) {
|
||||
const data = [];
|
||||
const sheets = sheetsUi.sheetValues as IDataObject[];
|
||||
for (const properties of sheets) {
|
||||
data.push({ properties });
|
||||
}
|
||||
body.sheets = data;
|
||||
}
|
||||
|
||||
body.properties.autoRecalc = options.autoRecalc ? (options.autoRecalc as string) : undefined;
|
||||
body.properties.locale = options.locale ? (options.locale as string) : undefined;
|
||||
|
||||
const response = await apiRequest.call(this, 'POST', '/v4/spreadsheets', body);
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
wrapData(response as IDataObject),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
returnData.push(...executionData);
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { wrapData } from '../../../../../../utils/utilities';
|
||||
import { GOOGLE_DRIVE_FILE_URL_REGEX } from '../../../../constants';
|
||||
import type { SpreadSheetProperties } from '../../helpers/GoogleSheets.types';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
export const description: SpreadSheetProperties = [
|
||||
{
|
||||
displayName: 'Document',
|
||||
name: 'documentId',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'spreadSheetsSearch',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: GOOGLE_DRIVE_FILE_URL_REGEX,
|
||||
},
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: GOOGLE_DRIVE_FILE_URL_REGEX,
|
||||
errorMessage: 'Not a valid Google Drive File URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '[a-zA-Z0-9\\-_]{2,}',
|
||||
errorMessage: 'Not a valid Google Drive File ID',
|
||||
},
|
||||
},
|
||||
],
|
||||
url: '=https://docs.google.com/spreadsheets/d/{{$value}}/edit',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['spreadsheet'],
|
||||
operation: ['deleteSpreadsheet'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[]> {
|
||||
const items = this.getInputData();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const documentId = this.getNodeParameter('documentId', i, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
await apiRequest.call(
|
||||
this,
|
||||
'DELETE',
|
||||
'',
|
||||
{},
|
||||
{},
|
||||
`https://www.googleapis.com/drive/v3/files/${documentId}`,
|
||||
);
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(wrapData({ success: true }), {
|
||||
itemData: { item: i },
|
||||
});
|
||||
|
||||
returnData.push(...executionData);
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { IDataObject, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { type GoogleSheet } from '../../helpers/GoogleSheet';
|
||||
import type {
|
||||
ILookupValues,
|
||||
RangeDetectionOptions,
|
||||
SheetRangeData,
|
||||
ValueRenderOption,
|
||||
} from '../../helpers/GoogleSheets.types';
|
||||
import { getRangeString, prepareSheetData } from '../../helpers/GoogleSheets.utils';
|
||||
|
||||
export async function readSheet(
|
||||
this: IExecuteFunctions,
|
||||
sheet: GoogleSheet,
|
||||
sheetName: string,
|
||||
itemIndex: number,
|
||||
returnData: INodeExecutionData[],
|
||||
nodeVersion: number,
|
||||
items: INodeExecutionData[],
|
||||
rangeString?: string,
|
||||
additionalOptions?: IDataObject,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const options = this.getNodeParameter('options', itemIndex, {});
|
||||
const outputFormattingOption =
|
||||
((options.outputFormatting as IDataObject)?.values as IDataObject) || {};
|
||||
|
||||
const dataLocationOnSheetOptions =
|
||||
((options.dataLocationOnSheet as IDataObject)?.values as RangeDetectionOptions) ||
|
||||
additionalOptions ||
|
||||
{};
|
||||
|
||||
if (dataLocationOnSheetOptions.rangeDefinition === undefined) {
|
||||
dataLocationOnSheetOptions.rangeDefinition = 'detectAutomatically';
|
||||
}
|
||||
|
||||
const includeHeadersWithEmptyCells =
|
||||
(additionalOptions?.includeHeadersWithEmptyCells as boolean) ?? false;
|
||||
|
||||
const range = rangeString ?? getRangeString(sheetName, dataLocationOnSheetOptions);
|
||||
|
||||
const valueRenderMode = (outputFormattingOption.general ||
|
||||
'UNFORMATTED_VALUE') as ValueRenderOption;
|
||||
const dateTimeRenderOption = (outputFormattingOption.date || 'FORMATTED_STRING') as string;
|
||||
|
||||
const sheetData = (await sheet.getData(
|
||||
range,
|
||||
valueRenderMode,
|
||||
dateTimeRenderOption,
|
||||
)) as SheetRangeData;
|
||||
|
||||
if (sheetData === undefined || sheetData.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const {
|
||||
data,
|
||||
headerRow: keyRowIndex,
|
||||
firstDataRow: dataStartRowIndex,
|
||||
} = prepareSheetData(sheetData, dataLocationOnSheetOptions as RangeDetectionOptions);
|
||||
|
||||
let responseData = [];
|
||||
|
||||
const lookupValues = this.getNodeParameter('filtersUI.values', itemIndex, []) as ILookupValues[];
|
||||
|
||||
const inputData = data as string[][];
|
||||
|
||||
if (lookupValues.length) {
|
||||
let returnAllMatches;
|
||||
if (nodeVersion < 4.5) {
|
||||
returnAllMatches = options.returnAllMatches === 'returnAllMatches' ? true : false;
|
||||
} else {
|
||||
returnAllMatches =
|
||||
(additionalOptions?.returnFirstMatch ?? options.returnFirstMatch) ? false : true;
|
||||
}
|
||||
|
||||
if (nodeVersion <= 4.1) {
|
||||
for (let i = 1; i < items.length; i++) {
|
||||
const itemLookupValues = this.getNodeParameter(
|
||||
'filtersUI.values',
|
||||
i,
|
||||
[],
|
||||
) as ILookupValues[];
|
||||
if (itemLookupValues.length) {
|
||||
lookupValues.push(...itemLookupValues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const combineFilters = this.getNodeParameter('combineFilters', itemIndex, 'OR') as 'AND' | 'OR';
|
||||
|
||||
responseData = await sheet.lookupValues({
|
||||
inputData,
|
||||
keyRowIndex,
|
||||
dataStartRowIndex,
|
||||
lookupValues,
|
||||
returnAllMatches,
|
||||
nodeVersion,
|
||||
combineFilters,
|
||||
});
|
||||
} else {
|
||||
responseData = sheet.structureArrayDataByColumn(
|
||||
inputData,
|
||||
keyRowIndex,
|
||||
dataStartRowIndex,
|
||||
includeHeadersWithEmptyCells,
|
||||
);
|
||||
}
|
||||
|
||||
returnData.push(
|
||||
...responseData.map((item) => {
|
||||
return {
|
||||
json: item,
|
||||
pairedItem: { item: itemIndex },
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
import type { INodeProperties, INodeTypeDescription } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import * as sheet from './sheet/Sheet.resource';
|
||||
import * as spreadsheet from './spreadsheet/SpreadSheet.resource';
|
||||
|
||||
export const authentication: INodeProperties = {
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Service Account',
|
||||
value: 'serviceAccount',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'OAuth2 (recommended)',
|
||||
value: 'oAuth2',
|
||||
},
|
||||
],
|
||||
default: 'oAuth2',
|
||||
};
|
||||
|
||||
export const versionDescription: INodeTypeDescription = {
|
||||
displayName: 'Google Sheets',
|
||||
name: 'googleSheets',
|
||||
icon: 'file:googleSheets.svg',
|
||||
group: ['input', 'output'],
|
||||
version: [3, 4, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7],
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Read, update and write data to Google Sheets',
|
||||
defaults: {
|
||||
name: 'Google Sheets',
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
usableAsTool: true,
|
||||
hints: [
|
||||
{
|
||||
message:
|
||||
"Use the 'Minimise API Calls' option for greater efficiency if your sheet is uniformly formatted without gaps between columns or rows",
|
||||
displayCondition:
|
||||
'={{$parameter["operation"] === "append" && !$parameter["options"]["useAppend"]}}',
|
||||
whenToDisplay: 'beforeExecution',
|
||||
location: 'outputPane',
|
||||
},
|
||||
{
|
||||
message: 'No columns found in Google Sheet. All rows will be appended',
|
||||
displayCondition:
|
||||
'={{ ["appendOrUpdate", "append"].includes($parameter["operation"]) && $parameter?.columns?.mappingMode === "defineBelow" && !$parameter?.columns?.schema?.length }}',
|
||||
whenToDisplay: 'beforeExecution',
|
||||
location: 'outputPane',
|
||||
},
|
||||
{
|
||||
type: 'info',
|
||||
message:
|
||||
'Note on using an expression for Sheet: It will be evaluated only once, so all items will use the <em>same</em> sheet. It will be calculated by evaluating the expression for the <strong>first input item</strong>.',
|
||||
displayCondition:
|
||||
'={{ $rawParameter.sheetName?.startsWith("=") && $input.all().length > 1 }}',
|
||||
whenToDisplay: 'always',
|
||||
location: 'outputPane',
|
||||
},
|
||||
{
|
||||
type: 'info',
|
||||
message:
|
||||
'Note on using an expression for Document: It will be evaluated only once, so all items will use the <em>same</em> document. It will be calculated by evaluating the expression for the <strong>first input item</strong>.',
|
||||
displayCondition:
|
||||
'={{ $rawParameter.documentId?.startsWith("=") && $input.all().length > 1 }}',
|
||||
whenToDisplay: 'always',
|
||||
location: 'outputPane',
|
||||
},
|
||||
],
|
||||
credentials: [
|
||||
{
|
||||
name: 'googleApi',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['serviceAccount'],
|
||||
},
|
||||
},
|
||||
testedBy: 'googleApiCredentialTest',
|
||||
},
|
||||
{
|
||||
name: 'googleSheetsOAuth2Api',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['oAuth2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
authentication,
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Document',
|
||||
value: 'spreadsheet',
|
||||
},
|
||||
{
|
||||
name: 'Sheet Within Document',
|
||||
value: 'sheet',
|
||||
},
|
||||
],
|
||||
default: 'sheet',
|
||||
},
|
||||
...sheet.descriptions,
|
||||
...spreadsheet.descriptions,
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,875 @@
|
||||
import get from 'lodash/get';
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
INode,
|
||||
IPollFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError, NodeOperationError } from 'n8n-workflow';
|
||||
import { utils as xlsxUtils } from 'xlsx';
|
||||
|
||||
import type {
|
||||
ILookupValues,
|
||||
ISheetUpdateData,
|
||||
ResourceLocator,
|
||||
SheetCellDecoded,
|
||||
SheetRangeData,
|
||||
SheetRangeDecoded,
|
||||
SpreadSheetResponse,
|
||||
ValueInputOption,
|
||||
ValueRenderOption,
|
||||
} from './GoogleSheets.types';
|
||||
import { getSheetId, removeEmptyColumns } from './GoogleSheets.utils';
|
||||
import { apiRequest } from '../transport';
|
||||
|
||||
export class GoogleSheet {
|
||||
id: string;
|
||||
|
||||
executeFunctions: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions;
|
||||
|
||||
constructor(
|
||||
spreadsheetId: string,
|
||||
executeFunctions: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
|
||||
) {
|
||||
this.executeFunctions = executeFunctions;
|
||||
this.id = spreadsheetId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the range that also none latin character work
|
||||
*
|
||||
* @param {string} range
|
||||
* @returns {string}
|
||||
* @memberof GoogleSheet
|
||||
*/
|
||||
private encodeRange(range: string): string {
|
||||
if (range.includes('!')) {
|
||||
const [sheet, ranges] = range.split('!');
|
||||
return `${encodeURIComponent(sheet)}!${ranges}`;
|
||||
}
|
||||
// Use '' so that sheet is not interpreted as range
|
||||
return encodeURIComponent(`'${range}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears values from a sheet
|
||||
*
|
||||
* @param {string} range
|
||||
* @returns {Promise<object>}
|
||||
* @memberof GoogleSheet
|
||||
*/
|
||||
async clearData(range: string): Promise<object> {
|
||||
const body = {
|
||||
spreadsheetId: this.id,
|
||||
range,
|
||||
};
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${this.id}/values/${this.encodeRange(range)}:clear`,
|
||||
body,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell values
|
||||
*/
|
||||
async getData(range: string, valueRenderMode: ValueRenderOption, dateTimeRenderOption?: string) {
|
||||
const query: IDataObject = {
|
||||
valueRenderOption: valueRenderMode,
|
||||
dateTimeRenderOption: 'FORMATTED_STRING',
|
||||
};
|
||||
|
||||
if (dateTimeRenderOption) {
|
||||
query.dateTimeRenderOption = dateTimeRenderOption;
|
||||
}
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'GET',
|
||||
`/v4/spreadsheets/${this.id}/values/${this.encodeRange(range)}`,
|
||||
{},
|
||||
query,
|
||||
);
|
||||
|
||||
return response.values as string[][] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the sheets in a Spreadsheet
|
||||
*/
|
||||
async spreadsheetGetSheets() {
|
||||
const query = {
|
||||
fields: 'sheets.properties',
|
||||
};
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'GET',
|
||||
`/v4/spreadsheets/${this.id}`,
|
||||
{},
|
||||
query,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the sheet within a spreadsheet based on name or ID
|
||||
*/
|
||||
async spreadsheetGetSheet(node: INode, mode: ResourceLocator, value: string) {
|
||||
const query = {
|
||||
fields: 'sheets.properties',
|
||||
};
|
||||
|
||||
const response = (await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'GET',
|
||||
`/v4/spreadsheets/${this.id}`,
|
||||
{},
|
||||
query,
|
||||
)) as SpreadSheetResponse;
|
||||
|
||||
const foundItem = response.sheets.find((item) => {
|
||||
if (mode === 'name') return item.properties.title === value;
|
||||
return item.properties.sheetId === getSheetId(value);
|
||||
});
|
||||
|
||||
if (!foundItem?.properties?.title) {
|
||||
const error = new Error(`Sheet with ${mode === 'name' ? 'name' : 'ID'} ${value} not found`);
|
||||
throw new NodeOperationError(node, error, { level: 'warning' });
|
||||
}
|
||||
|
||||
return foundItem.properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the grid properties of a sheet
|
||||
*/
|
||||
async getDataRange(sheetId: string) {
|
||||
const query = {
|
||||
fields: 'sheets.properties',
|
||||
};
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'GET',
|
||||
`/v4/spreadsheets/${this.id}`,
|
||||
{},
|
||||
query,
|
||||
);
|
||||
const foundItem = response.sheets.find(
|
||||
(item: { properties: { sheetId: string } }) => item.properties.sheetId === sheetId,
|
||||
);
|
||||
return foundItem.properties.gridProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets values in one or more ranges of a spreadsheet.
|
||||
*/
|
||||
async spreadsheetBatchUpdate(requests: IDataObject[]) {
|
||||
const body = {
|
||||
requests,
|
||||
};
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${this.id}:batchUpdate`,
|
||||
body,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the cell values
|
||||
*/
|
||||
async batchUpdate(updateData: ISheetUpdateData[], valueInputMode: ValueInputOption) {
|
||||
const body = {
|
||||
data: updateData,
|
||||
valueInputOption: valueInputMode,
|
||||
};
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${this.id}/values:batchUpdate`,
|
||||
body,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async appendEmptyRowsOrColumns(sheetId: string, rowsToAdd = 1, columnsToAdd = 1) {
|
||||
const requests: IDataObject[] = [];
|
||||
|
||||
if (rowsToAdd > 0) {
|
||||
requests.push({
|
||||
appendDimension: {
|
||||
sheetId,
|
||||
dimension: 'ROWS',
|
||||
length: rowsToAdd,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (columnsToAdd > 0) {
|
||||
requests.push({
|
||||
appendDimension: {
|
||||
sheetId,
|
||||
dimension: 'COLUMNS',
|
||||
length: columnsToAdd,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (requests.length === 0) {
|
||||
throw new ApplicationError('Must specify at least one column or row to add', {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
const response = await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${this.id}:batchUpdate`,
|
||||
{ requests },
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the cell values
|
||||
*/
|
||||
async appendData(
|
||||
range: string,
|
||||
data: string[][],
|
||||
valueInputMode: ValueInputOption,
|
||||
lastRow?: number,
|
||||
useAppend?: boolean,
|
||||
) {
|
||||
const lastRowWithData =
|
||||
lastRow ||
|
||||
(((await this.getData(range, 'UNFORMATTED_VALUE')) as string[][]) || []).length + 1;
|
||||
|
||||
const response = await this.updateRows(
|
||||
range,
|
||||
data,
|
||||
valueInputMode,
|
||||
lastRowWithData,
|
||||
data.length,
|
||||
useAppend,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async updateRows(
|
||||
sheetName: string,
|
||||
data: string[][],
|
||||
valueInputMode: ValueInputOption,
|
||||
row: number,
|
||||
rowsLength?: number,
|
||||
useAppend?: boolean,
|
||||
) {
|
||||
const [name, _sheetRange] = sheetName.split('!');
|
||||
const range = `${name}!${row}:${rowsLength ? row + rowsLength - 1 : row}`;
|
||||
|
||||
const body = {
|
||||
range,
|
||||
values: data,
|
||||
};
|
||||
|
||||
const query = {
|
||||
valueInputOption: valueInputMode,
|
||||
};
|
||||
|
||||
let response;
|
||||
|
||||
if (useAppend) {
|
||||
response = await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'POST',
|
||||
`/v4/spreadsheets/${this.id}/values/${this.encodeRange(range)}:append`,
|
||||
body,
|
||||
query,
|
||||
);
|
||||
} else {
|
||||
response = await apiRequest.call(
|
||||
this.executeFunctions,
|
||||
'PUT',
|
||||
`/v4/spreadsheets/${this.id}/values/${this.encodeRange(range)}`,
|
||||
body,
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the given sheet data in a structured way
|
||||
*/
|
||||
convertSheetDataArrayToObjectArray(
|
||||
sheet: SheetRangeData,
|
||||
startRow: number,
|
||||
columnKeys: string[],
|
||||
addEmpty?: boolean,
|
||||
includeHeadersWithEmptyCells?: boolean,
|
||||
): IDataObject[] {
|
||||
const returnData = [];
|
||||
|
||||
for (let rowIndex = startRow; rowIndex < sheet.length; rowIndex++) {
|
||||
const item: IDataObject = {};
|
||||
|
||||
const rowCount = sheet[rowIndex].length;
|
||||
const columnCount = includeHeadersWithEmptyCells ? columnKeys.length : rowCount;
|
||||
|
||||
for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) {
|
||||
const key = columnKeys[columnIndex];
|
||||
if (key) {
|
||||
item[key] = sheet[rowIndex][columnIndex] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(item).length || addEmpty === true) {
|
||||
returnData.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the given sheet data in a structured way using
|
||||
* the startRow as the one with the name of the key
|
||||
*/
|
||||
structureArrayDataByColumn(
|
||||
inputData: string[][],
|
||||
keyRow: number,
|
||||
dataStartRow: number,
|
||||
includeHeadersWithEmptyCells?: boolean,
|
||||
): IDataObject[] {
|
||||
const keys: string[] = [];
|
||||
|
||||
if (keyRow < 0 || dataStartRow < keyRow || keyRow >= inputData.length) {
|
||||
// The key row does not exist so it is not possible to structure data
|
||||
return [];
|
||||
}
|
||||
|
||||
const longestRow = inputData.reduce((a, b) => (a.length > b.length ? a : b), []).length;
|
||||
for (let columnIndex = 0; columnIndex < longestRow; columnIndex++) {
|
||||
keys.push(inputData[keyRow][columnIndex] || `col_${columnIndex}`);
|
||||
}
|
||||
|
||||
return this.convertSheetDataArrayToObjectArray(
|
||||
inputData,
|
||||
dataStartRow,
|
||||
keys,
|
||||
false,
|
||||
includeHeadersWithEmptyCells,
|
||||
);
|
||||
}
|
||||
|
||||
testFilter(inputData: string[][], keyRow: number, dataStartRow: number): string[] {
|
||||
const keys: string[] = [];
|
||||
//const returnData = [];
|
||||
|
||||
if (keyRow < 0 || dataStartRow < keyRow || keyRow >= inputData.length) {
|
||||
// The key row does not exist so it is not possible to structure data
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create the keys array
|
||||
for (let columnIndex = 0; columnIndex < inputData[keyRow].length; columnIndex++) {
|
||||
keys.push(inputData[keyRow][columnIndex]);
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
async appendSheetData({
|
||||
inputData,
|
||||
range,
|
||||
keyRowIndex,
|
||||
valueInputMode,
|
||||
usePathForKeyRow,
|
||||
columnNamesList,
|
||||
lastRow,
|
||||
useAppend,
|
||||
}: {
|
||||
inputData: IDataObject[];
|
||||
range: string;
|
||||
keyRowIndex: number;
|
||||
valueInputMode: ValueInputOption;
|
||||
usePathForKeyRow?: boolean;
|
||||
columnNamesList?: string[][];
|
||||
lastRow?: number;
|
||||
useAppend?: boolean;
|
||||
}): Promise<string[][]> {
|
||||
const data = await this.convertObjectArrayToSheetDataArray(
|
||||
inputData,
|
||||
range,
|
||||
keyRowIndex,
|
||||
usePathForKeyRow,
|
||||
columnNamesList,
|
||||
useAppend ? null : '',
|
||||
);
|
||||
return await this.appendData(range, data, valueInputMode, lastRow, useAppend);
|
||||
}
|
||||
|
||||
getColumnWithOffset(startColumn: string, offset: number): string {
|
||||
const columnIndex = xlsxUtils.decode_col(startColumn) + offset;
|
||||
return xlsxUtils.encode_col(columnIndex);
|
||||
}
|
||||
|
||||
async getColumnValues({
|
||||
range,
|
||||
keyIndex,
|
||||
dataStartRowIndex,
|
||||
valueRenderMode,
|
||||
sheetData,
|
||||
}: {
|
||||
range: string;
|
||||
keyIndex: number;
|
||||
dataStartRowIndex: number;
|
||||
valueRenderMode: ValueRenderOption;
|
||||
sheetData?: string[][];
|
||||
}): Promise<string[]> {
|
||||
let columnValuesList;
|
||||
if (sheetData) {
|
||||
columnValuesList = sheetData.slice(dataStartRowIndex - 1).map((row) => row[keyIndex]);
|
||||
} else {
|
||||
const decodedRange = this.getDecodedSheetRange(range);
|
||||
const startRowIndex = decodedRange.start?.row || dataStartRowIndex;
|
||||
const endRowIndex = decodedRange.end?.row || '';
|
||||
|
||||
const keyColumn = this.getColumnWithOffset(decodedRange.start?.column || 'A', keyIndex);
|
||||
const keyColumnRange = `${decodedRange.name}!${keyColumn}${startRowIndex}:${keyColumn}${endRowIndex}`;
|
||||
columnValuesList = await this.getData(keyColumnRange, valueRenderMode);
|
||||
}
|
||||
|
||||
if (columnValuesList === undefined) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
'Could not retrieve the data from key column',
|
||||
);
|
||||
}
|
||||
|
||||
//Remove the first row which contains the key and flaten the array
|
||||
return columnValuesList.splice(1).flatMap((value) => value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates data in a sheet
|
||||
*
|
||||
* @param {IDataObject[]} inputData Data to update Sheet with
|
||||
* @param {string} indexKey The name of the key which gets used to know which rows to update
|
||||
* @param {string} range The range to look for data
|
||||
* @param {number} keyRowIndex Index of the row which contains the keys
|
||||
* @param {number} dataStartRowIndex Index of the first row which contains data
|
||||
* @returns {Promise<string[][]>}
|
||||
* @memberof GoogleSheet
|
||||
*/
|
||||
async prepareDataForUpdateOrUpsert({
|
||||
inputData,
|
||||
indexKey,
|
||||
range,
|
||||
keyRowIndex,
|
||||
dataStartRowIndex,
|
||||
valueRenderMode,
|
||||
upsert = false,
|
||||
columnNamesList,
|
||||
columnValuesList,
|
||||
}: {
|
||||
inputData: IDataObject[];
|
||||
indexKey: string;
|
||||
range: string;
|
||||
keyRowIndex: number;
|
||||
dataStartRowIndex: number;
|
||||
valueRenderMode: ValueRenderOption;
|
||||
upsert?: boolean;
|
||||
columnNamesList?: string[][];
|
||||
columnValuesList?: string[];
|
||||
}) {
|
||||
const decodedRange = this.getDecodedSheetRange(range);
|
||||
const keyRowRange = `${decodedRange.name}!${decodedRange.start?.column || ''}${keyRowIndex + 1}:${decodedRange.end?.column || ''}${keyRowIndex + 1}`;
|
||||
|
||||
const sheetDatakeyRow = columnNamesList || (await this.getData(keyRowRange, valueRenderMode));
|
||||
|
||||
if (sheetDatakeyRow === undefined) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
'Could not retrieve the key row',
|
||||
);
|
||||
}
|
||||
|
||||
const columnNames = sheetDatakeyRow[0];
|
||||
|
||||
const keyIndex = columnNames.indexOf(indexKey);
|
||||
|
||||
if (keyIndex === -1 && !upsert) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
`Could not find column for key "${indexKey}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const columnValues: Array<string | number> =
|
||||
columnValuesList ||
|
||||
(await this.getColumnValues({ range, keyIndex, dataStartRowIndex, valueRenderMode }));
|
||||
|
||||
const updateData: ISheetUpdateData[] = [];
|
||||
const appendData: IDataObject[] = [];
|
||||
|
||||
const getKeyIndex = (key: string | number, data: Array<string | number>) => {
|
||||
let index = -1;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (data[i]?.toString() === key.toString()) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
for (const item of inputData) {
|
||||
const inputIndexKey = item[indexKey] as string;
|
||||
|
||||
if (inputIndexKey === undefined || inputIndexKey === null) {
|
||||
// Item does not have the indexKey so we can ignore it or append it if upsert true
|
||||
if (upsert) {
|
||||
appendData.push(item);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Item does have the key so check if it exists in Sheet
|
||||
const indexOfIndexKeyInSheet = getKeyIndex(inputIndexKey, columnValues);
|
||||
|
||||
if (indexOfIndexKeyInSheet === -1) {
|
||||
// Key does not exist in the Sheet so it can not be updated so skip it or append it if upsert true
|
||||
if (upsert) {
|
||||
appendData.push(item);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the row index in which the data should be updated
|
||||
const updateRowIndex = indexOfIndexKeyInSheet + dataStartRowIndex + 1;
|
||||
|
||||
// Check all the properties in the sheet and check which ones exist on the
|
||||
// item and should be updated
|
||||
for (const name of columnNames) {
|
||||
if (name === indexKey) {
|
||||
// Ignore the key itself as that does not get changed it gets
|
||||
// only used to find the correct row to update
|
||||
continue;
|
||||
}
|
||||
if (item[name] === undefined || item[name] === null) {
|
||||
// Property does not exist so skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
// Property exists so add it to the data to update
|
||||
// Get the column name in which the property data can be found
|
||||
const columnToUpdate = this.getColumnWithOffset(
|
||||
decodedRange.start?.column || 'A',
|
||||
columnNames.indexOf(name),
|
||||
);
|
||||
|
||||
let updateValue = item[name] as string;
|
||||
if (typeof updateValue === 'object') {
|
||||
try {
|
||||
updateValue = JSON.stringify(updateValue);
|
||||
} catch (error) {}
|
||||
}
|
||||
updateData.push({
|
||||
range: `${decodedRange.name}!${columnToUpdate}${updateRowIndex}`,
|
||||
values: [[updateValue]],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { updateData, appendData };
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates data in a sheet
|
||||
*
|
||||
* @param {IDataObject[]} inputData Data to update Sheet with
|
||||
* @param {string} range The range to look for data
|
||||
* @param {number} dataStartRowIndex Index of the first row which contains data
|
||||
* @param {string[][]} columnNamesList The column names to use
|
||||
* @returns {Promise<string[][]>}
|
||||
* @memberof GoogleSheet
|
||||
*/
|
||||
prepareDataForUpdatingByRowNumber(
|
||||
inputData: IDataObject[],
|
||||
range: string,
|
||||
columnNamesList: string[][],
|
||||
) {
|
||||
const decodedRange = this.getDecodedSheetRange(range);
|
||||
const columnNames = columnNamesList[0];
|
||||
const updateData: ISheetUpdateData[] = [];
|
||||
|
||||
for (const item of inputData) {
|
||||
const updateRowIndex = item.row_number as number;
|
||||
|
||||
for (const name of columnNames) {
|
||||
if (name === 'row_number') continue;
|
||||
if (item[name] === undefined || item[name] === null) continue;
|
||||
|
||||
const columnToUpdate = this.getColumnWithOffset(
|
||||
decodedRange.start?.column || 'A',
|
||||
columnNames.indexOf(name),
|
||||
);
|
||||
|
||||
let updateValue = item[name] as string;
|
||||
if (typeof updateValue === 'object') {
|
||||
try {
|
||||
updateValue = JSON.stringify(updateValue);
|
||||
} catch (error) {}
|
||||
}
|
||||
updateData.push({
|
||||
range: `${decodedRange.name}!${columnToUpdate}${updateRowIndex}`,
|
||||
values: [[updateValue]],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { updateData };
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks for a specific value in a column and if it gets found it returns the whole row
|
||||
*
|
||||
* @param {string[][]} inputData Data to check for lookup value in
|
||||
* @param {number} keyRowIndex Index of the row which contains the keys
|
||||
* @param {number} dataStartRowIndex Index of the first row which contains data
|
||||
* @param {ILookupValues[]} lookupValues The lookup values which decide what data to return
|
||||
* @param {boolean} [returnAllMatches] Returns all the found matches instead of only the first one
|
||||
* @returns {Promise<IDataObject[]>}
|
||||
* @memberof GoogleSheet
|
||||
*/
|
||||
async lookupValues({
|
||||
inputData,
|
||||
keyRowIndex,
|
||||
dataStartRowIndex,
|
||||
lookupValues,
|
||||
returnAllMatches,
|
||||
nodeVersion,
|
||||
combineFilters = 'OR',
|
||||
}: {
|
||||
inputData: string[][];
|
||||
keyRowIndex: number;
|
||||
dataStartRowIndex: number;
|
||||
lookupValues: ILookupValues[];
|
||||
nodeVersion: number;
|
||||
returnAllMatches?: boolean;
|
||||
combineFilters?: 'AND' | 'OR';
|
||||
}): Promise<IDataObject[]> {
|
||||
const keys: string[] = [];
|
||||
|
||||
if (keyRowIndex < 0 || dataStartRowIndex < keyRowIndex || keyRowIndex >= inputData.length) {
|
||||
// The key row does not exist so it is not possible to look up the data
|
||||
throw new NodeOperationError(this.executeFunctions.getNode(), 'The key row does not exist');
|
||||
}
|
||||
|
||||
// Create the keys array
|
||||
for (let columnIndex = 0; columnIndex < inputData[keyRowIndex].length; columnIndex++) {
|
||||
keys.push(inputData[keyRowIndex][columnIndex] || `col_${columnIndex}`);
|
||||
}
|
||||
|
||||
// Standardize values array, if rows is [[]], map it to [['']] (Keep the columns into consideration)
|
||||
for (let rowIndex = 0; rowIndex < inputData?.length; rowIndex++) {
|
||||
if (inputData[rowIndex].length === 0) {
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
inputData[rowIndex][i] = '';
|
||||
}
|
||||
} else if (inputData[rowIndex].length < keys.length) {
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
if (inputData[rowIndex][i] === undefined) {
|
||||
inputData[rowIndex].push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loop over all the lookup values and try to find a row to return
|
||||
let rowIndex: number;
|
||||
let returnColumnIndex: number;
|
||||
const addedRows: number[] = [];
|
||||
|
||||
// const returnData = [inputData[keyRowIndex]];
|
||||
const returnData = [keys];
|
||||
|
||||
if (combineFilters === 'OR') {
|
||||
lookupLoop: for (const lookupValue of lookupValues) {
|
||||
returnColumnIndex = keys.indexOf(lookupValue.lookupColumn);
|
||||
|
||||
if (returnColumnIndex === -1) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
`The column "${lookupValue.lookupColumn}" could not be found`,
|
||||
);
|
||||
}
|
||||
|
||||
// Loop over all the items and find the one with the matching value
|
||||
for (rowIndex = dataStartRowIndex; rowIndex < inputData.length; rowIndex++) {
|
||||
if (
|
||||
inputData[rowIndex][returnColumnIndex]?.toString() ===
|
||||
lookupValue.lookupValue.toString()
|
||||
) {
|
||||
if (addedRows.indexOf(rowIndex) === -1) {
|
||||
returnData.push(inputData[rowIndex]);
|
||||
addedRows.push(rowIndex);
|
||||
}
|
||||
|
||||
if (returnAllMatches !== true) {
|
||||
if (nodeVersion >= 4.6) {
|
||||
break lookupLoop;
|
||||
}
|
||||
continue lookupLoop;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lookupLoop: for (rowIndex = dataStartRowIndex; rowIndex < inputData.length; rowIndex++) {
|
||||
let allMatch = true;
|
||||
|
||||
for (const lookupValue of lookupValues) {
|
||||
returnColumnIndex = keys.indexOf(lookupValue.lookupColumn);
|
||||
|
||||
if (returnColumnIndex === -1) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
`The column "${lookupValue.lookupColumn}" could not be found`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
inputData[rowIndex][returnColumnIndex]?.toString() !==
|
||||
lookupValue.lookupValue.toString()
|
||||
) {
|
||||
allMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allMatch) {
|
||||
if (addedRows.indexOf(rowIndex) === -1) {
|
||||
returnData.push(inputData[rowIndex]);
|
||||
addedRows.push(rowIndex);
|
||||
}
|
||||
|
||||
if (returnAllMatches !== true) {
|
||||
break lookupLoop;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dataWithoutEmptyColumns = removeEmptyColumns(returnData);
|
||||
return this.convertSheetDataArrayToObjectArray(
|
||||
dataWithoutEmptyColumns,
|
||||
1,
|
||||
dataWithoutEmptyColumns[0] as string[],
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
private async convertObjectArrayToSheetDataArray(
|
||||
inputData: IDataObject[],
|
||||
range: string,
|
||||
keyRowIndex: number,
|
||||
usePathForKeyRow?: boolean,
|
||||
columnNamesList?: string[][],
|
||||
emptyValue: string | null = '',
|
||||
): Promise<string[][]> {
|
||||
const decodedRange = this.getDecodedSheetRange(range);
|
||||
|
||||
const columnNamesRow =
|
||||
columnNamesList ||
|
||||
(await this.getData(
|
||||
`${decodedRange.name}!${keyRowIndex}:${keyRowIndex}`,
|
||||
'UNFORMATTED_VALUE',
|
||||
));
|
||||
|
||||
if (columnNamesRow === undefined) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
'Could not retrieve the column data',
|
||||
);
|
||||
}
|
||||
|
||||
const columnNames = columnNamesRow ? columnNamesRow[0] : [];
|
||||
const setData: string[][] = [];
|
||||
|
||||
inputData.forEach((item) => {
|
||||
const rowData: string[] = [];
|
||||
columnNames.forEach((key) => {
|
||||
let value;
|
||||
if (usePathForKeyRow) {
|
||||
value = get(item, key) as string;
|
||||
} else {
|
||||
value = item[key] as string;
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
rowData.push(emptyValue as string);
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
rowData.push(JSON.stringify(value));
|
||||
} else {
|
||||
rowData.push(value);
|
||||
}
|
||||
});
|
||||
setData.push(rowData);
|
||||
});
|
||||
return setData;
|
||||
}
|
||||
|
||||
private getDecodedSheetRange(stringToDecode: string): SheetRangeDecoded {
|
||||
const decodedRange: IDataObject = {};
|
||||
const [name, range] = stringToDecode.split('!');
|
||||
|
||||
decodedRange.nameWithRange = stringToDecode;
|
||||
decodedRange.name = name;
|
||||
decodedRange.range = range || '';
|
||||
decodedRange.start = {};
|
||||
decodedRange.end = {};
|
||||
|
||||
if (range) {
|
||||
const [startCell, endCell] = range.split(':');
|
||||
if (startCell) {
|
||||
decodedRange.start = this.splitCellRange(startCell, range);
|
||||
}
|
||||
if (endCell) {
|
||||
decodedRange.end = this.splitCellRange(endCell, range);
|
||||
}
|
||||
}
|
||||
|
||||
return decodedRange as SheetRangeDecoded;
|
||||
}
|
||||
|
||||
private splitCellRange(cell: string, range: string): SheetCellDecoded {
|
||||
const cellData = cell.match(/([a-zA-Z]{1,10})([0-9]{0,10})/) || [];
|
||||
|
||||
if (cellData === null || cellData.length !== 3) {
|
||||
throw new NodeOperationError(
|
||||
this.executeFunctions.getNode(),
|
||||
`The range "${range}" is not valid`,
|
||||
);
|
||||
}
|
||||
|
||||
return { cell: cellData[0], column: cellData[1], row: +cellData[2] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { AllEntities, Entity, PropertiesOf } from 'n8n-workflow';
|
||||
|
||||
export const ROW_NUMBER = 'row_number';
|
||||
|
||||
export interface ISheetOptions {
|
||||
scope: string[];
|
||||
}
|
||||
|
||||
export interface IGoogleAuthCredentials {
|
||||
email: string;
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
export interface ISheetUpdateData {
|
||||
range: string;
|
||||
values: string[][];
|
||||
}
|
||||
|
||||
export interface ILookupValues {
|
||||
lookupColumn: string;
|
||||
lookupValue: string;
|
||||
}
|
||||
|
||||
export interface IToDeleteRange {
|
||||
amount: number;
|
||||
startIndex: number;
|
||||
sheetId: number;
|
||||
}
|
||||
|
||||
export interface IToDelete {
|
||||
[key: string]: IToDeleteRange[] | undefined;
|
||||
columns?: IToDeleteRange[];
|
||||
rows?: IToDeleteRange[];
|
||||
}
|
||||
|
||||
export type ValueInputOption = 'RAW' | 'USER_ENTERED';
|
||||
|
||||
export type ValueRenderOption = 'FORMATTED_VALUE' | 'FORMULA' | 'UNFORMATTED_VALUE';
|
||||
|
||||
export type RangeDetectionOptions = {
|
||||
rangeDefinition: 'detectAutomatically' | 'specifyRange' | 'specifyRangeA1';
|
||||
readRowsUntil?: 'firstEmptyRow' | 'lastRowInSheet';
|
||||
headerRow?: string;
|
||||
firstDataRow?: string;
|
||||
range?: string;
|
||||
};
|
||||
|
||||
export type SheetDataRow = Array<string | number>;
|
||||
export type SheetRangeData = SheetDataRow[];
|
||||
|
||||
// delete is del
|
||||
type GoogleSheetsMap = {
|
||||
spreadsheet: 'create' | 'deleteSpreadsheet';
|
||||
sheet: 'append' | 'clear' | 'create' | 'delete' | 'read' | 'remove' | 'update' | 'appendOrUpdate';
|
||||
};
|
||||
|
||||
export type GoogleSheets = AllEntities<GoogleSheetsMap>;
|
||||
|
||||
export type GoogleSheetsSpreadSheet = Entity<GoogleSheetsMap, 'spreadsheet'>;
|
||||
export type GoogleSheetsSheet = Entity<GoogleSheetsMap, 'sheet'>;
|
||||
|
||||
export type SpreadSheetProperties = PropertiesOf<GoogleSheetsSpreadSheet>;
|
||||
export type SheetProperties = PropertiesOf<GoogleSheetsSheet>;
|
||||
|
||||
export type ResourceLocator = 'id' | 'url' | 'list' | 'name';
|
||||
|
||||
export const ResourceLocatorUiNames = {
|
||||
id: 'By ID',
|
||||
url: 'By URL',
|
||||
list: 'From List',
|
||||
name: 'By Name',
|
||||
};
|
||||
|
||||
type SpreadSheetResponseSheet = {
|
||||
properties: {
|
||||
title: string;
|
||||
sheetId: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type SpreadSheetResponse = {
|
||||
sheets: SpreadSheetResponseSheet[];
|
||||
};
|
||||
|
||||
export type SheetCellDecoded = {
|
||||
cell?: string;
|
||||
column?: string;
|
||||
row?: number;
|
||||
};
|
||||
|
||||
export type SheetRangeDecoded = {
|
||||
nameWithRange: string;
|
||||
name: string;
|
||||
range: string;
|
||||
start?: SheetCellDecoded;
|
||||
end?: SheetCellDecoded;
|
||||
};
|
||||
@@ -0,0 +1,369 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
INodeListSearchItems,
|
||||
INodePropertyOptions,
|
||||
INode,
|
||||
ResourceMapperField,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type { GoogleSheet } from './GoogleSheet';
|
||||
import type {
|
||||
RangeDetectionOptions,
|
||||
ResourceLocator,
|
||||
SheetRangeData,
|
||||
ValueInputOption,
|
||||
} from './GoogleSheets.types';
|
||||
import { ResourceLocatorUiNames, ROW_NUMBER } from './GoogleSheets.types';
|
||||
|
||||
export const untilSheetSelected = { sheetName: [''] };
|
||||
|
||||
// Used to extract the ID from the URL
|
||||
export function getSpreadsheetId(
|
||||
node: INode,
|
||||
documentIdType: ResourceLocator,
|
||||
value: string,
|
||||
): string {
|
||||
if (!value) {
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`Can not get sheet '${ResourceLocatorUiNames[documentIdType]}' with a value of '${value}'`,
|
||||
{ level: 'warning' },
|
||||
);
|
||||
}
|
||||
if (documentIdType === 'url') {
|
||||
const regex = /([-\w]{25,})/;
|
||||
const parts = value.match(regex);
|
||||
|
||||
if (parts == null || parts.length < 2) {
|
||||
return '';
|
||||
} else {
|
||||
return parts[0];
|
||||
}
|
||||
}
|
||||
// If it is byID or byList we can just return
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getSheetId(value: string): number {
|
||||
if (value === 'gid=0') return 0;
|
||||
return parseInt(value);
|
||||
}
|
||||
|
||||
// Convert number to Sheets / Excel column name
|
||||
export function getColumnName(colNumber: number): string {
|
||||
const baseChar = 'A'.charCodeAt(0);
|
||||
let letters = '';
|
||||
do {
|
||||
colNumber -= 1;
|
||||
letters = String.fromCharCode(baseChar + (colNumber % 26)) + letters;
|
||||
colNumber = (colNumber / 26) >> 0;
|
||||
} while (colNumber > 0);
|
||||
|
||||
return letters;
|
||||
}
|
||||
|
||||
// Convert Column Name to Number (A = 1, B = 2, AA = 27)
|
||||
export function getColumnNumber(colPosition: string): number {
|
||||
let colNum = 0;
|
||||
for (let i = 0; i < colPosition.length; i++) {
|
||||
colNum *= 26;
|
||||
colNum += colPosition[i].charCodeAt(0) - 'A'.charCodeAt(0) + 1;
|
||||
}
|
||||
return colNum;
|
||||
}
|
||||
|
||||
// Hex to RGB
|
||||
export function hexToRgb(hex: string) {
|
||||
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
|
||||
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
|
||||
hex = hex.replace(shorthandRegex, (_, r, g, b) => {
|
||||
return r + r + g + g + b + b;
|
||||
});
|
||||
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
red: parseInt(result[1], 16),
|
||||
green: parseInt(result[2], 16),
|
||||
blue: parseInt(result[3], 16),
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function addRowNumber(data: SheetRangeData, headerRow: number) {
|
||||
if (data.length === 0) return data;
|
||||
const sheetData = data.map((row, i) => [i + 1, ...row]);
|
||||
sheetData[headerRow][0] = ROW_NUMBER;
|
||||
return sheetData;
|
||||
}
|
||||
|
||||
export function trimToFirstEmptyRow(data: SheetRangeData, includesRowNumber = true) {
|
||||
const baseLength = includesRowNumber ? 1 : 0;
|
||||
const emptyRowIndex = data.findIndex((row) => row.slice(baseLength).every((cell) => cell === ''));
|
||||
if (emptyRowIndex === -1) {
|
||||
return data;
|
||||
}
|
||||
return data.slice(0, emptyRowIndex);
|
||||
}
|
||||
|
||||
export function removeEmptyRows(data: SheetRangeData, includesRowNumber = true) {
|
||||
const baseLength = includesRowNumber ? 1 : 0;
|
||||
const notEmptyRows = data.filter((row) =>
|
||||
row
|
||||
.slice(baseLength)
|
||||
.some((cell) => cell || typeof cell === 'number' || typeof cell === 'boolean'),
|
||||
);
|
||||
if (includesRowNumber) {
|
||||
notEmptyRows[0][0] = ROW_NUMBER;
|
||||
}
|
||||
return notEmptyRows;
|
||||
}
|
||||
|
||||
export function trimLeadingEmptyRows(
|
||||
data: SheetRangeData,
|
||||
includesRowNumber = true,
|
||||
rowNumbersColumnName = ROW_NUMBER,
|
||||
) {
|
||||
const baseLength = includesRowNumber ? 1 : 0;
|
||||
const firstNotEmptyRowIndex = data.findIndex((row) =>
|
||||
row.slice(baseLength).some((cell) => cell || typeof cell === 'number'),
|
||||
);
|
||||
|
||||
const returnData = data.slice(firstNotEmptyRowIndex);
|
||||
if (includesRowNumber) {
|
||||
returnData[0][0] = rowNumbersColumnName;
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export function removeEmptyColumns(data: SheetRangeData) {
|
||||
if (!data || data.length === 0) return [];
|
||||
const returnData: SheetRangeData = [];
|
||||
const longestRow = data.reduce((a, b) => (a.length > b.length ? a : b), []).length;
|
||||
for (let col = 0; col < longestRow; col++) {
|
||||
const column = data.map((row) => row[col]);
|
||||
if (column[0] !== '') {
|
||||
returnData.push(column);
|
||||
continue;
|
||||
}
|
||||
const hasData = column.slice(1).some((cell) => cell || typeof cell === 'number');
|
||||
if (hasData) {
|
||||
returnData.push(column);
|
||||
}
|
||||
}
|
||||
return (returnData[0] || []).map((_, i) =>
|
||||
returnData.map((row) => (row[i] === undefined ? '' : row[i])),
|
||||
);
|
||||
}
|
||||
|
||||
export function prepareSheetData(
|
||||
data: SheetRangeData,
|
||||
options: RangeDetectionOptions,
|
||||
addRowNumbersToData = true,
|
||||
) {
|
||||
let returnData: SheetRangeData = [...(data || [])];
|
||||
|
||||
let headerRow = 0;
|
||||
let firstDataRow = 1;
|
||||
|
||||
if (options.rangeDefinition === 'specifyRange') {
|
||||
headerRow = parseInt(options.headerRow as string, 10) - 1;
|
||||
firstDataRow = parseInt(options.firstDataRow as string, 10) - 1;
|
||||
}
|
||||
|
||||
if (addRowNumbersToData) {
|
||||
returnData = addRowNumber(returnData, headerRow);
|
||||
}
|
||||
|
||||
if (options.rangeDefinition === 'detectAutomatically') {
|
||||
returnData = removeEmptyColumns(returnData);
|
||||
returnData = trimLeadingEmptyRows(returnData, addRowNumbersToData);
|
||||
|
||||
if (options.readRowsUntil === 'firstEmptyRow') {
|
||||
returnData = trimToFirstEmptyRow(returnData, addRowNumbersToData);
|
||||
} else {
|
||||
returnData = removeEmptyRows(returnData, addRowNumbersToData);
|
||||
}
|
||||
}
|
||||
|
||||
return { data: returnData, headerRow, firstDataRow };
|
||||
}
|
||||
|
||||
export function getRangeString(sheetName: string, options: RangeDetectionOptions) {
|
||||
if (options.rangeDefinition === 'specifyRangeA1') {
|
||||
return options.range ? `${sheetName}!${options.range}` : sheetName;
|
||||
}
|
||||
return sheetName;
|
||||
}
|
||||
|
||||
export async function getExistingSheetNames(sheet: GoogleSheet) {
|
||||
const { sheets } = await sheet.spreadsheetGetSheets();
|
||||
return ((sheets as IDataObject[]) || []).map((entry) => (entry.properties as IDataObject)?.title);
|
||||
}
|
||||
|
||||
export function mapFields(this: IExecuteFunctions, inputSize: number) {
|
||||
const returnData: IDataObject[] = [];
|
||||
|
||||
for (let i = 0; i < inputSize; i++) {
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
if (nodeVersion < 4) {
|
||||
const fields = this.getNodeParameter('fieldsUi.fieldValues', i, []) as IDataObject[];
|
||||
let dataToSend: IDataObject = {};
|
||||
for (const field of fields) {
|
||||
dataToSend = { ...dataToSend, [field.fieldId as string]: field.fieldValue };
|
||||
}
|
||||
returnData.push(dataToSend);
|
||||
} else {
|
||||
const mappingValues = this.getNodeParameter('columns.value', i) as IDataObject;
|
||||
if (Object.keys(mappingValues).length === 0) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"At least one value has to be added under 'Values to Send'",
|
||||
);
|
||||
}
|
||||
returnData.push(mappingValues);
|
||||
}
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export async function autoMapInputData(
|
||||
this: IExecuteFunctions,
|
||||
sheetNameWithRange: string,
|
||||
sheet: GoogleSheet,
|
||||
items: INodeExecutionData[],
|
||||
options: IDataObject,
|
||||
) {
|
||||
const returnData: IDataObject[] = [];
|
||||
const [sheetName, _sheetRange] = sheetNameWithRange.split('!');
|
||||
const locationDefine = (options.locationDefine as IDataObject)?.values as IDataObject;
|
||||
const handlingExtraData = (options.handlingExtraData as string) || 'insertInNewColumn';
|
||||
|
||||
let headerRow = 1;
|
||||
|
||||
if (locationDefine) {
|
||||
headerRow = parseInt(locationDefine.headerRow as string, 10);
|
||||
}
|
||||
|
||||
let columnNames: string[] = [];
|
||||
const response = await sheet.getData(`${sheetName}!${headerRow}:${headerRow}`, 'FORMATTED_VALUE');
|
||||
|
||||
columnNames = response ? response[0] : [];
|
||||
|
||||
if (handlingExtraData === 'insertInNewColumn') {
|
||||
if (!columnNames.length) {
|
||||
await sheet.updateRows(
|
||||
sheetName,
|
||||
[Object.keys(items[0].json).filter((key) => key !== ROW_NUMBER)],
|
||||
(options.cellFormat as ValueInputOption) || 'RAW',
|
||||
headerRow,
|
||||
);
|
||||
columnNames = Object.keys(items[0].json);
|
||||
}
|
||||
|
||||
const newColumns = new Set<string>();
|
||||
|
||||
items.forEach((item) => {
|
||||
Object.keys(item.json).forEach((key) => {
|
||||
if (key !== ROW_NUMBER && !columnNames.includes(key)) {
|
||||
newColumns.add(key);
|
||||
}
|
||||
});
|
||||
if (item.json[ROW_NUMBER]) {
|
||||
const { [ROW_NUMBER]: _, ...json } = item.json;
|
||||
returnData.push(json);
|
||||
return;
|
||||
}
|
||||
returnData.push(item.json);
|
||||
});
|
||||
if (newColumns.size) {
|
||||
await sheet.updateRows(
|
||||
sheetName,
|
||||
[columnNames.concat([...newColumns])],
|
||||
(options.cellFormat as ValueInputOption) || 'RAW',
|
||||
headerRow,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (handlingExtraData === 'ignoreIt') {
|
||||
items.forEach((item) => {
|
||||
returnData.push(item.json);
|
||||
});
|
||||
}
|
||||
if (handlingExtraData === 'error') {
|
||||
items.forEach((item, itemIndex) => {
|
||||
Object.keys(item.json).forEach((key) => {
|
||||
if (!columnNames.includes(key)) {
|
||||
throw new NodeOperationError(this.getNode(), 'Unexpected fields in node input', {
|
||||
itemIndex,
|
||||
description: `The input field '${key}' doesn't match any column in the Sheet. You can ignore this by changing the 'Handling extra data' field, which you can find under 'Options'.`,
|
||||
});
|
||||
}
|
||||
});
|
||||
returnData.push(item.json);
|
||||
});
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export function sortLoadOptions(data: INodePropertyOptions[] | INodeListSearchItems[]) {
|
||||
const returnData = [...data];
|
||||
returnData.sort((a, b) => {
|
||||
const aName = a.name.toLowerCase();
|
||||
const bName = b.name.toLowerCase();
|
||||
if (aName < bName) {
|
||||
return -1;
|
||||
}
|
||||
if (aName > bName) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export function cellFormatDefault(nodeVersion: number) {
|
||||
if (nodeVersion < 4.1) {
|
||||
return 'RAW';
|
||||
}
|
||||
return 'USER_ENTERED';
|
||||
}
|
||||
|
||||
export function checkForSchemaChanges(
|
||||
node: INode,
|
||||
columnNames: string[],
|
||||
schema: ResourceMapperField[],
|
||||
) {
|
||||
const updatedColumnNames: Array<{ oldName: string; newName: string }> = [];
|
||||
// RMC filters out empty columns so do the same here
|
||||
columnNames = columnNames.filter((col) => col !== '');
|
||||
|
||||
// if sheet does not contain ROW_NUMBER ignore it as data come from read rows operation
|
||||
const schemaColumns = columnNames.includes(ROW_NUMBER)
|
||||
? schema.map((s) => s.id)
|
||||
: schema.filter((s) => s.id !== ROW_NUMBER).map((s) => s.id);
|
||||
|
||||
for (const [columnIndex, columnName] of columnNames.entries()) {
|
||||
const schemaEntry = schemaColumns[columnIndex];
|
||||
if (schemaEntry === undefined) break;
|
||||
if (columnName !== schemaEntry) {
|
||||
updatedColumnNames.push({ oldName: schemaEntry, newName: columnName });
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedColumnNames.length) {
|
||||
throw new NodeOperationError(node, "Column names were updated after the node's setup", {
|
||||
description: `Refresh the columns list in the 'Column to Match On' parameter. Updated columns: ${updatedColumnNames.map((c) => `${c.oldName} -> ${c.newName}`).join(', ')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
ICredentialsDecrypted,
|
||||
ICredentialTestFunctions,
|
||||
INodeCredentialTestResult,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { getGoogleAccessToken } from '../../../GenericFunctions';
|
||||
|
||||
export async function googleApiCredentialTest(
|
||||
this: ICredentialTestFunctions,
|
||||
credential: ICredentialsDecrypted,
|
||||
): Promise<INodeCredentialTestResult> {
|
||||
try {
|
||||
const tokenRequest = await getGoogleAccessToken.call(this, credential.data!, 'sheetV2');
|
||||
if (!tokenRequest.access_token) {
|
||||
return {
|
||||
status: 'Error',
|
||||
message: 'Could not generate a token from your private key.',
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 'Error',
|
||||
message: `Private key validation failed: ${err.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'OK',
|
||||
message: 'Connection successful!',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * as loadOptions from './loadOptions';
|
||||
export * as listSearch from './listSearch';
|
||||
export * as credentialTest from './credentialTest';
|
||||
export * as resourceMapping from './resourceMapping';
|
||||
@@ -0,0 +1,93 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
ILoadOptionsFunctions,
|
||||
INodeListSearchItems,
|
||||
INodeListSearchResult,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type { ResourceLocator } from '../helpers/GoogleSheets.types';
|
||||
import { getSpreadsheetId } from '../helpers/GoogleSheets.utils';
|
||||
import { apiRequest } from '../transport';
|
||||
|
||||
export async function spreadSheetsSearch(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
paginationToken?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const query: string[] = [];
|
||||
if (filter) {
|
||||
query.push(`name contains '${filter.replace("'", "\\'")}'`);
|
||||
}
|
||||
query.push("mimeType = 'application/vnd.google-apps.spreadsheet'");
|
||||
|
||||
const qs = {
|
||||
q: query.join(' and '),
|
||||
pageToken: (paginationToken as string) || undefined,
|
||||
fields: 'nextPageToken, files(id, name, webViewLink)',
|
||||
orderBy: 'modifiedByMeTime desc,name_natural',
|
||||
includeItemsFromAllDrives: true,
|
||||
supportsAllDrives: true,
|
||||
};
|
||||
|
||||
const res = await apiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
'',
|
||||
{},
|
||||
qs,
|
||||
'https://www.googleapis.com/drive/v3/files',
|
||||
);
|
||||
return {
|
||||
results: res.files.map((sheet: IDataObject) => ({
|
||||
name: sheet.name as string,
|
||||
value: sheet.id as string,
|
||||
url: sheet.webViewLink as string,
|
||||
})),
|
||||
paginationToken: res.nextPageToken,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sheetsSearch(
|
||||
this: ILoadOptionsFunctions,
|
||||
_filter?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const documentId = this.getNodeParameter('documentId', 0) as IDataObject | null;
|
||||
|
||||
if (!documentId) return { results: [] };
|
||||
|
||||
const { mode, value } = documentId;
|
||||
|
||||
const spreadsheetId = getSpreadsheetId(this.getNode(), mode as ResourceLocator, value as string);
|
||||
|
||||
const query = {
|
||||
fields: 'sheets.properties',
|
||||
};
|
||||
|
||||
const responseData = await apiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
`/v4/spreadsheets/${spreadsheetId}`,
|
||||
{},
|
||||
query,
|
||||
);
|
||||
|
||||
if (responseData === undefined) {
|
||||
throw new NodeOperationError(this.getNode(), 'No data got returned');
|
||||
}
|
||||
|
||||
const returnData: INodeListSearchItems[] = [];
|
||||
for (const sheet of responseData.sheets!) {
|
||||
if (sheet.properties!.sheetType !== 'GRID') {
|
||||
continue;
|
||||
}
|
||||
|
||||
returnData.push({
|
||||
name: sheet.properties!.title as string,
|
||||
value: (sheet.properties!.sheetId as number) || 'gid=0',
|
||||
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit#gid=${sheet.properties!.sheetId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { results: returnData };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { IDataObject, ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { GoogleSheet } from '../helpers/GoogleSheet';
|
||||
import type { ResourceLocator } from '../helpers/GoogleSheets.types';
|
||||
import { getSpreadsheetId } from '../helpers/GoogleSheets.utils';
|
||||
|
||||
export async function getSheets(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
|
||||
const documentId = this.getNodeParameter('documentId', 0) as IDataObject | null;
|
||||
|
||||
if (!documentId) return [];
|
||||
|
||||
const { mode, value } = documentId;
|
||||
|
||||
const spreadsheetId = getSpreadsheetId(this.getNode(), mode as ResourceLocator, value as string);
|
||||
|
||||
const sheet = new GoogleSheet(spreadsheetId, this);
|
||||
const responseData = await sheet.spreadsheetGetSheets();
|
||||
|
||||
if (responseData === undefined) {
|
||||
throw new NodeOperationError(this.getNode(), 'No data got returned');
|
||||
}
|
||||
|
||||
const returnData: INodePropertyOptions[] = [];
|
||||
for (const entry of responseData.sheets!) {
|
||||
if (entry.properties!.sheetType !== 'GRID') {
|
||||
continue;
|
||||
}
|
||||
|
||||
returnData.push({
|
||||
name: entry.properties!.title as string,
|
||||
value: entry.properties!.sheetId as unknown as string,
|
||||
});
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export async function getSheetHeaderRow(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
const documentId = this.getNodeParameter('documentId', 0) as IDataObject | null;
|
||||
|
||||
if (!documentId) return [];
|
||||
|
||||
const { mode, value } = documentId;
|
||||
|
||||
const spreadsheetId = getSpreadsheetId(this.getNode(), mode as ResourceLocator, value as string);
|
||||
|
||||
const sheet = new GoogleSheet(spreadsheetId, this);
|
||||
const sheetWithinDocument = this.getNodeParameter('sheetName', undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
const { mode: sheetMode } = this.getNodeParameter('sheetName', 0) as {
|
||||
mode: ResourceLocator;
|
||||
};
|
||||
|
||||
const { title: sheetName } = await sheet.spreadsheetGetSheet(
|
||||
this.getNode(),
|
||||
sheetMode,
|
||||
sheetWithinDocument,
|
||||
);
|
||||
const sheetData = await sheet.getData(`${sheetName}!1:1`, 'FORMATTED_VALUE');
|
||||
|
||||
if (sheetData === undefined) {
|
||||
throw new NodeOperationError(this.getNode(), 'No data got returned');
|
||||
}
|
||||
|
||||
const columns = sheet.testFilter(sheetData, 0, 0);
|
||||
|
||||
const returnData: INodePropertyOptions[] = [];
|
||||
|
||||
for (const column of columns) {
|
||||
returnData.push({
|
||||
name: column as unknown as string,
|
||||
value: column as unknown as string,
|
||||
});
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export async function getSheetHeaderRowAndAddColumn(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
const returnData = await getSheetHeaderRow.call(this);
|
||||
returnData.push({
|
||||
name: 'New column ...',
|
||||
value: 'newColumn',
|
||||
});
|
||||
const columnToMatchOn = this.getNodeParameter('columnToMatchOn', 0) as string;
|
||||
return returnData.filter((column) => column.value !== columnToMatchOn);
|
||||
}
|
||||
|
||||
export async function getSheetHeaderRowWithGeneratedColumnNames(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
const returnData = await getSheetHeaderRow.call(this);
|
||||
return returnData.map((column, i) => {
|
||||
if (column.value !== '') return column;
|
||||
const indexBasedValue = `col_${i + 1}`;
|
||||
return {
|
||||
name: indexBasedValue,
|
||||
value: indexBasedValue,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSheetHeaderRowAndSkipEmpty(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
const returnData = await getSheetHeaderRow.call(this);
|
||||
return returnData.filter((column) => column.value);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
ILoadOptionsFunctions,
|
||||
ResourceMapperField,
|
||||
ResourceMapperFields,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { GoogleSheet } from '../helpers/GoogleSheet';
|
||||
import { ROW_NUMBER, type ResourceLocator } from '../helpers/GoogleSheets.types';
|
||||
import { getSpreadsheetId } from '../helpers/GoogleSheets.utils';
|
||||
|
||||
export async function getMappingColumns(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<ResourceMapperFields> {
|
||||
const documentId = this.getNodeParameter('documentId', 0) as IDataObject | null;
|
||||
|
||||
if (!documentId) return { fields: [] };
|
||||
|
||||
const { mode, value } = documentId;
|
||||
|
||||
const spreadsheetId = getSpreadsheetId(this.getNode(), mode as ResourceLocator, value as string);
|
||||
|
||||
const sheet = new GoogleSheet(spreadsheetId, this);
|
||||
const sheetWithinDocument = this.getNodeParameter('sheetName', undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
const { mode: sheetMode } = this.getNodeParameter('sheetName', 0) as { mode: ResourceLocator };
|
||||
|
||||
const { title: sheetName } = await sheet.spreadsheetGetSheet(
|
||||
this.getNode(),
|
||||
sheetMode,
|
||||
sheetWithinDocument,
|
||||
);
|
||||
|
||||
const locationDefine = this.getNodeParameter(
|
||||
'options.locationDefine.values',
|
||||
0,
|
||||
{},
|
||||
) as IDataObject;
|
||||
|
||||
let columnNamesRow = 1;
|
||||
|
||||
if (locationDefine.headerRow) {
|
||||
columnNamesRow = locationDefine.headerRow as number;
|
||||
}
|
||||
|
||||
const sheetData = await sheet.getData(
|
||||
`${sheetName}!${columnNamesRow}:${columnNamesRow}`,
|
||||
'FORMATTED_VALUE',
|
||||
);
|
||||
|
||||
const columns = sheet.testFilter(sheetData || [], 0, 0).filter((col) => col !== '');
|
||||
|
||||
const fields: ResourceMapperField[] = columns.map((col) => ({
|
||||
id: col,
|
||||
displayName: col,
|
||||
required: false,
|
||||
defaultMatch: col === 'id',
|
||||
display: true,
|
||||
type: 'string',
|
||||
canBeUsedToMatch: true,
|
||||
}));
|
||||
|
||||
const operation = this.getNodeParameter('operation', 0) as string;
|
||||
|
||||
if (operation === 'update') {
|
||||
fields.push({
|
||||
id: ROW_NUMBER,
|
||||
displayName: ROW_NUMBER,
|
||||
required: false,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
type: 'number',
|
||||
canBeUsedToMatch: true,
|
||||
readOnly: true,
|
||||
removed: true,
|
||||
});
|
||||
}
|
||||
|
||||
return { fields };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import set from 'lodash/set';
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
IHttpRequestMethods,
|
||||
ILoadOptionsFunctions,
|
||||
IPollFunctions,
|
||||
IRequestOptions,
|
||||
JsonObject,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import { getGoogleAccessToken } from '../../../GenericFunctions';
|
||||
|
||||
export async function apiRequest(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
resource: string,
|
||||
body: IDataObject = {},
|
||||
qs: IDataObject = {},
|
||||
uri?: string,
|
||||
headers: IDataObject = {},
|
||||
option: IDataObject = {},
|
||||
) {
|
||||
const authenticationMethod = this.getNodeParameter(
|
||||
'authentication',
|
||||
0,
|
||||
'serviceAccount',
|
||||
) as string;
|
||||
const options: IRequestOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method,
|
||||
body,
|
||||
qs,
|
||||
uri: uri || `https://sheets.googleapis.com${resource}`,
|
||||
json: true,
|
||||
...option,
|
||||
};
|
||||
try {
|
||||
if (Object.keys(headers).length !== 0) {
|
||||
options.headers = Object.assign({}, options.headers, headers);
|
||||
}
|
||||
if (Object.keys(body).length === 0) {
|
||||
delete options.body;
|
||||
}
|
||||
|
||||
if (authenticationMethod === 'serviceAccount') {
|
||||
const credentials = await this.getCredentials('googleApi');
|
||||
|
||||
const { access_token } = await getGoogleAccessToken.call(this, credentials, 'sheetV2');
|
||||
|
||||
options.headers!.Authorization = `Bearer ${access_token}`;
|
||||
|
||||
return await this.helpers.request(options);
|
||||
} else if (authenticationMethod === 'triggerOAuth2') {
|
||||
return await this.helpers.requestOAuth2.call(this, 'googleSheetsTriggerOAuth2Api', options);
|
||||
} else {
|
||||
return await this.helpers.requestOAuth2.call(this, 'googleSheetsOAuth2Api', options);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'ERR_OSSL_PEM_NO_START_LINE') {
|
||||
error.statusCode = '401';
|
||||
}
|
||||
|
||||
if (error instanceof NodeApiError) {
|
||||
if (error.message.includes('PERMISSION_DENIED')) {
|
||||
const details = error.description ? ` Details of the error: ${error.description}.` : '';
|
||||
const description = `Please check that the account you're using has the right permissions. (If you're trying to modify the sheet, you'll need edit access.)${details}`;
|
||||
|
||||
set(error, 'description', description);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequestAllItems(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
propertyName: string,
|
||||
method: IHttpRequestMethods,
|
||||
endpoint: string,
|
||||
body: IDataObject = {},
|
||||
query: IDataObject = {},
|
||||
uri?: string,
|
||||
) {
|
||||
const returnData: IDataObject[] = [];
|
||||
|
||||
let responseData;
|
||||
query.maxResults = 100;
|
||||
const url = uri ? uri : `https://sheets.googleapis.com${method}`;
|
||||
do {
|
||||
responseData = await apiRequest.call(this, method, endpoint, body, query, url);
|
||||
query.pageToken = responseData.nextPageToken;
|
||||
returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]);
|
||||
} while (responseData.nextPageToken !== undefined && responseData.nextPageToken !== '');
|
||||
|
||||
return returnData;
|
||||
}
|
||||
Reference in New Issue
Block a user