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)

*  Small improvements to #2238

Co-authored-by: Valentina <valentina.lilova98@gmail.com>
This commit is contained in:
Ricardo Espinoza 2021-10-26 23:45:26 -04:00 committed by GitHub
parent 15d05c7f01
commit 3e1fb3e0c9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 745 additions and 55 deletions

View file

@ -1,4 +1,7 @@
import { IExecuteFunctions } from 'n8n-core'; import {
IExecuteFunctions,
} from 'n8n-core';
import { import {
IDataObject, IDataObject,
INodeExecutionData, INodeExecutionData,
@ -22,16 +25,34 @@ import {
dealOperations dealOperations
} from './DealDescription'; } from './DealDescription';
import { IContact, IContactUpdate } from './ContactInterface'; import {
import { agileCrmApiRequest, agileCrmApiRequestUpdate, validateJSON } from './GenericFunctions'; IContact,
import { IDeal } from './DealInterface'; 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 { export class AgileCrm implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
displayName: 'Agile CRM', displayName: 'Agile CRM',
name: 'agileCrm', name: 'agileCrm',
icon: 'file:agilecrm.png', icon: 'file:agilecrm.png',
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
group: ['transform'], group: ['transform'],
version: 1, version: 1,
description: 'Consume Agile CRM API', description: 'Consume Agile CRM API',
@ -86,7 +107,6 @@ export class AgileCrm implements INodeType {
}; };
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData(); const items = this.getInputData();
@ -113,26 +133,59 @@ export class AgileCrm implements INodeType {
responseData = await agileCrmApiRequest.call(this, 'DELETE', endpoint, {}); responseData = await agileCrmApiRequest.call(this, 'DELETE', endpoint, {});
} else if (operation === 'getAll') { } 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 (resource === 'contact') {
if (returnAll) { contactType = 'PERSON';
const endpoint = 'api/contacts';
responseData = await agileCrmApiRequest.call(this, 'GET', endpoint, {});
} else { } else {
const limit = this.getNodeParameter('limit', i) as number; contactType = 'COMPANY';
const endpoint = `api/contacts?page_size=${limit}`;
responseData = await agileCrmApiRequest.call(this, 'GET', endpoint, {});
} }
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 { } else {
if (returnAll) { throw new NodeOperationError(this.getNode(), 'At least one condition must be added.');
const endpoint = 'api/contacts/companies/list';
responseData = await agileCrmApiRequest.call(this, 'POST', endpoint, {});
} 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, {});
} }
} 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') { } else if (operation === 'create') {
@ -461,15 +514,15 @@ export class AgileCrm implements INodeType {
responseData = await agileCrmApiRequest.call(this, 'DELETE', endpoint, {}); responseData = await agileCrmApiRequest.call(this, 'DELETE', endpoint, {});
} else if (operation === 'getAll') { } 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) { if (returnAll) {
const endpoint = 'api/opportunity'; const limit = 100;
responseData = await agileCrmApiRequest.call(this, 'GET', endpoint, {}); responseData = await agileCrmApiRequestAllItems.call(this, 'GET', endpoint, undefined, { page_size: limit });
} else { } else {
const limit = this.getNodeParameter('limit', i) as number; const limit = this.getNodeParameter('limit', 0) as number;
const endpoint = `api/opportunity?page_size=${limit}`; responseData = await agileCrmApiRequest.call(this, 'GET', endpoint, undefined, { page_size: limit });
responseData = await agileCrmApiRequest.call(this, 'GET', endpoint, {});
} }
} else if (operation === 'create') { } else if (operation === 'create') {

View file

@ -1,6 +1,7 @@
import { import {
INodeProperties, INodeProperties,
} from 'n8n-workflow'; } from 'n8n-workflow';
export const companyOperations = [ export const companyOperations = [
{ {
displayName: 'Operation', displayName: 'Operation',
@ -44,6 +45,7 @@ export const companyOperations = [
description: 'The operation to perform.', description: 'The operation to perform.',
}, },
] as INodeProperties[]; ] as INodeProperties[];
export const companyFields = [ export const companyFields = [
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* company:get */ /* company:get */
@ -91,7 +93,6 @@ export const companyFields = [
displayName: 'Limit', displayName: 'Limit',
name: 'limit', name: 'limit',
type: 'number', type: 'number',
default: 20,
displayOptions: { displayOptions: {
show: { show: {
resource: [ 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 <a href="https://github.com/agilecrm/rest-api#121-get-contacts-by-dynamic-filter" target="_blank">Agile CRM guide</a> 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 */ /* company:create */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@ -657,4 +931,5 @@ export const companyFields = [
}, },
], ],
}, },
] as INodeProperties[]; ] as INodeProperties[];

View file

@ -71,25 +71,7 @@ export const contactFields = [
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* contact:get all */ /* contact:get all */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
{
displayName: 'Limit',
name: 'limit',
type: 'number',
default: 20,
displayOptions: {
show: {
resource: [
'contact',
],
operation: [
'getAll',
],
returnAll: [
false,
],
},
},
},
{ {
displayName: 'Return All', displayName: 'Return All',
name: 'returnAll', name: 'returnAll',
@ -107,6 +89,296 @@ export const contactFields = [
default: false, default: false,
description: 'If all results should be returned or only up to a given limit.', 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 <a href="https://github.com/agilecrm/rest-api#121-get-contacts-by-dynamic-filter" target="_blank">Agile CRM guide</a> 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 */ /* contact:create */
@ -988,4 +1260,5 @@ export const contactFields = [
}, },
], ],
}, },
] as INodeProperties[]; ] as INodeProperties[];

View file

@ -110,8 +110,6 @@ export const dealFields = [
default: false, default: false,
description: 'If all results should be returned or only up to a given limit.', description: 'If all results should be returned or only up to a given limit.',
}, },
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* deal:create */ /* deal:create */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */

View file

@ -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;
}

View file

@ -1,6 +1,4 @@
import { import { OptionsWithUri } from 'request';
OptionsWithUri
} from 'request';
import { import {
IExecuteFunctions, IExecuteFunctions,
@ -10,12 +8,21 @@ import {
} from 'n8n-core'; } from 'n8n-core';
import { import {
IDataObject, NodeApiError, IDataObject,
NodeApiError,
} from 'n8n-workflow'; } 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<any> { // 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<any> { // tslint:disable-line:no-any
const credentials = await this.getCredentials('agileCrmApi'); const credentials = await this.getCredentials('agileCrmApi');
const options: OptionsWithUri = { const options: OptionsWithUri = {
@ -27,12 +34,18 @@ export async function agileCrmApiRequest(this: IHookFunctions | IExecuteFunction
username: credentials!.email as string, username: credentials!.email as string,
password: credentials!.apiKey as string, password: credentials!.apiKey as string,
}, },
qs: query,
uri: uri || `https://${credentials!.subdomain}.agilecrm.com/dev/${endpoint}`, uri: uri || `https://${credentials!.subdomain}.agilecrm.com/dev/${endpoint}`,
json: true, 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 // 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; options.body = body;
} }
@ -41,7 +54,30 @@ export async function agileCrmApiRequest(this: IHookFunctions | IExecuteFunction
} catch (error) { } catch (error) {
throw new NodeApiError(this.getNode(), 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<any> { // 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<any> { // tslint:disable-line:no-any export async function agileCrmApiRequestUpdate(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method = 'PUT', endpoint?: string, body: any = {}, query: IDataObject = {}, uri?: string): Promise<any> { // tslint:disable-line:no-any
@ -131,3 +167,39 @@ export function validateJSON(json: string | undefined): any { // tslint:disable-
} }
return result; 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;
}