From b69d20c12ec1ad0395e23747ce5f1d437de0231b Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Mon, 17 Jul 2023 19:42:30 +0300 Subject: [PATCH] feat(Airtable Node): Overhaul (#6200) --- cypress/e2e/17-sharing.cy.ts | 4 +- cypress/e2e/5-ndv.cy.ts | 10 +- .../ResourceMapper/MappingFields.vue | 16 +- .../ResourceMapper/MatchingColumnsSelect.vue | 2 +- .../ResourceMapper/ResourceMapper.vue | 6 +- .../AirtableTokenApi.credentials.ts | 10 + .../nodes/Airtable/Airtable.node.ts | 879 +----------------- .../nodes/Airtable/AirtableTrigger.node.ts | 17 +- .../test/v2/node/base/getMany.test.ts | 116 +++ .../test/v2/node/base/getSchema.test.ts | 48 + .../nodes/Airtable/test/v2/node/helpers.ts | 35 + .../test/v2/node/record/create.test.ts | 171 ++++ .../test/v2/node/record/deleteRecord.test.ts | 54 ++ .../Airtable/test/v2/node/record/get.test.ts | 76 ++ .../test/v2/node/record/search.test.ts | 160 ++++ .../test/v2/node/record/update.test.ts | 120 +++ .../nodes/Airtable/test/v2/utils.test.ts | 125 +++ .../nodes/Airtable/v1/AirtableV1.node.ts | 872 +++++++++++++++++ .../Airtable/{ => v1}/GenericFunctions.ts | 0 .../nodes/Airtable/v2/AirtableV2.node.ts | 32 + .../Airtable/v2/actions/base/Base.resource.ts | 37 + .../v2/actions/base/getMany.operation.ts | 115 +++ .../v2/actions/base/getSchema.operation.ts | 57 ++ .../v2/actions/common.descriptions.ts | 207 +++++ .../nodes/Airtable/v2/actions/node.type.ts | 9 + .../v2/actions/record/Record.resource.ts | 87 ++ .../v2/actions/record/create.operation.ts | 97 ++ .../actions/record/deleteRecord.operation.ts | 67 ++ .../v2/actions/record/get.operation.ts | 103 ++ .../v2/actions/record/search.operation.ts | 220 +++++ .../v2/actions/record/update.operation.ts | 150 +++ .../v2/actions/record/upsert.operation.ts | 158 ++++ .../nodes/Airtable/v2/actions/router.ts | 59 ++ .../Airtable/v2/actions/versionDescription.ts | 81 ++ .../nodes/Airtable/v2/helpers/interfaces.ts | 25 + .../nodes/Airtable/v2/helpers/utils.ts | 83 ++ .../nodes/Airtable/v2/methods/index.ts | 3 + .../nodes/Airtable/v2/methods/listSearch.ts | 149 +++ .../nodes/Airtable/v2/methods/loadOptions.ts | 99 ++ .../Airtable/v2/methods/resourceMapping.ts | 136 +++ .../nodes/Airtable/v2/transport/index.ts | 164 ++++ packages/workflow/src/Interfaces.ts | 1 + 42 files changed, 3989 insertions(+), 871 deletions(-) create mode 100644 packages/nodes-base/nodes/Airtable/test/v2/node/base/getMany.test.ts create mode 100644 packages/nodes-base/nodes/Airtable/test/v2/node/base/getSchema.test.ts create mode 100644 packages/nodes-base/nodes/Airtable/test/v2/node/helpers.ts create mode 100644 packages/nodes-base/nodes/Airtable/test/v2/node/record/create.test.ts create mode 100644 packages/nodes-base/nodes/Airtable/test/v2/node/record/deleteRecord.test.ts create mode 100644 packages/nodes-base/nodes/Airtable/test/v2/node/record/get.test.ts create mode 100644 packages/nodes-base/nodes/Airtable/test/v2/node/record/search.test.ts create mode 100644 packages/nodes-base/nodes/Airtable/test/v2/node/record/update.test.ts create mode 100644 packages/nodes-base/nodes/Airtable/test/v2/utils.test.ts create mode 100644 packages/nodes-base/nodes/Airtable/v1/AirtableV1.node.ts rename packages/nodes-base/nodes/Airtable/{ => v1}/GenericFunctions.ts (100%) create mode 100644 packages/nodes-base/nodes/Airtable/v2/AirtableV2.node.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/actions/base/Base.resource.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/actions/base/getMany.operation.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/actions/base/getSchema.operation.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/actions/common.descriptions.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/actions/node.type.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/actions/record/Record.resource.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/actions/record/create.operation.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/actions/record/deleteRecord.operation.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/actions/record/get.operation.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/actions/record/search.operation.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/actions/record/update.operation.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/actions/record/upsert.operation.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/actions/router.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/actions/versionDescription.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/helpers/interfaces.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/helpers/utils.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/methods/index.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/methods/listSearch.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/methods/loadOptions.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/methods/resourceMapping.ts create mode 100644 packages/nodes-base/nodes/Airtable/v2/transport/index.ts diff --git a/cypress/e2e/17-sharing.cy.ts b/cypress/e2e/17-sharing.cy.ts index 9af0bb5fe2..cf0f4ccd35 100644 --- a/cypress/e2e/17-sharing.cy.ts +++ b/cypress/e2e/17-sharing.cy.ts @@ -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); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 0b6ded4635..6e3f38ca5b 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -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); }); diff --git a/packages/editor-ui/src/components/ResourceMapper/MappingFields.vue b/packages/editor-ui/src/components/ResourceMapper/MappingFields.vue index 2d0e0517c9..56786442bb 100644 --- a/packages/editor-ui/src/components/ResourceMapper/MappingFields.vue +++ b/packages/editor-ui/src/components/ResourceMapper/MappingFields.vue @@ -51,7 +51,16 @@ const emit = defineEmits<{ const ndvStore = useNDVStore(); -const fieldsUi = computed(() => { +function markAsReadOnly(field: ResourceMapperField): boolean { + if ( + isMatchingField(field.id, props.paramValue.matchingColumns, props.showMatchingColumnsSelector) + ) { + return false; + } + return field.readOnly || false; +} + +const fieldsUi = computed & { readOnly?: boolean }>>(() => { return props.fieldsToMap .filter((field) => field.display !== false && field.removed !== true) .map((field) => { @@ -64,11 +73,12 @@ const fieldsUi = computed(() => { required: field.required, description: getFieldDescription(field), options: field.options, + readOnly: markAsReadOnly(field), }; }); }); -const orderedFields = computed(() => { +const orderedFields = computed & { 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" diff --git a/packages/editor-ui/src/components/ResourceMapper/MatchingColumnsSelect.vue b/packages/editor-ui/src/components/ResourceMapper/MatchingColumnsSelect.vue index 1356cbbd66..40dec46807 100644 --- a/packages/editor-ui/src/components/ResourceMapper/MatchingColumnsSelect.vue +++ b/packages/editor-ui/src/components/ResourceMapper/MatchingColumnsSelect.vue @@ -48,7 +48,7 @@ const emit = defineEmits<{ const availableMatchingFields = computed(() => { return props.fieldsToMap.filter((field) => { - return field.canBeUsedToMatch !== false && field.display !== false; + return (field.canBeUsedToMatch || field.defaultMatch) && field.display !== false; }); }); diff --git a/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.vue b/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.vue index dac617090f..4954236057 100644 --- a/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.vue +++ b/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.vue @@ -167,7 +167,9 @@ const hasAvailableMatchingColumns = computed(() => { 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(() => { 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; diff --git a/packages/nodes-base/credentials/AirtableTokenApi.credentials.ts b/packages/nodes-base/credentials/AirtableTokenApi.credentials.ts index b63a80d236..31030e41a1 100644 --- a/packages/nodes-base/credentials/AirtableTokenApi.credentials.ts +++ b/packages/nodes-base/credentials/AirtableTokenApi.credentials.ts @@ -20,6 +20,16 @@ export class AirtableTokenApi implements ICredentialType { typeOptions: { password: true }, default: '', }, + { + displayName: `Make sure you enabled the following scopes for your token:
+ data.records:read
+ data.records:write
+ schema.bases:read
+ `, + name: 'notice', + type: 'notice', + default: '', + }, ]; authenticate: IAuthenticateGeneric = { diff --git a/packages/nodes-base/nodes/Airtable/Airtable.node.ts b/packages/nodes-base/nodes/Airtable/Airtable.node.ts index 096b8bfe98..5c3d15d0d9 100644 --- a/packages/nodes-base/nodes/Airtable/Airtable.node.ts +++ b/packages/nodes-base/nodes/Airtable/Airtable.node.ts @@ -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 { - 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); } } diff --git a/packages/nodes-base/nodes/Airtable/AirtableTrigger.node.ts b/packages/nodes-base/nodes/Airtable/AirtableTrigger.node.ts index ce3b868c4f..a60ae12bdb 100644 --- a/packages/nodes-base/nodes/Airtable/AirtableTrigger.node.ts +++ b/packages/nodes-base/nodes/Airtable/AirtableTrigger.node.ts @@ -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', }, diff --git a/packages/nodes-base/nodes/Airtable/test/v2/node/base/getMany.test.ts b/packages/nodes-base/nodes/Airtable/test/v2/node/base/getMany.test.ts new file mode 100644 index 0000000000..6a1e6daf7b --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/test/v2/node/base/getMany.test.ts @@ -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, + }, + }, + ]); + }); +}); diff --git a/packages/nodes-base/nodes/Airtable/test/v2/node/base/getSchema.test.ts b/packages/nodes-base/nodes/Airtable/test/v2/node/base/getSchema.test.ts new file mode 100644 index 0000000000..df3b8ed930 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/test/v2/node/base/getSchema.test.ts @@ -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'); + }); +}); diff --git a/packages/nodes-base/nodes/Airtable/test/v2/node/helpers.ts b/packages/nodes-base/nodes/Airtable/test/v2/node/helpers.ts new file mode 100644 index 0000000000..d9f6a21806 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/test/v2/node/helpers.ts @@ -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; +}; diff --git a/packages/nodes-base/nodes/Airtable/test/v2/node/record/create.test.ts b/packages/nodes-base/nodes/Airtable/test/v2/node/record/create.test.ts new file mode 100644 index 0000000000..1b17b25ea7 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/test/v2/node/record/create.test.ts @@ -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, + }); + }); +}); diff --git a/packages/nodes-base/nodes/Airtable/test/v2/node/record/deleteRecord.test.ts b/packages/nodes-base/nodes/Airtable/test/v2/node/record/deleteRecord.test.ts new file mode 100644 index 0000000000..2ea8b11d75 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/test/v2/node/record/deleteRecord.test.ts @@ -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'); + }); +}); diff --git a/packages/nodes-base/nodes/Airtable/test/v2/node/record/get.test.ts b/packages/nodes-base/nodes/Airtable/test/v2/node/record/get.test.ts new file mode 100644 index 0000000000..3d8c709abd --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/test/v2/node/record/get.test.ts @@ -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, + }, + }, + ]); + }); +}); diff --git a/packages/nodes-base/nodes/Airtable/test/v2/node/record/search.test.ts b/packages/nodes-base/nodes/Airtable/test/v2/node/record/search.test.ts new file mode 100644 index 0000000000..8105323641 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/test/v2/node/record/search.test.ts @@ -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, + }, + }); + }); +}); diff --git a/packages/nodes-base/nodes/Airtable/test/v2/node/record/update.test.ts b/packages/nodes-base/nodes/Airtable/test/v2/node/record/update.test.ts new file mode 100644 index 0000000000..afe736c914 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/test/v2/node/record/update.test.ts @@ -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' }], + ); + }); +}); diff --git a/packages/nodes-base/nodes/Airtable/test/v2/utils.test.ts b/packages/nodes-base/nodes/Airtable/test/v2/utils.test.ts new file mode 100644 index 0000000000..e8028cd7c2 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/test/v2/utils.test.ts @@ -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', + }, + }, + ]); + }); +}); diff --git a/packages/nodes-base/nodes/Airtable/v1/AirtableV1.node.ts b/packages/nodes-base/nodes/Airtable/v1/AirtableV1.node.ts new file mode 100644 index 0000000000..3ed973c4fe --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v1/AirtableV1.node.ts @@ -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 { + 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); + } +} diff --git a/packages/nodes-base/nodes/Airtable/GenericFunctions.ts b/packages/nodes-base/nodes/Airtable/v1/GenericFunctions.ts similarity index 100% rename from packages/nodes-base/nodes/Airtable/GenericFunctions.ts rename to packages/nodes-base/nodes/Airtable/v1/GenericFunctions.ts diff --git a/packages/nodes-base/nodes/Airtable/v2/AirtableV2.node.ts b/packages/nodes-base/nodes/Airtable/v2/AirtableV2.node.ts new file mode 100644 index 0000000000..336ce44a2d --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/AirtableV2.node.ts @@ -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); + } +} diff --git a/packages/nodes-base/nodes/Airtable/v2/actions/base/Base.resource.ts b/packages/nodes-base/nodes/Airtable/v2/actions/base/Base.resource.ts new file mode 100644 index 0000000000..11de16a87e --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/actions/base/Base.resource.ts @@ -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, +]; diff --git a/packages/nodes-base/nodes/Airtable/v2/actions/base/getMany.operation.ts b/packages/nodes-base/nodes/Airtable/v2/actions/base/getMany.operation.ts new file mode 100644 index 0000000000..d29dd10960 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/actions/base/getMany.operation.ts @@ -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 { + 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; +} diff --git a/packages/nodes-base/nodes/Airtable/v2/actions/base/getSchema.operation.ts b/packages/nodes-base/nodes/Airtable/v2/actions/base/getSchema.operation.ts new file mode 100644 index 0000000000..d4ec44f614 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/actions/base/getSchema.operation.ts @@ -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 { + 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; +} diff --git a/packages/nodes-base/nodes/Airtable/v2/actions/common.descriptions.ts b/packages/nodes-base/nodes/Airtable/v2/actions/common.descriptions.ts new file mode 100644 index 0000000000..22654c8635 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/actions/common.descriptions.ts @@ -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'], + }, + }, + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Airtable/v2/actions/node.type.ts b/packages/nodes-base/nodes/Airtable/v2/actions/node.type.ts new file mode 100644 index 0000000000..b74b71a64c --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/actions/node.type.ts @@ -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; diff --git a/packages/nodes-base/nodes/Airtable/v2/actions/record/Record.resource.ts b/packages/nodes-base/nodes/Airtable/v2/actions/record/Record.resource.ts new file mode 100644 index 0000000000..3ac6c7e4c7 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/actions/record/Record.resource.ts @@ -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, +]; diff --git a/packages/nodes-base/nodes/Airtable/v2/actions/record/create.operation.ts b/packages/nodes-base/nodes/Airtable/v2/actions/record/create.operation.ts new file mode 100644 index 0000000000..801e5bbfec --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/actions/record/create.operation.ts @@ -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 { + 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; +} diff --git a/packages/nodes-base/nodes/Airtable/v2/actions/record/deleteRecord.operation.ts b/packages/nodes-base/nodes/Airtable/v2/actions/record/deleteRecord.operation.ts new file mode 100644 index 0000000000..af934a8bf0 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/actions/record/deleteRecord.operation.ts @@ -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. More info.', + }, +]; + +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 { + 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; +} diff --git a/packages/nodes-base/nodes/Airtable/v2/actions/record/get.operation.ts b/packages/nodes-base/nodes/Airtable/v2/actions/record/get.operation.ts new file mode 100644 index 0000000000..88767da22f --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/actions/record/get.operation.ts @@ -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. More info.', + }, + { + 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 { + 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; +} diff --git a/packages/nodes-base/nodes/Airtable/v2/actions/record/search.operation.ts b/packages/nodes-base/nodes/Airtable/v2/actions/record/search.operation.ts new file mode 100644 index 0000000000..a46aedf182 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/actions/record/search.operation.ts @@ -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. More info.', + }, + { + 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 expression.', + }, + { + 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 { + 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; +} diff --git a/packages/nodes-base/nodes/Airtable/v2/actions/record/update.operation.ts b/packages/nodes-base/nodes/Airtable/v2/actions/record/update.operation.ts new file mode 100644 index 0000000000..7c3f431cfe --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/actions/record/update.operation.ts @@ -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 { + 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; +} diff --git a/packages/nodes-base/nodes/Airtable/v2/actions/record/upsert.operation.ts b/packages/nodes-base/nodes/Airtable/v2/actions/record/upsert.operation.ts new file mode 100644 index 0000000000..b91cfa0337 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/actions/record/upsert.operation.ts @@ -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 { + 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; +} diff --git a/packages/nodes-base/nodes/Airtable/v2/actions/router.ts b/packages/nodes-base/nodes/Airtable/v2/actions/router.ts new file mode 100644 index 0000000000..64262e0861 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/actions/router.ts @@ -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 { + let returnData: INodeExecutionData[] = []; + + const items = this.getInputData(); + const resource = this.getNodeParameter('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); +} diff --git a/packages/nodes-base/nodes/Airtable/v2/actions/versionDescription.ts b/packages/nodes-base/nodes/Airtable/v2/actions/versionDescription.ts new file mode 100644 index 0000000000..3665469456 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/actions/versionDescription.ts @@ -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, + ], +}; diff --git a/packages/nodes-base/nodes/Airtable/v2/helpers/interfaces.ts b/packages/nodes-base/nodes/Airtable/v2/helpers/interfaces.ts new file mode 100644 index 0000000000..882f8f9212 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/helpers/interfaces.ts @@ -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; +}; diff --git a/packages/nodes-base/nodes/Airtable/v2/helpers/utils.ts b/packages/nodes-base/nodes/Airtable/v2/helpers/utils.ts new file mode 100644 index 0000000000..fb43c3df4a --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/helpers/utils.ts @@ -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), + }; +}; diff --git a/packages/nodes-base/nodes/Airtable/v2/methods/index.ts b/packages/nodes-base/nodes/Airtable/v2/methods/index.ts new file mode 100644 index 0000000000..9c4a59c911 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/methods/index.ts @@ -0,0 +1,3 @@ +export * as listSearch from './listSearch'; +export * as loadOptions from './loadOptions'; +export * as resourceMapping from './resourceMapping'; diff --git a/packages/nodes-base/nodes/Airtable/v2/methods/listSearch.ts b/packages/nodes-base/nodes/Airtable/v2/methods/listSearch.ts new file mode 100644 index 0000000000..3b0f0a60d8 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/methods/listSearch.ts @@ -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 { + 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 { + 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 { + 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}`, + })), + }; + } +} diff --git a/packages/nodes-base/nodes/Airtable/v2/methods/loadOptions.ts b/packages/nodes-base/nodes/Airtable/v2/methods/loadOptions.ts new file mode 100644 index 0000000000..7f2f00f33f --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/methods/loadOptions.ts @@ -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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/packages/nodes-base/nodes/Airtable/v2/methods/resourceMapping.ts b/packages/nodes-base/nodes/Airtable/v2/methods/resourceMapping.ts new file mode 100644 index 0000000000..abe6b90303 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/methods/resourceMapping.ts @@ -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>; + +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 { + 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 { + const returnData = await getColumns.call(this); + return { + fields: [ + { + id: 'id', + displayName: 'id', + required: false, + defaultMatch: true, + display: true, + type: 'string', + readOnly: true, + }, + ...returnData.fields, + ], + }; +} diff --git a/packages/nodes-base/nodes/Airtable/v2/transport/index.ts b/packages/nodes-base/nodes/Airtable/v2/transport/index.ts new file mode 100644 index 0000000000..63ea8757f8 --- /dev/null +++ b/packages/nodes-base/nodes/Airtable/v2/transport/index.ts @@ -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 { + 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; +} diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index dbdbfae89a..83e5046635 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2011,6 +2011,7 @@ export interface ResourceMapperField { type?: FieldType; removed?: boolean; options?: INodePropertyOptions[]; + readOnly?: boolean; } export type FieldType =