feat(Airtable Node): Overhaul (#6200)

This commit is contained in:
Michael Kret 2023-07-17 19:42:30 +03:00 committed by GitHub
parent fc8ed55c0d
commit b69d20c12e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 3989 additions and 871 deletions

View file

@ -69,9 +69,9 @@ describe('Sharing', { disableAutoLogin: true }, () => {
cy.visit(credentialsPage.url);
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.getters.newCredentialTypeOption('Airtable API').click();
credentialsModal.getters.newCredentialTypeOption('Airtable Personal Access Token API').click();
credentialsModal.getters.newCredentialTypeButton().click();
credentialsModal.getters.connectionParameter('API Key').type('1234567890');
credentialsModal.getters.connectionParameter('Access Token').type('1234567890');
credentialsModal.actions.setName('Credential C2');
credentialsModal.actions.changeTab('Sharing');
credentialsModal.actions.addUser(INSTANCE_OWNER.email);

View file

@ -64,15 +64,15 @@ describe('NDV', () => {
it('should show validation errors only after blur or re-opening of NDV', () => {
workflowPage.actions.addNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('Airtable', true, true, 'Read data from a table');
workflowPage.actions.addNodeToCanvas('Airtable', true, true, 'Search records');
ndv.getters.container().should('be.visible');
cy.get('.has-issues').should('have.length', 0);
// cy.get('.has-issues').should('have.length', 0);
ndv.getters.parameterInput('table').find('input').eq(1).focus().blur();
ndv.getters.parameterInput('application').find('input').eq(1).focus().blur();
cy.get('.has-issues').should('have.length', 2);
ndv.getters.parameterInput('base').find('input').eq(1).focus().blur();
cy.get('.has-issues').should('have.length', 0);
ndv.getters.backToCanvas().click();
workflowPage.actions.openNode('Airtable');
cy.get('.has-issues').should('have.length', 3);
cy.get('.has-issues').should('have.length', 2);
cy.get('[class*=hasIssues]').should('have.length', 1);
});

View file

@ -51,7 +51,16 @@ const emit = defineEmits<{
const ndvStore = useNDVStore();
const fieldsUi = computed<INodeProperties[]>(() => {
function markAsReadOnly(field: ResourceMapperField): boolean {
if (
isMatchingField(field.id, props.paramValue.matchingColumns, props.showMatchingColumnsSelector)
) {
return false;
}
return field.readOnly || false;
}
const fieldsUi = computed<Array<Partial<INodeProperties> & { readOnly?: boolean }>>(() => {
return props.fieldsToMap
.filter((field) => field.display !== false && field.removed !== true)
.map((field) => {
@ -64,11 +73,12 @@ const fieldsUi = computed<INodeProperties[]>(() => {
required: field.required,
description: getFieldDescription(field),
options: field.options,
readOnly: markAsReadOnly(field),
};
});
});
const orderedFields = computed<INodeProperties[]>(() => {
const orderedFields = computed<Array<Partial<INodeProperties> & { readOnly?: boolean }>>(() => {
// Sort so that matching columns are first
if (props.paramValue.matchingColumns) {
fieldsUi.value.forEach((field, i) => {
@ -333,7 +343,7 @@ defineExpose({
:value="getParameterValue(field.name)"
:displayOptions="true"
:path="`${props.path}.${field.name}`"
:isReadOnly="refreshInProgress"
:isReadOnly="refreshInProgress || field.readOnly"
:hideIssues="true"
:nodeValues="nodeValues"
:class="$style.parameterInputFull"

View file

@ -48,7 +48,7 @@ const emit = defineEmits<{
const availableMatchingFields = computed<ResourceMapperField[]>(() => {
return props.fieldsToMap.filter((field) => {
return field.canBeUsedToMatch !== false && field.display !== false;
return (field.canBeUsedToMatch || field.defaultMatch) && field.display !== false;
});
});

View file

@ -167,7 +167,9 @@ const hasAvailableMatchingColumns = computed<boolean>(() => {
return (
state.paramValue.schema.filter(
(field) =>
field.canBeUsedToMatch !== false && field.display !== false && field.removed !== true,
(field.canBeUsedToMatch || field.defaultMatch) &&
field.display !== false &&
field.removed !== true,
).length > 0
);
}
@ -178,7 +180,7 @@ const defaultSelectedMatchingColumns = computed<string[]>(() => {
return state.paramValue.schema.length === 1
? [state.paramValue.schema[0].id]
: state.paramValue.schema.reduce((acc, field) => {
if (field.defaultMatch && field.canBeUsedToMatch === true) {
if (field.defaultMatch) {
acc.push(field.id);
}
return acc;

View file

@ -20,6 +20,16 @@ export class AirtableTokenApi implements ICredentialType {
typeOptions: { password: true },
default: '',
},
{
displayName: `Make sure you enabled the following scopes for your token:<br>
<code>data.records:read</code><br>
<code>data.records:write</code><br>
<code>schema.bases:read</code><br>
`,
name: 'notice',
type: 'notice',
default: '',
},
];
authenticate: IAuthenticateGeneric = {

View file

@ -1,858 +1,25 @@
import type {
IExecuteFunctions,
IDataObject,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import type { IRecord } from './GenericFunctions';
import { apiRequest, apiRequestAllItems, downloadRecordAttachments } from './GenericFunctions';
export class Airtable implements INodeType {
description: 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: ['main'],
outputs: ['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: 'API Key',
value: 'airtableApi',
},
{
name: 'Access Token',
value: 'airtableTokenApi',
},
{
name: 'OAuth2',
value: 'airtableOAuth2Api',
},
],
default: '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',
},
],
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
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 = '';
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 data = await downloadRecordAttachments.call(
this,
responseData.records as IRecord[],
downloadFieldNames,
);
return [data];
}
// We can return from here
return [
this.helpers.constructExecutionMetaData(this.helpers.returnJsonArray(returnData), {
itemData: { item: 0 },
}),
];
} 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 this.prepareOutputData(returnData);
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,
};
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new AirtableV1(baseDescription),
2: new AirtableV2(baseDescription),
};
super(nodeVersions, baseDescription);
}
}

View file

@ -7,8 +7,8 @@ import type {
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import type { IRecord } from './GenericFunctions';
import { apiRequestAllItems, downloadRecordAttachments } from './GenericFunctions';
import type { IRecord } from './v1/GenericFunctions';
import { apiRequestAllItems, downloadRecordAttachments } from './v1/GenericFunctions';
import moment from 'moment';
@ -43,6 +43,15 @@ export class AirtableTrigger implements INodeType {
},
},
},
{
name: 'airtableOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: ['airtableOAuth2Api'],
},
},
},
],
polling: true,
inputs: [],
@ -61,6 +70,10 @@ export class AirtableTrigger implements INodeType {
name: 'Access Token',
value: 'airtableTokenApi',
},
{
name: 'OAuth2',
value: 'airtableOAuth2Api',
},
],
default: 'airtableApi',
},

View file

@ -0,0 +1,116 @@
import nock from 'nock';
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', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
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,
},
},
]);
});
});

View file

@ -0,0 +1,48 @@
import nock from 'nock';
import * as getSchema from '../../../../v2/actions/base/getSchema.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, base => getSchema', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
it('should return all bases', async () => {
const nodeParameters = {
resource: 'base',
operation: 'getSchema',
base: {
value: 'appYobase',
},
};
const items = [
{
json: {},
},
];
await getSchema.execute.call(createMockExecuteFunction(nodeParameters), items);
expect(transport.apiRequest).toBeCalledTimes(1);
expect(transport.apiRequest).toHaveBeenCalledWith('GET', 'meta/bases/appYobase/tables');
});
});

View file

@ -0,0 +1,35 @@
import type { IDataObject, IExecuteFunctions, IGetNodeParameterOptions, INode } from 'n8n-workflow';
import { get } from 'lodash';
import { constructExecutionMetaData } from 'n8n-core';
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 = {
getNodeParameter(
parameterName: string,
_itemIndex: number,
fallbackValue?: IDataObject | undefined,
options?: IGetNodeParameterOptions | undefined,
) {
const parameter = options?.extractValue ? `${parameterName}.value` : parameterName;
return get(nodeParameters, parameter, fallbackValue);
},
getNode() {
return node;
},
helpers: { constructExecutionMetaData },
continueOnFail: () => false,
} as unknown as IExecuteFunctions;
return fakeExecuteFunction;
};

View file

@ -0,0 +1,171 @@
import nock from 'nock';
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', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
afterEach(() => {
jest.restoreAllMocks();
});
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,
});
});
});

