From 28819ca502765412caefdf1d020f2dea826bf3f1 Mon Sep 17 00:00:00 2001 From: MedAliMarz Date: Sun, 1 Aug 2021 16:16:07 +0200 Subject: [PATCH] :sparkles: Add NocoDB node (#1969) * :sparkles: NocoDB node * Add continueOnFail * Refactor the NocoDB node * :zap: Improvements * Revert to using bulk endpoints * Add upload attachment to create/update:Row * Add download attachment to get:Row * Change create/update output, add attachment upload to defined fields * :zap: Improvements * :zap: Minor improvements Co-authored-by: ricardo Co-authored-by: Jan Oberhauser --- .../credentials/NocoDb.credentials.ts | 26 ++ .../nodes/NocoDB/GenericFunctions.ts | 135 ++++++ .../nodes-base/nodes/NocoDB/NocoDB.node.json | 22 + .../nodes-base/nodes/NocoDB/NocoDB.node.ts | 380 ++++++++++++++++ .../nodes/NocoDB/OperationDescription.ts | 383 ++++++++++++++++ packages/nodes-base/nodes/NocoDB/nocodb.svg | 425 ++++++++++++++++++ packages/nodes-base/package.json | 2 + 7 files changed, 1373 insertions(+) create mode 100644 packages/nodes-base/credentials/NocoDb.credentials.ts create mode 100644 packages/nodes-base/nodes/NocoDB/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/NocoDB/NocoDB.node.json create mode 100644 packages/nodes-base/nodes/NocoDB/NocoDB.node.ts create mode 100644 packages/nodes-base/nodes/NocoDB/OperationDescription.ts create mode 100644 packages/nodes-base/nodes/NocoDB/nocodb.svg diff --git a/packages/nodes-base/credentials/NocoDb.credentials.ts b/packages/nodes-base/credentials/NocoDb.credentials.ts new file mode 100644 index 0000000000..f001db77b9 --- /dev/null +++ b/packages/nodes-base/credentials/NocoDb.credentials.ts @@ -0,0 +1,26 @@ +import { + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + + +export class NocoDb implements ICredentialType { + name = 'nocoDb'; + displayName = 'NocoDB'; + documentationUrl = 'nocoDb'; + properties: INodeProperties[] = [ + { + displayName: 'API Token', + name: 'apiToken', + type: 'string', + default: '', + }, + { + displayName: 'Host', + name: 'host', + type: 'string', + default: '', + placeholder: 'http(s)://localhost:8080', + }, + ]; +} diff --git a/packages/nodes-base/nodes/NocoDB/GenericFunctions.ts b/packages/nodes-base/nodes/NocoDB/GenericFunctions.ts new file mode 100644 index 0000000000..abdb64c770 --- /dev/null +++ b/packages/nodes-base/nodes/NocoDB/GenericFunctions.ts @@ -0,0 +1,135 @@ +import { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + OptionsWithUri, +} from 'request'; + +import { + IBinaryKeyData, + IDataObject, + INodeExecutionData, + IPollFunctions, + NodeApiError, + NodeOperationError, +} from 'n8n-workflow'; + + +interface IAttachment { + url: string; + title: string; + mimetype: string; + size: number; +} + +/** + * Make an API request to NocoDB + * + * @param {IHookFunctions} this + * @param {string} method + * @param {string} url + * @param {object} body + * @returns {Promise} + */ +export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, method: string, endpoint: string, body: object, query?: IDataObject, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('nocoDb'); + + if (credentials === undefined) { + throw new NodeOperationError(this.getNode(), 'No credentials got returned!'); + } + + query = query || {}; + + const options: OptionsWithUri = { + headers: { + 'xc-auth': credentials.apiToken, + }, + method, + body, + qs: query, + uri: uri || `${credentials.host}${endpoint}`, + json: true, + + }; + + if (Object.keys(option).length !== 0) { + Object.assign(options, option); + } + + if (Object.keys(body).length === 0) { + delete options.body; + } + + try { + return await this.helpers.request!(options); + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} + + +/** + * Make an API request to paginated NocoDB endpoint + * and return all results + * + * @export + * @param {(IHookFunctions | IExecuteFunctions)} this + * @param {string} method + * @param {string} endpoint + * @param {IDataObject} body + * @param {IDataObject} [query] + * @returns {Promise} + */ +export async function apiRequestAllItems(this: IHookFunctions | IExecuteFunctions | IPollFunctions, method: string, endpoint: string, body: IDataObject, query?: IDataObject): Promise { // tslint:disable-line:no-any + + if (query === undefined) { + query = {}; + } + query.limit = 100; + query.offset = query?.offset ? query.offset as number : 0; + const returnData: IDataObject[] = []; + + let responseData; + + do { + responseData = await apiRequest.call(this, method, endpoint, body, query); + + returnData.push(...responseData); + + query.offset += query.limit; + + } while ( + responseData.length === 0 + ); + + return returnData; +} + +export async function downloadRecordAttachments(this: IExecuteFunctions | IPollFunctions, records: IDataObject[], fieldNames: string[]): Promise { + const elements: INodeExecutionData[] = []; + + for (const record of records) { + const element: INodeExecutionData = { json: {}, binary: {} }; + element.json = record as unknown as IDataObject; + for (const fieldName of fieldNames) { + if (record[fieldName]) { + for (const [index, attachment] of (JSON.parse(record[fieldName] as string) as IAttachment[]).entries()) { + const file = await apiRequest.call(this, 'GET', '', {}, {}, attachment.url, { json: false, encoding: null }); + element.binary![`${fieldName}_${index}`] = { + data: Buffer.from(file).toString('base64'), + fileName: attachment.title, + mimeType: attachment.mimetype, + }; + } + } + } + if (Object.keys(element.binary as IBinaryKeyData).length === 0) { + delete element.binary; + } + elements.push(element); + } + return elements; +} diff --git a/packages/nodes-base/nodes/NocoDB/NocoDB.node.json b/packages/nodes-base/nodes/NocoDB/NocoDB.node.json new file mode 100644 index 0000000000..0e9c652a98 --- /dev/null +++ b/packages/nodes-base/nodes/NocoDB/NocoDB.node.json @@ -0,0 +1,22 @@ +{ + "node": "n8n-nodes-base.nocoDb", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Data & Storage" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/nocoDb" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.nocoDb/" + } + ], + "generic": [ + ] + } +} diff --git a/packages/nodes-base/nodes/NocoDB/NocoDB.node.ts b/packages/nodes-base/nodes/NocoDB/NocoDB.node.ts new file mode 100644 index 0000000000..a32eb12db8 --- /dev/null +++ b/packages/nodes-base/nodes/NocoDB/NocoDB.node.ts @@ -0,0 +1,380 @@ +import { + BINARY_ENCODING, + IExecuteFunctions, +} from 'n8n-core'; + +import { + IBinaryData, + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, + NodeApiError, + NodeOperationError, +} from 'n8n-workflow'; + +import { + apiRequest, + apiRequestAllItems, + downloadRecordAttachments, +} from './GenericFunctions'; + +import { + operationFields +} from './OperationDescription'; + +export class NocoDB implements INodeType { + description: INodeTypeDescription = { + displayName: 'NocoDB', + name: 'nocoDb', + icon: 'file:nocodb.svg', + group: ['input'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Read, update, write and delete data from NocoDB', + defaults: { + name: 'NocoDB', + color: '#0989ff', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'nocoDb', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Row', + value: 'row', + }, + ], + default: 'row', + description: 'The Resource to operate on', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'row', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a row', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a row', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all rows', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a row', + }, + { + name: 'Update', + value: 'update', + description: 'Update a row', + }, + ], + default: 'get', + description: 'The operation to perform', + }, + ...operationFields, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + let responseData; + + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + const projectId = this.getNodeParameter('projectId', 0) as string; + const table = this.getNodeParameter('table', 0) as string; + + let returnAll = false; + let endpoint = ''; + let requestMethod = ''; + + let qs: IDataObject = {}; + + if (resource === 'row') { + + if (operation === 'create') { + + requestMethod = 'POST'; + endpoint = `/nc/${projectId}/api/v1/${table}/bulk`; + + const body: IDataObject[] = []; + + for (let i = 0; i < items.length; i++) { + const newItem: IDataObject = {}; + const dataToSend = this.getNodeParameter('dataToSend', i) as 'defineBelow' | 'autoMapInputData'; + + if (dataToSend === 'autoMapInputData') { + const incomingKeys = Object.keys(items[i].json); + const rawInputsToIgnore = this.getNodeParameter('inputsToIgnore', i) as string; + const inputDataToIgnore = rawInputsToIgnore.split(',').map(c => c.trim()); + for (const key of incomingKeys) { + if (inputDataToIgnore.includes(key)) continue; + newItem[key] = items[i].json[key]; + } + } else { + const fields = this.getNodeParameter('fieldsUi.fieldValues', i, []) as Array<{ + fieldName: string; + binaryData: boolean; + fieldValue?: string; + binaryProperty?: string; + }>; + + for (const field of fields) { + if (!field.binaryData) { + newItem[field.fieldName] = field.fieldValue; + } else if (field.binaryProperty) { + if (!items[i].binary) { + throw new NodeOperationError(this.getNode(), 'No binary data exists on item!'); + } + const binaryPropertyName = field.binaryProperty; + if (binaryPropertyName && !items[i].binary![binaryPropertyName]) { + throw new NodeOperationError(this.getNode(), `Binary property ${binaryPropertyName} does not exist on item!`); + } + const binaryData = items[i].binary![binaryPropertyName] as IBinaryData; + + const formData = { + file: { + value: Buffer.from(binaryData.data, BINARY_ENCODING), + options: { + filename: binaryData.fileName, + contentType: binaryData.mimeType, + }, + }, + json: JSON.stringify({ + api: 'xcAttachmentUpload', + project_id: projectId, + dbAlias: 'db', + args: {}, + }), + }; + const qs = { project_id: projectId }; + + responseData = await apiRequest.call(this, 'POST', '/dashboard', {}, qs, undefined, { formData }); + newItem[field.fieldName] = JSON.stringify([responseData]); + } + } + } + body.push(newItem); + } + try { + responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + + // Calculate ID manually and add to return data + let id = responseData[0]; + for (let i = body.length - 1; i >= 0; i--) { + body[i].id = id--; + } + + returnData.push(...body); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.toString() }); + } + throw new NodeApiError(this.getNode(), error); + } + } else if (operation === 'delete') { + + requestMethod = 'DELETE'; + endpoint = `/nc/${projectId}/api/v1/${table}/bulk`; + const body: IDataObject[] = []; + + for (let i = 0; i < items.length; i++) { + const id = this.getNodeParameter('id', i) as string; + body.push({ id }); + } + try { + responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + returnData.push(...items.map(item => item.json)); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.toString() }); + } + throw new NodeApiError(this.getNode(), error); + } + } else if (operation === 'getAll') { + const data = []; + const downloadAttachments = this.getNodeParameter('downloadAttachments', 0) as boolean; + try { + for (let i = 0; i < items.length; i++) { + requestMethod = 'GET'; + endpoint = `/nc/${projectId}/api/v1/${table}`; + + returnAll = this.getNodeParameter('returnAll', 0) as boolean; + qs = this.getNodeParameter('options', i, {}) as IDataObject; + + if (qs.sort) { + const properties = (qs.sort as IDataObject).property as Array<{ field: string, direction: string }>; + qs.sort = properties.map(prop => `${prop.direction === 'asc' ? '' : '-'}${prop.field}`).join(','); + } + + if (qs.fields) { + qs.fields = (qs.fields as IDataObject[]).join(','); + } + + if (returnAll === true) { + responseData = await apiRequestAllItems.call(this, requestMethod, endpoint, {}, qs); + } else { + qs.limit = this.getNodeParameter('limit', 0) as number; + responseData = await apiRequest.call(this, requestMethod, endpoint, {}, qs); + } + + returnData.push.apply(returnData, responseData); + + if (downloadAttachments === true) { + const downloadFieldNames = (this.getNodeParameter('downloadFieldNames', 0) as string).split(','); + const response = await downloadRecordAttachments.call(this, responseData, downloadFieldNames); + data.push(...response); + } + } + + if (downloadAttachments) { + return [data]; + } + + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.toString() }); + } + throw error; + } + } else if (operation === 'get') { + + requestMethod = 'GET'; + const newItems: INodeExecutionData[] = []; + + for (let i = 0; i < items.length; i++) { + try { + const id = this.getNodeParameter('id', i) as string; + endpoint = `/nc/${projectId}/api/v1/${table}/${id}`; + responseData = await apiRequest.call(this, requestMethod, endpoint, {}, qs); + const newItem: INodeExecutionData = { json: responseData }; + + const downloadAttachments = this.getNodeParameter('downloadAttachments', i) as boolean; + + if (downloadAttachments === true) { + const downloadFieldNames = (this.getNodeParameter('downloadFieldNames', i) as string).split(','); + const data = await downloadRecordAttachments.call(this, [responseData], downloadFieldNames); + newItem.binary = data[0].binary; + } + + newItems.push(newItem); + } catch (error) { + if (this.continueOnFail()) { + newItems.push({ json: { error: error.toString() } }); + continue; + } + throw new NodeApiError(this.getNode(), error); + } + } + return this.prepareOutputData(newItems); + + } else if (operation === 'update') { + + requestMethod = 'PUT'; + endpoint = `/nc/${projectId}/api/v1/${table}/bulk`; + + const body: IDataObject[] = []; + + for (let i = 0; i < items.length; i++) { + + const id = this.getNodeParameter('id', i) as string; + const newItem: IDataObject = { id }; + const dataToSend = this.getNodeParameter('dataToSend', i) as 'defineBelow' | 'autoMapInputData'; + + if (dataToSend === 'autoMapInputData') { + const incomingKeys = Object.keys(items[i].json); + const rawInputsToIgnore = this.getNodeParameter('inputsToIgnore', i) as string; + const inputDataToIgnore = rawInputsToIgnore.split(',').map(c => c.trim()); + for (const key of incomingKeys) { + if (inputDataToIgnore.includes(key)) continue; + newItem[key] = items[i].json[key]; + } + } else { + const fields = this.getNodeParameter('fieldsUi.fieldValues', i, []) as Array<{ + fieldName: string; + upload: boolean; + fieldValue?: string; + binaryProperty?: string; + }>; + + for (const field of fields) { + if (!field.upload) { + newItem[field.fieldName] = field.fieldValue; + } else if (field.binaryProperty) { + if (!items[i].binary) { + throw new NodeOperationError(this.getNode(), 'No binary data exists on item!'); + } + const binaryPropertyName = field.binaryProperty; + if (binaryPropertyName && !items[i].binary![binaryPropertyName]) { + throw new NodeOperationError(this.getNode(), `Binary property ${binaryPropertyName} does not exist on item!`); + } + const binaryData = items[i].binary![binaryPropertyName] as IBinaryData; + + const formData = { + file: { + value: Buffer.from(binaryData.data, BINARY_ENCODING), + options: { + filename: binaryData.fileName, + contentType: binaryData.mimeType, + }, + }, + json: JSON.stringify({ + api: 'xcAttachmentUpload', + project_id: projectId, + dbAlias: 'db', + args: {}, + }), + }; + const qs = { project_id: projectId }; + + responseData = await apiRequest.call(this, 'POST', '/dashboard', {}, qs, undefined, { formData }); + newItem[field.fieldName] = JSON.stringify([responseData]); + } + } + } + body.push(newItem); + } + + try { + responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + returnData.push(...body); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.toString() }); + } + throw new NodeApiError(this.getNode(), error); + } + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/NocoDB/OperationDescription.ts b/packages/nodes-base/nodes/NocoDB/OperationDescription.ts new file mode 100644 index 0000000000..c679e4c051 --- /dev/null +++ b/packages/nodes-base/nodes/NocoDB/OperationDescription.ts @@ -0,0 +1,383 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const operationFields = [ + // ---------------------------------- + // Shared + // ---------------------------------- + { + displayName: 'Project ID', + name: 'projectId', + type: 'string', + default: '', + required: true, + description: 'The ID of the project', + }, + { + displayName: 'Table', + name: 'table', + type: 'string', + default: '', + required: true, + description: 'The name of the table', + }, + // ---------------------------------- + // delete + // ---------------------------------- + { + displayName: 'Row ID', + name: 'id', + type: 'string', + displayOptions: { + show: { + operation: [ + 'delete', + ], + }, + }, + default: '', + required: true, + description: 'ID of the row to delete', + }, + // ---------------------------------- + // getAll + // ---------------------------------- + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 100, + description: 'The max number of results to return', + }, + { + displayName: 'Download Attachments', + name: 'downloadAttachments', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: `When set to true the attachment fields define in 'Download Fields' will be downloaded.`, + }, + { + displayName: 'Download Fields', + name: 'downloadFieldNames', + type: 'string', + required: true, + displayOptions: { + show: { + operation: [ + 'getAll', + ], + downloadAttachments: [ + true, + ], + }, + }, + default: '', + description: `Name of the fields of type 'attachment' that should be downloaded. Multiple ones can be defined separated by comma. Case sensitive.`, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + }, + }, + default: {}, + placeholder: 'Add Option', + options: [ + { + displayName: 'Fields', + name: 'fields', + type: 'string', + typeOptions: { + multipleValues: true, + multipleValueButtonText: 'Add Field', + }, + default: [], + placeholder: 'Name', + description: 'The select fields of the returned rows', + }, + { + displayName: 'Filter By Formula', + name: 'where', + type: 'string', + default: '', + placeholder: '(name,like,example%)~or(name,eq,test)', + description: 'A formula used to filter rows', + }, + { + displayName: 'Sort', + name: 'sort', + placeholder: 'Add Sort Rule', + description: 'The sorting rules for the returned rows', + 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', + }, + ], + }, + ], + }, + + ], + }, + // ---------------------------------- + // get + // ---------------------------------- + { + displayName: 'Row ID', + name: 'id', + type: 'string', + displayOptions: { + show: { + operation: [ + 'get', + ], + }, + }, + default: '', + required: true, + description: 'ID of the row to return', + }, + { + displayName: 'Download Attachments', + name: 'downloadAttachments', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'get', + ], + }, + }, + default: false, + description: `When set to true the attachment fields define in 'Download Fields' will be downloaded.`, + }, + { + displayName: 'Download Fields', + name: 'downloadFieldNames', + type: 'string', + required: true, + displayOptions: { + show: { + operation: [ + 'get', + ], + 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.`, + }, + // ---------------------------------- + // update + // ---------------------------------- + { + displayName: 'Row ID', + name: 'id', + type: 'string', + displayOptions: { + show: { + operation: [ + 'update', + ], + }, + }, + default: '', + required: true, + description: 'ID of the row to update', + }, + // ---------------------------------- + // Shared + // ---------------------------------- + { + displayName: 'Data to Send', + name: 'dataToSend', + type: 'options', + options: [ + { + name: 'Auto-map Input Data to Columns', + value: 'autoMapInputData', + description: 'Use when node input properties match destination column names', + }, + { + name: 'Define Below for Each Column', + value: 'defineBelow', + description: 'Set the value for each destination column', + }, + ], + displayOptions: { + show: { + operation: [ + 'create', + 'update', + ], + }, + }, + default: 'defineBelow', + 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: [ + 'autoMapInputData', + ], + }, + }, + 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: 'fieldsUi', + placeholder: 'Add Field', + type: 'fixedCollection', + typeOptions: { + multipleValueButtonText: 'Add Field to Send', + multipleValues: true, + }, + displayOptions: { + show: { + operation: [ + 'create', + 'update', + ], + dataToSend: [ + 'defineBelow', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Field', + name: 'fieldValues', + values: [ + { + displayName: 'Field Name', + name: 'fieldName', + type: 'string', + default: '', + }, + { + displayName: 'Is Binary Data', + name: 'binaryData', + type: 'boolean', + default: false, + description: 'If the field data to set is binary and should be taken from a binary property', + }, + { + displayName: 'Field Value', + name: 'fieldValue', + type: 'string', + default: '', + displayOptions: { + show: { + binaryData: [ + false, + ], + }, + }, + }, + { + displayName: 'Take Input From Field', + name: 'binaryProperty', + type: 'string', + description: 'The field containing the binary file data to be uploaded', + default: '', + displayOptions: { + show: { + binaryData: [ + true, + ], + }, + }, + }, + ], + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/NocoDB/nocodb.svg b/packages/nodes-base/nodes/NocoDB/nocodb.svg new file mode 100644 index 0000000000..42a90146ba --- /dev/null +++ b/packages/nodes-base/nodes/NocoDB/nocodb.svg @@ -0,0 +1,425 @@ + + + + diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 742f244014..c4854b2490 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -182,6 +182,7 @@ "dist/credentials/NasaApi.credentials.js", "dist/credentials/NextCloudApi.credentials.js", "dist/credentials/NextCloudOAuth2Api.credentials.js", + "dist/credentials/NocoDb.credentials.js", "dist/credentials/NotionApi.credentials.js", "dist/credentials/NotionOAuth2Api.credentials.js", "dist/credentials/OAuth1Api.credentials.js", @@ -481,6 +482,7 @@ "dist/nodes/Nasa/Nasa.node.js", "dist/nodes/NextCloud/NextCloud.node.js", "dist/nodes/NoOp.node.js", + "dist/nodes/NocoDB/NocoDB.node.js", "dist/nodes/Notion/Notion.node.js", "dist/nodes/Notion/NotionTrigger.node.js", "dist/nodes/N8nTrainingCustomerDatastore.node.js",