diff --git a/packages/nodes-base/credentials/WebflowOAuth2Api.credentials.ts b/packages/nodes-base/credentials/WebflowOAuth2Api.credentials.ts index 5f0324c67d..c9533bc392 100644 --- a/packages/nodes-base/credentials/WebflowOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/WebflowOAuth2Api.credentials.ts @@ -3,7 +3,6 @@ import { NodePropertyTypes, } from 'n8n-workflow'; - export class WebflowOAuth2Api implements ICredentialType { name = 'webflowOAuth2Api'; extends = [ diff --git a/packages/nodes-base/nodes/Webflow/GenericFunctions.ts b/packages/nodes-base/nodes/Webflow/GenericFunctions.ts index 4eea1dd057..8be810d11a 100644 --- a/packages/nodes-base/nodes/Webflow/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Webflow/GenericFunctions.ts @@ -4,17 +4,26 @@ import { import { IExecuteFunctions, - IExecuteSingleFunctions, IHookFunctions, ILoadOptionsFunctions, IWebhookFunctions, } from 'n8n-core'; import { - IDataObject, NodeApiError, NodeOperationError, - } from 'n8n-workflow'; + IDataObject, + NodeApiError, + NodeOperationError, +} from 'n8n-workflow'; -export async function webflowApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IWebhookFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any +export async function webflowApiRequest( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, + method: string, + resource: string, + body: IDataObject = {}, + qs: IDataObject = {}, + uri?: string, + option: IDataObject = {}, +) { const authenticationMethod = this.getNodeParameter('authentication', 0); let options: OptionsWithUri = { @@ -24,14 +33,18 @@ export async function webflowApiRequest(this: IHookFunctions | IExecuteFunctions method, qs, body, - uri: uri ||`https://api.webflow.com${resource}`, + uri: uri || `https://api.webflow.com${resource}`, json: true, }; options = Object.assign({}, options, option); + + if (Object.keys(options.qs).length === 0) { + delete options.qs; + } + if (Object.keys(options.body).length === 0) { delete options.body; } - try { if (authenticationMethod === 'accessToken') { const credentials = this.getCredentials('webflowApi'); @@ -49,3 +62,32 @@ export async function webflowApiRequest(this: IHookFunctions | IExecuteFunctions throw new NodeApiError(this.getNode(), error); } } + +export async function webflowApiRequestAllItems( + this: IExecuteFunctions | ILoadOptionsFunctions, + method: string, + endpoint: string, + body: IDataObject = {}, + query: IDataObject = {}, +) { + + const returnData: IDataObject[] = []; + + let responseData; + + query.limit = 100; + query.offset = 0; + + do { + responseData = await webflowApiRequest.call(this, method, endpoint, body, query); + if (responseData.offset !== undefined) { + query.offset += query.limit; + } + returnData.push.apply(returnData, responseData.items); + } while ( + returnData.length < responseData.total + ); + + return returnData; +} + diff --git a/packages/nodes-base/nodes/Webflow/ItemDescription.ts b/packages/nodes-base/nodes/Webflow/ItemDescription.ts new file mode 100644 index 0000000000..fcea401341 --- /dev/null +++ b/packages/nodes-base/nodes/Webflow/ItemDescription.ts @@ -0,0 +1,450 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const itemOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + default: 'get', + description: 'Operation to perform', + options: [ + { + name: 'Create', + value: 'create', + }, + { + name: 'Delete', + value: 'delete', + }, + { + name: 'Get', + value: 'get', + }, + { + name: 'Get All', + value: 'getAll', + }, + { + name: 'Update', + value: 'update', + }, + ], + displayOptions: { + show: { + resource: [ + 'item', + ], + }, + }, + }, +] as INodeProperties[]; + +export const itemFields = [ + // ---------------------------------- + // item: create + // ---------------------------------- + { + displayName: 'Site ID', + name: 'siteId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getSites', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'create', + ], + }, + }, + description: 'ID of the site containing the collection whose items to add to.', + }, + { + displayName: 'Collection ID', + name: 'collectionId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getCollections', + loadOptionsDependsOn: [ + 'siteId', + ], + }, + default: '', + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'create', + ], + }, + }, + description: 'ID of the collection to add an item to.', + }, + { + displayName: 'Live', + name: 'live', + type: 'boolean', + required: true, + default: false, + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'create', + ], + }, + }, + description: 'Whether the item should be published on the live site.', + }, + { + displayName: 'Fields', + name: 'fieldsUi', + placeholder: 'Add Field', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Field', + name: 'fieldValues', + values: [ + { + displayName: 'Field ID', + name: 'fieldId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getFields', + loadOptionsDependsOn: [ + 'collectionId', + ], + }, + default: '', + description: 'Field to set for the item to create.', + }, + { + displayName: 'Field Value', + name: 'fieldValue', + type: 'string', + default: '', + description: 'Value to set for the item to create.', + }, + ], + }, + ], + }, + + // ---------------------------------- + // item: get + // ---------------------------------- + { + displayName: 'Site ID', + name: 'siteId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getSites', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'delete', + 'get', + ], + }, + }, + description: 'ID of the site containing the collection whose items to operate on.', + }, + { + displayName: 'Collection ID', + name: 'collectionId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getCollections', + loadOptionsDependsOn: [ + 'siteId', + ], + }, + default: '', + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'delete', + 'get', + ], + }, + }, + description: 'ID of the collection whose items to operate on.', + }, + { + displayName: 'Item ID', + name: 'itemId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'delete', + 'get', + ], + }, + }, + description: 'ID of the item to operate on.', + }, + // ---------------------------------- + // item: update + // ---------------------------------- + { + displayName: 'Site ID', + name: 'siteId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getSites', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'update', + ], + }, + }, + description: 'ID of the site containing the collection whose items to update.', + }, + { + displayName: 'Collection ID', + name: 'collectionId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getCollections', + loadOptionsDependsOn: [ + 'siteId', + ], + }, + default: '', + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'update', + ], + }, + }, + description: 'ID of the collection whose items to update.', + }, + { + displayName: 'Item ID', + name: 'itemId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'update', + ], + }, + }, + description: 'ID of the item to update.', + }, + { + displayName: 'Live', + name: 'live', + type: 'boolean', + required: true, + default: false, + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'update', + ], + }, + }, + description: 'Whether the item should be published on the live site.', + }, + { + displayName: 'Fields', + name: 'fieldsUi', + placeholder: 'Add Field', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Field', + name: 'fieldValues', + values: [ + { + displayName: 'Field ID', + name: 'fieldId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getFields', + loadOptionsDependsOn: [ + 'collectionId', + ], + }, + default: '', + description: 'Field to set for the item to update.', + }, + { + displayName: 'Field Value', + name: 'fieldValue', + type: 'string', + default: '', + description: 'Value to set for the item to update.', + }, + ], + }, + ], + }, + // ---------------------------------- + // item:getAll + // ---------------------------------- + { + displayName: 'Site ID', + name: 'siteId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getSites', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'ID of the site containing the collection whose items to retrieve.', + }, + { + displayName: 'Collection ID', + name: 'collectionId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getCollections', + loadOptionsDependsOn: [ + 'siteId', + ], + }, + default: '', + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'ID of the collection whose items to retrieve.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'item', + ], + 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: { + resource: [ + 'item', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 100, + description: 'How many results to return.', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Webflow/Webflow.node.json b/packages/nodes-base/nodes/Webflow/Webflow.node.json new file mode 100644 index 0000000000..0fd6bce62c --- /dev/null +++ b/packages/nodes-base/nodes/Webflow/Webflow.node.json @@ -0,0 +1,20 @@ +{ + "node": "n8n-nodes-base.webflow", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Marketing & Content" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/webflow" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.webflow/" + } + ] + } +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Webflow/Webflow.node.ts b/packages/nodes-base/nodes/Webflow/Webflow.node.ts new file mode 100644 index 0000000000..8b71950d3f --- /dev/null +++ b/packages/nodes-base/nodes/Webflow/Webflow.node.ts @@ -0,0 +1,262 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + webflowApiRequest, + webflowApiRequestAllItems, +} from './GenericFunctions'; + +import { + itemFields, + itemOperations, +} from './ItemDescription'; + +export class Webflow implements INodeType { + description: INodeTypeDescription = { + displayName: 'Webflow', + name: 'webflow', + icon: 'file:webflow.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume the Webflow API', + defaults: { + name: 'Webflow', + color: '#245bf8', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'webflowApi', + required: true, + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + { + name: 'webflowOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, + ], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'Method of authentication.', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Item', + value: 'item', + }, + ], + default: 'item', + description: 'Resource to consume', + }, + ...itemOperations, + ...itemFields, + ], + }; + + methods = { + loadOptions: { + async getSites(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const sites = await webflowApiRequest.call(this, 'GET', '/sites'); + for (const site of sites) { + returnData.push({ + name: site.name, + value: site._id, + }); + } + return returnData; + }, + async getCollections(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const siteId = this.getCurrentNodeParameter('siteId'); + const collections = await webflowApiRequest.call(this, 'GET', `/sites/${siteId}/collections`); + for (const collection of collections) { + returnData.push({ + name: collection.name, + value: collection._id, + }); + } + return returnData; + }, + async getFields(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const collectionId = this.getCurrentNodeParameter('collectionId'); + const { fields } = await webflowApiRequest.call(this, 'GET', `/collections/${collectionId}`); + for (const field of fields) { + returnData.push({ + name: `${field.name} (${field.type}) ${(field.required) ? ' (required)' : ''}`, + value: field.slug, + }); + } + return returnData; + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + const qs: IDataObject = {}; + let responseData; + const returnData: IDataObject[] = []; + + for (let i = 0; i < items.length; i++) { + + if (resource === 'item') { + + // ********************************************************************* + // item + // ********************************************************************* + + // https://developers.webflow.com/#item-model + + if (operation === 'create') { + + // ---------------------------------- + // item: create + // ---------------------------------- + + // https://developers.webflow.com/#create-new-collection-item + + const collectionId = this.getNodeParameter('collectionId', i) as string; + + const properties = this.getNodeParameter('fieldsUi.fieldValues', i, []) as IDataObject[]; + + const live = this.getNodeParameter('live', i) as boolean; + + const fields = {} as IDataObject; + + properties.forEach(data => (fields[data.fieldId as string] = data.fieldValue)); + + const body: IDataObject = { + fields, + }; + + responseData = await webflowApiRequest.call(this, 'POST', `/collections/${collectionId}/items`, body, { live }); + + } else if (operation === 'delete') { + + // ---------------------------------- + // item: delete + // ---------------------------------- + + // https://developers.webflow.com/#remove-collection-item + + const collectionId = this.getNodeParameter('collectionId', i) as string; + const itemId = this.getNodeParameter('itemId', i) as string; + responseData = await webflowApiRequest.call(this, 'DELETE', `/collections/${collectionId}/items/${itemId}`); + + } else if (operation === 'get') { + + // ---------------------------------- + // item: get + // ---------------------------------- + + // https://developers.webflow.com/#get-single-item + + const collectionId = this.getNodeParameter('collectionId', i) as string; + const itemId = this.getNodeParameter('itemId', i) as string; + responseData = await webflowApiRequest.call(this, 'GET', `/collections/${collectionId}/items/${itemId}`); + responseData = responseData.items; + + } else if (operation === 'getAll') { + + // ---------------------------------- + // item: getAll + // ---------------------------------- + + // https://developers.webflow.com/#get-all-items-for-a-collection + + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + const collectionId = this.getNodeParameter('collectionId', i) as string; + const qs: IDataObject = {}; + + if (returnAll === true) { + responseData = await webflowApiRequestAllItems.call(this, 'GET', `/collections/${collectionId}/items`, {}, qs); + } else { + qs.limit = this.getNodeParameter('limit', 0) as number; + responseData = await webflowApiRequest.call(this, 'GET', `/collections/${collectionId}/items`, {}, qs); + responseData = responseData.items; + } + + } else if (operation === 'update') { + + // ---------------------------------- + // item: update + // ---------------------------------- + + // https://developers.webflow.com/#update-collection-item + + const collectionId = this.getNodeParameter('collectionId', i) as string; + + const itemId = this.getNodeParameter('itemId', i) as string; + + const properties = this.getNodeParameter('fieldsUi.fieldValues', i, []) as IDataObject[]; + + const live = this.getNodeParameter('live', i) as boolean; + + const fields = {} as IDataObject; + + properties.forEach(data => (fields[data.fieldId as string] = data.fieldValue)); + + const body: IDataObject = { + fields, + }; + + responseData = await webflowApiRequest.call(this, 'PUT', `/collections/${collectionId}/items/${itemId}`, body, { live }); + } + } + + Array.isArray(responseData) + ? returnData.push(...responseData) + : returnData.push(responseData); + } + + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Webflow/WebflowTrigger.node.ts b/packages/nodes-base/nodes/Webflow/WebflowTrigger.node.ts index f9fd7d8394..94519e2328 100644 --- a/packages/nodes-base/nodes/Webflow/WebflowTrigger.node.ts +++ b/packages/nodes-base/nodes/Webflow/WebflowTrigger.node.ts @@ -20,7 +20,7 @@ export class WebflowTrigger implements INodeType { description: INodeTypeDescription = { displayName: 'Webflow Trigger', name: 'webflowTrigger', - icon: 'file:webflow.png', + icon: 'file:webflow.svg', group: ['trigger'], version: 1, description: 'Handle Webflow events via webhooks', @@ -97,10 +97,6 @@ export class WebflowTrigger implements INodeType { type: 'options', required: true, options: [ - { - name: 'Form submission', - value: 'form_submission', - }, { name: 'Ecomm Inventory Changed', value: 'ecomm_inventory_changed', @@ -113,6 +109,10 @@ export class WebflowTrigger implements INodeType { name: 'Ecomm Order Changed', value: 'ecomm_order_changed', }, + { + name: 'Form Submission', + value: 'form_submission', + }, { name: 'Site Publish', value: 'site_publish', diff --git a/packages/nodes-base/nodes/Webflow/webflow.png b/packages/nodes-base/nodes/Webflow/webflow.png deleted file mode 100644 index a4716f6b7a..0000000000 Binary files a/packages/nodes-base/nodes/Webflow/webflow.png and /dev/null differ diff --git a/packages/nodes-base/nodes/Webflow/webflow.svg b/packages/nodes-base/nodes/Webflow/webflow.svg new file mode 100644 index 0000000000..fb80355245 --- /dev/null +++ b/packages/nodes-base/nodes/Webflow/webflow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index dbecf013b8..3025284dd7 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -530,6 +530,7 @@ "dist/nodes/UProc/UProc.node.js", "dist/nodes/Vero/Vero.node.js", "dist/nodes/Vonage/Vonage.node.js", + "dist/nodes/Webflow/Webflow.node.js", "dist/nodes/Webflow/WebflowTrigger.node.js", "dist/nodes/Webhook.node.js", "dist/nodes/Wekan/Wekan.node.js",