View file

@ -0,0 +1,54 @@
import nock from 'nock';
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', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
afterEach(() => {
jest.restoreAllMocks();
});
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');
});
});

View file

@ -0,0 +1,76 @@
import nock from 'nock';
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', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
afterEach(() => {
jest.restoreAllMocks();
});
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,
},
},
]);
});
});

View file

@ -0,0 +1,160 @@
import nock from 'nock';
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', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
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,
},
});
});
});

View file

@ -0,0 +1,120 @@
import nock from 'nock';
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', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
});
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' }],
);
});
});

View file

@ -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',
},
},
]);
});
});

View file

@ -0,0 +1,872 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import type {
IExecuteFunctions,
IDataObject,
INodeExecutionData,
INodeType,
INodeTypeDescription,
INodeTypeBaseDescription,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import type { IRecord } from './GenericFunctions';
import { apiRequest, apiRequestAllItems, downloadRecordAttachments } from './GenericFunctions';
import { oldVersionNotice } from '../../../utils/descriptions';
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: ['main'],
outputs: ['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: 'API Key',
value: 'airtableApi',
},
{
name: 'Access Token',
value: 'airtableTokenApi',
},
{
name: 'OAuth2',
value: 'airtableOAuth2Api',
},
],
default: 'airtableApi',
},
oldVersionNotice,
{
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 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 = '';
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 data = await downloadRecordAttachments.call(
this,
responseData.records as IRecord[],
downloadFieldNames,
);
return [data];
}
// We can return from here
return [
this.helpers.constructExecutionMetaData(this.helpers.returnJsonArray(returnData), {
itemData: { item: 0 },
}),
];
} 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 this.prepareOutputData(returnData);
}
}

