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,84 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.airtable",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Data & Storage"],
|
||||
"resources": {
|
||||
"credentialDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/credentials/airtable/"
|
||||
}
|
||||
],
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.airtable/"
|
||||
}
|
||||
],
|
||||
"generic": [
|
||||
{
|
||||
"label": "2021 Goals: Level Up Your Vocabulary With Vonage and n8n",
|
||||
"icon": "🎯",
|
||||
"url": "https://n8n.io/blog/2021-goals-level-up-your-vocabulary-with-vonage-and-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "2021: The Year to Automate the New You with n8n",
|
||||
"icon": "☀️",
|
||||
"url": "https://n8n.io/blog/2021-the-year-to-automate-the-new-you-with-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "How to build a low-code, self-hosted URL shortener in 3 steps",
|
||||
"icon": "🔗",
|
||||
"url": "https://n8n.io/blog/how-to-build-a-low-code-self-hosted-url-shortener/"
|
||||
},
|
||||
{
|
||||
"label": "How to get started with CRM automation (with 3 no-code workflow ideas",
|
||||
"icon": "👥",
|
||||
"url": "https://n8n.io/blog/how-to-get-started-with-crm-automation-and-no-code-workflow-ideas/"
|
||||
},
|
||||
{
|
||||
"label": "15 Google apps you can combine and automate to increase productivity",
|
||||
"icon": "💡",
|
||||
"url": "https://n8n.io/blog/automate-google-apps-for-productivity/"
|
||||
},
|
||||
{
|
||||
"label": "Building an expense tracking app in 10 minutes",
|
||||
"icon": "📱",
|
||||
"url": "https://n8n.io/blog/building-an-expense-tracking-app-in-10-minutes/"
|
||||
},
|
||||
{
|
||||
"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": "Learn to Build Powerful API Endpoints Using Webhooks",
|
||||
"icon": "🧰",
|
||||
"url": "https://n8n.io/blog/learn-to-build-powerful-api-endpoints-using-webhooks/"
|
||||
},
|
||||
{
|
||||
"label": "Sending SMS the Low-Code Way with Airtable, Twilio Programmable SMS, and n8n",
|
||||
"icon": "📱",
|
||||
"url": "https://n8n.io/blog/sending-sms-the-low-code-way-with-airtable-twilio-programmable-sms-and-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "Automating Conference Organization Processes with n8n",
|
||||
"icon": "🙋♀️",
|
||||
"url": "https://n8n.io/blog/automating-conference-organization-processes-with-n8n/"
|
||||
},
|
||||
{
|
||||
"label": "Benefits of automation and n8n: An interview with HubSpot's Hugh Durkin",
|
||||
"icon": "🎖",
|
||||
"url": "https://n8n.io/blog/benefits-of-automation-and-n8n-an-interview-with-hubspots-hugh-durkin/"
|
||||
},
|
||||
{
|
||||
"label": "How Goomer automated their operations with over 200 n8n workflows",
|
||||
"icon": "🛵",
|
||||
"url": "https://n8n.io/blog/how-goomer-automated-their-operations-with-over-200-n8n-workflows/"
|
||||
},
|
||||
{
|
||||
"label": "7 no-code workflow automations for Amazon Web Services",
|
||||
"url": "https://n8n.io/blog/aws-workflow-automation/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
|
||||
import { VersionedNodeType } from 'n8n-workflow';
|
||||
|
||||
import { AirtableV1 } from './v1/AirtableV1.node';
|
||||
import { AirtableV2 } from './v2/AirtableV2.node';
|
||||
|
||||
export class Airtable extends VersionedNodeType {
|
||||
constructor() {
|
||||
const baseDescription: INodeTypeBaseDescription = {
|
||||
displayName: 'Airtable',
|
||||
name: 'airtable',
|
||||
icon: 'file:airtable.svg',
|
||||
group: ['input'],
|
||||
description: 'Read, update, write and delete data from Airtable',
|
||||
defaultVersion: 2.1,
|
||||
};
|
||||
|
||||
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||
1: new AirtableV1(baseDescription),
|
||||
2: new AirtableV2(baseDescription),
|
||||
2.1: new AirtableV2(baseDescription),
|
||||
};
|
||||
|
||||
super(nodeVersions, baseDescription);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.airtableTrigger",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Data & Storage"],
|
||||
"resources": {
|
||||
"credentialDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/credentials/airtable/"
|
||||
}
|
||||
],
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.airtabletrigger/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import moment from 'moment-timezone';
|
||||
import type {
|
||||
IPollFunctions,
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type { IRecord } from './v1/GenericFunctions';
|
||||
import { apiRequestAllItems, downloadRecordAttachments } from './v1/GenericFunctions';
|
||||
|
||||
export class AirtableTrigger implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Airtable Trigger',
|
||||
name: 'airtableTrigger',
|
||||
icon: 'file:airtable.svg',
|
||||
group: ['trigger'],
|
||||
version: 1,
|
||||
description: 'Starts the workflow when Airtable events occur',
|
||||
subtitle: '={{$parameter["event"]}}',
|
||||
defaults: {
|
||||
name: 'Airtable Trigger',
|
||||
},
|
||||
credentials: [
|
||||
{
|
||||
name: 'airtableApi',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['airtableApi'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'airtableTokenApi',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['airtableTokenApi'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'airtableOAuth2Api',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['airtableOAuth2Api'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
polling: true,
|
||||
inputs: [],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'API Key',
|
||||
value: 'airtableApi',
|
||||
},
|
||||
{
|
||||
name: 'Access Token',
|
||||
value: 'airtableTokenApi',
|
||||
},
|
||||
{
|
||||
name: 'OAuth2',
|
||||
value: 'airtableOAuth2Api',
|
||||
},
|
||||
],
|
||||
default: 'airtableApi',
|
||||
},
|
||||
{
|
||||
displayName: 'Base',
|
||||
name: 'baseId',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'url', value: '' },
|
||||
required: true,
|
||||
description: 'The Airtable Base in which to operate on',
|
||||
modes: [
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
placeholder: 'https://airtable.com/app12DiScdfes/tblAAAAAAAAAAAAA/viwHdfasdfeieg5p',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: 'https://airtable.com/([a-zA-Z0-9]{2,})/.*',
|
||||
errorMessage: 'Not a valid Airtable Base URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: 'https://airtable.com/([a-zA-Z0-9]{2,})',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '[a-zA-Z0-9]{2,}',
|
||||
errorMessage: 'Not a valid Airtable Base ID',
|
||||
},
|
||||
},
|
||||
],
|
||||
placeholder: 'appD3dfaeidke',
|
||||
url: '=https://airtable.com/{{$value}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Table',
|
||||
name: 'tableId',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'url', value: '' },
|
||||
required: true,
|
||||
modes: [
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
placeholder: 'https://airtable.com/app12DiScdfes/tblAAAAAAAAAAAAA/viwHdfasdfeieg5p',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: 'https://airtable.com/[a-zA-Z0-9]{2,}/([a-zA-Z0-9]{2,})/.*',
|
||||
errorMessage: 'Not a valid Airtable Table URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: 'https://airtable.com/[a-zA-Z0-9]{2,}/([a-zA-Z0-9]{2,})',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '[a-zA-Z0-9]{2,}',
|
||||
errorMessage: 'Not a valid Airtable Table ID',
|
||||
},
|
||||
},
|
||||
],
|
||||
placeholder: 'tbl3dirwqeidke',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Trigger Field',
|
||||
name: 'triggerField',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'A Created Time or Last Modified Time field that will be used to sort records. If you do not have a Created Time or Last Modified Time field in your schema, please create one, because without this field trigger will not work correctly.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Download Attachments',
|
||||
name: 'downloadAttachments',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: "Whether the attachment fields define in 'Download Fields' will be downloaded",
|
||||
},
|
||||
{
|
||||
displayName: 'Download Fields',
|
||||
name: 'downloadFieldNames',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
downloadAttachments: [true],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description:
|
||||
"Name of the fields of type 'attachment' that should be downloaded. Multiple ones can be defined separated by comma. Case sensitive.",
|
||||
},
|
||||
{
|
||||
displayName: 'Additional Fields',
|
||||
name: 'additionalFields',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Fields',
|
||||
name: 'fields',
|
||||
type: 'string',
|
||||
requiresDataPath: 'multiple',
|
||||
default: '',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-miscased-id
|
||||
description:
|
||||
'Fields to be included in the response. Multiple ones can be set separated by comma. Example: <code>name, id</code>. By default just the trigger field will be included.',
|
||||
},
|
||||
{
|
||||
displayName: 'Formula',
|
||||
name: 'formula',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'Formulas may involve functions, numeric operations, logical operations, and text operations that operate on fields. More info <a href="https://support.airtable.com/hc/en-us/articles/203255215-Formula-Field-Reference">here</a>.',
|
||||
},
|
||||
{
|
||||
displayName: 'View ID',
|
||||
name: 'viewId',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description:
|
||||
'The name or ID of a view in the table. If set, only the records in that view will be returned.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async poll(this: IPollFunctions): Promise<INodeExecutionData[][] | null> {
|
||||
const downloadAttachments = this.getNodeParameter('downloadAttachments', 0) as boolean;
|
||||
const webhookData = this.getWorkflowStaticData('node');
|
||||
const additionalFields = this.getNodeParameter('additionalFields') as IDataObject;
|
||||
const base = this.getNodeParameter('baseId', '', { extractValue: true }) as string;
|
||||
const table = this.getNodeParameter('tableId', '', { extractValue: true }) as string;
|
||||
const triggerField = this.getNodeParameter('triggerField') as string;
|
||||
|
||||
const qs: IDataObject = {};
|
||||
|
||||
const endpoint = `${base}/${table}`;
|
||||
|
||||
const now = moment().utc().format();
|
||||
|
||||
const startDate = (webhookData.lastTimeChecked as string) || now;
|
||||
|
||||
const endDate = now;
|
||||
|
||||
if (additionalFields.viewId) {
|
||||
qs.view = additionalFields.viewId;
|
||||
}
|
||||
|
||||
if (additionalFields.fields) {
|
||||
qs['fields[]'] = (additionalFields.fields as string).split(',');
|
||||
}
|
||||
|
||||
qs.filterByFormula = `IS_AFTER({${triggerField}}, DATETIME_PARSE("${startDate}", "YYYY-MM-DD HH:mm:ss"))`;
|
||||
|
||||
if (additionalFields.formula) {
|
||||
qs.filterByFormula = `AND(${qs.filterByFormula}, ${additionalFields.formula})`;
|
||||
}
|
||||
|
||||
if (this.getMode() === 'manual') {
|
||||
delete qs.filterByFormula;
|
||||
qs.maxRecords = 1;
|
||||
}
|
||||
|
||||
const { records } = await apiRequestAllItems.call(this, 'GET', endpoint, {}, qs);
|
||||
|
||||
webhookData.lastTimeChecked = endDate;
|
||||
|
||||
if (Array.isArray(records) && records.length) {
|
||||
if (this.getMode() === 'manual' && records[0].fields[triggerField] === undefined) {
|
||||
throw new NodeOperationError(this.getNode(), `The Field "${triggerField}" does not exist.`);
|
||||
}
|
||||
|
||||
if (downloadAttachments) {
|
||||
const downloadFieldNames = (this.getNodeParameter('downloadFieldNames', 0) as string).split(
|
||||
',',
|
||||
);
|
||||
const data = await downloadRecordAttachments.call(
|
||||
this,
|
||||
records as IRecord[],
|
||||
downloadFieldNames,
|
||||
);
|
||||
return [data];
|
||||
}
|
||||
|
||||
return [this.helpers.returnJsonArray(records)];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 170"><path fill="#fcb400" d="M89 4.8 16.2 34.9c-4.1 1.7-4 7.4.1 9.1l73.2 29c6.4 2.6 13.6 2.6 20 0l73.2-29c4.1-1.6 4.1-7.4.1-9.1l-73-30.1C103.2 2 95.7 2 89 4.8"/><path fill="#18bfff" d="M105.9 88.9v72.5c0 3.4 3.5 5.8 6.7 4.5l81.6-31.7c1.9-.7 3.1-2.5 3.1-4.5V57.2c0-3.4-3.5-5.8-6.7-4.5L109 84.3c-1.9.8-3.1 2.6-3.1 4.6"/><path fill="#f82b60" d="m86.9 92.6-24.2 11.7-2.5 1.2L9.1 130c-3.2 1.6-7.4-.8-7.4-4.4V57.5c0-1.3.7-2.4 1.6-3.3q.6-.6 1.2-.9c1.2-.7 3-.9 4.4-.3l77.5 30.7c4 1.5 4.3 7.1.5 8.9"/><path fill="#ba1e45" d="m86.9 92.6-24.2 11.7-59.4-50q.6-.6 1.2-.9c1.2-.7 3-.9 4.4-.3l77.5 30.7c4 1.4 4.3 7 .5 8.8"/></svg>
|
||||
|
After Width: | Height: | Size: 671 B |
@@ -0,0 +1,34 @@
|
||||
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
|
||||
import type { WorkflowTestData } from 'n8n-workflow';
|
||||
import nock from 'nock';
|
||||
|
||||
const record = {
|
||||
id: 'rec2BWBoyS5QsS7pT',
|
||||
name: 'Tim',
|
||||
email: 'tim@email.com',
|
||||
createdTime: '2022-08-25T08:22:34.000Z',
|
||||
};
|
||||
|
||||
describe('Execute Airtable Node', () => {
|
||||
const testHarness = new NodeTestHarness();
|
||||
|
||||
beforeEach(() => {
|
||||
nock('https://api.airtable.com/v0')
|
||||
.get('/appIaXXdDqS5ORr4V/tbljyBEdYzCPF0NDh/rec2BWBoyS5QsS7pT')
|
||||
.reply(200, record);
|
||||
});
|
||||
|
||||
const testData: WorkflowTestData = {
|
||||
description: 'List Airtable Records',
|
||||
input: {
|
||||
workflowData: testHarness.readWorkflowJSON('workflow.json'),
|
||||
},
|
||||
output: {
|
||||
nodeData: {
|
||||
Airtable: [[{ json: record }]],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
testHarness.setupTest(testData, { credentials: { airtableTokenApi: {} } });
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import * as getMany from '../../../../v2/actions/base/getMany.operation';
|
||||
import * as transport from '../../../../v2/transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
const bases = [
|
||||
{
|
||||
id: 'appXXX',
|
||||
name: 'base 1',
|
||||
permissionLevel: 'create',
|
||||
},
|
||||
{
|
||||
id: 'appYYY',
|
||||
name: 'base 2',
|
||||
permissionLevel: 'edit',
|
||||
},
|
||||
{
|
||||
id: 'appZZZ',
|
||||
name: 'base 3',
|
||||
permissionLevel: 'create',
|
||||
},
|
||||
];
|
||||
|
||||
jest.mock('../../../../v2/transport', () => {
|
||||
const originalModule = jest.requireActual('../../../../v2/transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function () {
|
||||
return { bases };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test AirtableV2, base => getMany', () => {
|
||||
it('should return all bases', async () => {
|
||||
const nodeParameters = {
|
||||
resource: 'base',
|
||||
returnAll: true,
|
||||
options: {},
|
||||
};
|
||||
|
||||
const response = await getMany.execute.call(createMockExecuteFunction(nodeParameters));
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('GET', 'meta/bases');
|
||||
|
||||
expect(response).toEqual([
|
||||
{
|
||||
json: {
|
||||
id: 'appXXX',
|
||||
name: 'base 1',
|
||||
permissionLevel: 'create',
|
||||
},
|
||||
pairedItem: [
|
||||
{
|
||||
item: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
json: {
|
||||
id: 'appYYY',
|
||||
name: 'base 2',
|
||||
permissionLevel: 'edit',
|
||||
},
|
||||
pairedItem: [
|
||||
{
|
||||
item: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
json: {
|
||||
id: 'appZZZ',
|
||||
name: 'base 3',
|
||||
permissionLevel: 'create',
|
||||
},
|
||||
pairedItem: [
|
||||
{
|
||||
item: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return one base with edit permission', async () => {
|
||||
const nodeParameters = {
|
||||
resource: 'base',
|
||||
returnAll: false,
|
||||
limit: 2,
|
||||
options: { permissionLevel: ['edit'] },
|
||||
};
|
||||
|
||||
const response = await getMany.execute.call(createMockExecuteFunction(nodeParameters));
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('GET', 'meta/bases');
|
||||
|
||||
expect(response).toEqual([
|
||||
{
|
||||
json: {
|
||||
id: 'appYYY',
|
||||
name: 'base 2',
|
||||
permissionLevel: 'edit',
|
||||
},
|
||||
pairedItem: [
|
||||
{
|
||||
item: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import get from 'lodash/get';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import * as getSchema from '../../../../v2/actions/base/getSchema.operation';
|
||||
import * as transport from '../../../../v2/transport';
|
||||
|
||||
jest.mock('../../../../v2/transport', () => {
|
||||
const originalModule = jest.requireActual('../../../../v2/transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function () {
|
||||
return { tables: [] };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test AirtableV2, base => getSchema', () => {
|
||||
it('should return all bases', async () => {
|
||||
const nodeParameters = {
|
||||
resource: 'base',
|
||||
operation: 'getSchema',
|
||||
base: {
|
||||
value: '={{$json.id}}',
|
||||
},
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
json: { id: 'appYobase1' },
|
||||
},
|
||||
{
|
||||
json: { id: 'appYobase2' },
|
||||
},
|
||||
];
|
||||
|
||||
await getSchema.execute.call(
|
||||
mockDeep<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => items),
|
||||
getNodeParameter: jest.fn((param: string, itemIndex: number) => {
|
||||
if (param === 'base') {
|
||||
return items[itemIndex].json.id;
|
||||
}
|
||||
|
||||
return get(nodeParameters, param);
|
||||
}),
|
||||
}),
|
||||
items,
|
||||
);
|
||||
|
||||
expect(transport.apiRequest).toBeCalledTimes(2);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('GET', 'meta/bases/appYobase1/tables');
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('GET', 'meta/bases/appYobase2/tables');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import get from 'lodash/get';
|
||||
import { constructExecutionMetaData } from 'n8n-core';
|
||||
import type { IDataObject, IExecuteFunctions, IGetNodeParameterOptions, INode } from 'n8n-workflow';
|
||||
|
||||
export const node: INode = {
|
||||
id: '11',
|
||||
name: 'Airtable node',
|
||||
typeVersion: 2,
|
||||
type: 'n8n-nodes-base.airtable',
|
||||
position: [42, 42],
|
||||
parameters: {
|
||||
operation: 'create',
|
||||
},
|
||||
};
|
||||
|
||||
export const createMockExecuteFunction = (nodeParameters: IDataObject) => {
|
||||
const fakeExecuteFunction = {
|
||||
getInputData: jest.fn(() => {
|
||||
return [{ json: {} }];
|
||||
}),
|
||||
getNodeParameter: jest.fn(
|
||||
(
|
||||
parameterName: string,
|
||||
_itemIndex: number,
|
||||
fallbackValue?: IDataObject,
|
||||
options?: IGetNodeParameterOptions,
|
||||
) => {
|
||||
const parameter = options?.extractValue ? `${parameterName}.value` : parameterName;
|
||||
return get(nodeParameters, parameter, fallbackValue);
|
||||
},
|
||||
),
|
||||
getNode: jest.fn(() => {
|
||||
return node;
|
||||
}),
|
||||
helpers: { constructExecutionMetaData: jest.fn(constructExecutionMetaData) },
|
||||
continueOnFail: jest.fn(() => false),
|
||||
} as unknown as IExecuteFunctions;
|
||||
return fakeExecuteFunction;
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
import * as create from '../../../../v2/actions/record/create.operation';
|
||||
import * as transport from '../../../../v2/transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
jest.mock('../../../../v2/transport', () => {
|
||||
const originalModule = jest.requireActual('../../../../v2/transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function () {
|
||||
return {};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test AirtableV2, create operation', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('should create a record, autoMapInputData', async () => {
|
||||
const nodeParameters = {
|
||||
operation: 'create',
|
||||
columns: {
|
||||
mappingMode: 'autoMapInputData',
|
||||
value: {
|
||||
bar: 'bar 1',
|
||||
foo: 'foo 1',
|
||||
spam: 'eggs',
|
||||
},
|
||||
matchingColumns: [],
|
||||
schema: [
|
||||
{
|
||||
id: 'foo',
|
||||
displayName: 'foo',
|
||||
required: false,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: 'bar',
|
||||
displayName: 'bar',
|
||||
required: false,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: 'spam',
|
||||
displayName: 'spam',
|
||||
required: false,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
typecast: true,
|
||||
ignoreFields: 'spam',
|
||||
},
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
json: {
|
||||
foo: 'foo 1',
|
||||
spam: 'eggs',
|
||||
bar: 'bar 1',
|
||||
},
|
||||
},
|
||||
{
|
||||
json: {
|
||||
foo: 'foo 2',
|
||||
spam: 'eggs',
|
||||
bar: 'bar 2',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await create.execute.call(
|
||||
createMockExecuteFunction(nodeParameters),
|
||||
items,
|
||||
'appYoLbase',
|
||||
'tblltable',
|
||||
);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', 'appYoLbase/tblltable', {
|
||||
fields: {
|
||||
foo: 'foo 1',
|
||||
bar: 'bar 1',
|
||||
},
|
||||
typecast: true,
|
||||
});
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', 'appYoLbase/tblltable', {
|
||||
fields: {
|
||||
foo: 'foo 2',
|
||||
bar: 'bar 2',
|
||||
},
|
||||
typecast: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a record, defineBelow', async () => {
|
||||
const nodeParameters = {
|
||||
operation: 'create',
|
||||
columns: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: {
|
||||
bar: 'bar 1',
|
||||
foo: 'foo 1',
|
||||
},
|
||||
matchingColumns: [],
|
||||
schema: [
|
||||
{
|
||||
id: 'foo',
|
||||
displayName: 'foo',
|
||||
required: false,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: 'bar',
|
||||
displayName: 'bar',
|
||||
required: false,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {},
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
json: {},
|
||||
},
|
||||
];
|
||||
|
||||
await create.execute.call(
|
||||
createMockExecuteFunction(nodeParameters),
|
||||
items,
|
||||
'appYoLbase',
|
||||
'tblltable',
|
||||
);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', 'appYoLbase/tblltable', {
|
||||
fields: {
|
||||
foo: 'foo 1',
|
||||
bar: 'bar 1',
|
||||
},
|
||||
typecast: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip validation if typecast option is true', async () => {
|
||||
const nodeParameters = {
|
||||
operation: 'create',
|
||||
columns: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: {
|
||||
bar: 'bar 1',
|
||||
foo: 'foo 1',
|
||||
},
|
||||
matchingColumns: [],
|
||||
schema: [
|
||||
{
|
||||
id: 'foo',
|
||||
displayName: 'foo',
|
||||
required: false,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: 'bar',
|
||||
displayName: 'bar',
|
||||
required: false,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
typecast: true,
|
||||
},
|
||||
};
|
||||
|
||||
const mockExecuteFunctions = createMockExecuteFunction(nodeParameters);
|
||||
await create.execute.call(mockExecuteFunctions, [{ json: {} }], 'appYoLbase', 'tblltable');
|
||||
|
||||
expect(mockExecuteFunctions.getNodeParameter).toHaveBeenCalledWith('columns.value', 0, [], {
|
||||
skipValidation: true,
|
||||
});
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('POST', 'appYoLbase/tblltable', {
|
||||
fields: {
|
||||
foo: 'foo 1',
|
||||
bar: 'bar 1',
|
||||
},
|
||||
typecast: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as deleteRecord from '../../../../v2/actions/record/deleteRecord.operation';
|
||||
import * as transport from '../../../../v2/transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
jest.mock('../../../../v2/transport', () => {
|
||||
const originalModule = jest.requireActual('../../../../v2/transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function () {
|
||||
return {};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test AirtableV2, deleteRecord operation', () => {
|
||||
it('should delete a record', async () => {
|
||||
const nodeParameters = {
|
||||
operation: 'deleteRecord',
|
||||
id: 'recXXX',
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
json: {},
|
||||
},
|
||||
];
|
||||
|
||||
await deleteRecord.execute.call(
|
||||
createMockExecuteFunction(nodeParameters),
|
||||
items,
|
||||
'appYoLbase',
|
||||
'tblltable',
|
||||
);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('DELETE', 'appYoLbase/tblltable/recXXX');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as get from '../../../../v2/actions/record/get.operation';
|
||||
import * as transport from '../../../../v2/transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
jest.mock('../../../../v2/transport', () => {
|
||||
const originalModule = jest.requireActual('../../../../v2/transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function (method: string) {
|
||||
if (method === 'GET') {
|
||||
return {
|
||||
id: 'recXXX',
|
||||
fields: {
|
||||
foo: 'foo 1',
|
||||
bar: 'bar 1',
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test AirtableV2, create operation', () => {
|
||||
it('should create a record, autoMapInputData', async () => {
|
||||
const nodeParameters = {
|
||||
operation: 'get',
|
||||
id: 'recXXX',
|
||||
options: {},
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
json: {},
|
||||
},
|
||||
];
|
||||
|
||||
const responce = await get.execute.call(
|
||||
createMockExecuteFunction(nodeParameters),
|
||||
items,
|
||||
'appYoLbase',
|
||||
'tblltable',
|
||||
);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith('GET', 'appYoLbase/tblltable/recXXX');
|
||||
|
||||
expect(responce).toEqual([
|
||||
{
|
||||
json: {
|
||||
id: 'recXXX',
|
||||
foo: 'foo 1',
|
||||
bar: 'bar 1',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import * as search from '../../../../v2/actions/record/search.operation';
|
||||
import * as transport from '../../../../v2/transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
jest.mock('../../../../v2/transport', () => {
|
||||
const originalModule = jest.requireActual('../../../../v2/transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function (method: string) {
|
||||
if (method === 'GET') {
|
||||
return {
|
||||
records: [
|
||||
{
|
||||
id: 'recYYY',
|
||||
fields: {
|
||||
foo: 'foo 2',
|
||||
bar: 'bar 2',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}),
|
||||
apiRequestAllItems: jest.fn(async function (method: string) {
|
||||
if (method === 'GET') {
|
||||
return {
|
||||
records: [
|
||||
{
|
||||
id: 'recYYY',
|
||||
fields: {
|
||||
foo: 'foo 2',
|
||||
bar: 'bar 2',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'recXXX',
|
||||
fields: {
|
||||
foo: 'foo 1',
|
||||
bar: 'bar 1',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test AirtableV2, search operation', () => {
|
||||
it('should return all records', async () => {
|
||||
const nodeParameters = {
|
||||
operation: 'search',
|
||||
filterByFormula: 'foo',
|
||||
returnAll: true,
|
||||
options: {
|
||||
fields: ['foo', 'bar'],
|
||||
view: {
|
||||
value: 'viwView',
|
||||
mode: 'list',
|
||||
},
|
||||
},
|
||||
sort: {
|
||||
property: [
|
||||
{
|
||||
field: 'bar',
|
||||
direction: 'desc',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
json: {},
|
||||
},
|
||||
];
|
||||
|
||||
const result = await search.execute.call(
|
||||
createMockExecuteFunction(nodeParameters),
|
||||
items,
|
||||
'appYoLbase',
|
||||
'tblltable',
|
||||
);
|
||||
|
||||
expect(transport.apiRequestAllItems).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequestAllItems).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'appYoLbase/tblltable',
|
||||
{},
|
||||
{
|
||||
fields: ['foo', 'bar'],
|
||||
filterByFormula: 'foo',
|
||||
sort: [{ direction: 'desc', field: 'bar' }],
|
||||
view: 'viwView',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
json: { id: 'recYYY', foo: 'foo 2', bar: 'bar 2' },
|
||||
pairedItem: [
|
||||
{
|
||||
item: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return all records', async () => {
|
||||
const nodeParameters = {
|
||||
operation: 'search',
|
||||
filterByFormula: 'foo',
|
||||
returnAll: false,
|
||||
limit: 1,
|
||||
options: {
|
||||
fields: ['foo', 'bar'],
|
||||
},
|
||||
sort: {},
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
json: {},
|
||||
},
|
||||
];
|
||||
|
||||
const result = await search.execute.call(
|
||||
createMockExecuteFunction(nodeParameters),
|
||||
items,
|
||||
'appYoLbase',
|
||||
'tblltable',
|
||||
);
|
||||
|
||||
expect(transport.apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(transport.apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'appYoLbase/tblltable',
|
||||
{},
|
||||
{ fields: ['foo', 'bar'], filterByFormula: 'foo', maxRecords: 1 },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
json: { id: 'recYYY', foo: 'foo 2', bar: 'bar 2' },
|
||||
pairedItem: [
|
||||
{
|
||||
item: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { MockProxy } from 'jest-mock-extended';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import * as update from '../../../../v2/actions/record/update.operation';
|
||||
import * as transport from '../../../../v2/transport';
|
||||
import { createMockExecuteFunction } from '../helpers';
|
||||
|
||||
jest.mock('../../../../v2/transport', () => {
|
||||
const originalModule = jest.requireActual('../../../../v2/transport');
|
||||
return {
|
||||
...originalModule,
|
||||
apiRequest: jest.fn(async function () {
|
||||
return {};
|
||||
}),
|
||||
batchUpdate: jest.fn(async function () {
|
||||
return {};
|
||||
}),
|
||||
apiRequestAllItems: jest.fn(async function (method: string) {
|
||||
if (method === 'GET') {
|
||||
return {
|
||||
records: [
|
||||
{
|
||||
id: 'recYYY',
|
||||
fields: {
|
||||
foo: 'foo 2',
|
||||
bar: 'bar 2',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'recXXX',
|
||||
fields: {
|
||||
foo: 'foo 1',
|
||||
bar: 'bar 1',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test AirtableV2, update operation', () => {
|
||||
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should skip validation if typecast option is true', async () => {
|
||||
mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
mockExecuteFunctions.helpers.constructExecutionMetaData = jest.fn(() => []);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((key: string) => {
|
||||
if (key === 'columns.mappingMode') {
|
||||
return 'defineBelow';
|
||||
}
|
||||
if (key === 'columns.matchingColumns') {
|
||||
return ['id'];
|
||||
}
|
||||
if (key === 'options') {
|
||||
return {
|
||||
typecast: true,
|
||||
};
|
||||
}
|
||||
if (key === 'columns.value') {
|
||||
return {
|
||||
id: 'recXXX',
|
||||
field1: 'foo 1',
|
||||
field2: 'bar 1',
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await update.execute.call(mockExecuteFunctions, [{ json: {} }], 'base', 'table');
|
||||
|
||||
expect(mockExecuteFunctions.getNodeParameter).toHaveBeenCalledWith('columns.value', 0, [], {
|
||||
skipValidation: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should update a record by id, autoMapInputData', async () => {
|
||||
const nodeParameters = {
|
||||
operation: 'update',
|
||||
columns: {
|
||||
mappingMode: 'autoMapInputData',
|
||||
matchingColumns: ['id'],
|
||||
},
|
||||
options: {},
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
json: {
|
||||
id: 'recXXX',
|
||||
foo: 'foo 1',
|
||||
bar: 'bar 1',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await update.execute.call(
|
||||
createMockExecuteFunction(nodeParameters),
|
||||
items,
|
||||
'appYoLbase',
|
||||
'tblltable',
|
||||
);
|
||||
|
||||
expect(transport.batchUpdate).toHaveBeenCalledWith(
|
||||
'appYoLbase/tblltable',
|
||||
{ typecast: false },
|
||||
[{ fields: { bar: 'bar 1', foo: 'foo 1' }, id: 'recXXX' }],
|
||||
);
|
||||
});
|
||||
|
||||
it('should update a record by field name, autoMapInputData', async () => {
|
||||
const nodeParameters = {
|
||||
operation: 'update',
|
||||
columns: {
|
||||
mappingMode: 'autoMapInputData',
|
||||
matchingColumns: ['foo'],
|
||||
},
|
||||
options: {},
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
json: {
|
||||
id: 'recXXX',
|
||||
foo: 'foo 1',
|
||||
bar: 'bar 1',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await update.execute.call(
|
||||
createMockExecuteFunction(nodeParameters),
|
||||
items,
|
||||
'appYoLbase',
|
||||
'tblltable',
|
||||
);
|
||||
|
||||
expect(transport.batchUpdate).toHaveBeenCalledWith(
|
||||
'appYoLbase/tblltable',
|
||||
{ typecast: false },
|
||||
[{ fields: { bar: 'bar 1', foo: 'foo 1', id: 'recXXX' }, id: 'recXXX' }],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { findMatches, removeIgnored } from '../../v2/helpers/utils';
|
||||
|
||||
describe('test AirtableV2, removeIgnored', () => {
|
||||
it('should remove ignored fields', () => {
|
||||
const data = {
|
||||
foo: 'foo',
|
||||
baz: 'baz',
|
||||
spam: 'spam',
|
||||
};
|
||||
|
||||
const ignore = 'baz,spam';
|
||||
|
||||
const result = removeIgnored(data, ignore);
|
||||
|
||||
expect(result).toEqual({
|
||||
foo: 'foo',
|
||||
});
|
||||
});
|
||||
it('should return the same data if ignore field does not present', () => {
|
||||
const data = {
|
||||
foo: 'foo',
|
||||
};
|
||||
|
||||
const ignore = 'bar';
|
||||
|
||||
const result = removeIgnored(data, ignore);
|
||||
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
it('should return the same data if empty string', () => {
|
||||
const data = {
|
||||
foo: 'foo',
|
||||
};
|
||||
|
||||
const ignore = '';
|
||||
|
||||
const result = removeIgnored(data, ignore);
|
||||
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('test AirtableV2, findMatches', () => {
|
||||
it('should find match', () => {
|
||||
const data = [
|
||||
{
|
||||
fields: {
|
||||
id: 'rec123',
|
||||
data: 'data 1',
|
||||
},
|
||||
},
|
||||
{
|
||||
fields: {
|
||||
id: 'rec456',
|
||||
data: 'data 2',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const key = 'id';
|
||||
|
||||
const result = findMatches(data, [key], {
|
||||
id: 'rec123',
|
||||
data: 'data 1',
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
fields: {
|
||||
id: 'rec123',
|
||||
data: 'data 1',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('should find all matches', () => {
|
||||
const data = [
|
||||
{
|
||||
fields: {
|
||||
id: 'rec123',
|
||||
data: 'data 1',
|
||||
},
|
||||
},
|
||||
{
|
||||
fields: {
|
||||
id: 'rec456',
|
||||
data: 'data 2',
|
||||
},
|
||||
},
|
||||
{
|
||||
fields: {
|
||||
id: 'rec123',
|
||||
data: 'data 3',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const key = 'id';
|
||||
|
||||
const result = findMatches(
|
||||
data,
|
||||
[key],
|
||||
{
|
||||
id: 'rec123',
|
||||
data: 'data 1',
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
fields: {
|
||||
id: 'rec123',
|
||||
data: 'data 1',
|
||||
},
|
||||
},
|
||||
{
|
||||
fields: {
|
||||
id: 'rec123',
|
||||
data: 'data 3',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"meta": {
|
||||
"instanceId": "104a4d08d8897b8bdeb38aaca515021075e0bd8544c983c2bb8c86e6a8e6081c"
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "f857c37f-36c1-4c9c-9b5f-f6ef49db67e3",
|
||||
"name": "On clicking 'execute'",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [820, 380]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"base": "appIaXXdDqS5ORr4V",
|
||||
"resource": "record",
|
||||
"operation": "get",
|
||||
"id": "rec2BWBoyS5QsS7pT",
|
||||
"application": {
|
||||
"__rl": true,
|
||||
"value": "https://airtable.com/appIaXXdDqS5ORr4V/tbljyBEdYzCPF0NDh/rec2BWBoyS5QsS7pT",
|
||||
"mode": "url",
|
||||
"__regex": "https://airtable.com/([a-zA-Z0-9]{2,})"
|
||||
},
|
||||
"table": {
|
||||
"__rl": true,
|
||||
"value": "https://airtable.com/appIaXXdDqS5ORr4V/tbljyBEdYzCPF0NDh/rec2BWBoyS5QsS7pT",
|
||||
"mode": "url",
|
||||
"__regex": "https://airtable.com/[a-zA-Z0-9]{2,}/([a-zA-Z0-9]{2,})"
|
||||
},
|
||||
"additionalOptions": {}
|
||||
},
|
||||
"id": "5654d3b3-fe83-4988-889b-94f107d41807",
|
||||
"name": "Airtable",
|
||||
"type": "n8n-nodes-base.airtable",
|
||||
"typeVersion": 2,
|
||||
"position": [1020, 380],
|
||||
"credentials": {
|
||||
"airtableTokenApi": {
|
||||
"id": "20",
|
||||
"name": "Airtable account"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"On clicking 'execute'": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Airtable",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
INodeTypeBaseDescription,
|
||||
IHttpRequestMethods,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type { IRecord } from './GenericFunctions';
|
||||
import { apiRequest, apiRequestAllItems, downloadRecordAttachments } from './GenericFunctions';
|
||||
import { oldVersionNotice } from '../../../utils/descriptions';
|
||||
import { generatePairedItemData } from '../../../utils/utilities';
|
||||
|
||||
const versionDescription: INodeTypeDescription = {
|
||||
displayName: 'Airtable',
|
||||
name: 'airtable',
|
||||
icon: 'file:airtable.svg',
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
description: 'Read, update, write and delete data from Airtable',
|
||||
defaults: {
|
||||
name: 'Airtable',
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'airtableApi',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['airtableApi'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'airtableTokenApi',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['airtableTokenApi'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'airtableOAuth2Api',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['airtableOAuth2Api'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Access Token',
|
||||
value: 'airtableTokenApi',
|
||||
},
|
||||
{
|
||||
name: 'OAuth2',
|
||||
value: 'airtableOAuth2Api',
|
||||
},
|
||||
{
|
||||
name: 'API Key (Deprecated)',
|
||||
value: 'airtableApi',
|
||||
},
|
||||
],
|
||||
default: 'airtableApi',
|
||||
},
|
||||
oldVersionNotice,
|
||||
{
|
||||
displayName:
|
||||
"This type of connection (API Key) was deprecated and can't be used anymore. Please create a new credential of type 'Access Token' instead.",
|
||||
name: 'deprecated',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['airtableApi'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Append',
|
||||
value: 'append',
|
||||
description: 'Append the data to a table',
|
||||
action: 'Append data to a table',
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
value: 'delete',
|
||||
description: 'Delete data from a table',
|
||||
action: 'Delete data from a table',
|
||||
},
|
||||
{
|
||||
name: 'List',
|
||||
value: 'list',
|
||||
description: 'List data from a table',
|
||||
action: 'List data from a table',
|
||||
},
|
||||
{
|
||||
name: 'Read',
|
||||
value: 'read',
|
||||
description: 'Read data from a table',
|
||||
action: 'Read data from a table',
|
||||
},
|
||||
{
|
||||
name: 'Update',
|
||||
value: 'update',
|
||||
description: 'Update data in a table',
|
||||
action: 'Update data in a table',
|
||||
},
|
||||
],
|
||||
default: 'read',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// All
|
||||
// ----------------------------------
|
||||
|
||||
{
|
||||
displayName: 'Base',
|
||||
name: 'application',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'url', value: '' },
|
||||
required: true,
|
||||
description: 'The Airtable Base in which to operate on',
|
||||
modes: [
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
placeholder: 'https://airtable.com/app12DiScdfes/tblAAAAAAAAAAAAA/viwHdfasdfeieg5p',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: 'https://airtable.com/([a-zA-Z0-9]{2,})/.*',
|
||||
errorMessage: 'Not a valid Airtable Base URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: 'https://airtable.com/([a-zA-Z0-9]{2,})',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '[a-zA-Z0-9]{2,}',
|
||||
errorMessage: 'Not a valid Airtable Base ID',
|
||||
},
|
||||
},
|
||||
],
|
||||
placeholder: 'appD3dfaeidke',
|
||||
url: '=https://airtable.com/{{$value}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Table',
|
||||
name: 'table',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'url', value: '' },
|
||||
required: true,
|
||||
modes: [
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
placeholder: 'https://airtable.com/app12DiScdfes/tblAAAAAAAAAAAAA/viwHdfasdfeieg5p',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: 'https://airtable.com/[a-zA-Z0-9]{2,}/([a-zA-Z0-9]{2,})/.*',
|
||||
errorMessage: 'Not a valid Airtable Table URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: 'https://airtable.com/[a-zA-Z0-9]{2,}/([a-zA-Z0-9]{2,})',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '[a-zA-Z0-9]{2,}',
|
||||
errorMessage: 'Not a valid Airtable Table ID',
|
||||
},
|
||||
},
|
||||
],
|
||||
placeholder: 'tbl3dirwqeidke',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// append
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Add All Fields',
|
||||
name: 'addAllFields',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['append'],
|
||||
},
|
||||
},
|
||||
default: true,
|
||||
description: 'Whether all fields should be sent to Airtable or only specific ones',
|
||||
},
|
||||
{
|
||||
displayName: 'Fields',
|
||||
name: 'fields',
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
multipleValueButtonText: 'Add Field',
|
||||
},
|
||||
requiresDataPath: 'single',
|
||||
displayOptions: {
|
||||
show: {
|
||||
addAllFields: [false],
|
||||
operation: ['append'],
|
||||
},
|
||||
},
|
||||
default: [],
|
||||
placeholder: 'Name',
|
||||
required: true,
|
||||
description: 'The name of fields for which data should be sent to Airtable',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// delete
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['delete'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'ID of the record to delete',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// list
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Return All',
|
||||
name: 'returnAll',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['list'],
|
||||
},
|
||||
},
|
||||
default: true,
|
||||
description: 'Whether to return all results or only up to a given limit',
|
||||
},
|
||||
{
|
||||
displayName: 'Limit',
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['list'],
|
||||
returnAll: [false],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
maxValue: 100,
|
||||
},
|
||||
default: 100,
|
||||
description: 'Max number of results to return',
|
||||
},
|
||||
{
|
||||
displayName: 'Download Attachments',
|
||||
name: 'downloadAttachments',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['list'],
|
||||
},
|
||||
},
|
||||
default: false,
|
||||
description: "Whether the attachment fields define in 'Download Fields' will be downloaded",
|
||||
},
|
||||
{
|
||||
displayName: 'Download Fields',
|
||||
name: 'downloadFieldNames',
|
||||
type: 'string',
|
||||
required: true,
|
||||
requiresDataPath: 'multiple',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['list'],
|
||||
downloadAttachments: [true],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description:
|
||||
"Name of the fields of type 'attachment' that should be downloaded. Multiple ones can be defined separated by comma. Case sensitive and cannot include spaces after a comma.",
|
||||
},
|
||||
{
|
||||
displayName: 'Additional Options',
|
||||
name: 'additionalOptions',
|
||||
type: 'collection',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['list'],
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
description: 'Additional options which decide which records should be returned',
|
||||
placeholder: 'Add option',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Fields',
|
||||
name: 'fields',
|
||||
type: 'string',
|
||||
requiresDataPath: 'single',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
multipleValueButtonText: 'Add Field',
|
||||
},
|
||||
default: [],
|
||||
placeholder: 'Name',
|
||||
description:
|
||||
'Only data for fields whose names are in this list will be included in the records',
|
||||
},
|
||||
{
|
||||
displayName: 'Filter By Formula',
|
||||
name: 'filterByFormula',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: "NOT({Name} = '')",
|
||||
description:
|
||||
'A formula used to filter records. The formula will be evaluated for each record, and if the result is not 0, false, "", NaN, [], or #Error! the record will be included in the response.',
|
||||
},
|
||||
{
|
||||
displayName: 'Sort',
|
||||
name: 'sort',
|
||||
placeholder: 'Add Sort Rule',
|
||||
description: 'Defines how the returned records should be ordered',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
name: 'property',
|
||||
displayName: 'Property',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Field',
|
||||
name: 'field',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Name of the field to sort on',
|
||||
},
|
||||
{
|
||||
displayName: 'Direction',
|
||||
name: 'direction',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'ASC',
|
||||
value: 'asc',
|
||||
description: 'Sort in ascending order (small -> large)',
|
||||
},
|
||||
{
|
||||
name: 'DESC',
|
||||
value: 'desc',
|
||||
description: 'Sort in descending order (large -> small)',
|
||||
},
|
||||
],
|
||||
default: 'asc',
|
||||
description: 'The sort direction',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'View',
|
||||
name: 'view',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'All Stories',
|
||||
description:
|
||||
'The name or ID of a view in the Stories table. If set, only the records in that view will be returned. The records will be sorted according to the order of the view.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// read
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['read'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'ID of the record to return',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// update
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'ID of the record to update',
|
||||
},
|
||||
{
|
||||
displayName: 'Update All Fields',
|
||||
name: 'updateAllFields',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
default: true,
|
||||
description: 'Whether all fields should be sent to Airtable or only specific ones',
|
||||
},
|
||||
{
|
||||
displayName: 'Fields',
|
||||
name: 'fields',
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
multipleValueButtonText: 'Add Field',
|
||||
},
|
||||
requiresDataPath: 'single',
|
||||
displayOptions: {
|
||||
show: {
|
||||
updateAllFields: [false],
|
||||
operation: ['update'],
|
||||
},
|
||||
},
|
||||
default: [],
|
||||
placeholder: 'Name',
|
||||
required: true,
|
||||
description: 'The name of fields for which data should be sent to Airtable',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// append + delete + update
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['append', 'delete', 'update'],
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Bulk Size',
|
||||
name: 'bulkSize',
|
||||
type: 'number',
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
maxValue: 10,
|
||||
},
|
||||
default: 10,
|
||||
description: 'Number of records to process at once',
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore Fields',
|
||||
name: 'ignoreFields',
|
||||
type: 'string',
|
||||
requiresDataPath: 'multiple',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operation': ['update'],
|
||||
'/updateAllFields': [true],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'Comma-separated list of fields to ignore',
|
||||
},
|
||||
{
|
||||
displayName: 'Typecast',
|
||||
name: 'typecast',
|
||||
type: 'boolean',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operation': ['append', 'update'],
|
||||
},
|
||||
},
|
||||
default: false,
|
||||
description:
|
||||
'Whether the Airtable API should attempt mapping of string values for linked records & select options',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export class AirtableV1 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
...versionDescription,
|
||||
};
|
||||
}
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const authentication = this.getNodeParameter('authentication', 0);
|
||||
if (authentication === 'airtableApi') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'The API Key connection was deprecated by Airtable, please use Access Token or OAuth2 instead.',
|
||||
);
|
||||
}
|
||||
const items = this.getInputData();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
let responseData;
|
||||
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
|
||||
const application = this.getNodeParameter('application', 0, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const table = encodeURI(
|
||||
this.getNodeParameter('table', 0, undefined, {
|
||||
extractValue: true,
|
||||
}) as string,
|
||||
);
|
||||
|
||||
let returnAll = false;
|
||||
let endpoint = '';
|
||||
let requestMethod: IHttpRequestMethods;
|
||||
|
||||
const body: IDataObject = {};
|
||||
const qs: IDataObject = {};
|
||||
|
||||
if (operation === 'append') {
|
||||
// ----------------------------------
|
||||
// append
|
||||
// ----------------------------------
|
||||
|
||||
requestMethod = 'POST';
|
||||
endpoint = `${application}/${table}`;
|
||||
|
||||
let addAllFields: boolean;
|
||||
let fields: string[];
|
||||
let options: IDataObject;
|
||||
|
||||
const rows: IDataObject[] = [];
|
||||
let bulkSize = 10;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
addAllFields = this.getNodeParameter('addAllFields', i) as boolean;
|
||||
options = this.getNodeParameter('options', i, {});
|
||||
bulkSize = (options.bulkSize as number) || bulkSize;
|
||||
|
||||
const row: IDataObject = {};
|
||||
|
||||
if (addAllFields) {
|
||||
// Add all the fields the item has
|
||||
row.fields = { ...items[i].json };
|
||||
delete (row.fields as any).id;
|
||||
} else {
|
||||
// Add only the specified fields
|
||||
const rowFields: IDataObject = {};
|
||||
|
||||
fields = this.getNodeParameter('fields', i, []) as string[];
|
||||
|
||||
for (const fieldName of fields) {
|
||||
rowFields[fieldName] = items[i].json[fieldName];
|
||||
}
|
||||
|
||||
row.fields = rowFields;
|
||||
}
|
||||
|
||||
rows.push(row);
|
||||
|
||||
if (rows.length === bulkSize || i === items.length - 1) {
|
||||
if (options.typecast === true) {
|
||||
body.typecast = true;
|
||||
}
|
||||
|
||||
body.records = rows;
|
||||
|
||||
responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs);
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray(responseData.records as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
returnData.push(...executionData);
|
||||
// empty rows
|
||||
rows.length = 0;
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { error: error.message } });
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (operation === 'delete') {
|
||||
requestMethod = 'DELETE';
|
||||
|
||||
const rows: string[] = [];
|
||||
const options = this.getNodeParameter('options', 0, {});
|
||||
const bulkSize = (options.bulkSize as number) || 10;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
const id = this.getNodeParameter('id', i) as string;
|
||||
|
||||
rows.push(id);
|
||||
|
||||
if (rows.length === bulkSize || i === items.length - 1) {
|
||||
endpoint = `${application}/${table}`;
|
||||
|
||||
// Make one request after another. This is slower but makes
|
||||
// sure that we do not run into the rate limit they have in
|
||||
// place and so block for 30 seconds. Later some global
|
||||
// functionality in core should make it easy to make requests
|
||||
// according to specific rules like not more than 5 requests
|
||||
// per seconds.
|
||||
qs.records = rows;
|
||||
|
||||
responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs);
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray(responseData.records as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
returnData.push(...executionData);
|
||||
// empty rows
|
||||
rows.length = 0;
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { error: error.message } });
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (operation === 'list') {
|
||||
// ----------------------------------
|
||||
// list
|
||||
// ----------------------------------
|
||||
try {
|
||||
requestMethod = 'GET';
|
||||
endpoint = `${application}/${table}`;
|
||||
|
||||
returnAll = this.getNodeParameter('returnAll', 0);
|
||||
|
||||
const downloadAttachments = this.getNodeParameter('downloadAttachments', 0);
|
||||
|
||||
const additionalOptions = this.getNodeParameter('additionalOptions', 0, {}) as IDataObject;
|
||||
|
||||
for (const key of Object.keys(additionalOptions)) {
|
||||
if (key === 'sort' && (additionalOptions.sort as IDataObject).property !== undefined) {
|
||||
qs[key] = (additionalOptions[key] as IDataObject).property;
|
||||
} else {
|
||||
qs[key] = additionalOptions[key];
|
||||
}
|
||||
}
|
||||
|
||||
if (returnAll) {
|
||||
responseData = await apiRequestAllItems.call(this, requestMethod, endpoint, body, qs);
|
||||
} else {
|
||||
qs.maxRecords = this.getNodeParameter('limit', 0);
|
||||
responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs);
|
||||
}
|
||||
|
||||
returnData.push.apply(returnData, responseData.records as INodeExecutionData[]);
|
||||
|
||||
if (downloadAttachments === true) {
|
||||
const downloadFieldNames = (
|
||||
this.getNodeParameter('downloadFieldNames', 0) as string
|
||||
).split(',');
|
||||
const pairedItem = generatePairedItemData(items.length);
|
||||
const data = await downloadRecordAttachments.call(
|
||||
this,
|
||||
responseData.records as IRecord[],
|
||||
downloadFieldNames,
|
||||
pairedItem,
|
||||
);
|
||||
return [data];
|
||||
}
|
||||
|
||||
// We can return from here
|
||||
const itemData = generatePairedItemData(items.length);
|
||||
|
||||
return [
|
||||
this.helpers.constructExecutionMetaData(this.helpers.returnJsonArray(returnData), {
|
||||
itemData,
|
||||
}),
|
||||
];
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { error: error.message } });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (operation === 'read') {
|
||||
// ----------------------------------
|
||||
// read
|
||||
// ----------------------------------
|
||||
|
||||
requestMethod = 'GET';
|
||||
|
||||
let id: string;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
id = this.getNodeParameter('id', i) as string;
|
||||
|
||||
endpoint = `${application}/${table}/${id}`;
|
||||
|
||||
// Make one request after another. This is slower but makes
|
||||
// sure that we do not run into the rate limit they have in
|
||||
// place and so block for 30 seconds. Later some global
|
||||
// functionality in core should make it easy to make requests
|
||||
// according to specific rules like not more than 5 requests
|
||||
// per seconds.
|
||||
try {
|
||||
responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs);
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray(responseData as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
returnData.push(...executionData);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { error: error.message } });
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (operation === 'update') {
|
||||
// ----------------------------------
|
||||
// update
|
||||
// ----------------------------------
|
||||
|
||||
requestMethod = 'PATCH';
|
||||
|
||||
let updateAllFields: boolean;
|
||||
let fields: string[];
|
||||
let options: IDataObject;
|
||||
|
||||
const rows: IDataObject[] = [];
|
||||
let bulkSize = 10;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
updateAllFields = this.getNodeParameter('updateAllFields', i) as boolean;
|
||||
options = this.getNodeParameter('options', i, {});
|
||||
bulkSize = (options.bulkSize as number) || bulkSize;
|
||||
|
||||
const row: IDataObject = {};
|
||||
row.fields = {} as IDataObject;
|
||||
|
||||
if (updateAllFields) {
|
||||
// Update all the fields the item has
|
||||
row.fields = { ...items[i].json };
|
||||
// remove id field
|
||||
delete (row.fields as any).id;
|
||||
|
||||
if (options.ignoreFields && options.ignoreFields !== '') {
|
||||
const ignoreFields = (options.ignoreFields as string)
|
||||
.split(',')
|
||||
.map((field) => field.trim())
|
||||
.filter((field) => !!field);
|
||||
if (ignoreFields.length) {
|
||||
// From: https://stackoverflow.com/questions/17781472/how-to-get-a-subset-of-a-javascript-objects-properties
|
||||
row.fields = Object.entries(items[i].json)
|
||||
.filter(([key]) => !ignoreFields.includes(key))
|
||||
.reduce((obj, [key, val]) => Object.assign(obj, { [key]: val }), {});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fields = this.getNodeParameter('fields', i, []) as string[];
|
||||
|
||||
const rowFields: IDataObject = {};
|
||||
for (const fieldName of fields) {
|
||||
rowFields[fieldName] = items[i].json[fieldName];
|
||||
}
|
||||
|
||||
row.fields = rowFields;
|
||||
}
|
||||
|
||||
row.id = this.getNodeParameter('id', i) as string;
|
||||
|
||||
rows.push(row);
|
||||
|
||||
if (rows.length === bulkSize || i === items.length - 1) {
|
||||
endpoint = `${application}/${table}`;
|
||||
|
||||
// Make one request after another. This is slower but makes
|
||||
// sure that we do not run into the rate limit they have in
|
||||
// place and so block for 30 seconds. Later some global
|
||||
// functionality in core should make it easy to make requests
|
||||
// according to specific rules like not more than 5 requests
|
||||
// per seconds.
|
||||
|
||||
const data = { records: rows, typecast: options.typecast ? true : false };
|
||||
|
||||
responseData = await apiRequest.call(this, requestMethod, endpoint, data, qs);
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray(responseData.records as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
returnData.push(...executionData);
|
||||
|
||||
// empty rows
|
||||
rows.length = 0;
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { error: error.message } });
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new NodeOperationError(this.getNode(), `The operation "${operation}" is not known!`);
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type {
|
||||
IBinaryKeyData,
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
IPollFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
INodeExecutionData,
|
||||
IPairedItemData,
|
||||
IRequestOptions,
|
||||
IHttpRequestMethods,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
interface IAttachment {
|
||||
url: string;
|
||||
filename: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface IRecord {
|
||||
fields: {
|
||||
[key: string]: string | IAttachment[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an API request to Airtable
|
||||
*
|
||||
*/
|
||||
export async function apiRequest(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
endpoint: string,
|
||||
body: object,
|
||||
query?: IDataObject,
|
||||
uri?: string,
|
||||
option: IDataObject = {},
|
||||
): Promise<any> {
|
||||
query = query || {};
|
||||
|
||||
// For some reason for some endpoints the bearer auth does not work
|
||||
// and it returns 404 like for the /meta request. So we always send
|
||||
// it as query string.
|
||||
// query.api_key = credentials.apiKey;
|
||||
|
||||
const options: IRequestOptions = {
|
||||
headers: {},
|
||||
method,
|
||||
body,
|
||||
qs: query,
|
||||
uri: uri || `https://api.airtable.com/v0/${endpoint}`,
|
||||
useQuerystring: false,
|
||||
json: true,
|
||||
};
|
||||
|
||||
if (Object.keys(option).length !== 0) {
|
||||
Object.assign(options, option);
|
||||
}
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
delete options.body;
|
||||
}
|
||||
const authenticationMethod = this.getNodeParameter('authentication', 0) as string;
|
||||
return await this.helpers.requestWithAuthentication.call(this, authenticationMethod, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an API request to paginated Airtable endpoint
|
||||
* and return all results
|
||||
*
|
||||
* @param {(IExecuteFunctions | IExecuteFunctions)} this
|
||||
*/
|
||||
export async function apiRequestAllItems(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
endpoint: string,
|
||||
body: IDataObject,
|
||||
query?: IDataObject,
|
||||
): Promise<any> {
|
||||
if (query === undefined) {
|
||||
query = {};
|
||||
}
|
||||
query.pageSize = 100;
|
||||
|
||||
const returnData: IDataObject[] = [];
|
||||
|
||||
let responseData;
|
||||
|
||||
do {
|
||||
responseData = await apiRequest.call(this, method, endpoint, body, query);
|
||||
returnData.push.apply(returnData, responseData.records as IDataObject[]);
|
||||
|
||||
query.offset = responseData.offset;
|
||||
} while (responseData.offset !== undefined);
|
||||
|
||||
return {
|
||||
records: returnData,
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadRecordAttachments(
|
||||
this: IExecuteFunctions | IPollFunctions,
|
||||
records: IRecord[],
|
||||
fieldNames: string[],
|
||||
pairedItem?: IPairedItemData[],
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const elements: INodeExecutionData[] = [];
|
||||
for (const record of records) {
|
||||
const element: INodeExecutionData = { json: {}, binary: {} };
|
||||
if (pairedItem) {
|
||||
element.pairedItem = pairedItem;
|
||||
}
|
||||
element.json = record as unknown as IDataObject;
|
||||
for (const fieldName of fieldNames) {
|
||||
if (record.fields[fieldName] !== undefined) {
|
||||
for (const [index, attachment] of (record.fields[fieldName] as IAttachment[]).entries()) {
|
||||
const file = await apiRequest.call(this, 'GET', '', {}, {}, attachment.url, {
|
||||
json: false,
|
||||
encoding: null,
|
||||
});
|
||||
element.binary![`${fieldName}_${index}`] = await this.helpers.prepareBinaryData(
|
||||
Buffer.from(file as string),
|
||||
attachment.filename,
|
||||
attachment.type,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(element.binary as IBinaryKeyData).length === 0) {
|
||||
delete element.binary;
|
||||
}
|
||||
elements.push(element);
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
INodeTypeBaseDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { router } from './actions/router';
|
||||
import { versionDescription } from './actions/versionDescription';
|
||||
import { listSearch, loadOptions, resourceMapping } from './methods';
|
||||
|
||||
export class AirtableV2 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
...versionDescription,
|
||||
usableAsTool: true,
|
||||
};
|
||||
}
|
||||
|
||||
methods = {
|
||||
listSearch,
|
||||
loadOptions,
|
||||
resourceMapping,
|
||||
};
|
||||
|
||||
async execute(this: IExecuteFunctions) {
|
||||
return await router.call(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as getMany from './getMany.operation';
|
||||
import * as getSchema from './getSchema.operation';
|
||||
|
||||
export { getMany, getSchema };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Get Many',
|
||||
value: 'getMany',
|
||||
description: 'List all the bases',
|
||||
action: 'Get many bases',
|
||||
},
|
||||
{
|
||||
name: 'Get Schema',
|
||||
value: 'getSchema',
|
||||
description: 'Get the schema of the tables in a base',
|
||||
action: 'Get base schema',
|
||||
},
|
||||
],
|
||||
default: 'getMany',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['base'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...getMany.description,
|
||||
...getSchema.description,
|
||||
];
|
||||
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
IExecuteFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
generatePairedItemData,
|
||||
updateDisplayOptions,
|
||||
wrapData,
|
||||
} from '../../../../../utils/utilities';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Return All',
|
||||
name: 'returnAll',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to return all results or only up to a given limit',
|
||||
},
|
||||
{
|
||||
displayName: 'Limit',
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
displayOptions: {
|
||||
show: {
|
||||
returnAll: [false],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
maxValue: 100,
|
||||
},
|
||||
default: 100,
|
||||
description: 'Max number of results to return',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Permission Level',
|
||||
name: 'permissionLevel',
|
||||
type: 'multiOptions',
|
||||
options: [
|
||||
{
|
||||
name: 'Comment',
|
||||
value: 'comment',
|
||||
},
|
||||
{
|
||||
name: 'Create',
|
||||
value: 'create',
|
||||
},
|
||||
{
|
||||
name: 'Edit',
|
||||
value: 'edit',
|
||||
},
|
||||
{
|
||||
name: 'None',
|
||||
value: 'none',
|
||||
},
|
||||
{
|
||||
name: 'Read',
|
||||
value: 'read',
|
||||
},
|
||||
],
|
||||
default: [],
|
||||
description: 'Filter the returned bases by one or more permission levels',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['base'],
|
||||
operation: ['getMany'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions): Promise<INodeExecutionData[]> {
|
||||
const returnAll = this.getNodeParameter('returnAll', 0);
|
||||
|
||||
const endpoint = 'meta/bases';
|
||||
let bases: IDataObject[] = [];
|
||||
|
||||
if (returnAll) {
|
||||
let offset: string | undefined = undefined;
|
||||
do {
|
||||
const responseData = await apiRequest.call(this, 'GET', endpoint);
|
||||
bases.push(...(responseData.bases as IDataObject[]));
|
||||
offset = responseData.offset;
|
||||
} while (offset);
|
||||
} else {
|
||||
const responseData = await apiRequest.call(this, 'GET', endpoint);
|
||||
|
||||
const limit = this.getNodeParameter('limit', 0);
|
||||
if (limit && responseData.bases?.length) {
|
||||
bases = responseData.bases.slice(0, limit);
|
||||
}
|
||||
}
|
||||
|
||||
const permissionLevel = this.getNodeParameter('options.permissionLevel', 0, []) as string[];
|
||||
if (permissionLevel.length) {
|
||||
bases = bases.filter((base) => permissionLevel.includes(base.permissionLevel as string));
|
||||
}
|
||||
|
||||
const itemData = generatePairedItemData(this.getInputData().length);
|
||||
|
||||
const returnData = this.helpers.constructExecutionMetaData(wrapData(bases), {
|
||||
itemData,
|
||||
});
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
NodeApiError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions, wrapData } from '../../../../../utils/utilities';
|
||||
import { processAirtableError } from '../../helpers/utils';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { baseRLC } from '../common.descriptions';
|
||||
import type { TablesResponse } from '../types';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
...baseRLC,
|
||||
description: 'The Airtable Base to retrieve the schema from',
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['base'],
|
||||
operation: ['getSchema'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
items: INodeExecutionData[],
|
||||
): Promise<INodeExecutionData[]> {
|
||||
let returnData: INodeExecutionData[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
const baseId = this.getNodeParameter('base', i, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const responseData: TablesResponse = await apiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
`meta/bases/${baseId}/tables`,
|
||||
);
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(wrapData(responseData.tables), {
|
||||
itemData: { item: i },
|
||||
});
|
||||
|
||||
returnData = returnData.concat(executionData);
|
||||
} catch (error) {
|
||||
error = processAirtableError(error as NodeApiError, undefined, i);
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { error: error.message } });
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const baseRLC: INodeProperties = {
|
||||
displayName: 'Base',
|
||||
name: 'base',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
// description: 'The Airtable Base in which to operate on',
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'baseSearch',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. https://airtable.com/app12DiScdfes/tbl9WvGeEPa6lZyVq/viwHdfasdfeieg5p',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: 'https://airtable.com/([a-zA-Z0-9]{2,})/.*',
|
||||
errorMessage: 'Not a valid Airtable Base URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: 'https://airtable.com/([a-zA-Z0-9]{2,})',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '[a-zA-Z0-9]{2,}',
|
||||
errorMessage: 'Not a valid Airtable Base ID',
|
||||
},
|
||||
},
|
||||
],
|
||||
placeholder: 'e.g. appD3dfaeidke',
|
||||
url: '=https://airtable.com/{{$value}}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const tableRLC: INodeProperties = {
|
||||
displayName: 'Table',
|
||||
name: 'table',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['base.value'],
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'tableSearch',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
placeholder: 'https://airtable.com/app12DiScdfes/tblAAAAAAAAAAAAA/viwHdfasdfeieg5p',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: 'https://airtable.com/[a-zA-Z0-9]{2,}/([a-zA-Z0-9]{2,})/.*',
|
||||
errorMessage: 'Not a valid Airtable Table URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: 'https://airtable.com/[a-zA-Z0-9]{2,}/([a-zA-Z0-9]{2,})',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '[a-zA-Z0-9]{2,}',
|
||||
errorMessage: 'Not a valid Airtable Table ID',
|
||||
},
|
||||
},
|
||||
],
|
||||
placeholder: 'tbl3dirwqeidke',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const viewRLC: INodeProperties = {
|
||||
displayName: 'View',
|
||||
name: 'view',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'viewSearch',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
placeholder: 'https://airtable.com/app12DiScdfes/tblAAAAAAAAAAAAA/viwHdfasdfeieg5p',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: 'https://airtable.com/[a-zA-Z0-9]{2,}/[a-zA-Z0-9]{2,}/([a-zA-Z0-9]{2,})/.*',
|
||||
errorMessage: 'Not a valid Airtable View URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: 'https://airtable.com/[a-zA-Z0-9]{2,}/[a-zA-Z0-9]{2,}/([a-zA-Z0-9]{2,})',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '[a-zA-Z0-9]{2,}',
|
||||
errorMessage: 'Not a valid Airtable View ID',
|
||||
},
|
||||
},
|
||||
],
|
||||
placeholder: 'viw3dirwqeidke',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const insertUpdateOptions: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Typecast',
|
||||
name: 'typecast',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether the Airtable API should attempt mapping of string values for linked records & select options',
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore Fields From Input',
|
||||
name: 'ignoreFields',
|
||||
type: 'string',
|
||||
requiresDataPath: 'multiple',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/columns.mappingMode': ['autoMapInputData'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'Comma-separated list of fields in input to ignore when updating',
|
||||
},
|
||||
{
|
||||
displayName: 'Update All Matches',
|
||||
name: 'updateAllMatches',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to update all records matching the value in the "Column to Match On". If not set, only the first matching record will be updated.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/operation': ['update', 'upsert'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { AllEntities } from 'n8n-workflow';
|
||||
|
||||
type NodeMap = {
|
||||
record: 'create' | 'upsert' | 'deleteRecord' | 'get' | 'search' | 'update';
|
||||
base: 'getMany' | 'getSchema';
|
||||
table: 'create';
|
||||
};
|
||||
|
||||
export type AirtableType = AllEntities<NodeMap>;
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as create from './create.operation';
|
||||
import * as deleteRecord from './deleteRecord.operation';
|
||||
import * as get from './get.operation';
|
||||
import * as search from './search.operation';
|
||||
import * as update from './update.operation';
|
||||
import * as upsert from './upsert.operation';
|
||||
import { baseRLC, tableRLC } from '../common.descriptions';
|
||||
|
||||
export { create, deleteRecord, get, search, update, upsert };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Create',
|
||||
value: 'create',
|
||||
description: 'Create a new record in a table',
|
||||
action: 'Create a record',
|
||||
},
|
||||
{
|
||||
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 record',
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
value: 'deleteRecord',
|
||||
description: 'Delete a record from a table',
|
||||
action: 'Delete a record',
|
||||
},
|
||||
{
|
||||
name: 'Get',
|
||||
value: 'get',
|
||||
description: 'Retrieve a record from a table',
|
||||
action: 'Get a record',
|
||||
},
|
||||
{
|
||||
name: 'Search',
|
||||
value: 'search',
|
||||
description: 'Search for specific records or list all',
|
||||
action: 'Search records',
|
||||
},
|
||||
{
|
||||
name: 'Update',
|
||||
value: 'update',
|
||||
description: 'Update a record in a table',
|
||||
action: 'Update record',
|
||||
},
|
||||
],
|
||||
default: 'get',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['record'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...baseRLC,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['record'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...tableRLC,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['record'],
|
||||
},
|
||||
},
|
||||
},
|
||||
...create.description,
|
||||
...deleteRecord.description,
|
||||
...get.description,
|
||||
...search.description,
|
||||
...update.description,
|
||||
...upsert.description,
|
||||
];
|
||||
@@ -0,0 +1,101 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
IExecuteFunctions,
|
||||
NodeApiError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions, wrapData } from '../../../../../utils/utilities';
|
||||
import { processAirtableError, removeIgnored } from '../../helpers/utils';
|
||||
import { apiRequest } from '../../transport';
|
||||
import { insertUpdateOptions } from '../common.descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
noDataExpression: true,
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['table.value', 'base.value'],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'getColumns',
|
||||
mode: 'add',
|
||||
fieldWords: {
|
||||
singular: 'column',
|
||||
plural: 'columns',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
...insertUpdateOptions,
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['record'],
|
||||
operation: ['create'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
items: INodeExecutionData[],
|
||||
base: string,
|
||||
table: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
const endpoint = `${base}/${table}`;
|
||||
|
||||
const dataMode = this.getNodeParameter('columns.mappingMode', 0) as string;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
const typecast = Boolean(options.typecast);
|
||||
|
||||
const body: IDataObject = { typecast };
|
||||
|
||||
if (dataMode === 'autoMapInputData') {
|
||||
body.fields = removeIgnored(items[i].json, options.ignoreFields as string);
|
||||
}
|
||||
|
||||
if (dataMode === 'defineBelow') {
|
||||
const fields = this.getNodeParameter('columns.value', i, [], {
|
||||
skipValidation: typecast,
|
||||
}) as IDataObject;
|
||||
|
||||
body.fields = fields;
|
||||
}
|
||||
|
||||
const responseData = await apiRequest.call(this, 'POST', endpoint, body);
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
wrapData(responseData as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
returnData.push.apply(returnData, executionData);
|
||||
} catch (error) {
|
||||
error = processAirtableError(error as NodeApiError, undefined, i);
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { message: error.message, error } });
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
NodeApiError,
|
||||
IExecuteFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions, wrapData } from '../../../../../utils/utilities';
|
||||
import { processAirtableError } from '../../helpers/utils';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Record ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. recf7EaZp707CEc8g',
|
||||
required: true,
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-miscased-id
|
||||
description:
|
||||
'ID of the record to delete. <a href="https://support.airtable.com/docs/record-id" target="_blank">More info</a>.',
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['record'],
|
||||
operation: ['deleteRecord'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
items: INodeExecutionData[],
|
||||
base: string,
|
||||
table: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let id;
|
||||
try {
|
||||
id = this.getNodeParameter('id', i) as string;
|
||||
|
||||
const responseData = await apiRequest.call(this, 'DELETE', `${base}/${table}/${id}`);
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
wrapData(responseData as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
returnData.push(...executionData);
|
||||
} catch (error) {
|
||||
error = processAirtableError(error as NodeApiError, id, i);
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { error: error.message } });
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
NodeApiError,
|
||||
IExecuteFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions, wrapData } from '../../../../../utils/utilities';
|
||||
import type { IRecord } from '../../helpers/interfaces';
|
||||
import { flattenOutput, processAirtableError } from '../../helpers/utils';
|
||||
import { apiRequest, downloadRecordAttachments } from '../../transport';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Record ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. recf7EaZp707CEc8g',
|
||||
required: true,
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-miscased-id
|
||||
description:
|
||||
'ID of the record to get. <a href="https://support.airtable.com/docs/record-id" target="_blank">More info</a>.',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
description: 'Additional options which decide which records should be returned',
|
||||
placeholder: 'Add option',
|
||||
options: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-multi-options
|
||||
displayName: 'Download Attachments',
|
||||
name: 'downloadFields',
|
||||
type: 'multiOptions',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getAttachmentColumns',
|
||||
loadOptionsDependsOn: ['base.value', 'table.value'],
|
||||
},
|
||||
default: [],
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-multi-options
|
||||
description: "The fields of type 'attachment' that should be downloaded",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['record'],
|
||||
operation: ['get'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
items: INodeExecutionData[],
|
||||
base: string,
|
||||
table: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let id;
|
||||
try {
|
||||
id = this.getNodeParameter('id', i) as string;
|
||||
|
||||
const responseData = await apiRequest.call(this, 'GET', `${base}/${table}/${id}`);
|
||||
|
||||
const options = this.getNodeParameter('options', 0, {});
|
||||
|
||||
if (options.downloadFields) {
|
||||
const itemWithAttachments = await downloadRecordAttachments.call(
|
||||
this,
|
||||
[responseData] as IRecord[],
|
||||
options.downloadFields as string[],
|
||||
);
|
||||
returnData.push(...itemWithAttachments);
|
||||
continue;
|
||||
}
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
wrapData(flattenOutput(responseData as IDataObject)),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
returnData.push(...executionData);
|
||||
} catch (error) {
|
||||
error = processAirtableError(error as NodeApiError, id, i);
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { error: error.message } });
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
IExecuteFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { generatePairedItemData, updateDisplayOptions } from '../../../../../utils/utilities';
|
||||
import type { IRecord } from '../../helpers/interfaces';
|
||||
import { flattenOutput } from '../../helpers/utils';
|
||||
import { apiRequest, apiRequestAllItems, downloadRecordAttachments } from '../../transport';
|
||||
import { viewRLC } from '../common.descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Filter By Formula',
|
||||
name: 'filterByFormula',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: "e.g. NOT({Name} = 'Admin')",
|
||||
hint: 'If empty, all the records will be returned',
|
||||
description:
|
||||
'The formula will be evaluated for each record, and if the result is not 0, false, "", NaN, [], or #Error! the record will be included in the response. <a href="https://support.airtable.com/docs/formula-field-reference" target="_blank">More info</a>.',
|
||||
},
|
||||
{
|
||||
displayName: 'Return All',
|
||||
name: 'returnAll',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Whether to return all results or only up to a given limit',
|
||||
},
|
||||
{
|
||||
displayName: 'Limit',
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
displayOptions: {
|
||||
show: {
|
||||
returnAll: [false],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
maxValue: 100,
|
||||
},
|
||||
default: 100,
|
||||
description: 'Max number of results to return',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
description: 'Additional options which decide which records should be returned',
|
||||
placeholder: 'Add option',
|
||||
options: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-multi-options
|
||||
displayName: 'Download Attachments',
|
||||
name: 'downloadFields',
|
||||
type: 'multiOptions',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getAttachmentColumns',
|
||||
loadOptionsDependsOn: ['base.value', 'table.value'],
|
||||
},
|
||||
default: [],
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-multi-options
|
||||
description: "The fields of type 'attachment' that should be downloaded",
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-multi-options
|
||||
displayName: 'Output Fields',
|
||||
name: 'fields',
|
||||
type: 'multiOptions',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getColumns',
|
||||
loadOptionsDependsOn: ['base.value', 'table.value'],
|
||||
},
|
||||
default: [],
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-multi-options
|
||||
description: 'The fields you want to include in the output',
|
||||
},
|
||||
viewRLC,
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Sort',
|
||||
name: 'sort',
|
||||
placeholder: 'Add Sort Rule',
|
||||
description: 'Defines how the returned records should be ordered',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
name: 'property',
|
||||
displayName: 'Property',
|
||||
values: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
|
||||
displayName: 'Field',
|
||||
name: 'field',
|
||||
type: 'options',
|
||||
typeOptions: {
|
||||
loadOptionsMethod: 'getColumns',
|
||||
loadOptionsDependsOn: ['base.value', 'table.value'],
|
||||
},
|
||||
default: '',
|
||||
description:
|
||||
'Name of the field to sort on. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
|
||||
},
|
||||
{
|
||||
displayName: 'Direction',
|
||||
name: 'direction',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'ASC',
|
||||
value: 'asc',
|
||||
description: 'Sort in ascending order (small -> large)',
|
||||
},
|
||||
{
|
||||
name: 'DESC',
|
||||
value: 'desc',
|
||||
description: 'Sort in descending order (large -> small)',
|
||||
},
|
||||
],
|
||||
default: 'asc',
|
||||
description: 'The sort direction',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['record'],
|
||||
operation: ['search'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
items: INodeExecutionData[],
|
||||
base: string,
|
||||
table: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
|
||||
const endpoint = `${base}/${table}`;
|
||||
|
||||
let itemsLength = items.length ? 1 : 0;
|
||||
let fallbackPairedItems;
|
||||
|
||||
if (nodeVersion >= 2.1) {
|
||||
itemsLength = items.length;
|
||||
} else {
|
||||
fallbackPairedItems = generatePairedItemData(items.length);
|
||||
}
|
||||
|
||||
for (let i = 0; i < itemsLength; i++) {
|
||||
try {
|
||||
const returnAll = this.getNodeParameter('returnAll', i);
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
const sort = this.getNodeParameter('sort', i, {}) as IDataObject;
|
||||
const filterByFormula = this.getNodeParameter('filterByFormula', i) as string;
|
||||
|
||||
const body: IDataObject = {};
|
||||
const qs: IDataObject = {};
|
||||
|
||||
if (filterByFormula) {
|
||||
qs.filterByFormula = filterByFormula;
|
||||
}
|
||||
|
||||
if (options.fields) {
|
||||
if (typeof options.fields === 'string') {
|
||||
qs.fields = options.fields.split(',').map((field) => field.trim());
|
||||
} else {
|
||||
qs.fields = options.fields as string[];
|
||||
}
|
||||
}
|
||||
|
||||
if (sort.property) {
|
||||
qs.sort = sort.property;
|
||||
}
|
||||
|
||||
if (options.view) {
|
||||
qs.view = (options.view as IDataObject).value as string;
|
||||
}
|
||||
|
||||
let responseData;
|
||||
|
||||
if (returnAll) {
|
||||
responseData = await apiRequestAllItems.call(this, 'GET', endpoint, body, qs);
|
||||
} else {
|
||||
qs.maxRecords = this.getNodeParameter('limit', i);
|
||||
responseData = await apiRequest.call(this, 'GET', endpoint, body, qs);
|
||||
}
|
||||
|
||||
if (options.downloadFields) {
|
||||
const itemWithAttachments = await downloadRecordAttachments.call(
|
||||
this,
|
||||
responseData.records as IRecord[],
|
||||
options.downloadFields as string[],
|
||||
fallbackPairedItems || [{ item: i }],
|
||||
);
|
||||
returnData.push(...itemWithAttachments);
|
||||
continue;
|
||||
}
|
||||
|
||||
let records = responseData.records;
|
||||
|
||||
records = (records as IDataObject[]).map((record) => ({
|
||||
json: flattenOutput(record),
|
||||
})) as INodeExecutionData[];
|
||||
|
||||
const itemData = fallbackPairedItems || [{ item: i }];
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(records, {
|
||||
itemData,
|
||||
});
|
||||
|
||||
returnData.push(...executionData);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { message: error.message, error }, pairedItem: { item: i } });
|
||||
continue;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
NodeApiError,
|
||||
IExecuteFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions, wrapData } from '../../../../../utils/utilities';
|
||||
import type { UpdateRecord } from '../../helpers/interfaces';
|
||||
import { findMatches, processAirtableError, removeIgnored } from '../../helpers/utils';
|
||||
import { apiRequestAllItems, batchUpdate } from '../../transport';
|
||||
import { insertUpdateOptions } from '../common.descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['table.value', 'base.value'],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'getColumnsWithRecordId',
|
||||
mode: 'update',
|
||||
fieldWords: {
|
||||
singular: 'column',
|
||||
plural: 'columns',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
...insertUpdateOptions,
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['record'],
|
||||
operation: ['update'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
items: INodeExecutionData[],
|
||||
base: string,
|
||||
table: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
const endpoint = `${base}/${table}`;
|
||||
|
||||
const dataMode = this.getNodeParameter('columns.mappingMode', 0) as string;
|
||||
|
||||
const columnsToMatchOn = this.getNodeParameter('columns.matchingColumns', 0) as string[];
|
||||
|
||||
let tableData: UpdateRecord[] = [];
|
||||
if (!columnsToMatchOn.includes('id')) {
|
||||
const response = await apiRequestAllItems.call(
|
||||
this,
|
||||
'GET',
|
||||
endpoint,
|
||||
{},
|
||||
{ fields: columnsToMatchOn },
|
||||
);
|
||||
tableData = response.records as UpdateRecord[];
|
||||
}
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let recordId = '';
|
||||
try {
|
||||
const records: UpdateRecord[] = [];
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
const typecast = options.typecast ? true : false;
|
||||
|
||||
if (dataMode === 'autoMapInputData') {
|
||||
if (columnsToMatchOn.includes('id')) {
|
||||
const { id, ...fields } = items[i].json;
|
||||
recordId = id as string;
|
||||
|
||||
records.push({
|
||||
id: recordId,
|
||||
fields: removeIgnored(fields, options.ignoreFields as string),
|
||||
});
|
||||
} else {
|
||||
const matches = findMatches(
|
||||
tableData,
|
||||
columnsToMatchOn,
|
||||
items[i].json,
|
||||
options.updateAllMatches as boolean,
|
||||
);
|
||||
|
||||
for (const match of matches) {
|
||||
const id = match.id as string;
|
||||
const fields = items[i].json;
|
||||
records.push({ id, fields: removeIgnored(fields, options.ignoreFields as string) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dataMode === 'defineBelow') {
|
||||
const getNodeParameterOptions = typecast ? { skipValidation: true } : undefined;
|
||||
if (columnsToMatchOn.includes('id')) {
|
||||
const { id, ...fields } = this.getNodeParameter(
|
||||
'columns.value',
|
||||
i,
|
||||
[],
|
||||
getNodeParameterOptions,
|
||||
) as IDataObject;
|
||||
records.push({ id: id as string, fields });
|
||||
} else {
|
||||
const fields = this.getNodeParameter(
|
||||
'columns.value',
|
||||
i,
|
||||
[],
|
||||
getNodeParameterOptions,
|
||||
) as IDataObject;
|
||||
|
||||
const matches = findMatches(
|
||||
tableData,
|
||||
columnsToMatchOn,
|
||||
fields,
|
||||
options.updateAllMatches as boolean,
|
||||
);
|
||||
|
||||
for (const match of matches) {
|
||||
const id = match.id as string;
|
||||
records.push({ id, fields: removeIgnored(fields, columnsToMatchOn) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const body: IDataObject = { typecast };
|
||||
|
||||
const responseData = await batchUpdate.call(this, endpoint, body, records);
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
wrapData(responseData.records as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
returnData.push(...executionData);
|
||||
} catch (error) {
|
||||
error = processAirtableError(error as NodeApiError, recordId, i);
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { message: error.message, error } });
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
IExecuteFunctions,
|
||||
NodeApiError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { updateDisplayOptions, wrapData } from '../../../../../utils/utilities';
|
||||
import type { UpdateRecord } from '../../helpers/interfaces';
|
||||
import { processAirtableError, removeIgnored } from '../../helpers/utils';
|
||||
import { apiRequest, apiRequestAllItems, batchUpdate } from '../../transport';
|
||||
import { insertUpdateOptions } from '../common.descriptions';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Columns',
|
||||
name: 'columns',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['table.value', 'base.value'],
|
||||
resourceMapper: {
|
||||
resourceMapperMethod: 'getColumnsWithRecordId',
|
||||
mode: 'update',
|
||||
fieldWords: {
|
||||
singular: 'column',
|
||||
plural: 'columns',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
...insertUpdateOptions,
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['record'],
|
||||
operation: ['upsert'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
items: INodeExecutionData[],
|
||||
base: string,
|
||||
table: string,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
const endpoint = `${base}/${table}`;
|
||||
|
||||
const dataMode = this.getNodeParameter('columns.mappingMode', 0) as string;
|
||||
|
||||
const columnsToMatchOn = this.getNodeParameter('columns.matchingColumns', 0) as string[];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
const records: UpdateRecord[] = [];
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
|
||||
if (dataMode === 'autoMapInputData') {
|
||||
if (columnsToMatchOn.includes('id')) {
|
||||
const { id, ...fields } = items[i].json;
|
||||
|
||||
records.push({
|
||||
id: id as string,
|
||||
fields: removeIgnored(fields, options.ignoreFields as string),
|
||||
});
|
||||
} else {
|
||||
records.push({ fields: removeIgnored(items[i].json, options.ignoreFields as string) });
|
||||
}
|
||||
}
|
||||
|
||||
if (dataMode === 'defineBelow') {
|
||||
const fields = this.getNodeParameter('columns.value', i, []) as IDataObject;
|
||||
|
||||
if (columnsToMatchOn.includes('id')) {
|
||||
const id = fields.id as string;
|
||||
delete fields.id;
|
||||
records.push({ id, fields });
|
||||
} else {
|
||||
records.push({ fields });
|
||||
}
|
||||
}
|
||||
|
||||
const body: IDataObject = {
|
||||
typecast: options.typecast ? true : false,
|
||||
};
|
||||
|
||||
if (!columnsToMatchOn.includes('id')) {
|
||||
body.performUpsert = { fieldsToMergeOn: columnsToMatchOn };
|
||||
}
|
||||
|
||||
let responseData;
|
||||
try {
|
||||
responseData = await batchUpdate.call(this, endpoint, body, records);
|
||||
} catch (error) {
|
||||
if (error.httpCode === '422' && columnsToMatchOn.includes('id')) {
|
||||
const createBody = {
|
||||
...body,
|
||||
records: records.map(({ fields }) => ({ fields })),
|
||||
};
|
||||
responseData = await apiRequest.call(this, 'POST', endpoint, createBody);
|
||||
} else if (error?.description?.includes('Cannot update more than one record')) {
|
||||
const conditions = columnsToMatchOn
|
||||
.map((column) => `{${column}} = '${records[0].fields[column]}'`)
|
||||
.join(',');
|
||||
const response = await apiRequestAllItems.call(
|
||||
this,
|
||||
'GET',
|
||||
endpoint,
|
||||
{},
|
||||
{
|
||||
fields: columnsToMatchOn,
|
||||
filterByFormula: `AND(${conditions})`,
|
||||
},
|
||||
);
|
||||
const matches = response.records as UpdateRecord[];
|
||||
|
||||
const updateRecords: UpdateRecord[] = [];
|
||||
|
||||
if (options.updateAllMatches) {
|
||||
updateRecords.push(...matches.map(({ id }) => ({ id, fields: records[0].fields })));
|
||||
} else {
|
||||
updateRecords.push({ id: matches[0].id, fields: records[0].fields });
|
||||
}
|
||||
|
||||
responseData = await batchUpdate.call(this, endpoint, body, updateRecords);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
wrapData(responseData.records as IDataObject[]),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
|
||||
returnData.push(...executionData);
|
||||
} catch (error) {
|
||||
error = processAirtableError(error as NodeApiError, undefined, i);
|
||||
if (this.continueOnFail()) {
|
||||
returnData.push({ json: { message: error.message, error } });
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return returnData;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import * as base from './base/Base.resource';
|
||||
import type { AirtableType } from './node.type';
|
||||
import * as record from './record/Record.resource';
|
||||
|
||||
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
let returnData: INodeExecutionData[] = [];
|
||||
|
||||
const items = this.getInputData();
|
||||
const resource = this.getNodeParameter<AirtableType>('resource', 0);
|
||||
const operation = this.getNodeParameter('operation', 0);
|
||||
|
||||
const airtableNodeData = {
|
||||
resource,
|
||||
operation,
|
||||
} as AirtableType;
|
||||
|
||||
try {
|
||||
switch (airtableNodeData.resource) {
|
||||
case 'record':
|
||||
const baseId = this.getNodeParameter('base', 0, undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const table = encodeURI(
|
||||
this.getNodeParameter('table', 0, undefined, {
|
||||
extractValue: true,
|
||||
}) as string,
|
||||
);
|
||||
returnData = await record[airtableNodeData.operation].execute.call(
|
||||
this,
|
||||
items,
|
||||
baseId,
|
||||
table,
|
||||
);
|
||||
break;
|
||||
case 'base':
|
||||
returnData = await base[airtableNodeData.operation].execute.call(this, items);
|
||||
break;
|
||||
default:
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The operation "${operation}" is not supported!`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error.description &&
|
||||
(error.description as string).includes('cannot accept the provided value')
|
||||
) {
|
||||
error.description = `${error.description}. Consider using 'Typecast' option`;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type Table = {
|
||||
id: string;
|
||||
name: string;
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
export type TablesResponse = {
|
||||
tables: Table[];
|
||||
};
|
||||
|
||||
export type Field = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import * as base from './base/Base.resource';
|
||||
import * as record from './record/Record.resource';
|
||||
|
||||
export const versionDescription: INodeTypeDescription = {
|
||||
displayName: 'Airtable',
|
||||
name: 'airtable',
|
||||
icon: 'file:airtable.svg',
|
||||
group: ['input'],
|
||||
version: [2, 2.1],
|
||||
subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}',
|
||||
description: 'Read, update, write and delete data from Airtable',
|
||||
defaults: {
|
||||
name: 'Airtable',
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'airtableTokenApi',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['airtableTokenApi'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'airtableOAuth2Api',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['airtableOAuth2Api'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Access Token',
|
||||
value: 'airtableTokenApi',
|
||||
},
|
||||
{
|
||||
name: 'OAuth2',
|
||||
value: 'airtableOAuth2Api',
|
||||
},
|
||||
],
|
||||
default: 'airtableTokenApi',
|
||||
},
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Base',
|
||||
value: 'base',
|
||||
},
|
||||
{
|
||||
name: 'Record',
|
||||
value: 'record',
|
||||
},
|
||||
// {
|
||||
// name: 'Table',
|
||||
// value: 'table',
|
||||
// },
|
||||
],
|
||||
default: 'record',
|
||||
},
|
||||
...record.description,
|
||||
...base.description,
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
export interface IAttachment {
|
||||
url: string;
|
||||
filename: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface IRecord {
|
||||
fields: {
|
||||
[key: string]: string | IAttachment[];
|
||||
};
|
||||
}
|
||||
|
||||
export type UpdateRecord = {
|
||||
fields: IDataObject;
|
||||
id?: string;
|
||||
};
|
||||
export type UpdateBody = {
|
||||
records: UpdateRecord[];
|
||||
performUpsert?: {
|
||||
fieldsToMergeOn: string[];
|
||||
};
|
||||
typecast?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import set from 'lodash/set';
|
||||
import { ApplicationError, type IDataObject, type NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import type { UpdateRecord } from './interfaces';
|
||||
|
||||
export function removeIgnored(data: IDataObject, ignore: string | string[]) {
|
||||
if (ignore) {
|
||||
let ignoreFields: string[] = [];
|
||||
|
||||
if (typeof ignore === 'string') {
|
||||
ignoreFields = ignore.split(',').map((field) => field.trim());
|
||||
} else {
|
||||
ignoreFields = ignore;
|
||||
}
|
||||
|
||||
const newData: IDataObject = {};
|
||||
|
||||
for (const field of Object.keys(data)) {
|
||||
if (!ignoreFields.includes(field)) {
|
||||
newData[field] = data[field];
|
||||
}
|
||||
}
|
||||
|
||||
return newData;
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export function findMatches(
|
||||
data: UpdateRecord[],
|
||||
keys: string[],
|
||||
fields: IDataObject,
|
||||
updateAll?: boolean,
|
||||
) {
|
||||
if (updateAll) {
|
||||
const matches = data.filter((record) => {
|
||||
for (const key of keys) {
|
||||
if (record.fields[key] !== fields[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!matches?.length) {
|
||||
throw new ApplicationError('No records match provided keys', { level: 'warning' });
|
||||
}
|
||||
|
||||
return matches;
|
||||
} else {
|
||||
const match = data.find((record) => {
|
||||
for (const key of keys) {
|
||||
if (record.fields[key] !== fields[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!match) {
|
||||
throw new ApplicationError('Record matching provided keys was not found', {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
return [match];
|
||||
}
|
||||
}
|
||||
|
||||
export function processAirtableError(error: NodeApiError, id?: string, itemIndex?: number) {
|
||||
if (error.description === 'NOT_FOUND' && id) {
|
||||
error.description = `${id} is not a valid Record ID`;
|
||||
}
|
||||
if (error.description?.includes('You must provide an array of up to 10 record objects') && id) {
|
||||
error.description = `${id} is not a valid Record ID`;
|
||||
}
|
||||
|
||||
if (itemIndex !== undefined) {
|
||||
set(error, 'context.itemIndex', itemIndex);
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
export const flattenOutput = (record: IDataObject) => {
|
||||
const { fields, ...rest } = record;
|
||||
return {
|
||||
...rest,
|
||||
...(fields as IDataObject),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * as listSearch from './listSearch';
|
||||
export * as loadOptions from './loadOptions';
|
||||
export * as resourceMapping from './resourceMapping';
|
||||
@@ -0,0 +1,152 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
ILoadOptionsFunctions,
|
||||
INodeListSearchItems,
|
||||
INodeListSearchResult,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../transport';
|
||||
|
||||
export async function baseSearch(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
paginationToken?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
let qs;
|
||||
if (paginationToken) {
|
||||
qs = {
|
||||
offset: paginationToken,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await apiRequest.call(this, 'GET', 'meta/bases', undefined, qs);
|
||||
|
||||
if (filter) {
|
||||
const results: INodeListSearchItems[] = [];
|
||||
|
||||
for (const base of response.bases || []) {
|
||||
if ((base.name as string)?.toLowerCase().includes(filter.toLowerCase())) {
|
||||
results.push({
|
||||
name: base.name as string,
|
||||
value: base.id as string,
|
||||
url: `https://airtable.com/${base.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
results,
|
||||
paginationToken: response.offset,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
results: (response.bases || []).map((base: IDataObject) => ({
|
||||
name: base.name as string,
|
||||
value: base.id as string,
|
||||
url: `https://airtable.com/${base.id}`,
|
||||
})),
|
||||
paginationToken: response.offset,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function tableSearch(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
paginationToken?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const baseId = this.getNodeParameter('base', undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
let qs;
|
||||
if (paginationToken) {
|
||||
qs = {
|
||||
offset: paginationToken,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await apiRequest.call(this, 'GET', `meta/bases/${baseId}/tables`, undefined, qs);
|
||||
|
||||
if (filter) {
|
||||
const results: INodeListSearchItems[] = [];
|
||||
|
||||
for (const table of response.tables || []) {
|
||||
if ((table.name as string)?.toLowerCase().includes(filter.toLowerCase())) {
|
||||
results.push({
|
||||
name: table.name as string,
|
||||
value: table.id as string,
|
||||
url: `https://airtable.com/${baseId}/${table.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
results,
|
||||
paginationToken: response.offset,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
results: (response.tables || []).map((table: IDataObject) => ({
|
||||
name: table.name as string,
|
||||
value: table.id as string,
|
||||
url: `https://airtable.com/${baseId}/${table.id}`,
|
||||
})),
|
||||
paginationToken: response.offset,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function viewSearch(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const baseId = this.getNodeParameter('base', undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const tableId = encodeURI(
|
||||
this.getNodeParameter('table', undefined, {
|
||||
extractValue: true,
|
||||
}) as string,
|
||||
);
|
||||
|
||||
const response = await apiRequest.call(this, 'GET', `meta/bases/${baseId}/tables`);
|
||||
|
||||
const tableData = ((response.tables as IDataObject[]) || []).find((table: IDataObject) => {
|
||||
return table.id === tableId;
|
||||
});
|
||||
|
||||
if (!tableData) {
|
||||
throw new NodeOperationError(this.getNode(), 'Table information could not be found!', {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
if (filter) {
|
||||
const results: INodeListSearchItems[] = [];
|
||||
|
||||
for (const view of (tableData.views as IDataObject[]) || []) {
|
||||
if ((view.name as string)?.toLowerCase().includes(filter.toLowerCase())) {
|
||||
results.push({
|
||||
name: view.name as string,
|
||||
value: view.id as string,
|
||||
url: `https://airtable.com/${baseId}/${tableId}/${view.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
results,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
results: ((tableData.views as IDataObject[]) || []).map((view) => ({
|
||||
name: view.name as string,
|
||||
value: view.id as string,
|
||||
url: `https://airtable.com/${baseId}/${tableId}/${view.id}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { IDataObject, ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../transport';
|
||||
|
||||
export async function getColumns(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
|
||||
const base = this.getNodeParameter('base', undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const tableId = encodeURI(
|
||||
this.getNodeParameter('table', undefined, {
|
||||
extractValue: true,
|
||||
}) as string,
|
||||
);
|
||||
|
||||
const response = await apiRequest.call(this, 'GET', `meta/bases/${base}/tables`);
|
||||
|
||||
const tableData = ((response.tables as IDataObject[]) || []).find((table: IDataObject) => {
|
||||
return table.id === tableId;
|
||||
});
|
||||
|
||||
if (!tableData) {
|
||||
throw new NodeOperationError(this.getNode(), 'Table information could not be found!', {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
const result: INodePropertyOptions[] = [];
|
||||
|
||||
for (const field of tableData.fields as IDataObject[]) {
|
||||
result.push({
|
||||
name: field.name as string,
|
||||
value: field.name as string,
|
||||
description: `Type: ${field.type}`,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getColumnsWithRecordId(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
const returnData = await getColumns.call(this);
|
||||
return [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased-id, n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'id',
|
||||
value: 'id' as string,
|
||||
description: 'Type: primaryFieldId',
|
||||
},
|
||||
...returnData,
|
||||
];
|
||||
}
|
||||
|
||||
export async function getColumnsWithoutColumnToMatchOn(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
const columnToMatchOn = this.getNodeParameter('columnToMatchOn') as string;
|
||||
const returnData = await getColumns.call(this);
|
||||
return returnData.filter((column) => column.value !== columnToMatchOn);
|
||||
}
|
||||
|
||||
export async function getAttachmentColumns(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
const base = this.getNodeParameter('base', undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const tableId = encodeURI(
|
||||
this.getNodeParameter('table', undefined, {
|
||||
extractValue: true,
|
||||
}) as string,
|
||||
);
|
||||
|
||||
const response = await apiRequest.call(this, 'GET', `meta/bases/${base}/tables`);
|
||||
|
||||
const tableData = ((response.tables as IDataObject[]) || []).find((table: IDataObject) => {
|
||||
return table.id === tableId;
|
||||
});
|
||||
|
||||
if (!tableData) {
|
||||
throw new NodeOperationError(this.getNode(), 'Table information could not be found!', {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
const result: INodePropertyOptions[] = [];
|
||||
|
||||
for (const field of tableData.fields as IDataObject[]) {
|
||||
if (!(field.type as string)?.toLowerCase()?.includes('attachment')) {
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
name: field.name as string,
|
||||
value: field.name as string,
|
||||
description: `Type: ${field.type}`,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type {
|
||||
FieldType,
|
||||
IDataObject,
|
||||
ILoadOptionsFunctions,
|
||||
INodePropertyOptions,
|
||||
ResourceMapperField,
|
||||
ResourceMapperFields,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { apiRequest } from '../transport';
|
||||
|
||||
type AirtableSchema = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
options?: IDataObject;
|
||||
};
|
||||
|
||||
type TypesMap = Partial<Record<FieldType, string[]>>;
|
||||
|
||||
const airtableReadOnlyFields = [
|
||||
'autoNumber',
|
||||
'button',
|
||||
'count',
|
||||
'createdBy',
|
||||
'createdTime',
|
||||
'formula',
|
||||
'lastModifiedBy',
|
||||
'lastModifiedTime',
|
||||
'lookup',
|
||||
'rollup',
|
||||
'externalSyncSource',
|
||||
'multipleLookupValues',
|
||||
];
|
||||
|
||||
const airtableTypesMap: TypesMap = {
|
||||
string: ['singleLineText', 'multilineText', 'richText', 'email', 'phoneNumber', 'url'],
|
||||
number: ['rating', 'percent', 'number', 'duration', 'currency'],
|
||||
boolean: ['checkbox'],
|
||||
dateTime: ['dateTime', 'date'],
|
||||
time: [],
|
||||
object: [],
|
||||
options: ['singleSelect'],
|
||||
array: ['multipleSelects', 'multipleRecordLinks', 'multipleAttachments'],
|
||||
};
|
||||
|
||||
function mapForeignType(foreignType: string, typesMap: TypesMap): FieldType {
|
||||
let type: FieldType = 'string';
|
||||
|
||||
for (const nativeType of Object.keys(typesMap)) {
|
||||
const mappedForeignTypes = typesMap[nativeType as FieldType];
|
||||
|
||||
if (mappedForeignTypes?.includes(foreignType)) {
|
||||
type = nativeType as FieldType;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
export async function getColumns(this: ILoadOptionsFunctions): Promise<ResourceMapperFields> {
|
||||
const base = this.getNodeParameter('base', undefined, {
|
||||
extractValue: true,
|
||||
}) as string;
|
||||
|
||||
const tableId = encodeURI(
|
||||
this.getNodeParameter('table', undefined, {
|
||||
extractValue: true,
|
||||
}) as string,
|
||||
);
|
||||
|
||||
const response = await apiRequest.call(this, 'GET', `meta/bases/${base}/tables`);
|
||||
|
||||
const tableData = ((response.tables as IDataObject[]) || []).find((table: IDataObject) => {
|
||||
return table.id === tableId;
|
||||
});
|
||||
|
||||
if (!tableData) {
|
||||
throw new NodeOperationError(this.getNode(), 'Table information could not be found!', {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
const fields: ResourceMapperField[] = [];
|
||||
|
||||
const constructOptions = (field: AirtableSchema) => {
|
||||
if (field?.options?.choices) {
|
||||
return (field.options.choices as IDataObject[]).map((choice) => ({
|
||||
name: choice.name,
|
||||
value: choice.name,
|
||||
})) as INodePropertyOptions[];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
for (const field of tableData.fields as AirtableSchema[]) {
|
||||
const type = mapForeignType(field.type, airtableTypesMap);
|
||||
const isReadOnly = airtableReadOnlyFields.includes(field.type);
|
||||
const options = constructOptions(field);
|
||||
fields.push({
|
||||
id: field.name,
|
||||
displayName: field.name,
|
||||
required: false,
|
||||
defaultMatch: false,
|
||||
canBeUsedToMatch: true,
|
||||
display: true,
|
||||
type,
|
||||
options,
|
||||
readOnly: isReadOnly,
|
||||
removed: isReadOnly,
|
||||
});
|
||||
}
|
||||
|
||||
return { fields };
|
||||
}
|
||||
|
||||
export async function getColumnsWithRecordId(
|
||||
this: ILoadOptionsFunctions,
|
||||
): Promise<ResourceMapperFields> {
|
||||
const returnData = await getColumns.call(this);
|
||||
return {
|
||||
fields: [
|
||||
{
|
||||
id: 'id',
|
||||
displayName: 'id',
|
||||
required: false,
|
||||
defaultMatch: true,
|
||||
display: true,
|
||||
type: 'string',
|
||||
readOnly: true,
|
||||
},
|
||||
...returnData.fields,
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type {
|
||||
IBinaryKeyData,
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
IPollFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
INodeExecutionData,
|
||||
IPairedItemData,
|
||||
IHttpRequestMethods,
|
||||
IRequestOptions,
|
||||
} from 'n8n-workflow';
|
||||
import { ApplicationError } from '@n8n/errors';
|
||||
|
||||
import type { IAttachment, IRecord } from '../helpers/interfaces';
|
||||
import { flattenOutput } from '../helpers/utils';
|
||||
|
||||
/**
|
||||
* Make an API request to Airtable
|
||||
*
|
||||
*/
|
||||
export async function apiRequest(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
endpoint: string,
|
||||
body: IDataObject = {},
|
||||
query?: IDataObject,
|
||||
uri?: string,
|
||||
option: IDataObject = {},
|
||||
) {
|
||||
query = query || {};
|
||||
|
||||
const options: IRequestOptions = {
|
||||
headers: {},
|
||||
method,
|
||||
body,
|
||||
qs: query,
|
||||
uri: uri || `https://api.airtable.com/v0/${endpoint}`,
|
||||
useQuerystring: false,
|
||||
json: true,
|
||||
};
|
||||
|
||||
if (Object.keys(option).length !== 0) {
|
||||
Object.assign(options, option);
|
||||
}
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
delete options.body;
|
||||
}
|
||||
|
||||
const authenticationMethod = this.getNodeParameter('authentication', 0) as string;
|
||||
return await this.helpers.requestWithAuthentication.call(this, authenticationMethod, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an API request to paginated Airtable endpoint
|
||||
* and return all results
|
||||
*
|
||||
* @param {(IExecuteFunctions | IExecuteFunctions)} this
|
||||
*/
|
||||
export async function apiRequestAllItems(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
endpoint: string,
|
||||
body?: IDataObject,
|
||||
query?: IDataObject,
|
||||
) {
|
||||
if (query === undefined) {
|
||||
query = {};
|
||||
}
|
||||
query.pageSize = 100;
|
||||
|
||||
const returnData: IDataObject[] = [];
|
||||
|
||||
let responseData;
|
||||
|
||||
do {
|
||||
responseData = await apiRequest.call(this, method, endpoint, body, query);
|
||||
returnData.push.apply(returnData, responseData.records as IDataObject[]);
|
||||
|
||||
query.offset = responseData.offset;
|
||||
} while (responseData.offset !== undefined);
|
||||
|
||||
return {
|
||||
records: returnData,
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadRecordAttachments(
|
||||
this: IExecuteFunctions | IPollFunctions,
|
||||
records: IRecord[],
|
||||
fieldNames: string | string[],
|
||||
pairedItem?: IPairedItemData[],
|
||||
): Promise<INodeExecutionData[]> {
|
||||
if (typeof fieldNames === 'string') {
|
||||
fieldNames = fieldNames.split(',').map((item) => item.trim());
|
||||
}
|
||||
if (!fieldNames.length) {
|
||||
throw new ApplicationError("Specify field to download in 'Download Attachments' option", {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
const elements: INodeExecutionData[] = [];
|
||||
for (const record of records) {
|
||||
const element: INodeExecutionData = { json: {}, binary: {} };
|
||||
if (pairedItem) {
|
||||
element.pairedItem = pairedItem;
|
||||
}
|
||||
element.json = flattenOutput(record as unknown as IDataObject);
|
||||
for (const fieldName of fieldNames) {
|
||||
if (record.fields[fieldName] !== undefined) {
|
||||
for (const [index, attachment] of (record.fields[fieldName] as IAttachment[]).entries()) {
|
||||
const file = await apiRequest.call(this, 'GET', '', {}, {}, attachment.url, {
|
||||
json: false,
|
||||
encoding: null,
|
||||
});
|
||||
element.binary![`${fieldName}_${index}`] = await this.helpers.prepareBinaryData(
|
||||
Buffer.from(file as string),
|
||||
attachment.filename,
|
||||
attachment.type,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(element.binary as IBinaryKeyData).length === 0) {
|
||||
delete element.binary;
|
||||
}
|
||||
elements.push(element);
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
export async function batchUpdate(
|
||||
this: IExecuteFunctions | IPollFunctions,
|
||||
endpoint: string,
|
||||
body: IDataObject,
|
||||
updateRecords: IDataObject[],
|
||||
) {
|
||||
if (!updateRecords.length) {
|
||||
return { records: [] };
|
||||
}
|
||||
|
||||
let responseData: IDataObject;
|
||||
|
||||
if (updateRecords.length && updateRecords.length <= 10) {
|
||||
const updateBody = {
|
||||
...body,
|
||||
records: updateRecords,
|
||||
};
|
||||
|
||||
responseData = await apiRequest.call(this, 'PATCH', endpoint, updateBody);
|
||||
return responseData;
|
||||
}
|
||||
|
||||
const batchSize = 10;
|
||||
const batches = Math.ceil(updateRecords.length / batchSize);
|
||||
const updatedRecords: IDataObject[] = [];
|
||||
|
||||
for (let j = 0; j < batches; j++) {
|
||||
const batch = updateRecords.slice(j * batchSize, (j + 1) * batchSize);
|
||||
|
||||
const updateBody = {
|
||||
...body,
|
||||
records: batch,
|
||||
};
|
||||
|
||||
const updateResponse = await apiRequest.call(this, 'PATCH', endpoint, updateBody);
|
||||
updatedRecords.push(...((updateResponse.records as IDataObject[]) || []));
|
||||
}
|
||||
|
||||
responseData = { records: updatedRecords };
|
||||
|
||||
return responseData;
|
||||
}
|
||||
Reference in New Issue
Block a user