diff --git a/packages/nodes-base/credentials/CodaApi.credentials.ts b/packages/nodes-base/credentials/CodaApi.credentials.ts new file mode 100644 index 0000000000..226dd6ee68 --- /dev/null +++ b/packages/nodes-base/credentials/CodaApi.credentials.ts @@ -0,0 +1,17 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class CodaApi implements ICredentialType { + name = 'codaApi'; + displayName = 'Coda API'; + properties = [ + { + displayName: 'Access Token', + name: 'accessToken', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Coda/Coda.node.ts b/packages/nodes-base/nodes/Coda/Coda.node.ts new file mode 100644 index 0000000000..5c6e475da6 --- /dev/null +++ b/packages/nodes-base/nodes/Coda/Coda.node.ts @@ -0,0 +1,273 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; +import { + IDataObject, + INodeTypeDescription, + INodeExecutionData, + INodeType, + ILoadOptionsFunctions, + INodePropertyOptions, +} from 'n8n-workflow'; +import { + codaApiRequest, + codaApiRequestAllItems, +} from './GenericFunctions'; +import { + tableFields, + tableOperations, +} from './TableDescription'; + +export class Coda implements INodeType { + description: INodeTypeDescription = { + displayName: 'Coda', + name: 'Coda', + icon: 'file:coda.png', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Coda Beta API', + defaults: { + name: 'Coda', + color: '#c02428', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'codaApi', + required: true, + } + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Table', + value: 'table', + description: `Access data of tables in documents.`, + }, + ], + default: 'table', + description: 'Resource to consume.', + }, + ...tableOperations, + ...tableFields, + ], + }; + + methods = { + loadOptions: { + // Get all the available docs to display them to user so that he can + // select them easily + async getDocs(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const qs = {}; + let docs; + try { + docs = await codaApiRequestAllItems.call(this,'items', 'GET', `/docs`, {}, qs); + } catch (err) { + throw new Error(`Coda Error: ${err}`); + } + for (const doc of docs) { + const docName = doc.name; + const docId = doc.id; + returnData.push({ + name: docName, + value: docId, + }); + } + return returnData; + }, + // Get all the available tables to display them to user so that he can + // select them easily + async getTables(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + let tables; + + const docId = this.getCurrentNodeParameter('docId'); + + try { + tables = await codaApiRequestAllItems.call(this, 'items', 'GET', `/docs/${docId}/tables`, {}); + } catch (err) { + throw new Error(`Coda Error: ${err}`); + } + for (const table of tables) { + const tableName = table.name; + const tableId = table.id; + returnData.push({ + name: tableName, + value: tableId, + }); + } + return returnData; + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const returnData: IDataObject[] = []; + const items = this.getInputData(); + let responseData; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + let qs: IDataObject = {}; + + if (resource === 'table') { + // https://coda.io/developers/apis/v1beta1#operation/upsertRows + if (operation === 'createRow') { + const sendData = {} as IDataObject; + for (let i = 0; i < items.length; i++) { + qs = {}; + const docId = this.getNodeParameter('docId', i) as string; + const tableId = this.getNodeParameter('tableId', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const endpoint = `/docs/${docId}/tables/${tableId}/rows`; + + if (additionalFields.keyColumns) { + // @ts-ignore + items[i].json['keyColumns'] = additionalFields.keyColumns.split(',') as string[]; + } + if (additionalFields.disableParsing) { + qs.disableParsing = additionalFields.disableParsing as boolean; + } + + const cells = []; + cells.length = 0; + for (const key of Object.keys(items[i].json)) { + cells.push({ + column: key, + value: items[i].json[key], + }); + } + + // Collect all the data for the different docs/tables + if (sendData[endpoint] === undefined) { + sendData[endpoint] = { + rows: [], + // TODO: This is not perfect as it ignores if qs changes between + // different items but should be OK for now + qs, + }; + } + ((sendData[endpoint]! as IDataObject).rows! as IDataObject[]).push({ cells }); + } + + // Now that all data got collected make all the requests + for (const endpoint of Object.keys(sendData)) { + await codaApiRequest.call(this, 'POST', endpoint, sendData[endpoint], (sendData[endpoint]! as IDataObject).qs! as IDataObject); + } + + // Return the incoming data + return [items]; + } + // https://coda.io/developers/apis/v1beta1#operation/getRow + if (operation === 'getRow') { + for (let i = 0; i < items.length; i++) { + const docId = this.getNodeParameter('docId', i) as string; + const tableId = this.getNodeParameter('tableId', i) as string; + const rowId = this.getNodeParameter('rowId', i) as string; + const options = this.getNodeParameter('options', i) as IDataObject; + + const endpoint = `/docs/${docId}/tables/${tableId}/rows/${rowId}`; + if (options.useColumnNames === false) { + qs.useColumnNames = options.useColumnNames as boolean; + } else { + qs.useColumnNames = true; + } + if (options.valueFormat) { + qs.valueFormat = options.valueFormat as string; + } + + responseData = await codaApiRequest.call(this, 'GET', endpoint, {}, qs); + if (options.rawData === true) { + returnData.push(responseData); + } else { + returnData.push({ + id: responseData.id, + ...responseData.values + }); + } + } + + return [this.helpers.returnJsonArray(returnData)]; + } + // https://coda.io/developers/apis/v1beta1#operation/listRows + if (operation === 'getAllRows') { + const docId = this.getNodeParameter('docId', 0) as string; + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + const tableId = this.getNodeParameter('tableId', 0) as string; + const options = this.getNodeParameter('options', 0) as IDataObject; + const endpoint = `/docs/${docId}/tables/${tableId}/rows`; + if (options.useColumnNames === false) { + qs.useColumnNames = options.useColumnNames as boolean; + } else { + qs.useColumnNames = true; + } + if (options.valueFormat) { + qs.valueFormat = options.valueFormat as string; + } + if (options.sortBy) { + qs.sortBy = options.sortBy as string; + } + if (options.visibleOnly) { + qs.visibleOnly = options.visibleOnly as boolean; + } + try { + if (returnAll === true) { + responseData = await codaApiRequestAllItems.call(this, 'items', 'GET', endpoint, {}, qs); + } else { + qs.limit = this.getNodeParameter('limit', 0) as number; + responseData = await codaApiRequest.call(this, 'GET', endpoint, {}, qs); + responseData = responseData.items; + } + } catch (err) { + throw new Error(`Flow Error: ${err.message}`); + } + + if (options.rawData === true) { + return [this.helpers.returnJsonArray(responseData)]; + } else { + for (const item of responseData) { + returnData.push({ + id: item.id, + ...item.values + }); + } + return [this.helpers.returnJsonArray(returnData)]; + } + } + // https://coda.io/developers/apis/v1beta1#operation/deleteRows + if (operation === 'deleteRow') { + const sendData = {} as IDataObject; + for (let i = 0; i < items.length; i++) { + const docId = this.getNodeParameter('docId', i) as string; + const tableId = this.getNodeParameter('tableId', i) as string; + const rowId = this.getNodeParameter('rowId', i) as string; + const endpoint = `/docs/${docId}/tables/${tableId}/rows`; + + // Collect all the data for the different docs/tables + if (sendData[endpoint] === undefined) { + sendData[endpoint] = []; + } + + (sendData[endpoint] as string[]).push(rowId); + } + + // Now that all data got collected make all the requests + for (const endpoint of Object.keys(sendData)) { + await codaApiRequest.call(this, 'DELETE', endpoint, { rowIds: sendData[endpoint]}, qs); + } + + // Return the incoming data + return [items]; + } + } + + return []; + } +} diff --git a/packages/nodes-base/nodes/Coda/GenericFunctions.ts b/packages/nodes-base/nodes/Coda/GenericFunctions.ts new file mode 100644 index 0000000000..2d26d593a7 --- /dev/null +++ b/packages/nodes-base/nodes/Coda/GenericFunctions.ts @@ -0,0 +1,64 @@ +import { OptionsWithUri } from 'request'; +import { + IExecuteFunctions, + ILoadOptionsFunctions, + IExecuteSingleFunctions, +} from 'n8n-core'; +import { IDataObject } from 'n8n-workflow'; + +export async function codaApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('codaApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + let options: OptionsWithUri = { + headers: { 'Authorization': `Bearer ${credentials.accessToken}`}, + method, + qs, + body, + uri: uri ||`https://coda.io/apis/v1beta1${resource}`, + json: true + }; + options = Object.assign({}, options, option); + if (Object.keys(options.body).length === 0) { + delete options.body; + } + try { + return await this.helpers.request!(options); + } catch (error) { + let errorMessage = error.message; + if (error.response.body) { + errorMessage = error.response.body.message || error.response.body.Message || error.message; + } + + throw new Error(errorMessage); + } +} + +/** + * Make an API request to paginated coda endpoint + * and return all results + */ +export async function codaApiRequestAllItems(this: IExecuteFunctions | ILoadOptionsFunctions, propertyName: string, method: string, resource: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + + query.limit = 100; + + let uri: string | undefined; + + do { + responseData = await codaApiRequest.call(this, method, resource, body, query, uri); + uri = responseData.nextPageLink; + // @ts-ignore + returnData.push.apply(returnData, responseData[propertyName]); + } while ( + responseData.nextPageLink !== undefined && + responseData.nextPageLink !== '' + ); + + return returnData; +} diff --git a/packages/nodes-base/nodes/Coda/TableDescription.ts b/packages/nodes-base/nodes/Coda/TableDescription.ts new file mode 100644 index 0000000000..f7f6752f50 --- /dev/null +++ b/packages/nodes-base/nodes/Coda/TableDescription.ts @@ -0,0 +1,476 @@ +import { INodeProperties } from 'n8n-workflow'; + +export const tableOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'table', + ], + }, + }, + options: [ + { + name: 'Create Row', + value: 'createRow', + description: 'Create/Upsert a row', + }, + { + name: 'Get Row', + value: 'getRow', + description: 'Get row', + }, + { + name: 'Get All Rows', + value: 'getAllRows', + description: 'Get all the rows', + }, + { + name: 'Delete Row', + value: 'deleteRow', + description: 'Delete one or multiple rows', + }, + ], + default: 'createRow', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const tableFields = [ + +/* -------------------------------------------------------------------------- */ +/* table:createRow */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Doc', + name: 'docId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getDocs', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'table', + ], + operation: [ + 'createRow', + ] + }, + }, + description: 'ID of the doc.', + }, + { + displayName: 'Table', + name: 'tableId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTables', + }, + required: true, + default: [], + displayOptions: { + show: { + resource: [ + 'table', + ], + operation: [ + 'createRow', + ] + }, + }, + description: 'The table to create the row in.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'table', + ], + operation: [ + 'createRow', + ], + }, + }, + options: [ + { + displayName: 'Key Columns', + name: 'keyColumns', + type: 'string', + default: '', + description: `Optional column IDs, URLs, or names (fragile and discouraged), + specifying columns to be used as upsert keys. If more than one separate by ,`, + }, + { + displayName: 'Disable Parsing', + name: 'disableParsing', + type: 'boolean', + default: false, + description: `If true, the API will not attempt to parse the data in any way.`, + }, + ] + }, + +/* -------------------------------------------------------------------------- */ +/* table:get */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Doc', + name: 'docId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getDocs', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'table', + ], + operation: [ + 'getRow', + ] + }, + }, + description: 'ID of the doc.', + }, + { + displayName: 'Table', + name: 'tableId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTables', + }, + required: true, + default: [], + displayOptions: { + show: { + resource: [ + 'table', + ], + operation: [ + 'getRow', + ] + }, + }, + description: 'The table to get the row from.', + }, + { + displayName: 'Row ID', + name: 'rowId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'table', + ], + operation: [ + 'getRow', + ] + }, + }, + description: `ID or name of the row. Names are discouraged because they're easily prone to being changed by users. + If you're using a name, be sure to URI-encode it. + If there are multiple rows with the same value in the identifying column, an arbitrary one will be selected`, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: [ + 'table', + ], + operation: [ + 'getRow', + ], + }, + }, + options: [ + { + displayName: 'Use Column Names', + name: 'useColumnNames', + type: 'boolean', + default: false, + description: `Use column names instead of column IDs in the returned output.
+ This is generally discouraged as it is fragile. If columns are renamed,
+ code using original names may throw errors.`, + }, + { + displayName: 'RAW Data', + name: 'rawData', + type: 'boolean', + default: false, + description: `Returns the data exactly in the way it got received from the API.`, + }, + { + displayName: 'ValueFormat', + name: 'valueFormat', + type: 'options', + default: [], + options: [ + { + name: 'Simple', + value: 'simple', + }, + { + name: 'Simple With Arrays', + value: 'simpleWithArrays', + }, + { + name: 'Rich', + value: 'rich', + }, + ], + description: `The format that cell values are returned as.`, + }, + ] + }, +/* -------------------------------------------------------------------------- */ +/* table:getAll */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Doc', + name: 'docId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getDocs', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'table', + ], + operation: [ + 'getAllRows', + ] + }, + }, + description: 'ID of the doc.', + }, + { + displayName: 'Table', + name: 'tableId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTables', + }, + required: true, + default: [], + displayOptions: { + show: { + resource: [ + 'table', + ], + operation: [ + 'getAllRows', + ] + }, + }, + description: 'The table to get the rows from.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'table', + ], + operation: [ + 'getAllRows', + ] + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: [ + 'table', + ], + operation: [ + 'getAllRows', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 50, + description: 'How many results to return.', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: [ + 'table', + ], + operation: [ + 'getAllRows', + ], + }, + }, + options: [ + { + displayName: 'Use Column Names', + name: 'useColumnNames', + type: 'boolean', + default: false, + description: `Use column names instead of column IDs in the returned output.
+ This is generally discouraged as it is fragile. If columns are renamed,
+ code using original names may throw errors.`, + }, + { + displayName: 'ValueFormat', + name: 'valueFormat', + type: 'options', + default: [], + options: [ + { + name: 'Simple', + value: 'simple', + }, + { + name: 'Simple With Arrays', + value: 'simpleWithArrays', + }, + { + name: 'Rich', + value: 'rich', + }, + ], + description: `The format that cell values are returned as.`, + }, + { + displayName: 'RAW Data', + name: 'rawData', + type: 'boolean', + default: false, + description: `Returns the data exactly in the way it got received from the API.`, + }, + { + displayName: 'Sort By', + name: 'sortBy', + type: 'options', + default: [], + options: [ + { + name: 'Created At', + value: 'createdAt', + }, + { + name: 'Natural', + value: 'natural', + }, + ], + description: `Specifies the sort order of the rows returned. + If left unspecified, rows are returned by creation time ascending.`, + }, + { + displayName: 'Visible Only', + name: 'visibleOnly', + type: 'boolean', + default: false, + description: `If true, returns only visible rows and columns for the table.`, + }, + ] + }, +/* -------------------------------------------------------------------------- */ +/* row:delete */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Doc', + name: 'docId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getDocs', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'table', + ], + operation: [ + 'deleteRow', + ] + }, + }, + description: 'ID of the doc.', + }, + { + displayName: 'Table', + name: 'tableId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTables', + }, + required: true, + default: [], + displayOptions: { + show: { + resource: [ + 'table', + ], + operation: [ + 'deleteRow', + ] + }, + }, + description: 'The table to delete the row in.', + }, + { + displayName: 'Row ID', + name: 'rowId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'table', + ], + operation: [ + 'deleteRow', + ] + }, + }, + description: 'Row IDs to delete.', + }, + +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Coda/coda.png b/packages/nodes-base/nodes/Coda/coda.png new file mode 100644 index 0000000000..cd74b6330f Binary files /dev/null and b/packages/nodes-base/nodes/Coda/coda.png differ diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 9790b8279b..5afa6b431c 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -32,6 +32,7 @@ "dist/credentials/AsanaApi.credentials.js", "dist/credentials/Aws.credentials.js", "dist/credentials/ChargebeeApi.credentials.js", + "dist/credentials/CodaApi.credentials.js", "dist/credentials/DropboxApi.credentials.js", "dist/credentials/FreshdeskApi.credentials.js", "dist/credentials/FileMaker.credentials.js", @@ -85,6 +86,7 @@ "dist/nodes/Chargebee/Chargebee.node.js", "dist/nodes/Chargebee/ChargebeeTrigger.node.js", "dist/nodes/Cron.node.js", + "dist/nodes/Coda/Coda.node.js", "dist/nodes/Dropbox/Dropbox.node.js", "dist/nodes/Discord/Discord.node.js", "dist/nodes/EditImage.node.js",