View file

@ -0,0 +1,32 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import type {
IExecuteFunctions,
INodeType,
INodeTypeDescription,
INodeTypeBaseDescription,
} from 'n8n-workflow';
import { versionDescription } from './actions/versionDescription';
import { router } from './actions/router';
import { listSearch, loadOptions, resourceMapping } from './methods';
export class AirtableV2 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...versionDescription,
};
}
methods = {
listSearch,
loadOptions,
resourceMapping,
};
async execute(this: IExecuteFunctions) {
return router.call(this);
}
}

View file

@ -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,
];

View file

@ -0,0 +1,115 @@
import type {
IDataObject,
INodeExecutionData,
INodeProperties,
IExecuteFunctions,
} from 'n8n-workflow';
import { 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 returnData = this.helpers.constructExecutionMetaData(wrapData(bases), {
itemData: { item: 0 },
});
return returnData;
}

View file

@ -0,0 +1,57 @@
import type {
IDataObject,
INodeExecutionData,
INodeProperties,
IExecuteFunctions,
} from 'n8n-workflow';
import { updateDisplayOptions, wrapData } from '../../../../../utils/utilities';
import { apiRequest } from '../../transport';
import { baseRLC } from '../common.descriptions';
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[]> {
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
try {
const baseId = this.getNodeParameter('base', 0, undefined, {
extractValue: true,
}) as string;
const responseData = await apiRequest.call(this, 'GET', `meta/bases/${baseId}/tables`);
const executionData = this.helpers.constructExecutionMetaData(
wrapData(responseData.tables as IDataObject[]),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} catch (error) {
if (this.continueOnFail()) {
returnData.push({ json: { error: error.message } });
continue;
}
throw error;
}
}
return returnData;
}

View file

@ -0,0 +1,207 @@
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,
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'],
},
},
},
],
},
];

View file

@ -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>;

View file

@ -0,0 +1,87 @@
import type { INodeProperties } from 'n8n-workflow';
import { baseRLC, tableRLC } from '../common.descriptions';
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';
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',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-option-name-wrong-for-upsert
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: 'read',
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,
];

