diff --git a/packages/nodes-base/credentials/GristApi.credentials.ts b/packages/nodes-base/credentials/GristApi.credentials.ts new file mode 100644 index 0000000000..9ac52c09fb --- /dev/null +++ b/packages/nodes-base/credentials/GristApi.credentials.ts @@ -0,0 +1,50 @@ +import { + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class GristApi implements ICredentialType { + name = 'gristApi'; + displayName = 'Grist API'; + documentationUrl = 'grist'; + properties: INodeProperties[] = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string', + default: '', + required: true, + }, + { + displayName: 'Plan Type', + name: 'planType', + type: 'options', + default: 'free', + options: [ + { + name: 'Free', + value: 'free', + }, + { + name: 'Paid', + value: 'paid', + }, + ], + }, + { + displayName: 'Custom Subdomain', + name: 'customSubdomain', + type: 'string', + default: '', + required: true, + description: 'Custom subdomain of your team', + displayOptions: { + show: { + planType: [ + 'paid', + ], + }, + }, + }, + ]; +} diff --git a/packages/nodes-base/nodes/Grist/GenericFunctions.ts b/packages/nodes-base/nodes/Grist/GenericFunctions.ts new file mode 100644 index 0000000000..f7ef0768f1 --- /dev/null +++ b/packages/nodes-base/nodes/Grist/GenericFunctions.ts @@ -0,0 +1,109 @@ +import { + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + OptionsWithUri, +} from 'request'; + +import { + IDataObject, + NodeApiError, + NodeOperationError, +} from 'n8n-workflow'; + +import { + GristCredentials, + GristDefinedFields, + GristFilterProperties, + GristSortProperties, +} from './types'; + +export async function gristApiRequest( + this: IExecuteFunctions | ILoadOptionsFunctions, + method: string, + endpoint: string, + body: IDataObject | number[] = {}, + qs: IDataObject = {}, +) { + const { + apiKey, + planType, + customSubdomain, + } = await this.getCredentials('gristApi') as GristCredentials; + + const subdomain = planType === 'free' ? 'docs' : customSubdomain; + + const options: OptionsWithUri = { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + method, + uri: `https://${subdomain}.getgrist.com/api${endpoint}`, + qs, + body, + json: true, + }; + + if (!Object.keys(body).length) { + delete options.body; + } + + if (!Object.keys(qs).length) { + delete options.qs; + } + + try { + return await this.helpers.request!(options); + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} + +export function parseSortProperties(sortProperties: GristSortProperties) { + return sortProperties.reduce((acc, cur, curIdx) => { + if (cur.direction === 'desc') acc += '-'; + acc += cur.field; + if (curIdx !== sortProperties.length - 1) acc += ','; + return acc; + }, ''); +} + +export function parseFilterProperties(filterProperties: GristFilterProperties) { + return filterProperties.reduce<{ [key: string]: Array; }>((acc, cur) => { + acc[cur.field] = acc[cur.field] ?? []; + const values = isNaN(Number(cur.values)) ? cur.values : Number(cur.values); + acc[cur.field].push(values); + return acc; + }, {}); +} + +export function parseDefinedFields(fieldsToSendProperties: GristDefinedFields) { + return fieldsToSendProperties.reduce<{ [key: string]: string; }>((acc, cur) => { + acc[cur.fieldId] = cur.fieldValue; + return acc; + }, {}); +} + +export function parseAutoMappedInputs( + incomingKeys: string[], + inputsToIgnore: string[], + item: any, // tslint:disable-line:no-any +) { + return incomingKeys.reduce<{ [key: string]: any; }>((acc, curKey) => { // tslint:disable-line:no-any + if (inputsToIgnore.includes(curKey)) return acc; + acc = { ...acc, [curKey]: item[curKey] }; + return acc; + }, {}); +} + +export function throwOnZeroDefinedFields(this: IExecuteFunctions, fields: GristDefinedFields) { + if (!fields?.length) { + throw new NodeOperationError( + this.getNode(), + 'No defined data found. Please specify the data to send in \'Fields to Send\'.', + ); + } +} + diff --git a/packages/nodes-base/nodes/Grist/Grist.node.json b/packages/nodes-base/nodes/Grist/Grist.node.json new file mode 100644 index 0000000000..4771346cb9 --- /dev/null +++ b/packages/nodes-base/nodes/Grist/Grist.node.json @@ -0,0 +1,20 @@ +{ + "node": "n8n-nodes-base.grist", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Data & Storage" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/grist" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.grist/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Grist/Grist.node.ts b/packages/nodes-base/nodes/Grist/Grist.node.ts new file mode 100644 index 0000000000..50f4b070f9 --- /dev/null +++ b/packages/nodes-base/nodes/Grist/Grist.node.ts @@ -0,0 +1,280 @@ +import { + IExecuteFunctions +} from 'n8n-core'; + +import { + ICredentialsDecrypted, + ICredentialTestFunctions, + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodeType, + INodeTypeDescription, + NodeCredentialTestResult, +} from 'n8n-workflow'; + +import { + OptionsWithUri, +} from 'request'; + +import { + gristApiRequest, + parseAutoMappedInputs, + parseDefinedFields, + parseFilterProperties, + parseSortProperties, + throwOnZeroDefinedFields, +} from './GenericFunctions'; + +import { + operationFields, +} from './OperationDescription'; + +import { + FieldsToSend, + GristColumns, + GristCreateRowPayload, + GristCredentials, + GristGetAllOptions, + GristUpdateRowPayload, + SendingOptions, +} from './types'; + +export class Grist implements INodeType { + description: INodeTypeDescription = { + displayName: 'Grist', + name: 'grist', + icon: 'file:grist.svg', + subtitle: '={{$parameter["operation"]}}', + group: ['input'], + version: 1, + description: 'Consume the Grist API', + defaults: { + name: 'Grist', + color: '#394650', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'gristApi', + required: true, + testedBy: 'gristApiTest', + }, + ], + properties: operationFields, + }; + + methods = { + loadOptions: { + async getTableColumns(this: ILoadOptionsFunctions) { + const docId = this.getNodeParameter('docId', 0) as string; + const tableId = this.getNodeParameter('tableId', 0) as string; + const endpoint = `/docs/${docId}/tables/${tableId}/columns`; + + const { columns } = await gristApiRequest.call(this, 'GET', endpoint) as GristColumns; + return columns.map(({ id }) => ({ name: id, value: id })); + }, + }, + + credentialTest: { + async gristApiTest( + this: ICredentialTestFunctions, + credential: ICredentialsDecrypted, + ): Promise { + const { + apiKey, + planType, + customSubdomain, + } = credential.data as GristCredentials; + + const subdomain = planType === 'free' ? 'docs' : customSubdomain; + + const endpoint = '/orgs'; + + const options: OptionsWithUri = { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + method: 'GET', + uri: `https://${subdomain}.getgrist.com/api${endpoint}`, + qs: { limit: 1 }, + json: true, + }; + + try { + await this.helpers.request(options); + return { + status: 'OK', + message: 'Authentication successful', + }; + } catch (error) { + return { + status: 'Error', + message: error.message, + }; + } + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + let responseData; + const returnData: IDataObject[] = []; + + const operation = this.getNodeParameter('operation', 0) as string; + + for (let i = 0; i < items.length; i++) { + + try { + + if (operation === 'create') { + + // ---------------------------------- + // create + // ---------------------------------- + + // https://support.getgrist.com/api/#tag/records/paths/~1docs~1{docId}~1tables~1{tableId}~1records/post + + const body = { records: [] } as GristCreateRowPayload; + + const dataToSend = this.getNodeParameter('dataToSend', 0) as SendingOptions; + + if (dataToSend === 'autoMapInputs') { + + const incomingKeys = Object.keys(items[i].json); + const rawInputsToIgnore = this.getNodeParameter('inputsToIgnore', i) as string; + const inputsToIgnore = rawInputsToIgnore.split(',').map(c => c.trim()); + const fields = parseAutoMappedInputs(incomingKeys, inputsToIgnore, items[i].json); + body.records.push({ fields }); + + } else if (dataToSend === 'defineInNode') { + + const { properties } = this.getNodeParameter('fieldsToSend', i, []) as FieldsToSend; + throwOnZeroDefinedFields.call(this, properties); + body.records.push({ fields: parseDefinedFields(properties) }); + + } + + const docId = this.getNodeParameter('docId', 0) as string; + const tableId = this.getNodeParameter('tableId', 0) as string; + const endpoint = `/docs/${docId}/tables/${tableId}/records`; + + responseData = await gristApiRequest.call(this, 'POST', endpoint, body); + responseData = { + id: responseData.records[0].id, + ...body.records[0].fields, + }; + + } else if (operation === 'delete') { + + // ---------------------------------- + // delete + // ---------------------------------- + + // https://support.getgrist.com/api/#tag/data/paths/~1docs~1{docId}~1tables~1{tableId}~1data~1delete/post + + const docId = this.getNodeParameter('docId', 0) as string; + const tableId = this.getNodeParameter('tableId', 0) as string; + const endpoint = `/docs/${docId}/tables/${tableId}/data/delete`; + + const rawRowIds = (this.getNodeParameter('rowId', i) as string).toString(); + const body = rawRowIds.split(',').map(c => c.trim()).map(Number); + + await gristApiRequest.call(this, 'POST', endpoint, body); + responseData = { success: true }; + + } else if (operation === 'update') { + + // ---------------------------------- + // update + // ---------------------------------- + + // https://support.getgrist.com/api/#tag/records/paths/~1docs~1{docId}~1tables~1{tableId}~1records/patch + + const body = { records: [] } as GristUpdateRowPayload; + + const rowId = this.getNodeParameter('rowId', i) as string; + const dataToSend = this.getNodeParameter('dataToSend', 0) as SendingOptions; + + if (dataToSend === 'autoMapInputs') { + + const incomingKeys = Object.keys(items[i].json); + const rawInputsToIgnore = this.getNodeParameter('inputsToIgnore', i) as string; + const inputsToIgnore = rawInputsToIgnore.split(',').map(c => c.trim()); + const fields = parseAutoMappedInputs(incomingKeys, inputsToIgnore, items[i].json); + body.records.push({ id: Number(rowId), fields }); + + } else if (dataToSend === 'defineInNode') { + + const { properties } = this.getNodeParameter('fieldsToSend', i, []) as FieldsToSend; + throwOnZeroDefinedFields.call(this, properties); + const fields = parseDefinedFields(properties); + body.records.push({ id: Number(rowId), fields }); + + } + + const docId = this.getNodeParameter('docId', 0) as string; + const tableId = this.getNodeParameter('tableId', 0) as string; + const endpoint = `/docs/${docId}/tables/${tableId}/records`; + + await gristApiRequest.call(this, 'PATCH', endpoint, body); + responseData = { + id: rowId, + ...body.records[0].fields, + }; + + } else if (operation === 'getAll') { + + // ---------------------------------- + // getAll + // ---------------------------------- + + // https://support.getgrist.com/api/#tag/records + + const docId = this.getNodeParameter('docId', 0) as string; + const tableId = this.getNodeParameter('tableId', 0) as string; + const endpoint = `/docs/${docId}/tables/${tableId}/records`; + + const qs: IDataObject = {}; + + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + if (!returnAll) { + qs.limit = this.getNodeParameter('limit', i) as number; + } + + const { sort, filter } = this.getNodeParameter('additionalOptions', i) as GristGetAllOptions; + + if (sort?.sortProperties.length) { + qs.sort = parseSortProperties(sort.sortProperties); + } + + if (filter?.filterProperties.length) { + const parsed = parseFilterProperties(filter.filterProperties); + qs.filter = JSON.stringify(parsed); + } + + responseData = await gristApiRequest.call(this, 'GET', endpoint, {}, qs); + responseData = responseData.records.map((data: IDataObject) => { + return { id: data.id, ...(data.fields as object) }; + }); + } + + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + throw error; + } + + Array.isArray(responseData) + ? returnData.push(...responseData) + : returnData.push(responseData); + } + + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Grist/OperationDescription.ts b/packages/nodes-base/nodes/Grist/OperationDescription.ts new file mode 100644 index 0000000000..65faf4f5cb --- /dev/null +++ b/packages/nodes-base/nodes/Grist/OperationDescription.ts @@ -0,0 +1,338 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const operationFields: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Create Row', + value: 'create', + description: 'Create rows in a table', + }, + { + name: 'Delete Row', + value: 'delete', + description: 'Delete rows from a table', + }, + { + name: 'Get All Rows', + value: 'getAll', + description: 'Read rows from a table', + }, + { + name: 'Update Row', + value: 'update', + description: 'Update rows in a table', + }, + ], + default: 'getAll', + }, + + // ---------------------------------- + // shared + // ---------------------------------- + { + displayName: 'Document ID', + name: 'docId', + type: 'string', + default: '', + required: true, + description: 'In your document, click your profile icon, then Document Settings, then copy the value under "This document\'s ID"', + }, + { + displayName: 'Table ID', + name: 'tableId', + type: 'string', + default: '', + required: true, + description: 'ID of table to operate on. If unsure, look at the Code View.', + }, + + // ---------------------------------- + // delete + // ---------------------------------- + { + displayName: 'Row ID', + name: 'rowId', + type: 'string', + displayOptions: { + show: { + operation: [ + 'delete', + ], + }, + }, + default: '', + description: 'ID of the row to delete, or comma-separated list of row IDs to delete', + required: true, + }, + + // ---------------------------------- + // getAll + // ---------------------------------- + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + typeOptions: { + minValue: 1, + }, + default: 50, + description: 'Max number of results to return', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + { + displayName: 'Additional Options', + name: 'additionalOptions', + type: 'collection', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + }, + }, + default: {}, + placeholder: 'Add Option', + options: [ + { + displayName: 'Filter', + name: 'filter', + placeholder: 'Add Filter', + description: 'Only return rows matching all of the given filters. For complex filters, create a formula column and filter for the value "true".', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + displayName: 'Filter Properties', + name: 'filterProperties', + values: [ + { + displayName: 'Column', + name: 'field', + type: 'options', + typeOptions: { + loadOptionsDependsOn: [ + 'docId', + 'tableId', + ], + loadOptionsMethod: 'getTableColumns', + }, + default: '', + description: 'Column to apply the filter in', + required: true, + }, + { + displayName: 'Values', + name: 'values', + type: 'string', + default: '', + description: 'Comma-separated list of values to search for in the filtered column', + }, + ], + }, + ], + }, + { + displayName: 'Sort Order', + name: 'sort', + placeholder: 'Add Field', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + displayName: 'Sort Properties', + name: 'sortProperties', + values: [ + { + displayName: 'Column', + name: 'field', + type: 'options', + typeOptions: { + loadOptionsDependsOn: [ + 'docId', + 'tableId', + ], + loadOptionsMethod: 'getTableColumns', + }, + default: '', + required: true, + description: 'Column to sort on', + }, + { + displayName: 'Direction', + name: 'direction', + type: 'options', + options: [ + { + name: 'Ascending', + value: 'asc', + }, + { + name: 'Descending', + value: 'desc', + }, + ], + default: 'asc', + description: 'Direction to sort in', + }, + ], + }, + ], + }, + ], + }, + + // ---------------------------------- + // update + // ---------------------------------- + { + displayName: 'Row ID', + name: 'rowId', + type: 'string', + displayOptions: { + show: { + operation: [ + 'update', + ], + }, + }, + default: '', + description: 'ID of the row to update', + required: true, + }, + + // ---------------------------------- + // create + update + // ---------------------------------- + { + displayName: 'Data to Send', + name: 'dataToSend', + type: 'options', + options: [ + { + name: 'Auto-Map Input Data to Columns', + value: 'autoMapInputs', + description: 'Use when node input properties match destination column names', + }, + { + name: 'Define Below for Each Column', + value: 'defineInNode', + description: 'Set the value for each destination column', + }, + ], + displayOptions: { + show: { + operation: [ + 'create', + 'update', + ], + }, + }, + default: 'defineInNode', + description: 'Whether to insert the input data this node receives in the new row', + }, + { + displayName: 'Inputs to Ignore', + name: 'inputsToIgnore', + type: 'string', + displayOptions: { + show: { + operation: [ + 'create', + 'update', + ], + dataToSend: [ + 'autoMapInputs', + ], + }, + }, + default: '', + required: false, + description: 'List of input properties to avoid sending, separated by commas. Leave empty to send all properties.', + placeholder: 'Enter properties...', + }, + { + displayName: 'Fields to Send', + name: 'fieldsToSend', + placeholder: 'Add Field', + type: 'fixedCollection', + typeOptions: { + multipleValueButtonText: 'Add Field to Send', + multipleValues: true, + }, + displayOptions: { + show: { + operation: [ + 'create', + 'update', + ], + dataToSend: [ + 'defineInNode', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Properties', + name: 'properties', + values: [ + { + displayName: 'Column Name/ID', + name: 'fieldId', + description: 'Choose from the list, or specify an ID using an expression', + type: 'options', + typeOptions: { + loadOptionsDependsOn: [ + 'tableId', + ], + loadOptionsMethod: 'getTableColumns', + }, + default: '', + }, + { + displayName: 'Field Value', + name: 'fieldValue', + type: 'string', + default: '', + }, + ], + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Grist/grist.svg b/packages/nodes-base/nodes/Grist/grist.svg new file mode 100644 index 0000000000..180f36b827 --- /dev/null +++ b/packages/nodes-base/nodes/Grist/grist.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/nodes-base/nodes/Grist/types.d.ts b/packages/nodes-base/nodes/Grist/types.d.ts new file mode 100644 index 0000000000..d45afae160 --- /dev/null +++ b/packages/nodes-base/nodes/Grist/types.d.ts @@ -0,0 +1,46 @@ +export type GristCredentials = { + apiKey: string; + planType: 'free' | 'paid'; + customSubdomain?: string; +} + +export type GristColumns = { + columns: Array<{ id: string }>; +}; + +export type GristSortProperties = Array<{ + field: string; + direction: 'asc' | 'desc'; +}>; + +export type GristFilterProperties = Array<{ + field: string; + values: string; +}>; + +export type GristGetAllOptions = { + sort?: { sortProperties: GristSortProperties }; + filter?: { filterProperties: GristFilterProperties }; +}; + +export type GristDefinedFields = Array<{ + fieldId: string; + fieldValue: string; +}>; + +export type GristCreateRowPayload = { + records: Array<{ + fields: { [key: string]: any }; + }> +}; + +export type GristUpdateRowPayload = { + records: Array<{ + id: number; + fields: { [key: string]: any }; + }> +} + +export type SendingOptions = 'defineInNode' | 'autoMapInputs'; + +export type FieldsToSend = { properties: GristDefinedFields; }; diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index e8d5d27196..fc00fea912 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -126,6 +126,7 @@ "dist/credentials/GoogleTranslateOAuth2Api.credentials.js", "dist/credentials/GotifyApi.credentials.js", "dist/credentials/GoToWebinarOAuth2Api.credentials.js", + "dist/credentials/GristApi.credentials.js", "dist/credentials/YouTubeOAuth2Api.credentials.js", "dist/credentials/GumroadApi.credentials.js", "dist/credentials/HarvestApi.credentials.js", @@ -430,6 +431,7 @@ "dist/nodes/Gotify/Gotify.node.js", "dist/nodes/GoToWebinar/GoToWebinar.node.js", "dist/nodes/GraphQL/GraphQL.node.js", + "dist/nodes/Grist/Grist.node.js", "dist/nodes/Gumroad/GumroadTrigger.node.js", "dist/nodes/HackerNews/HackerNews.node.js", "dist/nodes/Harvest/Harvest.node.js",