From 3e1fb3e0c9f7d2588ef64fc77b0ac90159e39b3e Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Tue, 26 Oct 2021 23:45:26 -0400 Subject: [PATCH] :zap: Add search filters to contacts and companies (AgileCRM) (#2373) * Added search options for the AgileCRM node * Adjusting AgileCRM getAll operation (using Magento 2 node as a reference) * :zap: Small improvements to #2238 Co-authored-by: Valentina --- .../nodes/AgileCrm/AgileCrm.node.ts | 105 ++++-- .../nodes/AgileCrm/CompanyDescription.ts | 277 +++++++++++++++- .../nodes/AgileCrm/ContactDescription.ts | 311 ++++++++++++++++-- .../nodes/AgileCrm/DealDescription.ts | 2 - .../nodes/AgileCrm/FilterInterface.ts | 19 ++ .../nodes/AgileCrm/GenericFunctions.ts | 86 ++++- 6 files changed, 745 insertions(+), 55 deletions(-) create mode 100644 packages/nodes-base/nodes/AgileCrm/FilterInterface.ts diff --git a/packages/nodes-base/nodes/AgileCrm/AgileCrm.node.ts b/packages/nodes-base/nodes/AgileCrm/AgileCrm.node.ts index c3401f7d86..a31fdb2164 100644 --- a/packages/nodes-base/nodes/AgileCrm/AgileCrm.node.ts +++ b/packages/nodes-base/nodes/AgileCrm/AgileCrm.node.ts @@ -1,4 +1,7 @@ -import { IExecuteFunctions } from 'n8n-core'; +import { + IExecuteFunctions, +} from 'n8n-core'; + import { IDataObject, INodeExecutionData, @@ -22,16 +25,34 @@ import { dealOperations } from './DealDescription'; -import { IContact, IContactUpdate } from './ContactInterface'; -import { agileCrmApiRequest, agileCrmApiRequestUpdate, validateJSON } from './GenericFunctions'; -import { IDeal } from './DealInterface'; +import { + IContact, + IContactUpdate, +} from './ContactInterface'; +import { + agileCrmApiRequest, agileCrmApiRequestAllItems, + agileCrmApiRequestUpdate, + getFilterRules, + simplifyResponse, + validateJSON, +} from './GenericFunctions'; + +import { + IDeal, +} from './DealInterface'; + +import { + IFilter, + ISearchConditions, +} from './FilterInterface'; export class AgileCrm implements INodeType { description: INodeTypeDescription = { displayName: 'Agile CRM', name: 'agileCrm', icon: 'file:agilecrm.png', + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', group: ['transform'], version: 1, description: 'Consume Agile CRM API', @@ -86,7 +107,6 @@ export class AgileCrm implements INodeType { }; - async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); @@ -113,26 +133,59 @@ export class AgileCrm implements INodeType { responseData = await agileCrmApiRequest.call(this, 'DELETE', endpoint, {}); } else if (operation === 'getAll') { - const returnAll = this.getNodeParameter('returnAll', i) as boolean; + const simple = this.getNodeParameter('simple', 0) as boolean; + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + const filterType = this.getNodeParameter('filterType', i) as string; + const sort = this.getNodeParameter('options.sort.sort', i, {}) as { direction: string, field: string }; + const body: IDataObject = {}; + const filterJson: IFilter = {}; + let contactType = ''; if (resource === 'contact') { - if (returnAll) { - const endpoint = 'api/contacts'; - responseData = await agileCrmApiRequest.call(this, 'GET', endpoint, {}); - } else { - const limit = this.getNodeParameter('limit', i) as number; - const endpoint = `api/contacts?page_size=${limit}`; - responseData = await agileCrmApiRequest.call(this, 'GET', endpoint, {}); - } + contactType = 'PERSON'; } else { - if (returnAll) { - const endpoint = 'api/contacts/companies/list'; - responseData = await agileCrmApiRequest.call(this, 'POST', endpoint, {}); + contactType = 'COMPANY'; + } + filterJson.contact_type = contactType; + + if (filterType === 'manual') { + const conditions = this.getNodeParameter('filters.conditions', i, []) as ISearchConditions[]; + const matchType = this.getNodeParameter('matchType', i) as string; + let rules; + if (conditions.length !== 0) { + rules = getFilterRules(conditions, matchType); + Object.assign(filterJson, rules); } else { - const limit = this.getNodeParameter('limit', i) as number; - const endpoint = `api/contacts/companies/list?page_size=${limit}`; - responseData = await agileCrmApiRequest.call(this, 'POST', endpoint, {}); + throw new NodeOperationError(this.getNode(), 'At least one condition must be added.'); } + } else if (filterType === 'json') { + const filterJsonRules = this.getNodeParameter('filterJson', i) as string; + if (validateJSON(filterJsonRules) !== undefined) { + Object.assign(filterJson, JSON.parse(filterJsonRules) as IFilter); + } else { + throw new NodeOperationError(this.getNode(), 'Filter (JSON) must be a valid json'); + } + } + body.filterJson = JSON.stringify(filterJson); + + if (sort) { + if (sort.direction === 'ASC') { + body.global_sort_key = sort.field; + } else if (sort.direction === 'DESC') { + body.global_sort_key = `-${sort.field}`; + } + } + + if (returnAll) { + body.page_size = 100; + responseData = await agileCrmApiRequestAllItems.call(this, 'POST', `api/filters/filter/dynamic-filter`, body, undefined, undefined, true); + } else { + body.page_size = this.getNodeParameter('limit', 0) as number; + responseData = await agileCrmApiRequest.call(this, 'POST', `api/filters/filter/dynamic-filter`, body, undefined, undefined, true); + } + + if (simple) { + responseData = simplifyResponse(responseData); } } else if (operation === 'create') { @@ -461,15 +514,15 @@ export class AgileCrm implements INodeType { responseData = await agileCrmApiRequest.call(this, 'DELETE', endpoint, {}); } else if (operation === 'getAll') { - const returnAll = this.getNodeParameter('returnAll', i) as boolean; + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + const endpoint = 'api/opportunity'; if (returnAll) { - const endpoint = 'api/opportunity'; - responseData = await agileCrmApiRequest.call(this, 'GET', endpoint, {}); + const limit = 100; + responseData = await agileCrmApiRequestAllItems.call(this, 'GET', endpoint, undefined, { page_size: limit }); } else { - const limit = this.getNodeParameter('limit', i) as number; - const endpoint = `api/opportunity?page_size=${limit}`; - responseData = await agileCrmApiRequest.call(this, 'GET', endpoint, {}); + const limit = this.getNodeParameter('limit', 0) as number; + responseData = await agileCrmApiRequest.call(this, 'GET', endpoint, undefined, { page_size: limit }); } } else if (operation === 'create') { diff --git a/packages/nodes-base/nodes/AgileCrm/CompanyDescription.ts b/packages/nodes-base/nodes/AgileCrm/CompanyDescription.ts index da5eeef238..6b888cf4ee 100644 --- a/packages/nodes-base/nodes/AgileCrm/CompanyDescription.ts +++ b/packages/nodes-base/nodes/AgileCrm/CompanyDescription.ts @@ -1,6 +1,7 @@ import { INodeProperties, } from 'n8n-workflow'; + export const companyOperations = [ { displayName: 'Operation', @@ -44,6 +45,7 @@ export const companyOperations = [ description: 'The operation to perform.', }, ] as INodeProperties[]; + export const companyFields = [ /* -------------------------------------------------------------------------- */ /* company:get */ @@ -91,7 +93,6 @@ export const companyFields = [ displayName: 'Limit', name: 'limit', type: 'number', - default: 20, displayOptions: { show: { resource: [ @@ -105,7 +106,280 @@ export const companyFields = [ ], }, }, + default: 20, + description: 'Number of results to fetch.', }, + + { + displayName: 'Filter', + name: 'filterType', + type: 'options', + options: [ + { + name: 'None', + value: 'none', + }, + { + name: 'Build Manually', + value: 'manual', + }, + { + name: 'JSON', + value: 'json', + }, + ], + displayOptions: { + show: { + resource: [ + 'company', + ], + operation: [ + 'getAll', + ], + }, + }, + default: 'none', + }, + { + displayName: 'Must Match', + name: 'matchType', + type: 'options', + options: [ + { + name: 'Any filter', + value: 'anyFilter', + }, + { + name: 'All Filters', + value: 'allFilters', + }, + ], + displayOptions: { + show: { + resource: [ + 'company', + ], + operation: [ + 'getAll', + ], + filterType: [ + 'manual', + ], + }, + }, + default: 'anyFilter', + }, + { + displayName: 'Simplify Response', + name: 'simple', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'company', + ], + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: 'Return a simplified version of the response instead of the raw data.', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + resource: [ + 'company', + ], + operation: [ + 'getAll', + ], + filterType: [ + 'manual', + ], + }, + }, + default: '', + placeholder: 'Add Condition', + options: [ + { + displayName: 'Conditions', + name: 'conditions', + values: [ + { + displayName: 'Field', + name: 'field', + type: 'string', + default: '', + description: 'Any searchable field.', + }, + { + displayName: 'Condition Type', + name: 'condition_type', + type: 'options', + options: [ + { + name: 'Equals', + value: 'EQUALS', + }, + { + name: 'Not Equal', + value: 'NOTEQUALS', + }, + { + name: 'Last', + value: 'LAST', + }, + { + name: 'Between', + value: 'BETWEEN', + }, + { + name: 'On', + value: 'ON', + }, + { + name: 'Before', + value: 'BEFORE', + }, + { + name: 'After', + value: 'AFTER', + }, + ], + default: 'EQUALS', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + { + displayName: 'Value 2', + name: 'value2', + type: 'string', + displayOptions: { + show: { + condition_type: [ + 'BETWEEN', + ], + }, + }, + default: '', + }, + ], + }, + ], + }, + { + displayName: 'See Agile CRM guide to creating filters', + name: 'jsonNotice', + type: 'notice', + displayOptions: { + show: { + resource: [ + 'company', + ], + operation: [ + 'getAll', + ], + filterType: [ + 'json', + ], + }, + }, + default: '', + }, + { + displayName: 'Filters (JSON)', + name: 'filterJson', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + displayOptions: { + show: { + resource: [ + 'company', + ], + operation: [ + 'getAll', + ], + filterType: [ + 'json', + ], + }, + }, + default: '', + description: '', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: [ + 'company', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Sort', + name: 'sort', + type: 'fixedCollection', + placeholder: 'Add Sort', + default: [], + options: [ + { + displayName: 'Sort', + name: 'sort', + values: [ + { + displayName: 'Direction', + name: 'direction', + type: 'options', + options: [ + { + name: 'Ascending', + value: 'ASC', + }, + { + name: 'Descending', + value: 'DESC', + }, + ], + default: 'ASC', + description: 'The sorting direction', + }, + { + displayName: 'Field', + name: 'field', + type: 'string', + default: '', + description: `The sorting field`, + }, + ], + }, + ], + }, + ], + }, + /* -------------------------------------------------------------------------- */ /* company:create */ /* -------------------------------------------------------------------------- */ @@ -657,4 +931,5 @@ export const companyFields = [ }, ], }, + ] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/AgileCrm/ContactDescription.ts b/packages/nodes-base/nodes/AgileCrm/ContactDescription.ts index 82cd03afe1..3ccf9b1cb9 100644 --- a/packages/nodes-base/nodes/AgileCrm/ContactDescription.ts +++ b/packages/nodes-base/nodes/AgileCrm/ContactDescription.ts @@ -71,25 +71,7 @@ export const contactFields = [ /* -------------------------------------------------------------------------- */ /* contact:get all */ /* -------------------------------------------------------------------------- */ - { - displayName: 'Limit', - name: 'limit', - type: 'number', - default: 20, - displayOptions: { - show: { - resource: [ - 'contact', - ], - operation: [ - 'getAll', - ], - returnAll: [ - false, - ], - }, - }, - }, + { displayName: 'Return All', name: 'returnAll', @@ -107,6 +89,296 @@ export const contactFields = [ 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: [ + 'contact', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + default: 20, + description: 'Number of results to fetch.', + }, + + { + displayName: 'Filter', + name: 'filterType', + type: 'options', + options: [ + { + name: 'None', + value: 'none', + }, + { + name: 'Build Manually', + value: 'manual', + }, + { + name: 'JSON', + value: 'json', + }, + ], + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'getAll', + ], + }, + }, + default: 'none', + }, + { + displayName: 'Must Match', + name: 'matchType', + type: 'options', + options: [ + { + name: 'Any filter', + value: 'anyFilter', + }, + { + name: 'All Filters', + value: 'allFilters', + }, + ], + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'getAll', + ], + filterType: [ + 'manual', + ], + }, + }, + default: 'anyFilter', + }, + { + displayName: 'Simplify Response', + name: 'simple', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: 'Return a simplified version of the response instead of the raw data.', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'getAll', + ], + filterType: [ + 'manual', + ], + }, + }, + default: '', + placeholder: 'Add Condition', + options: [ + { + displayName: 'Conditions', + name: 'conditions', + values: [ + { + displayName: 'Field', + name: 'field', + type: 'string', + default: '', + description: 'Any searchable field.', + }, + { + displayName: 'Condition Type', + name: 'condition_type', + type: 'options', + options: [ + { + name: 'Equals', + value: 'EQUALS', + }, + { + name: 'Not Equal', + value: 'NOTEQUALS', + }, + { + name: 'Last', + value: 'LAST', + }, + { + name: 'Between', + value: 'BETWEEN', + }, + { + name: 'On', + value: 'ON', + }, + { + name: 'Before', + value: 'BEFORE', + }, + { + name: 'After', + value: 'AFTER', + }, + ], + default: 'EQUALS', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + { + displayName: 'Value 2', + name: 'value2', + type: 'string', + displayOptions: { + show: { + condition_type: [ + 'BETWEEN', + ], + }, + }, + default: '', + }, + ], + }, + ], + }, + { + displayName: 'See Agile CRM guide to creating filters', + name: 'jsonNotice', + type: 'notice', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'getAll', + ], + filterType: [ + 'json', + ], + }, + }, + default: '', + }, + { + displayName: 'Filters (JSON)', + name: 'filterJson', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'getAll', + ], + filterType: [ + 'json', + ], + }, + }, + default: '', + description: '', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Sort', + name: 'sort', + type: 'fixedCollection', + placeholder: 'Add Sort', + default: [], + options: [ + { + displayName: 'Sort', + name: 'sort', + values: [ + { + displayName: 'Direction', + name: 'direction', + type: 'options', + options: [ + { + name: 'Ascending', + value: 'ASC', + }, + { + name: 'Descending', + value: 'DESC', + }, + ], + default: 'ASC', + description: 'The sorting direction', + }, + { + displayName: 'Field', + name: 'field', + type: 'string', + default: '', + description: `The sorting field`, + }, + ], + }, + ], + }, + ], + }, /* -------------------------------------------------------------------------- */ /* contact:create */ @@ -988,4 +1260,5 @@ export const contactFields = [ }, ], }, + ] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/AgileCrm/DealDescription.ts b/packages/nodes-base/nodes/AgileCrm/DealDescription.ts index 7fb7f55e5f..7dd93643e0 100644 --- a/packages/nodes-base/nodes/AgileCrm/DealDescription.ts +++ b/packages/nodes-base/nodes/AgileCrm/DealDescription.ts @@ -110,8 +110,6 @@ export const dealFields = [ default: false, description: 'If all results should be returned or only up to a given limit.', }, - - /* -------------------------------------------------------------------------- */ /* deal:create */ /* -------------------------------------------------------------------------- */ diff --git a/packages/nodes-base/nodes/AgileCrm/FilterInterface.ts b/packages/nodes-base/nodes/AgileCrm/FilterInterface.ts new file mode 100644 index 0000000000..001dea948a --- /dev/null +++ b/packages/nodes-base/nodes/AgileCrm/FilterInterface.ts @@ -0,0 +1,19 @@ +export interface ISearchConditions { + field?: string; + condition_type?: string; + value?: string; + value2?: string; +} + +export interface IFilterRules { + LHS?: string; + CONDITION?: string; + RHS?: string; + RHS_NEW?: string; +} + +export interface IFilter { + or_rules?: IFilterRules; + rules?: IFilterRules; + contact_type?: string; +} diff --git a/packages/nodes-base/nodes/AgileCrm/GenericFunctions.ts b/packages/nodes-base/nodes/AgileCrm/GenericFunctions.ts index 0143b0f624..6c01d3c0b8 100644 --- a/packages/nodes-base/nodes/AgileCrm/GenericFunctions.ts +++ b/packages/nodes-base/nodes/AgileCrm/GenericFunctions.ts @@ -1,6 +1,4 @@ -import { - OptionsWithUri -} from 'request'; +import { OptionsWithUri } from 'request'; import { IExecuteFunctions, @@ -10,12 +8,21 @@ import { } from 'n8n-core'; import { - IDataObject, NodeApiError, + IDataObject, + NodeApiError, } from 'n8n-workflow'; -import { IContactUpdate } from './ContactInterface'; +import { + IContactUpdate, +} from './ContactInterface'; -export async function agileCrmApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: any = {}, query: IDataObject = {}, uri?: string): Promise { // tslint:disable-line:no-any +import { + IFilterRules, + ISearchConditions, +} from './FilterInterface'; + +export async function agileCrmApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, + method: string, endpoint: string, body: any = {}, query: IDataObject = {}, uri?: string, sendAsForm?: boolean): Promise { // tslint:disable-line:no-any const credentials = await this.getCredentials('agileCrmApi'); const options: OptionsWithUri = { @@ -27,12 +34,18 @@ export async function agileCrmApiRequest(this: IHookFunctions | IExecuteFunction username: credentials!.email as string, password: credentials!.apiKey as string, }, + qs: query, uri: uri || `https://${credentials!.subdomain}.agilecrm.com/dev/${endpoint}`, json: true, }; + // To send the request as 'content-type': 'application/x-www-form-urlencoded' add form to options instead of body + if(sendAsForm) { + options.form = body; + } // Only add Body property if method not GET or DELETE to avoid 400 response - if (method !== 'GET' && method !== 'DELETE') { + // And when not sending a form + else if (method !== 'GET' && method !== 'DELETE') { options.body = body; } @@ -41,7 +54,30 @@ export async function agileCrmApiRequest(this: IHookFunctions | IExecuteFunction } catch (error) { throw new NodeApiError(this.getNode(), error); } +} +export async function agileCrmApiRequestAllItems(this: IHookFunctions | ILoadOptionsFunctions | IExecuteFunctions, + method: string, resource: string, body: any = {}, query: IDataObject = {}, uri?: string, sendAsForm?: boolean): Promise { // tslint:disable-line:no-any + // https://github.com/agilecrm/rest-api#11-listing-contacts- + + const returnData: IDataObject[] = []; + let responseData; + do { + responseData = await agileCrmApiRequest.call(this, method, resource, body, query, uri, sendAsForm); + if (responseData.length !== 0) { + returnData.push.apply(returnData, responseData); + if (sendAsForm) { + body.cursor = responseData[responseData.length-1].cursor; + } else { + query.cursor = responseData[responseData.length-1].cursor; + } + } + } while ( + responseData.length !== 0 && + responseData[responseData.length-1].hasOwnProperty('cursor') + ); + + return returnData; } export async function agileCrmApiRequestUpdate(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method = 'PUT', endpoint?: string, body: any = {}, query: IDataObject = {}, uri?: string): Promise { // tslint:disable-line:no-any @@ -131,3 +167,39 @@ export function validateJSON(json: string | undefined): any { // tslint:disable- } return result; } + +export function getFilterRules(conditions: ISearchConditions[], matchType: string): IDataObject { // tslint:disable-line:no-any + const rules = []; + + for (const key in conditions) { + if (conditions.hasOwnProperty(key)) { + const searchConditions: ISearchConditions = conditions[key] as ISearchConditions; + const rule: IFilterRules = { + LHS: searchConditions.field, + CONDITION: searchConditions.condition_type, + RHS: searchConditions.value as string, + RHS_NEW: searchConditions.value2 as string, + }; + rules.push(rule); + } + } + + if (matchType === 'anyFilter') { + return { + or_rules: rules, + }; + } + else { + return { + rules, + }; + } +} + +export function simplifyResponse(records: [{ id: string, properties: [{ name: string, value: string }] } ]) { + const results = []; + for (const record of records) { + results.push(record.properties.reduce((obj, value) => Object.assign(obj, { [`${value.name}`]: value.value }), { id: record.id })); + } + return results; +}