View file

@ -0,0 +1,97 @@
import type {
IDataObject,
INodeExecutionData,
INodeProperties,
IExecuteFunctions,
} from 'n8n-workflow';
import { updateDisplayOptions, wrapData } from '../../../../../utils/utilities';
import { apiRequest } from '../../transport';
import { insertUpdateOptions } from '../common.descriptions';
import { removeIgnored } from '../../helpers/utils';
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 body: IDataObject = {
typecast: options.typecast ? true : false,
};
if (dataMode === 'autoMapInputData') {
body.fields = removeIgnored(items[i].json, options.ignoreFields as string);
}
if (dataMode === 'defineBelow') {
const fields = this.getNodeParameter('columns.value', i, []) 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(...executionData);
} catch (error) {
if (this.continueOnFail()) {
returnData.push({ json: { message: error.message, error } });
continue;
}
throw error;
}
}
return returnData;
}

View file

@ -0,0 +1,67 @@
import type {
IDataObject,
INodeExecutionData,
INodeProperties,
NodeApiError,
IExecuteFunctions,
} from 'n8n-workflow';
import { updateDisplayOptions, wrapData } from '../../../../../utils/utilities';
import { apiRequest } from '../../transport';
import { processAirtableError } from '../../helpers/utils';
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);
if (this.continueOnFail()) {
returnData.push({ json: { error: error.message } });
continue;
}
throw error;
}
}
return returnData;
}

View file

@ -0,0 +1,103 @@
import type {
IDataObject,
INodeExecutionData,
INodeProperties,
NodeApiError,
IExecuteFunctions,
} from 'n8n-workflow';
import { updateDisplayOptions, wrapData } from '../../../../../utils/utilities';
import { apiRequest, downloadRecordAttachments } from '../../transport';
import { flattenOutput, processAirtableError } from '../../helpers/utils';
import type { IRecord } from '../../helpers/interfaces';
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);
if (this.continueOnFail()) {
returnData.push({ json: { error: error.message } });
continue;
}
throw error;
}
}
return returnData;
}

View file

@ -0,0 +1,220 @@
import type {
IDataObject,
INodeExecutionData,
INodeProperties,
IExecuteFunctions,
} from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import { apiRequest, apiRequestAllItems, downloadRecordAttachments } from '../../transport';
import type { IRecord } from '../../helpers/interfaces';
import { flattenOutput } from '../../helpers/utils';
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-examples/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[]> {
let returnData: INodeExecutionData[] = [];
const body: IDataObject = {};
const qs: IDataObject = {};
const endpoint = `${base}/${table}`;
try {
const returnAll = this.getNodeParameter('returnAll', 0);
const options = this.getNodeParameter('options', 0, {});
const sort = this.getNodeParameter('sort', 0, {}) as IDataObject;
const filterByFormula = this.getNodeParameter('filterByFormula', 0) as string;
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', 0);
responseData = await apiRequest.call(this, 'GET', endpoint, body, qs);
}
returnData = responseData.records as INodeExecutionData[];
if (options.downloadFields) {
return await downloadRecordAttachments.call(
this,
responseData.records as IRecord[],
options.downloadFields as string[],
);
}
returnData = returnData.map((record) => ({
json: flattenOutput(record as IDataObject),
}));
returnData = this.helpers.constructExecutionMetaData(returnData, {
itemData: { item: 0 },
});
} catch (error) {
if (this.continueOnFail()) {
returnData.push({ json: { message: error.message, error } });
} else {
throw error;
}
}
return returnData;
}

View file

@ -0,0 +1,150 @@
import type {
IDataObject,
INodeExecutionData,
INodeProperties,
NodeApiError,
IExecuteFunctions,
} from 'n8n-workflow';
import { updateDisplayOptions, wrapData } from '../../../../../utils/utilities';
import { apiRequestAllItems, batchUpdate } from '../../transport';
import { findMatches, processAirtableError, removeIgnored } from '../../helpers/utils';
import type { UpdateRecord } from '../../helpers/interfaces';
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, {});
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') {
if (columnsToMatchOn.includes('id')) {
const { id, ...fields } = this.getNodeParameter('columns.value', i, []) as IDataObject;
records.push({ id: id as string, fields });
} else {
const fields = this.getNodeParameter('columns.value', i, []) 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: options.typecast ? true : false };
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);
if (this.continueOnFail()) {
returnData.push({ json: { message: error.message, error } });
continue;
}
throw error;
}
}
return returnData;
}

