From 28b0c871367bf094571609ceb2a4b1362120d981 Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 4 Nov 2020 10:53:33 +0100 Subject: [PATCH 1/6] :sparkles: Add Storyblok Node (#1125) * :sparkles: Add Storyblok node * :hammer: Minor changes * :zap: Small improvements * :zap: Improvements * :zap: Prepare Storyblok Node for release Co-authored-by: Tanay Pant Co-authored-by: ricardo --- .../StoryblokContentApi.credentials.ts | 18 + .../StoryblokManagementApi.credentials.ts | 18 + .../nodes/Storyblok/GenericFunctions.ts | 92 +++ .../Storyblok/StoryContentDescription.ts | 143 ++++ .../Storyblok/StoryManagementDescription.ts | 647 ++++++++++++++++++ .../nodes/Storyblok/Storyblok.node.ts | 334 +++++++++ .../nodes-base/nodes/Storyblok/storyblok.svg | 17 + packages/nodes-base/package.json | 5 + 8 files changed, 1274 insertions(+) create mode 100644 packages/nodes-base/credentials/StoryblokContentApi.credentials.ts create mode 100644 packages/nodes-base/credentials/StoryblokManagementApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Storyblok/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Storyblok/StoryContentDescription.ts create mode 100644 packages/nodes-base/nodes/Storyblok/StoryManagementDescription.ts create mode 100644 packages/nodes-base/nodes/Storyblok/Storyblok.node.ts create mode 100644 packages/nodes-base/nodes/Storyblok/storyblok.svg diff --git a/packages/nodes-base/credentials/StoryblokContentApi.credentials.ts b/packages/nodes-base/credentials/StoryblokContentApi.credentials.ts new file mode 100644 index 0000000000..71c32e5358 --- /dev/null +++ b/packages/nodes-base/credentials/StoryblokContentApi.credentials.ts @@ -0,0 +1,18 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class StoryblokContentApi implements ICredentialType { + name = 'storyblokContentApi'; + displayName = 'Storyblok Content API'; + documentationUrl = 'storyblok'; + properties = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/credentials/StoryblokManagementApi.credentials.ts b/packages/nodes-base/credentials/StoryblokManagementApi.credentials.ts new file mode 100644 index 0000000000..de390da8e4 --- /dev/null +++ b/packages/nodes-base/credentials/StoryblokManagementApi.credentials.ts @@ -0,0 +1,18 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class StoryblokManagementApi implements ICredentialType { + name = 'storyblokManagementApi'; + displayName = 'Storyblok Management API'; + documentationUrl = 'storyblok'; + properties = [ + { + displayName: 'Personal Access Token', + name: 'accessToken', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Storyblok/GenericFunctions.ts b/packages/nodes-base/nodes/Storyblok/GenericFunctions.ts new file mode 100644 index 0000000000..71ff1857b9 --- /dev/null +++ b/packages/nodes-base/nodes/Storyblok/GenericFunctions.ts @@ -0,0 +1,92 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function storyblokApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + const authenticationMethod = this.getNodeParameter('source', 0) as string; + + let options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/json', + }, + method, + qs, + body, + uri: '', + json: true, + }; + + options = Object.assign({}, options, option); + + if (Object.keys(options.body).length === 0) { + delete options.body; + } + + if (authenticationMethod === 'contentApi') { + const credentials = this.getCredentials('storyblokContentApi') as IDataObject; + + options.uri = `https://api.storyblok.com${resource}`; + + Object.assign(options.qs, { token: credentials.apiKey }); + } else { + const credentials = this.getCredentials('storyblokManagementApi') as IDataObject; + + options.uri = `https://mapi.storyblok.com${resource}`; + + Object.assign(options.headers, { 'Authorization': credentials.accessToken }); + } + + try { + return this.helpers.request!(options); + } catch (error) { + if (error.response && error.response.body && error.response.body.message) { + // Try to return the error prettier + const errorBody = error.response.body; + throw new Error(`Storyblok error response [${error.statusCode}]: ${errorBody.message}`); + } + + // Expected error data did not get returned so throw the actual error + throw error; + } +} + +export async function storyblokApiRequestAllItems(this: IHookFunctions | ILoadOptionsFunctions | IExecuteFunctions, propertyName: string, method: string, resource: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any + const returnData: IDataObject[] = []; + + let responseData; + + query.per_page = 100; + + query.page = 1; + + do { + responseData = await storyblokApiRequest.call(this, method, resource, body, query); + query.page++; + returnData.push.apply(returnData, responseData[propertyName]); + } while ( + responseData[propertyName].length !== 0 + ); + + return returnData; +} + +export function validateJSON(json: string | undefined): any { // tslint:disable-line:no-any + let result; + try { + result = JSON.parse(json!); + } catch (exception) { + result = undefined; + } + return result; +} diff --git a/packages/nodes-base/nodes/Storyblok/StoryContentDescription.ts b/packages/nodes-base/nodes/Storyblok/StoryContentDescription.ts new file mode 100644 index 0000000000..48b78e9d54 --- /dev/null +++ b/packages/nodes-base/nodes/Storyblok/StoryContentDescription.ts @@ -0,0 +1,143 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const storyContentOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + source: [ + 'contentApi', + ], + resource: [ + 'story', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get a story', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all stories', + }, + ], + default: 'get', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const storyContentFields = [ + + /* -------------------------------------------------------------------------- */ + /* story:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Identifier', + name: 'identifier', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + source: [ + 'contentApi', + ], + resource: [ + 'story', + ], + operation: [ + 'get', + ], + }, + }, + description: 'The ID or slug of the story to get.', + }, + + /* -------------------------------------------------------------------------- */ + /* story:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + source: [ + 'contentApi', + ], + resource: [ + 'story', + ], + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: 'Returns a list of your user contacts.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + source: [ + 'contentApi', + ], + resource: [ + 'story', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 50, + description: 'How many results to return.', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + source: [ + 'contentApi', + ], + resource: [ + 'story', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Starts With', + name: 'starts_with', + type: 'string', + default: '', + description: 'Filter by slug.', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Storyblok/StoryManagementDescription.ts b/packages/nodes-base/nodes/Storyblok/StoryManagementDescription.ts new file mode 100644 index 0000000000..9e0acb49c3 --- /dev/null +++ b/packages/nodes-base/nodes/Storyblok/StoryManagementDescription.ts @@ -0,0 +1,647 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const storyManagementOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + source: [ + 'managementApi', + ], + resource: [ + 'story', + ], + }, + }, + options: [ + // { + // name: 'Create', + // value: 'create', + // description: 'Create a story', + // }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a story', + }, + { + name: 'Get', + value: 'get', + description: 'Get a story', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all stories', + }, + { + name: 'Publish', + value: 'publish', + description: 'Publish a story', + }, + { + name: 'Unpublish', + value: 'unpublish', + description: 'Unpublish a story', + }, + ], + default: 'get', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const storyManagementFields = [ + + // /* -------------------------------------------------------------------------- */ + // /* story:create */ + // /* -------------------------------------------------------------------------- */ + // { + // displayName: 'Space ID', + // name: 'space', + // type: 'options', + // typeOptions: { + // loadOptionsMethod: 'getSpaces', + // }, + // default: '', + // required: true, + // displayOptions: { + // show: { + // source: [ + // 'managementApi', + // ], + // resource: [ + // 'story', + // ], + // operation: [ + // 'create', + // ], + // }, + // }, + // description: 'The name of the space.', + // }, + // { + // displayName: 'Name', + // name: 'name', + // type: 'string', + // default: '', + // required: true, + // displayOptions: { + // show: { + // source: [ + // 'managementApi', + // ], + // resource: [ + // 'story', + // ], + // operation: [ + // 'create', + // ], + // }, + // }, + // description: 'The name you give this story.', + // }, + // { + // displayName: 'Slug', + // name: 'slug', + // type: 'string', + // default: '', + // required: true, + // displayOptions: { + // show: { + // source: [ + // 'managementApi', + // ], + // resource: [ + // 'story', + // ], + // operation: [ + // 'create', + // ], + // }, + // }, + // description: 'The slug/path you give this story.', + // }, + // { + // displayName: 'JSON Parameters', + // name: 'jsonParameters', + // type: 'boolean', + // default: false, + // description: '', + // displayOptions: { + // show: { + // source: [ + // 'managementApi', + // ], + // resource: [ + // 'story', + // ], + // operation: [ + // 'create', + // ], + // }, + // }, + // }, + // { + // displayName: 'Additional Fields', + // name: 'additionalFields', + // type: 'collection', + // placeholder: 'Add Field', + // displayOptions: { + // show: { + // source: [ + // 'managementApi', + // ], + // resource: [ + // 'story', + // ], + // operation: [ + // 'create', + // ], + // }, + // }, + // default: {}, + // options: [ + // { + // displayName: 'Content', + // name: 'contentUi', + // type: 'fixedCollection', + // description: 'Add Content', + // typeOptions: { + // multipleValues: false, + // }, + // displayOptions: { + // show: { + // '/jsonParameters': [ + // false, + // ], + // }, + // }, + // placeholder: 'Add Content', + // default: '', + // options: [ + // { + // displayName: 'Content Data', + // name: 'contentValue', + // values: [ + // { + // displayName: 'Component', + // name: 'component', + // type: 'options', + // typeOptions: { + // loadOptionsMethod: 'getComponents', + // loadOptionsDependsOn: [ + // 'space', + // ], + // }, + // default: '', + // }, + // { + // displayName: 'Elements', + // name: 'elementUi', + // type: 'fixedCollection', + // description: 'Add Body', + // typeOptions: { + // multipleValues: true, + // }, + // placeholder: 'Add Element', + // default: '', + // options: [ + // { + // displayName: 'Element', + // name: 'elementValues', + // values: [ + // { + // displayName: 'Component', + // name: 'component', + // type: 'options', + // typeOptions: { + // loadOptionsMethod: 'getComponents', + // loadOptionsDependsOn: [ + // 'space', + // ], + // }, + // default: '', + // }, + // { + // displayName: 'Element Data', + // name: 'dataUi', + // type: 'fixedCollection', + // description: 'Add Data', + // typeOptions: { + // multipleValues: true, + // }, + // placeholder: 'Add Data', + // default: '', + // options: [ + // { + // displayName: 'Data', + // name: 'dataValues', + // values: [ + // { + // displayName: 'Key', + // name: 'key', + // type: 'string', + // default: '', + // }, + // { + // displayName: 'Value', + // name: 'value', + // type: 'string', + // default: '', + // }, + // ], + // }, + // ], + // }, + // ], + // }, + // ], + // }, + // ], + // }, + // ], + // }, + // { + // displayName: 'Content (JSON)', + // name: 'contentJson', + // type: 'string', + // displayOptions: { + // show: { + // '/jsonParameters': [ + // true, + // ], + // }, + // }, + // default: '', + // }, + // { + // displayName: 'Parent ID', + // name: 'parentId', + // type: 'string', + // default: '', + // description: 'Parent story/folder numeric ID.', + // }, + // { + // displayName: 'Path', + // name: 'path', + // type: 'string', + // default: '', + // description: 'Given real path, used in the preview editor.', + // }, + // { + // displayName: 'Is Startpage', + // name: 'isStartpage', + // type: 'boolean', + // default: false, + // description: 'Is startpage of current folder.', + // }, + // { + // displayName: 'First Published At', + // name: 'firstPublishedAt', + // type: 'dateTime', + // default: '', + // description: 'First publishing date.', + // }, + // ], + // }, + + /* -------------------------------------------------------------------------- */ + /* story:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Space ID', + name: 'space', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSpaces', + }, + default: '', + required: true, + displayOptions: { + show: { + source: [ + 'managementApi', + ], + resource: [ + 'story', + ], + operation: [ + 'delete', + ], + }, + }, + description: 'The name of the space.', + }, + { + displayName: 'Story ID', + name: 'storyId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + source: [ + 'managementApi', + ], + resource: [ + 'story', + ], + operation: [ + 'delete', + ], + }, + }, + description: 'Numeric ID of the story.', + }, + + /* -------------------------------------------------------------------------- */ + /* story:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Space ID', + name: 'space', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSpaces', + }, + default: '', + required: true, + displayOptions: { + show: { + source: [ + 'managementApi', + ], + resource: [ + 'story', + ], + operation: [ + 'get', + ], + }, + }, + description: 'The name of the space.', + }, + { + displayName: 'Story ID', + name: 'storyId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + source: [ + 'managementApi', + ], + resource: [ + 'story', + ], + operation: [ + 'get', + ], + }, + }, + description: 'Numeric ID of the story.', + }, + + /* -------------------------------------------------------------------------- */ + /* story:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Space ID', + name: 'space', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSpaces', + }, + default: '', + required: true, + displayOptions: { + show: { + source: [ + 'managementApi', + ], + resource: [ + 'story', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'The name of the space', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + source: [ + 'managementApi', + ], + resource: [ + 'story', + ], + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: 'Returns a list of your user contacts.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + source: [ + 'managementApi', + ], + resource: [ + 'story', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 50, + description: 'How many results to return.', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + source: [ + 'managementApi', + ], + resource: [ + 'story', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Starts With', + name: 'starts_with', + type: 'string', + default: '', + description: 'Filter by slug.', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* story:publish */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Space ID', + name: 'space', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSpaces', + }, + default: '', + required: true, + displayOptions: { + show: { + source: [ + 'managementApi', + ], + resource: [ + 'story', + ], + operation: [ + 'publish', + ], + }, + }, + description: 'The name of the space.', + }, + { + displayName: 'Story ID', + name: 'storyId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + source: [ + 'managementApi', + ], + resource: [ + 'story', + ], + operation: [ + 'publish', + ], + }, + }, + description: 'Numeric ID of the story.', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + displayOptions: { + show: { + source: [ + 'managementApi', + ], + resource: [ + 'story', + ], + operation: [ + 'publish', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Release ID', + name: 'releaseId', + type: 'string', + default: '', + description: 'Numeric ID of release.', + }, + { + displayName: 'Language', + name: 'language', + type: 'string', + default: '', + description: 'Language code to publish the story individually (must be enabled in the space settings).', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* story:unpublish */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Space ID', + name: 'space', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSpaces', + }, + default: '', + required: true, + displayOptions: { + show: { + source: [ + 'managementApi', + ], + resource: [ + 'story', + ], + operation: [ + 'unpublish', + ], + }, + }, + description: 'The name of the space.', + }, + { + displayName: 'Story ID', + name: 'storyId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + source: [ + 'managementApi', + ], + resource: [ + 'story', + ], + operation: [ + 'unpublish', + ], + }, + }, + description: 'Numeric ID of the story.', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Storyblok/Storyblok.node.ts b/packages/nodes-base/nodes/Storyblok/Storyblok.node.ts new file mode 100644 index 0000000000..fac9331865 --- /dev/null +++ b/packages/nodes-base/nodes/Storyblok/Storyblok.node.ts @@ -0,0 +1,334 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + storyblokApiRequest, + storyblokApiRequestAllItems, + validateJSON, +} from './GenericFunctions'; + +import { + storyContentFields, + storyContentOperations, +} from './StoryContentDescription'; + +import { + storyManagementFields, + storyManagementOperations, +} from './StoryManagementDescription'; + +import { v4 as uuidv4 } from 'uuid'; + +export class Storyblok implements INodeType { + description: INodeTypeDescription = { + displayName: 'Storyblok', + name: 'storyblok', + icon: 'file:storyblok.svg', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Storyblok API', + defaults: { + name: 'Storyblok', + color: '#09b3af', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'storyblokContentApi', + required: true, + displayOptions: { + show: { + source: [ + 'contentApi', + ], + }, + }, + }, + { + name: 'storyblokManagementApi', + required: true, + displayOptions: { + show: { + source: [ + 'managementApi', + ], + }, + }, + }, + ], + properties: [ + { + displayName: 'Source', + name: 'source', + type: 'options', + default: 'contentApi', + description: 'Pick where your data comes from, Content or Management API', + options: [ + { + name: 'Content API', + value: 'contentApi', + }, + { + name: 'Management API', + value: 'managementApi', + }, + ], + }, + // Resources: Content API + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Story', + value: 'story', + }, + ], + default: 'story', + description: 'Resource to consume.', + displayOptions: { + show: { + source: [ + 'contentApi', + ], + }, + }, + }, + // Resources: Management API + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Story', + value: 'story', + }, + ], + default: 'story', + description: 'Resource to consume.', + displayOptions: { + show: { + source: [ + 'managementApi', + ], + }, + }, + }, + // Content API - Story + ...storyContentOperations, + ...storyContentFields, + // Management API - Story + ...storyManagementOperations, + ...storyManagementFields, + ], + }; + + methods = { + loadOptions: { + async getSpaces( + this: ILoadOptionsFunctions, + ): Promise { + const returnData: INodePropertyOptions[] = []; + const { spaces } = await storyblokApiRequest.call( + this, + 'GET', + '/v1/spaces', + ); + for (const space of spaces) { + returnData.push({ + name: space.name, + value: space.id, + }); + } + return returnData; + }, + async getComponents( + this: ILoadOptionsFunctions, + ): Promise { + const returnData: INodePropertyOptions[] = []; + const space = this.getCurrentNodeParameter('space') as string; + const { components } = await storyblokApiRequest.call( + this, + 'GET', + `/v1/spaces/${space}/components`, + ); + for (const component of components) { + returnData.push({ + name: `${component.name} ${(component.is_root ? '(root)' : '')}`, + value: component.name, + }); + } + return returnData; + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = items.length as unknown as number; + const qs: IDataObject = {}; + let responseData; + const source = this.getNodeParameter('source', 0) as string; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + for (let i = 0; i < length; i++) { + if (source === 'contentApi') { + if (resource === 'story') { + if (operation === 'get') { + const identifier = this.getNodeParameter('identifier', i) as string; + + responseData = await storyblokApiRequest.call(this, 'GET', `/v1/cdn/stories/${identifier}`); + responseData = responseData.story; + } + if (operation === 'getAll') { + const filters = this.getNodeParameter('filters', i) as string; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + Object.assign(qs, filters); + + if (returnAll) { + responseData = await storyblokApiRequestAllItems.call(this, 'stories', 'GET', '/v1/cdn/stories', {}, qs); + } else { + const limit = this.getNodeParameter('limit', i) as number; + qs.per_page = limit; + responseData = await storyblokApiRequest.call(this, 'GET', `/v1/cdn/stories`, {}, qs); + responseData = responseData.stories; + } + } + } + } + if (source === 'managementApi') { + if (resource === 'story') { + // if (operation === 'create') { + // const space = this.getNodeParameter('space', i) as string; + // const name = this.getNodeParameter('name', i) as string; + // const slug = this.getNodeParameter('slug', i) as string; + // const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean; + // const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + // const body: IDataObject = { + // name, + // slug, + // }; + + // if (jsonParameters) { + // if (additionalFields.contentJson) { + // const json = validateJSON(additionalFields.contentJson as string); + // body.content = json; + // } + // } else { + // if (additionalFields.contentUi) { + // const contentValue = (additionalFields.contentUi as IDataObject).contentValue as IDataObject; + // const content: { component: string, body: IDataObject[] } = { component: '', body: [] }; + // if (contentValue) { + // content.component = contentValue.component as string; + // const elementValues = (contentValue.elementUi as IDataObject).elementValues as IDataObject[]; + // for (const elementValue of elementValues) { + // const body: IDataObject = {}; + // body._uid = uuidv4(); + // body.component = elementValue.component; + // if (elementValue.dataUi) { + // const dataValues = (elementValue.dataUi as IDataObject).dataValues as IDataObject[]; + // for (const dataValue of dataValues) { + // body[dataValue.key as string] = dataValue.value; + // } + // } + // content.body.push(body); + // } + // } + // body.content = content; + // } + // } + + // if (additionalFields.parentId) { + // body.parent_id = additionalFields.parentId as string; + // } + // if (additionalFields.path) { + // body.path = additionalFields.path as string; + // } + // if (additionalFields.isStartpage) { + // body.is_startpage = additionalFields.isStartpage as string; + // } + // if (additionalFields.firstPublishedAt) { + // body.first_published_at = additionalFields.firstPublishedAt as string; + // } + + // responseData = await storyblokApiRequest.call(this, 'POST', `/v1/spaces/${space}/stories`, { story: body }); + // responseData = responseData.story; + // } + if (operation === 'delete') { + const space = this.getNodeParameter('space', i) as string; + const storyId = this.getNodeParameter('storyId', i) as string; + + responseData = await storyblokApiRequest.call(this, 'DELETE', `/v1/spaces/${space}/stories/${storyId}`); + responseData = responseData.story; + } + if (operation === 'get') { + const space = this.getNodeParameter('space', i) as string; + const storyId = this.getNodeParameter('storyId', i) as string; + + responseData = await storyblokApiRequest.call(this, 'GET', `/v1/spaces/${space}/stories/${storyId}`); + responseData = responseData.story; + } + if (operation === 'getAll') { + const space = this.getNodeParameter('space', i) as string; + const filters = this.getNodeParameter('filters', i) as string; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + Object.assign(qs, filters); + + if (returnAll) { + responseData = await storyblokApiRequestAllItems.call(this, 'stories', 'GET', `/v1/spaces/${space}/stories`, {}, qs); + } else { + const limit = this.getNodeParameter('limit', i) as number; + qs.per_page = limit; + responseData = await storyblokApiRequest.call(this, 'GET', `/v1/spaces/${space}/stories`, {}, qs); + responseData = responseData.stories; + } + } + if (operation === 'publish') { + const space = this.getNodeParameter('space', i) as string; + const storyId = this.getNodeParameter('storyId', i) as string; + const options = this.getNodeParameter('options', i) as IDataObject; + const query: IDataObject = {}; + // Not sure if these two options work + if (options.releaseId) { + query.release_id = options.releaseId as string; + } + if (options.language) { + query.lang = options.language as string; + } + + responseData = await storyblokApiRequest.call(this, 'GET', `/v1/spaces/${space}/stories/${storyId}/publish`, {}, query); + responseData = responseData.story; + } + if (operation === 'unpublish') { + const space = this.getNodeParameter('space', i) as string; + const storyId = this.getNodeParameter('storyId', i) as string; + + responseData = await storyblokApiRequest.call(this, 'GET', `/v1/spaces/${space}/stories/${storyId}/unpublish`); + responseData = responseData.story; + } + } + } + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else { + returnData.push(responseData as IDataObject); + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Storyblok/storyblok.svg b/packages/nodes-base/nodes/Storyblok/storyblok.svg new file mode 100644 index 0000000000..b5c3a9f264 --- /dev/null +++ b/packages/nodes-base/nodes/Storyblok/storyblok.svg @@ -0,0 +1,17 @@ + + + + colored-standalone + Created with Sketch. + + + + + + \ No newline at end of file diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 7daa70ae6c..6ec6297836 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -170,6 +170,10 @@ "dist/credentials/StravaOAuth2Api.credentials.js", "dist/credentials/StripeApi.credentials.js", "dist/credentials/Sftp.credentials.js", + "dist/credentials/Signl4Api.credentials.js", + "dist/credentials/SpotifyOAuth2Api.credentials.js", + "dist/credentials/StoryblokContentApi.credentials.js", + "dist/credentials/StoryblokManagementApi.credentials.js", "dist/credentials/SurveyMonkeyApi.credentials.js", "dist/credentials/SurveyMonkeyOAuth2Api.credentials.js", "dist/credentials/TaigaCloudApi.credentials.js", @@ -371,6 +375,7 @@ "dist/nodes/SpreadsheetFile.node.js", "dist/nodes/SseTrigger.node.js", "dist/nodes/Start.node.js", + "dist/nodes/Storyblok/Storyblok.node.js", "dist/nodes/Strava/Strava.node.js", "dist/nodes/Strava/StravaTrigger.node.js", "dist/nodes/Stripe/StripeTrigger.node.js", From ff9964e10bfb9cde98da221f9691d26b83910325 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Wed, 4 Nov 2020 06:25:18 -0500 Subject: [PATCH 2/6] :sparkles: Add Facebook Trigger Node (#1106) * :zap: Facebook trigger from webhooks Uses tokens just like the GraphQL API but this one requires an app token instead of an user access token. * :zap: Improvements :zap Improvements * :bug: Fix credential file name Co-authored-by: Omar Ajoue --- ...acebookGraphSubscriptionApi.credentials.ts | 21 ++ .../nodes/Facebook/FacebookGraphApi.node.ts | 6 +- .../nodes/Facebook/FacebookTrigger.node.ts | 251 ++++++++++++++++++ .../nodes/Facebook/GenericFunctions.ts | 53 ++++ .../nodes-base/nodes/Facebook/facebook.png | Bin 1371 -> 2413 bytes packages/nodes-base/package.json | 2 + 6 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 packages/nodes-base/credentials/FacebookGraphSubscriptionApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts create mode 100644 packages/nodes-base/nodes/Facebook/GenericFunctions.ts diff --git a/packages/nodes-base/credentials/FacebookGraphSubscriptionApi.credentials.ts b/packages/nodes-base/credentials/FacebookGraphSubscriptionApi.credentials.ts new file mode 100644 index 0000000000..b96f841347 --- /dev/null +++ b/packages/nodes-base/credentials/FacebookGraphSubscriptionApi.credentials.ts @@ -0,0 +1,21 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class FacebookGraphSubscriptionApi implements ICredentialType { + name = 'facebookGraphSubscriptionApi'; + displayName = 'Facebook Graph API'; + extends = [ + 'facebookGraphApi', + ]; + properties = [ + { + displayName: 'APP Secret', + name: 'appSecret', + type: 'string' as NodePropertyTypes, + default: '', + description: '(Optional) When the app secret is set the node will verify this signature to validate the integrity and origin of the payload.', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts b/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts index 42d13cc873..d8a9aa2d3c 100644 --- a/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts +++ b/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts @@ -10,7 +10,9 @@ import { INodeTypeDescription, } from 'n8n-workflow'; -import { OptionsWithUri } from 'request'; +import { + OptionsWithUri, +} from 'request'; export class FacebookGraphApi implements INodeType { description: INodeTypeDescription = { @@ -22,7 +24,7 @@ export class FacebookGraphApi implements INodeType { description: 'Interacts with Facebook using the Graph API', defaults: { name: 'Facebook Graph API', - color: '#772244', + color: '#3B5998', }, inputs: ['main'], outputs: ['main'], diff --git a/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts b/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts new file mode 100644 index 0000000000..cb0f16b7af --- /dev/null +++ b/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts @@ -0,0 +1,251 @@ +import { + IHookFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeType, + INodeTypeDescription, + IWebhookResponseData, +} from 'n8n-workflow'; + +import * as uuid from 'uuid/v4'; + +import { + snakeCase, +} from 'change-case'; + +import { + facebookApiRequest, +} from './GenericFunctions'; + +import { + createHmac, +} from 'crypto'; + +export class FacebookTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Facebook Trigger', + name: 'facebookTrigger', + icon: 'file:facebook.png', + group: ['trigger'], + version: 1, + subtitle: '={{$parameter["appId"] +"/"+ $parameter["object"]}}', + description: 'Starts the workflow when a Facebook events occurs.', + defaults: { + name: 'Facebook Trigger', + color: '#3B5998', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'facebookGraphSubscriptionApi', + required: true, + }, + ], + webhooks: [ + { + name: 'setup', + httpMethod: 'GET', + responseMode: 'onReceived', + path: 'webhook', + }, + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Object', + name: 'object', + type: 'options', + options: [ + { + name: 'Ad Account', + value: 'adAccount', + description: 'Get updates about Ad Account', + }, + { + name: 'Application', + value: 'application', + description: 'Get updates about the app', + }, + { + name: 'Certificate Transparency', + value: 'certificateTransparency', + description: 'Get updates about Certificate Transparency', + }, + { + name: 'Group', + value: 'group', + description: 'Get updates about activity in groups and events in groups for Workplace', + }, + { + name: 'Instagram', + value: 'instagram', + description: 'Get updates about comments on your media', + }, + { + name: 'Link', + value: 'link', + description: 'Get updates about links for rich previews by an external provider', + }, + { + name: 'Page', + value: 'page', + description: 'Page updates', + }, + { + name: 'Permissions', + value: 'permissions', + description: 'Updates regarding granting or revoking permissions', + }, + { + name: 'User', + value: 'user', + description: 'User profile updates', + }, + { + name: 'Whatsapp Business Account', + value: 'whatsappBusinessAccount', + description: 'Get updates about Whatsapp business account', + }, + { + name: 'Workplace Security', + value: 'workplaceSecurity', + description: 'Get updates about Workplace Security', + }, + ], + required: true, + default: 'user', + description: 'The object to subscribe to', + }, + { + displayName: 'App ID', + name: 'appId', + type: 'string', + required: true, + default: '', + description: 'Facebook APP ID', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + default: {}, + placeholder: 'Add option', + options: [ + { + displayName: 'Include values', + name: 'includeValues', + type: 'boolean', + default: true, + description: 'Indicates if change notifications should include the new values.', + }, + ], + }, + ], + }; + + // @ts-ignore (because of request) + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default') as string; + const object = this.getNodeParameter('object') as string; + const appId = this.getNodeParameter('appId') as string; + + const { data } = await facebookApiRequest.call(this, 'GET', `/${appId}/subscriptions`, {}); + + for (const webhook of data) { + if (webhook.target === webhookUrl && webhook.object === object && webhook.status === true) { + return true; + } + } + return false; + }, + async create(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const webhookUrl = this.getNodeWebhookUrl('default') as string; + const object = this.getNodeParameter('object') as string; + const appId = this.getNodeParameter('appId') as string; + const options = this.getNodeParameter('options') as IDataObject; + + const body = { + object: snakeCase(object), + callback_url: webhookUrl, + verify_token: uuid(), + } as IDataObject; + + if (options.includeValues !== undefined) { + body.include_values = options.includeValues; + } + + const responseData = await facebookApiRequest.call(this, 'POST', `/${appId}/subscriptions`, body); + + webhookData.verifyToken = body.verify_token; + + if (responseData.success !== true) { + // Facebook did not return success, so something went wrong + throw new Error('Facebook webhook creation response did not contain the expected data.'); + } + return true; + }, + async delete(this: IHookFunctions): Promise { + const appId = this.getNodeParameter('appId') as string; + const object = this.getNodeParameter('object') as string; + + try { + await facebookApiRequest.call(this, 'DELETE', `/${appId}/subscriptions`, { object: snakeCase(object) }); + } catch (e) { + return false; + } + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const bodyData = this.getBodyData() as IDataObject; + const query = this.getQueryData() as IDataObject; + const res = this.getResponseObject(); + const req = this.getRequestObject(); + const headerData = this.getHeaderData() as IDataObject; + const credentials = this.getCredentials('facebookGraphSubscriptionApi') as IDataObject; + // Check if we're getting facebook's challenge request (https://developers.facebook.com/docs/graph-api/webhooks/getting-started) + if (this.getWebhookName() === 'setup') { + if (query['hub.challenge']) { + //TODO + //compare hub.verify_token with the saved token + //const webhookData = this.getWorkflowStaticData('node'); + // if (webhookData.verifyToken !== query['hub.verify_token']) { + // return {}; + // } + res.status(200).send(query['hub.challenge']).end(); + return { + noWebhookResponse: true, + }; + } + } + + // validate signature if app secret is set + if (credentials.appSecret !== '') { + //@ts-ignore + const computedSignature = createHmac('sha1', credentials.appSecret as string).update(req.rawBody).digest('hex'); + if (headerData['x-hub-signature'] !== `sha1=${computedSignature}`) { + return {}; + } + } + + return { + workflowData: [ + this.helpers.returnJsonArray(bodyData.entry as IDataObject[]), + ], + }; + } +} diff --git a/packages/nodes-base/nodes/Facebook/GenericFunctions.ts b/packages/nodes-base/nodes/Facebook/GenericFunctions.ts new file mode 100644 index 0000000000..45412ff8f0 --- /dev/null +++ b/packages/nodes-base/nodes/Facebook/GenericFunctions.ts @@ -0,0 +1,53 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + ILoadOptionsFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function facebookApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IWebhookFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + + let credentials; + + if (this.getNode().name.includes('Trigger')) { + credentials = this.getCredentials('facebookGraphSubscriptionApi') as IDataObject; + } else { + credentials = this.getCredentials('facebookGraphApi') as IDataObject; + } + + qs.access_token = credentials!.accessToken; + + const options: OptionsWithUri = { + headers: { + accept: 'application/json,text/*;q=0.99', + }, + method, + qs, + body, + gzip: true, + uri: uri ||`https://graph.facebook.com/v8.0${resource}`, + json: true, + }; + + try { + return await this.helpers.request!(options); + } catch (error) { + + if (error.response.body && error.response.body.error) { + const message = error.response.body.error.message; + throw new Error( + `Facebook Trigger error response [${error.statusCode}]: ${message}`, + ); + } + throw new Error(error); + } +} diff --git a/packages/nodes-base/nodes/Facebook/facebook.png b/packages/nodes-base/nodes/Facebook/facebook.png index e0cc04460903fb05de99b1c4189d5170f44e6a02..85e6670eeba4930e5b4a9ab96fdf727f4ccb038e 100644 GIT binary patch literal 2413 zcmb7_c|4SB8^`b2%vdrrl%;GF*@{Hj8M}Csr7UfjlBG^BV^^3g89JQ?LzL_~)Nv#! zidUf{ojS?B^~NMeMqb8PW*9T?GgAHW{`FqZ=kwgx=lNXs^|`;l=en+2FeDfUIA;eZ z2LOWsa1eTcfB~-A$A$y~;Nk+b0RR|)f=K}c8@90x z00bNfLje&qMpR533TVdx7y|l+K#CyIB1nu#1Pl%kc%&2owNH8{PQ=<>e=CWsicV6= zve{PZVemlhw4O|Bc(-l#{$vc}h}Et$vIpkr5oK<6btx?MqlN)Fk!}7xspWEEA2sZa z&OY>vs>p4>ve4@geR;ysE3jTvOn$qjmbS5p=|LxFZ=YkwgMvfO#l*&4Orxe}T+O_Z zmw)qC$(>&-tM1;bee}4Y@yXNYFaGFw+4<_vcYXZ>gG0k3G9jA59%{n2cVvK~l0xeX2@2*kk=LhO&PE~z7eM2(~D=RB? zJ%@kTTRNK%tt&{Ea1_`gjs0wfR{^5LQ4nz=`CuQP~JF&t`o z6kO3xpJ^?)o>zQAVvNpgTiK$c-$RYBTKTK3Xp~l7G^XvszBOz7)Z}oreB;a$!&*mC z1*-3s{HXXQw>i3o7&WfFzjKM?SH@&=qk~^}Ee2M_+_pGL^V+tmWj{iW3}!3(cn23Y z$hzwE|Lo{no)^Sz`Q39>Rl@@jLeG)E51kAH{eKt=9*~-0v~?FhE4MQINZQ{fYj>?h z8yk-@qX#rGE3v0uy7U(gnmwM_8_-VBoqsRie=@u53V{^dbf78BMK0-HaL+$6M!L@j zdRWH)elTk`$7&f+-Qm)qeTNn@M){?2LcbF@AFO&(ua`9&Q5I97J+0}{fBr(JYuui) zEdI5SQMZWTr{xkaAi1R76Njk1&Zf?Db-s~EuN%^z zrm+&u%dT&0dAim!!$eb0R(1JtpP;FO9ZeF5*_IL1`HgH)TSM7MmiOzVsS9TjidK(t z_?|e|@~3_UN~aYvYaH^=%#>#KDveBv%C^~uP4HBm?1px%Jzt+FNn-6mH9Tnj0iLW&UKQR+$S+r!sghizxBDbuG- zSCuB6-=S+V78H~3$ZK+yHP$K;B@acG)t9#>4y`B9aonO;=8uj{4yAMRX~RL ziwIXF(cT)25xoExvQk3ToeJfG%wOy;X)xRdS%bCQ>#x_$DkpxEqUm5sY{k6gXpGWN zu;-?-*-22AwcKAd#uw>JmUpW7m3C^90sytY0Zuqw;X;GP z003}tXao}WS-X?SNm->2TIaiQ43>(Xm79Oq<;;@W=kLb9RSJ<%W2f+&Pio>x&)ZnH z`nTx%lh~KVTt3-1-J>Cw%JZYlyE8xE=Q~_3KIGuz!JyF~kqT6aTmfiHt_X~GIJ9Uudt|@)XdLmIl*ZK zX`g?}PN)zHh8NCB1c1R&(B%N#g`zNkC+x#Pim8z2vyw_5bhk2C|09^Nm~lBK{-zg} z3GsO+m`T-LMa>EyT$XG0T0UdWnz1ukk4cNHr6>!wn5m6AeUL%5B(%LBT^ghvB6Amc zU2{{Rm8ez*XTwX&Pmy576`q=6tQQsUH3M zKefD?p@}{pE7n*#nUOI0K4EWz`4>uFQg6HhXH+i84He&+%aT|)AC!CxXBBTeRr#`V zfznWD`Fpe&KmH;`V=bM|&04Qny6p=S% z<#{VSE^D`j?#ur5W@6-*fim&A!!4Y(M!9$er)oumaRE3Z0Lo4>q9DB}h#yXxD4)L9 zcdh~lrAU@~kv{J3;fQ6Z@V$8gpv8cS>rE`B-{xlV9=mn>aA(Wc#g4)fn=~g*acNkt zP8^;OD83=dz{4M-=`SUsamKeJz-94bEU&q`&yL+^8_IQD@wVKlMjnpMwT_yczjSUz zVj4TXGB#cpU7VF@L76;l^s|MOXYz8hEc4Tzi-U_f3mVt3W5gz-4w)Tanj7wkXRP3! H35Nd*gH!z` literal 1371 zcmV-h1*H0kP)pYF^Q@H$9xBGO*{C2JLA)WRju>TH=@f42oE~WT1x&Jeh@-mI^LA?JxmGND-{7tv~ zW54}q!~Jx@{HV_O6rcGLmh}Z^;}@Lw3ykv~ru!wc{~NFWF{$|+d+I5x{2`e07IWtu zgzXrM@gI8YA9v~@cIGgx{40LyER*v#l<_5t?=`3RJDK!Ex&1w|{6oC{M5*{euKQcA z`8|{HJ&x^5!2egA@L{z14~g#|t^F65^$L9J2z%-afbAl${u6HGA+P=uZsZ}C^&*z^ z9;W*rs{AIO_78ODBa!hKhVCM({0(yFADs3fknk9W?j4El9-#OnrurtU`zD|F8GPz3 zwf+=v<~OGJEvow_qxmG7_B6EpE0OdcjPWn0`6hYiFQNB6vHCBf`98AyEr97XqWC|n z_(O{AHiGCbedsug>_oErNsa47it0?C@(GLa3z72!eCq>w>H~P`43hH{r27-0`W2@9 z0d(gMmh}yk^aX$I0(R&Da_0eX<`ba#5SsT0iSPx1?H8&27N`6WocIcj@&|?Q0dD0I zp7{rd?*@bJ0c_+CnDzi_<^gr0(R*Zr1}DK z=V6=rlK=n!gLG0(Qvd?)K@(C21+RyP`R%W-r<{jKI5aL83HSEx?9su8hi^3~A|MnF z1pfW|@$A{x*V4zu#lW_)udkz=kd1_aetmjzVq#WOQ9U_0CH(vN_V)Aa?CR<0=;h(z z;ojce+}qpR*45O`$-TR}xV5OJrJcZ|^(zJOJeRuknmJ^!D1ix>~#5G3xo7QswTq+kO#jXZqbaw`Tp;?=rUQ z&AFZJ-4Lc=bEvYnu1n#MRKjg#+1llMUMq6Yl%+lSG6M}6Yt!ymX(W?LGS_7I zGBh>1TYbGZbPXExu2cTi`X;|fIq|3*Z5FLTqGC|-3s-!S(gsr;^GY*LZ&epQNIC8S zBwm5#RTcekbu^lYra;305XHzP64fs1;qD138nZaYHhPzKy6o^)cymst+cvXl~+OcSOC7D6n@mvgg8v@M~;r4hn( zp?v9FFKlU-_snmh*dC$8Eg`~8THF~DXraU%0YXR~d#?lpIw)~rz~_s@C+>>} zdI}(J Date: Wed, 4 Nov 2020 12:26:09 +0100 Subject: [PATCH 3/6] :zap: Small changes to Facebook Trigger Node --- ....credentials.ts => FacebookGraphAppApi.credentials.ts} | 8 ++++---- .../nodes-base/nodes/Facebook/FacebookTrigger.node.ts | 4 ++-- packages/nodes-base/nodes/Facebook/GenericFunctions.ts | 2 +- packages/nodes-base/package.json | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) rename packages/nodes-base/credentials/{FacebookGraphSubscriptionApi.credentials.ts => FacebookGraphAppApi.credentials.ts} (67%) diff --git a/packages/nodes-base/credentials/FacebookGraphSubscriptionApi.credentials.ts b/packages/nodes-base/credentials/FacebookGraphAppApi.credentials.ts similarity index 67% rename from packages/nodes-base/credentials/FacebookGraphSubscriptionApi.credentials.ts rename to packages/nodes-base/credentials/FacebookGraphAppApi.credentials.ts index b96f841347..e7d3c4e27b 100644 --- a/packages/nodes-base/credentials/FacebookGraphSubscriptionApi.credentials.ts +++ b/packages/nodes-base/credentials/FacebookGraphAppApi.credentials.ts @@ -3,15 +3,15 @@ import { NodePropertyTypes, } from 'n8n-workflow'; -export class FacebookGraphSubscriptionApi implements ICredentialType { - name = 'facebookGraphSubscriptionApi'; - displayName = 'Facebook Graph API'; +export class FacebookGraphAppApi implements ICredentialType { + name = 'facebookGraphAppApi'; + displayName = 'Facebook Graph API (App)'; extends = [ 'facebookGraphApi', ]; properties = [ { - displayName: 'APP Secret', + displayName: 'App Secret', name: 'appSecret', type: 'string' as NodePropertyTypes, default: '', diff --git a/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts b/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts index cb0f16b7af..278ca729bc 100644 --- a/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts +++ b/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts @@ -41,7 +41,7 @@ export class FacebookTrigger implements INodeType { outputs: ['main'], credentials: [ { - name: 'facebookGraphSubscriptionApi', + name: 'facebookGraphAppApi', required: true, }, ], @@ -216,7 +216,7 @@ export class FacebookTrigger implements INodeType { const res = this.getResponseObject(); const req = this.getRequestObject(); const headerData = this.getHeaderData() as IDataObject; - const credentials = this.getCredentials('facebookGraphSubscriptionApi') as IDataObject; + const credentials = this.getCredentials('facebookGraphAppApi') as IDataObject; // Check if we're getting facebook's challenge request (https://developers.facebook.com/docs/graph-api/webhooks/getting-started) if (this.getWebhookName() === 'setup') { if (query['hub.challenge']) { diff --git a/packages/nodes-base/nodes/Facebook/GenericFunctions.ts b/packages/nodes-base/nodes/Facebook/GenericFunctions.ts index 45412ff8f0..20e16ecf49 100644 --- a/packages/nodes-base/nodes/Facebook/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Facebook/GenericFunctions.ts @@ -19,7 +19,7 @@ export async function facebookApiRequest(this: IHookFunctions | IExecuteFunction let credentials; if (this.getNode().name.includes('Trigger')) { - credentials = this.getCredentials('facebookGraphSubscriptionApi') as IDataObject; + credentials = this.getCredentials('facebookGraphAppApi') as IDataObject; } else { credentials = this.getCredentials('facebookGraphApi') as IDataObject; } diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 90973cc2ae..ca4b4ea066 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -66,7 +66,7 @@ "dist/credentials/EventbriteApi.credentials.js", "dist/credentials/EventbriteOAuth2Api.credentials.js", "dist/credentials/FacebookGraphApi.credentials.js", - "dist/credentials/FacebookGraphSubscriptionApi.credentials.js", + "dist/credentials/FacebookGraphAppApi.credentials.js", "dist/credentials/FreshdeskApi.credentials.js", "dist/credentials/FileMaker.credentials.js", "dist/credentials/FlowApi.credentials.js", From 9465653fabd51b98513a1109c685a1efe1c3d7d3 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 4 Nov 2020 13:04:40 +0100 Subject: [PATCH 4/6] :zap: Improve editor-UI dirty checking --- .../src/components/mixins/moveNodeWorkflow.ts | 4 ++-- .../src/components/mixins/workflowRun.ts | 1 - packages/editor-ui/src/store.ts | 20 ------------------- packages/editor-ui/src/views/NodeView.vue | 6 ++++++ 4 files changed, 8 insertions(+), 23 deletions(-) diff --git a/packages/editor-ui/src/components/mixins/moveNodeWorkflow.ts b/packages/editor-ui/src/components/mixins/moveNodeWorkflow.ts index b1250003fd..7aca13623d 100644 --- a/packages/editor-ui/src/components/mixins/moveNodeWorkflow.ts +++ b/packages/editor-ui/src/components/mixins/moveNodeWorkflow.ts @@ -33,7 +33,7 @@ export const moveNodeWorkflow = mixins( const nodeViewOffsetPositionX = offsetPosition[0] + (position.x - this.moveLastPosition[0]); const nodeViewOffsetPositionY = offsetPosition[1] + (position.y - this.moveLastPosition[1]); - this.$store.commit('setNodeViewOffsetPosition', {newOffset: [nodeViewOffsetPositionX, nodeViewOffsetPositionY], setStateDirty: true}); + this.$store.commit('setNodeViewOffsetPosition', {newOffset: [nodeViewOffsetPositionX, nodeViewOffsetPositionY]}); // Update the last position this.moveLastPosition[0] = position.x; @@ -101,7 +101,7 @@ export const moveNodeWorkflow = mixins( const offsetPosition = this.$store.getters.getNodeViewOffsetPosition; const nodeViewOffsetPositionX = offsetPosition[0] - normalized.pixelX; const nodeViewOffsetPositionY = offsetPosition[1] - normalized.pixelY; - this.$store.commit('setNodeViewOffsetPosition', {newOffset: [nodeViewOffsetPositionX, nodeViewOffsetPositionY], setStateDirty: true}); + this.$store.commit('setNodeViewOffsetPosition', {newOffset: [nodeViewOffsetPositionX, nodeViewOffsetPositionY]}); }, }, }); diff --git a/packages/editor-ui/src/components/mixins/workflowRun.ts b/packages/editor-ui/src/components/mixins/workflowRun.ts index 08ac66a110..ea375f7399 100644 --- a/packages/editor-ui/src/components/mixins/workflowRun.ts +++ b/packages/editor-ui/src/components/mixins/workflowRun.ts @@ -29,7 +29,6 @@ export const workflowRun = mixins( // because then it can not receive the data as it executes. throw new Error('No active connection to server. It is maybe down.'); } - const workflow = this.getWorkflow(); this.$store.commit('addActiveAction', 'workflowRunning'); diff --git a/packages/editor-ui/src/store.ts b/packages/editor-ui/src/store.ts index 0ad38d81f3..8275908fc0 100644 --- a/packages/editor-ui/src/store.ts +++ b/packages/editor-ui/src/store.ts @@ -96,7 +96,6 @@ export const store = new Vuex.Store({ // Active Executions addActiveExecution (state, newActiveExecution: IExecutionsCurrentSummaryExtended) { - state.stateIsDirty = true; // Check if the execution exists already const activeExecution = state.activeExecutions.find(execution => { return execution.idActive === newActiveExecution.idActive; @@ -113,7 +112,6 @@ export const store = new Vuex.Store({ state.activeExecutions.unshift(newActiveExecution); }, finishActiveExecution (state, finishedActiveExecution: IPushDataExecutionFinished) { - state.stateIsDirty = true; // Find the execution to set to finished const activeExecution = state.activeExecutions.find(execution => { return execution.idActive === finishedActiveExecution.executionIdActive; @@ -132,7 +130,6 @@ export const store = new Vuex.Store({ Vue.set(activeExecution, 'stoppedAt', finishedActiveExecution.data.stoppedAt); }, setActiveExecutions (state, newActiveExecutions: IExecutionsCurrentSummaryExtended[]) { - state.stateIsDirty = true; Vue.set(state, 'activeExecutions', newActiveExecutions); }, @@ -165,7 +162,6 @@ export const store = new Vuex.Store({ state.selectedNodes.push(node); }, removeNodeFromSelection (state, node: INodeUi) { - state.stateIsDirty = true; let index; for (index in state.selectedNodes) { if (state.selectedNodes[index].name === node.name) { @@ -377,7 +373,6 @@ export const store = new Vuex.Store({ // Set/Overwrite the value Vue.set(node.issues!, nodeIssueData.type, nodeIssueData.value); - state.stateIsDirty = true; } return true; @@ -466,7 +461,6 @@ export const store = new Vuex.Store({ state.nodeIndex.push(nodeName); }, setNodeIndex (state, newData: { index: number, name: string | null}) { - state.stateIsDirty = true; state.nodeIndex[newData.index] = newData.name; }, resetNodeIndex (state) { @@ -478,9 +472,6 @@ export const store = new Vuex.Store({ state.nodeViewMoveInProgress = value; }, setNodeViewOffsetPosition (state, data) { - if (data.setStateDirty === true) { - state.stateIsDirty = true; - } state.nodeViewOffsetPosition = data.newOffset; }, @@ -541,16 +532,6 @@ export const store = new Vuex.Store({ Vue.set(state, 'oauthCallbackUrls', urls); }, - addNodeType (state, typeData: INodeTypeDescription) { - if (!typeData.hasOwnProperty('name')) { - // All node-types have to have a name - // TODO: Check if there is an error or whatever that is supposed to be returned - return; - } - state.stateIsDirty = true; - state.nodeTypes.push(typeData); - }, - setActiveNode (state, nodeName: string) { state.activeNode = nodeName; }, @@ -573,7 +554,6 @@ export const store = new Vuex.Store({ if (state.workflowExecutionData.data.resultData.runData[pushData.nodeName] === undefined) { Vue.set(state.workflowExecutionData.data.resultData.runData, pushData.nodeName, []); } - state.stateIsDirty = true; state.workflowExecutionData.data.resultData.runData[pushData.nodeName].push(pushData.data); }, diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 2cf41645eb..fff03cb087 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -1006,6 +1006,8 @@ export default mixins( await this.addNodes([newNodeData]); + this.$store.commit('setStateDirty', true); + // Automatically deselect all nodes and select the current one and also active // current node this.deselectAllNodes(); @@ -1500,6 +1502,8 @@ export default mixins( await this.addNodes([newNodeData]); + this.$store.commit('setStateDirty', true); + // Automatically deselect all nodes and select the current one and also active // current node this.deselectAllNodes(); @@ -1834,6 +1838,8 @@ export default mixins( // Add the nodes with the changed node names, expressions and connections await this.addNodes(Object.values(tempWorkflow.nodes), tempWorkflow.connectionsBySourceNode); + this.$store.commit('setStateDirty', true); + return { nodes: Object.values(tempWorkflow.nodes), connections: tempWorkflow.connectionsBySourceNode, From 3c62402f9c4b7c809f80d3baa5513a433b0b8389 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 4 Nov 2020 13:11:49 +0100 Subject: [PATCH 5/6] :zap: Add documentationUrl to FacebookGraphAppApi credential --- .../nodes-base/credentials/FacebookGraphAppApi.credentials.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/nodes-base/credentials/FacebookGraphAppApi.credentials.ts b/packages/nodes-base/credentials/FacebookGraphAppApi.credentials.ts index e7d3c4e27b..271921397a 100644 --- a/packages/nodes-base/credentials/FacebookGraphAppApi.credentials.ts +++ b/packages/nodes-base/credentials/FacebookGraphAppApi.credentials.ts @@ -6,6 +6,7 @@ import { export class FacebookGraphAppApi implements ICredentialType { name = 'facebookGraphAppApi'; displayName = 'Facebook Graph API (App)'; + documentationUrl = 'facebookGraphApp'; extends = [ 'facebookGraphApi', ]; From 80ca8e79ec4cb5270ec10422f94e9b9ffe1dd0dd Mon Sep 17 00:00:00 2001 From: AxelRueweler Date: Wed, 4 Nov 2020 13:21:49 +0100 Subject: [PATCH 6/6] :zap: Added description to ShopifyCredentials subdomain (#1114) --- packages/nodes-base/credentials/ShopifyApi.credentials.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/nodes-base/credentials/ShopifyApi.credentials.ts b/packages/nodes-base/credentials/ShopifyApi.credentials.ts index e921b9fa23..7d5740a232 100644 --- a/packages/nodes-base/credentials/ShopifyApi.credentials.ts +++ b/packages/nodes-base/credentials/ShopifyApi.credentials.ts @@ -28,6 +28,7 @@ export class ShopifyApi implements ICredentialType { required: true, type: 'string' as NodePropertyTypes, default: '', + description: 'Only the subdomain without .myshopify.com', }, { displayName: 'Shared Secret',