View file

@ -0,0 +1,158 @@
import type {
IDataObject,
INodeExecutionData,
INodeProperties,
IExecuteFunctions,
} from 'n8n-workflow';
import { updateDisplayOptions, wrapData } from '../../../../../utils/utilities';
import { apiRequest, apiRequestAllItems, batchUpdate } from '../../transport';
import { removeIgnored } from '../../helpers/utils';
import type { UpdateRecord } from '../../helpers/interfaces';
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) {
if (this.continueOnFail()) {
returnData.push({ json: { message: error.message, error } });
continue;
}
throw error;
}
}
return returnData;
}

View file

@ -0,0 +1,59 @@
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import type { AirtableType } from './node.type';
import * as record from './record/Record.resource';
import * as base from './base/Base.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 this.prepareOutputData(returnData);
}

View file

@ -0,0 +1,81 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import type { INodeTypeDescription } from 'n8n-workflow';
import * as record from './record/Record.resource';
import * as base from './base/Base.resource';
export const versionDescription: INodeTypeDescription = {
displayName: 'Airtable',
name: 'airtable',
icon: 'file:airtable.svg',
group: ['input'],
version: 2,
subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}',
description: 'Read, update, write and delete data from Airtable',
defaults: {
name: 'Airtable',
},
inputs: ['main'],
outputs: ['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,
],
};

View file

@ -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;
};

View file

@ -0,0 +1,83 @@
import type { IDataObject, 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 Error('No records match provided keys');
}
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 Error('Record matching provided keys was not found');
}
return [match];
}
}
export function processAirtableError(error: NodeApiError, id?: string) {
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`;
}
return error;
}
export const flattenOutput = (record: IDataObject) => {
const { fields, ...rest } = record;
return {
...rest,
...(fields as IDataObject),
};
};

View file

@ -0,0 +1,3 @@
export * as listSearch from './listSearch';
export * as loadOptions from './loadOptions';
export * as resourceMapping from './resourceMapping';

View file

@ -0,0 +1,149 @@
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!');
}
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}`,
})),
};
}
}

View file

@ -0,0 +1,99 @@
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!');
}
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!');
}
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;
}

View file

@ -0,0 +1,136 @@
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',
'multipleRecordLinks',
];
const airtableTypesMap: TypesMap = {
string: ['singleLineText', 'multilineText', 'richText', 'email', 'phoneNumber', 'url'],
number: ['rating', 'percent', 'number', 'duration', 'currency'],
boolean: ['checkbox'],
dateTime: ['dateTime', 'date'],
time: [],
object: ['multipleAttachments'],
options: ['singleSelect'],
array: ['multipleSelects'],
};
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!');
}
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,
],
};
}

View file

@ -0,0 +1,164 @@
import type { OptionsWithUri } from 'request';
import type {
IBinaryKeyData,
IDataObject,
IExecuteFunctions,
IPollFunctions,
ILoadOptionsFunctions,
INodeExecutionData,
} from 'n8n-workflow';
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: string,
endpoint: string,
body: IDataObject = {},
query?: IDataObject,
uri?: string,
option: IDataObject = {},
) {
query = query || {};
const options: OptionsWithUri = {
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 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: string,
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[],
): Promise<INodeExecutionData[]> {
if (typeof fieldNames === 'string') {
fieldNames = fieldNames.split(',').map((item) => item.trim());
}
if (!fieldNames.length) {
throw new Error("Specify field to download in 'Download Attachments' option");
}
const elements: INodeExecutionData[] = [];
for (const record of records) {
const element: INodeExecutionData = { json: {}, binary: {} };
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;
}

View file

@ -2011,6 +2011,7 @@ export interface ResourceMapperField {
type?: FieldType;
removed?: boolean;
options?: INodePropertyOptions[];
readOnly?: boolean;
}
export type FieldType =