From 8027463c349b31371f8a6cef790ff562f6a47e29 Mon Sep 17 00:00:00 2001 From: Bernhard Wittmann Date: Mon, 29 Jun 2026 12:09:23 +0200 Subject: [PATCH] fix(Salesforce Node): Use server-side search for owner selectors (#32815) Co-authored-by: Alexander Gekov <40495748+alexander-gekov@users.noreply.github.com> --- .../nodes/Salesforce/AccountDescription.ts | 117 ++++-- .../nodes/Salesforce/AttachmentDescription.ts | 78 +++- .../nodes/Salesforce/CaseDescription.ts | 78 +++- .../nodes/Salesforce/ContactDescription.ts | 117 ++++-- .../nodes/Salesforce/DocumentDescription.ts | 39 +- .../nodes/Salesforce/GenericFunctions.ts | 35 +- .../nodes/Salesforce/LeadDescription.ts | 117 ++++-- .../Salesforce/OpportunityDescription.ts | 117 ++++-- .../nodes/Salesforce/Salesforce.node.ts | 349 ++++++++++-------- .../nodes/Salesforce/TaskDescription.ts | 78 +++- .../__test__/GenericFunctions.test.ts | 35 ++ .../__test__/Salesforce.node.test.ts | 253 ++++++++++--- 12 files changed, 1046 insertions(+), 367 deletions(-) diff --git a/packages/nodes-base/nodes/Salesforce/AccountDescription.ts b/packages/nodes-base/nodes/Salesforce/AccountDescription.ts index 925d9bbcaeb..e9dddcceb01 100644 --- a/packages/nodes-base/nodes/Salesforce/AccountDescription.ts +++ b/packages/nodes-base/nodes/Salesforce/AccountDescription.ts @@ -272,15 +272,38 @@ export const accountFields: INodeProperties[] = [ default: '', }, { - displayName: 'Owner Name or ID', + displayName: 'Owner', name: 'owner', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - default: '', - description: - 'The owner of the account. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user...', + typeOptions: { + searchListMethod: 'searchUsers', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '0051700000ABCDE', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?:[a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})$', + errorMessage: 'User ID must be 15 or 18 alphanumeric characters', + }, + }, + ], + }, + ], + description: 'The user who owns the account', }, { displayName: 'Parent ID', @@ -553,15 +576,38 @@ export const accountFields: INodeProperties[] = [ default: '', }, { - displayName: 'Owner Name or ID', + displayName: 'Owner', name: 'ownerId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - default: '', - description: - 'The owner of the account. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user...', + typeOptions: { + searchListMethod: 'searchUsers', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '0051700000ABCDE', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?:[a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})$', + errorMessage: 'User ID must be 15 or 18 alphanumeric characters', + }, + }, + ], + }, + ], + description: 'The user who owns the account', }, { displayName: 'Parent ID', @@ -875,15 +921,38 @@ export const accountFields: INodeProperties[] = [ 'Whether true, only the note owner or a user with the “Modify All Data” permission can view the note or query it via the API', }, { - displayName: 'Owner Name or ID', + displayName: 'Owner', name: 'ownerId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - default: '', - description: - 'ID of the user who owns the note. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user...', + typeOptions: { + searchListMethod: 'searchUsers', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '0051700000ABCDE', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?:[a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})$', + errorMessage: 'User ID must be 15 or 18 alphanumeric characters', + }, + }, + ], + }, + ], + description: 'The user who owns the note', }, ], }, diff --git a/packages/nodes-base/nodes/Salesforce/AttachmentDescription.ts b/packages/nodes-base/nodes/Salesforce/AttachmentDescription.ts index 5efe74ee96e..b12c40a06e6 100644 --- a/packages/nodes-base/nodes/Salesforce/AttachmentDescription.ts +++ b/packages/nodes-base/nodes/Salesforce/AttachmentDescription.ts @@ -129,15 +129,38 @@ export const attachmentFields: INodeProperties[] = [ 'Whether this record is viewable only by the owner and administrators (true) or viewable by all otherwise-allowed users (false)', }, { - displayName: 'Owner Name or ID', + displayName: 'Owner', name: 'owner', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - default: '', - description: - 'ID of the User who owns the attachment. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user...', + typeOptions: { + searchListMethod: 'searchUsers', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '0051700000ABCDE', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?:[a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})$', + errorMessage: 'User ID must be 15 or 18 alphanumeric characters', + }, + }, + ], + }, + ], + description: 'The user who owns the attachment', }, ], }, @@ -204,15 +227,38 @@ export const attachmentFields: INodeProperties[] = [ 'Required. Name of the attached file. Maximum size is 255 characters. Label is File Name.', }, { - displayName: 'Owner Name or ID', + displayName: 'Owner', name: 'owner', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - default: '', - description: - 'ID of the User who owns the attachment. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user...', + typeOptions: { + searchListMethod: 'searchUsers', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '0051700000ABCDE', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?:[a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})$', + errorMessage: 'User ID must be 15 or 18 alphanumeric characters', + }, + }, + ], + }, + ], + description: 'The user who owns the attachment', }, ], }, diff --git a/packages/nodes-base/nodes/Salesforce/CaseDescription.ts b/packages/nodes-base/nodes/Salesforce/CaseDescription.ts index 69d0e694deb..77ef419329a 100644 --- a/packages/nodes-base/nodes/Salesforce/CaseDescription.ts +++ b/packages/nodes-base/nodes/Salesforce/CaseDescription.ts @@ -171,15 +171,38 @@ export const caseFields: INodeProperties[] = [ 'The source of the case, such as Email, Phone, or Web. Label is Case Origin. Choose from the list, or specify an ID using an expression.', }, { - displayName: 'Owner Name or ID', + displayName: 'Owner', name: 'owner', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getCaseOwners', - }, - default: '', - description: - 'The owner of the case. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user or queue...', + typeOptions: { + searchListMethod: 'searchCaseOwners', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '0051700000ABCDE', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?:[a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})$', + errorMessage: 'Owner ID must be 15 or 18 alphanumeric characters', + }, + }, + ], + }, + ], + description: 'The user or queue that owns the case', }, { displayName: 'Parent ID', @@ -381,15 +404,38 @@ export const caseFields: INodeProperties[] = [ 'The source of the case, such as Email, Phone, or Web. Label is Case Origin. Choose from the list, or specify an ID using an expression.', }, { - displayName: 'Owner Name or ID', + displayName: 'Owner', name: 'owner', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getCaseOwners', - }, - default: '', - description: - 'The owner of the case. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user or queue...', + typeOptions: { + searchListMethod: 'searchCaseOwners', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '0051700000ABCDE', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?:[a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})$', + errorMessage: 'Owner ID must be 15 or 18 alphanumeric characters', + }, + }, + ], + }, + ], + description: 'The user or queue that owns the case', }, { displayName: 'Parent ID', diff --git a/packages/nodes-base/nodes/Salesforce/ContactDescription.ts b/packages/nodes-base/nodes/Salesforce/ContactDescription.ts index a5a6b517f67..c1baf611a9f 100644 --- a/packages/nodes-base/nodes/Salesforce/ContactDescription.ts +++ b/packages/nodes-base/nodes/Salesforce/ContactDescription.ts @@ -383,15 +383,38 @@ export const contactFields: INodeProperties[] = [ description: 'Street for alternate address', }, { - displayName: 'Owner Name or ID', + displayName: 'Owner', name: 'owner', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - default: '', - description: - 'The owner of the contact. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user...', + typeOptions: { + searchListMethod: 'searchUsers', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '0051700000ABCDE', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?:[a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})$', + errorMessage: 'User ID must be 15 or 18 alphanumeric characters', + }, + }, + ], + }, + ], + description: 'The user who owns the contact', }, { displayName: 'Phone', @@ -725,15 +748,38 @@ export const contactFields: INodeProperties[] = [ description: 'Street for alternate address', }, { - displayName: 'Owner Name or ID', + displayName: 'Owner', name: 'owner', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - default: '', - description: - 'The owner of the contact. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user...', + typeOptions: { + searchListMethod: 'searchUsers', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '0051700000ABCDE', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?:[a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})$', + errorMessage: 'User ID must be 15 or 18 alphanumeric characters', + }, + }, + ], + }, + ], + description: 'The user who owns the contact', }, { displayName: 'Phone', @@ -1061,15 +1107,38 @@ export const contactFields: INodeProperties[] = [ 'Whether only the note owner or a user with the “Modify All Data” permission can view the note or query it via the API', }, { - displayName: 'Owner Name or ID', + displayName: 'Owner', name: 'owner', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - default: '', - description: - 'ID of the user who owns the note. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user...', + typeOptions: { + searchListMethod: 'searchUsers', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '0051700000ABCDE', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?:[a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})$', + errorMessage: 'User ID must be 15 or 18 alphanumeric characters', + }, + }, + ], + }, + ], + description: 'The user who owns the note', }, ], }, diff --git a/packages/nodes-base/nodes/Salesforce/DocumentDescription.ts b/packages/nodes-base/nodes/Salesforce/DocumentDescription.ts index cfc79932663..47f72ce9985 100644 --- a/packages/nodes-base/nodes/Salesforce/DocumentDescription.ts +++ b/packages/nodes-base/nodes/Salesforce/DocumentDescription.ts @@ -86,15 +86,38 @@ export const documentFields: INodeProperties[] = [ description: 'ID of the object you want to link this document to', }, { - displayName: 'Owner Name or ID', + displayName: 'Owner', name: 'ownerId', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - default: '', - description: - 'ID of the owner of this document. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user...', + typeOptions: { + searchListMethod: 'searchUsers', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '0051700000ABCDE', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?:[a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})$', + errorMessage: 'User ID must be 15 or 18 alphanumeric characters', + }, + }, + ], + }, + ], + description: 'The user who owns this document', }, ], }, diff --git a/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts b/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts index df6d91c5d5f..392e715aa21 100644 --- a/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts @@ -51,6 +51,20 @@ function getOptions( return options; } +/** + * Merges extra request options onto the base options. Unlike a plain + * `Object.assign`, headers are merged rather than replaced, so a caller can add + * a header (e.g. `Sforce-Query-Options`) without dropping the `Content-Type` + * header the request builder already set. + */ +function assignOptions(options: IRequestOptions | IHttpRequestOptions, option: IDataObject): void { + const { headers: extraHeaders, ...rest } = option; + if (extraHeaders) { + options.headers = { ...options.headers, ...(extraHeaders as IDataObject) }; + } + Object.assign(options, rest); +} + export async function salesforceApiRequest( this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, method: IHttpRequestMethods, @@ -84,7 +98,7 @@ export async function salesforceApiRequest( delete options.body; } - Object.assign(options, option); + assignOptions(options, option); this.logger.debug( `Authentication for "Salesforce" node is using "jwt". Invoking URI ${options.url}`, ); @@ -106,7 +120,7 @@ export async function salesforceApiRequest( this.logger.debug( `Authentication for "Salesforce" node is using "OAuth2". Invoking URI ${options.uri}`, ); - Object.assign(options, option); + assignOptions(options, option); return await this.helpers.requestOAuth2.call(this, credentialsType, options); } @@ -163,6 +177,23 @@ export async function salesforceApiRequestAllItems( return returnData; } +/** + * Owner fields accept two shapes for backward compatibility: a legacy `options` + * field stored its value as a raw string, while the current `resourceLocator` + * field stores `{ __rl, mode, value }`. This normalises both to the string id, + * or `undefined` when empty/missing. + */ +export function getResourceLocatorValue(value: unknown): string | undefined { + if (value === undefined || value === null || value === '') return undefined; + if (typeof value === 'string') return value; + if (typeof value === 'object' && '__rl' in value) { + const inner = (value as { value?: unknown }).value; + if (inner === undefined || inner === null || inner === '') return undefined; + return String(inner); + } + return undefined; +} + /** * Sorts the given options alphabetically * diff --git a/packages/nodes-base/nodes/Salesforce/LeadDescription.ts b/packages/nodes-base/nodes/Salesforce/LeadDescription.ts index 792c50708c3..02035d67958 100644 --- a/packages/nodes-base/nodes/Salesforce/LeadDescription.ts +++ b/packages/nodes-base/nodes/Salesforce/LeadDescription.ts @@ -307,15 +307,38 @@ export const leadFields: INodeProperties[] = [ description: 'Number of employees at the lead’s company. Label is Employees.', }, { - displayName: 'Owner Name or ID', + displayName: 'Owner', name: 'owner', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getLeadOwners', - }, - default: '', - description: - 'The owner of the lead. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user or queue...', + typeOptions: { + searchListMethod: 'searchLeadOwners', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '0051700000ABCDE', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?:[a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})$', + errorMessage: 'Owner ID must be 15 or 18 alphanumeric characters', + }, + }, + ], + }, + ], + description: 'The user or queue that owns the lead', }, { displayName: 'Phone', @@ -598,15 +621,38 @@ export const leadFields: INodeProperties[] = [ description: 'Number of employees at the lead’s company. Label is Employees.', }, { - displayName: 'Owner Name or ID', + displayName: 'Owner', name: 'owner', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getLeadOwners', - }, - default: '', - description: - 'The owner of the lead. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user or queue...', + typeOptions: { + searchListMethod: 'searchLeadOwners', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '0051700000ABCDE', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?:[a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})$', + errorMessage: 'Owner ID must be 15 or 18 alphanumeric characters', + }, + }, + ], + }, + ], + description: 'The user or queue that owns the lead', }, { displayName: 'Postal Code', @@ -965,15 +1011,38 @@ export const leadFields: INodeProperties[] = [ 'Whether true, only the note owner or a user with the “Modify All Data” permission can view the note or query it via the API', }, { - displayName: 'Owner Name or ID', + displayName: 'Owner', name: 'owner', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - default: '', - description: - 'ID of the user who owns the note. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user...', + typeOptions: { + searchListMethod: 'searchUsers', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '0051700000ABCDE', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?:[a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})$', + errorMessage: 'User ID must be 15 or 18 alphanumeric characters', + }, + }, + ], + }, + ], + description: 'The user who owns the note', }, ], }, diff --git a/packages/nodes-base/nodes/Salesforce/OpportunityDescription.ts b/packages/nodes-base/nodes/Salesforce/OpportunityDescription.ts index 2ea1559fee6..6a031a4c9e6 100644 --- a/packages/nodes-base/nodes/Salesforce/OpportunityDescription.ts +++ b/packages/nodes-base/nodes/Salesforce/OpportunityDescription.ts @@ -267,15 +267,38 @@ export const opportunityFields: INodeProperties[] = [ description: 'Description of next task in closing opportunity. Limit: 255 characters.', }, { - displayName: 'Owner Name or ID', + displayName: 'Owner', name: 'owner', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - default: '', - description: - 'The owner of the opportunity. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user...', + typeOptions: { + searchListMethod: 'searchUsers', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '0051700000ABCDE', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?:[a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})$', + errorMessage: 'User ID must be 15 or 18 alphanumeric characters', + }, + }, + ], + }, + ], + description: 'The user who owns the opportunity', }, { displayName: 'Phone', @@ -469,15 +492,38 @@ export const opportunityFields: INodeProperties[] = [ description: 'Description of next task in closing opportunity. Limit: 255 characters.', }, { - displayName: 'Owner Name or ID', + displayName: 'Owner', name: 'owner', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - default: '', - description: - 'The owner of the opportunity. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user...', + typeOptions: { + searchListMethod: 'searchUsers', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '0051700000ABCDE', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?:[a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})$', + errorMessage: 'User ID must be 15 or 18 alphanumeric characters', + }, + }, + ], + }, + ], + description: 'The user who owns the opportunity', }, { displayName: 'Phone', @@ -753,15 +799,38 @@ export const opportunityFields: INodeProperties[] = [ 'Whether true, only the note owner or a user with the “Modify All Data” permission can view the note or query it via the API', }, { - displayName: 'Owner Name or ID', + displayName: 'Owner', name: 'owner', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - default: '', - description: - 'ID of the user who owns the note. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user...', + typeOptions: { + searchListMethod: 'searchUsers', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '0051700000ABCDE', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?:[a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})$', + errorMessage: 'User ID must be 15 or 18 alphanumeric characters', + }, + }, + ], + }, + ], + description: 'The user who owns the note', }, ], }, diff --git a/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts b/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts index 42187e3885d..fd3c340c755 100644 --- a/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts +++ b/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts @@ -3,6 +3,8 @@ import type { IDataObject, ILoadOptionsFunctions, INodeExecutionData, + INodeListSearchItems, + INodeListSearchResult, INodePropertyOptions, INodeType, INodeTypeDescription, @@ -26,6 +28,7 @@ import { flowFields, flowOperations } from './FlowDescription'; import { escapeSoqlString, getQuery, + getResourceLocatorValue, salesforceApiRequest, salesforceApiRequestAllItems, sortOptions, @@ -40,6 +43,75 @@ import { taskFields, taskOperations } from './TaskDescription'; import type { ITask } from './TaskInterface'; import { userFields, userOperations } from './UserDescription'; +// 200 is Salesforce's minimum query batchSize; smaller values are ignored. +const USER_SEARCH_PAGE_SIZE = 200; + +async function searchOwners( + this: ILoadOptionsFunctions, + queueSobjectType: 'Case' | 'Lead' | undefined, + filter?: string, + paginationToken?: string, +): Promise { + const results: INodeListSearchItems[] = []; + + if (queueSobjectType && !paginationToken) { + // Owner queues have no SOQL typeahead, so fetch them all once (as the legacy + // owner loaders did) and filter/sort in-memory alongside the users. + const queueRecords = (await salesforceApiRequestAllItems.call( + this, + 'records', + 'GET', + '/query', + {}, + { + q: `SELECT Queue.Id, Queue.Name FROM QueuesObject WHERE Queue.Type = 'Queue' AND SobjectType = '${queueSobjectType}'`, + }, + )) as Array<{ Queue: { Id: string; Name: string } }>; + const lowerFilter = (filter ?? '').toLowerCase(); + const queues = queueRecords + .filter((record) => !lowerFilter || record.Queue.Name.toLowerCase().includes(lowerFilter)) + .map((record) => ({ name: `Queue: ${record.Queue.Name}`, value: record.Queue.Id })) + .sort((a, b) => a.name.localeCompare(b.name)); + results.push(...queues); + } + + let userResponse: { records?: Array<{ Id: string; Name: string }>; nextRecordsUrl?: string }; + if (paginationToken) { + // `nextRecordsUrl` is a full Salesforce path like + // `/services/data/v59.0/query/01g4o00000abcdef-2000`. salesforceApiRequest + // re-prefixes the API base itself, so we pass only the `/query/` suffix. + const locator = paginationToken.split('/').pop(); + userResponse = (await salesforceApiRequest.call( + this, + 'GET', + `/query/${locator}`, + )) as typeof userResponse; + } else { + const escapedFilter = filter ? escapeSoqlString(filter) : ''; + const whereClause = escapedFilter ? `WHERE Name LIKE '%${escapedFilter}%' ` : ''; + // No LIMIT: it would cap the result below the batch size and suppress the + // nextRecordsUrl cursor. batchSize bounds the page instead. + userResponse = (await salesforceApiRequest.call( + this, + 'GET', + '/query', + {}, + { q: `SELECT Id, Name FROM User ${whereClause}ORDER BY Name` }, + undefined, + { headers: { 'Sforce-Query-Options': `batchSize=${USER_SEARCH_PAGE_SIZE}` } }, + )) as typeof userResponse; + } + + // Prefix users with "User: " only when queues share this result (mirrors the legacy + // labels); a list of just users — including any paginated page — stays unprefixed. + const userPrefix = results.length > 0 ? 'User: ' : ''; + for (const user of userResponse.records ?? []) { + results.push({ name: `${userPrefix}${user.Name}`, value: user.Id }); + } + + return { results, paginationToken: userResponse.nextRecordsUrl }; +} + export class Salesforce implements INodeType { description: INodeTypeDescription = { displayName: 'Salesforce', @@ -218,124 +290,6 @@ export class Salesforce implements INodeType { sortOptions(returnData); return returnData; }, - // Get all the users to display them to user so that they can - // select them easily - async getUsers(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - const qs = { - q: 'SELECT id, Name FROM User', - }; - const users = await salesforceApiRequestAllItems.call( - this, - 'records', - 'GET', - '/query', - {}, - qs, - ); - for (const user of users) { - const userName = user.Name; - const userId = user.Id; - returnData.push({ - name: userName, - value: userId, - }); - } - sortOptions(returnData); - return returnData; - }, - // Get all the users and case queues to display them to user so that they can - // select them easily - async getCaseOwners(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - const qsQueues = { - q: "SELECT Queue.Id, Queue.Name FROM QueuesObject where Queue.Type='Queue' and SobjectType = 'Case'", - }; - const queues = await salesforceApiRequestAllItems.call( - this, - 'records', - 'GET', - '/query', - {}, - qsQueues, - ); - for (const queue of queues) { - const queueName = queue.Queue.Name; - const queueId = queue.Queue.Id; - returnData.push({ - name: `Queue: ${queueName}`, - value: queueId, - }); - } - const qsUsers = { - q: 'SELECT id, Name FROM User', - }; - const users = await salesforceApiRequestAllItems.call( - this, - 'records', - 'GET', - '/query', - {}, - qsUsers, - ); - const userPrefix = returnData.length > 0 ? 'User: ' : ''; - for (const user of users) { - const userName = user.Name; - const userId = user.Id; - returnData.push({ - name: userPrefix + (userName as string), - value: userId, - }); - } - sortOptions(returnData); - return returnData; - }, - // Get all the users and lead queues to display them to user so that they can - // select them easily - async getLeadOwners(this: ILoadOptionsFunctions): Promise { - const returnData: INodePropertyOptions[] = []; - const qsQueues = { - q: "SELECT Queue.Id, Queue.Name FROM QueuesObject where Queue.Type='Queue' and SobjectType = 'Lead'", - }; - const queues = await salesforceApiRequestAllItems.call( - this, - 'records', - 'GET', - '/query', - {}, - qsQueues, - ); - for (const queue of queues) { - const queueName = queue.Queue.Name; - const queueId = queue.Queue.Id; - returnData.push({ - name: `Queue: ${queueName}`, - value: queueId, - }); - } - const qsUsers = { - q: 'SELECT id, Name FROM User', - }; - const users = await salesforceApiRequestAllItems.call( - this, - 'records', - 'GET', - '/query', - {}, - qsUsers, - ); - const userPrefix = returnData.length > 0 ? 'User: ' : ''; - for (const user of users) { - const userName = user.Name; - const userId = user.Id; - returnData.push({ - name: userPrefix + (userName as string), - value: userId, - }); - } - sortOptions(returnData); - return returnData; - }, // Get all the lead sources to display them to user so that they can // select them easily async getLeadSources(this: ILoadOptionsFunctions): Promise { @@ -1040,6 +994,32 @@ export class Salesforce implements INodeType { // return returnData; // }, }, + listSearch: { + // Server-side typeahead for the owner (User) selectors. + async searchUsers( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, + ): Promise { + return await searchOwners.call(this, undefined, filter, paginationToken); + }, + // Owner selector for Case fields — users plus case queues. + async searchCaseOwners( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, + ): Promise { + return await searchOwners.call(this, 'Case', filter, paginationToken); + }, + // Owner selector for Lead fields — users plus lead queues. + async searchLeadOwners( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, + ): Promise { + return await searchOwners.call(this, 'Lead', filter, paginationToken); + }, + }, }; async execute(this: IExecuteFunctions): Promise { @@ -1103,8 +1083,11 @@ export class Salesforce implements INodeType { if (additionalFields.country !== undefined) { body.Country = additionalFields.country as string; } - if (additionalFields.owner !== undefined) { - body.OwnerId = additionalFields.owner as string; + { + const owner = getResourceLocatorValue(additionalFields.owner); + if (owner !== undefined) { + body.OwnerId = owner; + } } if (additionalFields.website !== undefined) { body.Website = additionalFields.website as string; @@ -1221,8 +1204,11 @@ export class Salesforce implements INodeType { if (updateFields.country !== undefined) { body.Country = updateFields.country as string; } - if (updateFields.owner !== undefined) { - body.OwnerId = updateFields.owner as string; + { + const owner = getResourceLocatorValue(updateFields.owner); + if (owner !== undefined) { + body.OwnerId = owner; + } } if (updateFields.website !== undefined) { body.Website = updateFields.website as string; @@ -1363,8 +1349,11 @@ export class Salesforce implements INodeType { if (options.body) { body.Body = options.body as string; } - if (options.owner) { - body.OwnerId = options.owner as string; + { + const owner = getResourceLocatorValue(options.owner); + if (owner !== undefined) { + body.OwnerId = owner; + } } if (options.isPrivate) { body.IsPrivate = options.isPrivate as boolean; @@ -1398,8 +1387,11 @@ export class Salesforce implements INodeType { if (additionalFields.recordTypeId !== undefined) { body.RecordTypeId = additionalFields.recordTypeId as string; } - if (additionalFields.owner !== undefined) { - body.OwnerId = additionalFields.owner as string; + { + const owner = getResourceLocatorValue(additionalFields.owner); + if (owner !== undefined) { + body.OwnerId = owner; + } } if (additionalFields.acconuntId !== undefined) { body.AccountId = additionalFields.acconuntId as string; @@ -1543,8 +1535,11 @@ export class Salesforce implements INodeType { if (updateFields.jigsaw !== undefined) { body.Jigsaw = updateFields.jigsaw as string; } - if (updateFields.owner !== undefined) { - body.OwnerId = updateFields.owner as string; + { + const owner = getResourceLocatorValue(updateFields.owner); + if (owner !== undefined) { + body.OwnerId = owner; + } } if (updateFields.acconuntId !== undefined) { body.AccountId = updateFields.acconuntId as string; @@ -1737,8 +1732,11 @@ export class Salesforce implements INodeType { if (options.body !== undefined) { body.Body = options.body as string; } - if (options.owner !== undefined) { - body.OwnerId = options.owner as string; + { + const owner = getResourceLocatorValue(options.owner); + if (owner !== undefined) { + body.OwnerId = owner; + } } if (options.isPrivate !== undefined) { body.IsPrivate = options.isPrivate as boolean; @@ -1867,8 +1865,11 @@ export class Salesforce implements INodeType { ContentLocation: 'S', }, }; - if (additionalFields.ownerId) { - body.entity_content.ownerId = additionalFields.ownerId as string; + { + const ownerId = getResourceLocatorValue(additionalFields.ownerId); + if (ownerId !== undefined) { + body.entity_content.ownerId = ownerId; + } } if (additionalFields.linkToObjectId) { body.entity_content.FirstPublishLocationId = @@ -1923,8 +1924,11 @@ export class Salesforce implements INodeType { if (additionalFields.amount !== undefined) { body.Amount = additionalFields.amount as number; } - if (additionalFields.owner !== undefined) { - body.OwnerId = additionalFields.owner as string; + { + const owner = getResourceLocatorValue(additionalFields.owner); + if (owner !== undefined) { + body.OwnerId = owner; + } } if (additionalFields.nextStep !== undefined) { body.NextStep = additionalFields.nextStep as string; @@ -1992,8 +1996,11 @@ export class Salesforce implements INodeType { if (updateFields.amount !== undefined) { body.Amount = updateFields.amount as number; } - if (updateFields.owner !== undefined) { - body.OwnerId = updateFields.owner as string; + { + const owner = getResourceLocatorValue(updateFields.owner); + if (owner !== undefined) { + body.OwnerId = owner; + } } if (updateFields.nextStep !== undefined) { body.NextStep = updateFields.nextStep as string; @@ -2104,8 +2111,11 @@ export class Salesforce implements INodeType { if (options.body !== undefined) { body.Body = options.body as string; } - if (options.owner !== undefined) { - body.OwnerId = options.owner as string; + { + const owner = getResourceLocatorValue(options.owner); + if (owner !== undefined) { + body.OwnerId = owner; + } } if (options.isPrivate !== undefined) { body.IsPrivate = options.isPrivate as boolean; @@ -2133,8 +2143,11 @@ export class Salesforce implements INodeType { if (additionalFields.phone !== undefined) { body.Phone = additionalFields.phone as string; } - if (additionalFields.owner !== undefined) { - body.OwnerId = additionalFields.owner as string; + { + const owner = getResourceLocatorValue(additionalFields.owner); + if (owner !== undefined) { + body.OwnerId = owner; + } } if (additionalFields.sicDesc !== undefined) { body.SicDesc = additionalFields.sicDesc as string; @@ -2241,8 +2254,11 @@ export class Salesforce implements INodeType { if (updateFields.phone !== undefined) { body.Phone = updateFields.phone as string; } - if (updateFields.ownerId !== undefined) { - body.OwnerId = updateFields.ownerId as string; + { + const ownerId = getResourceLocatorValue(updateFields.ownerId); + if (ownerId !== undefined) { + body.OwnerId = ownerId; + } } if (updateFields.sicDesc !== undefined) { body.SicDesc = updateFields.sicDesc as string; @@ -2392,8 +2408,11 @@ export class Salesforce implements INodeType { if (options.body !== undefined) { body.Body = options.body as string; } - if (options.owner !== undefined) { - body.OwnerId = options.owner as string; + { + const ownerId = getResourceLocatorValue(options.ownerId); + if (ownerId !== undefined) { + body.OwnerId = ownerId; + } } if (options.isPrivate !== undefined) { body.IsPrivate = options.isPrivate as boolean; @@ -2418,8 +2437,11 @@ export class Salesforce implements INodeType { if (additionalFields.status !== undefined) { body.Status = additionalFields.status as string; } - if (additionalFields.owner !== undefined) { - body.OwnerId = additionalFields.owner as string; + { + const owner = getResourceLocatorValue(additionalFields.owner); + if (owner !== undefined) { + body.OwnerId = owner; + } } if (additionalFields.subject !== undefined) { body.Subject = additionalFields.subject as string; @@ -2486,8 +2508,11 @@ export class Salesforce implements INodeType { if (updateFields.status !== undefined) { body.Status = updateFields.status as string; } - if (updateFields.owner !== undefined) { - body.OwnerId = updateFields.owner as string; + { + const owner = getResourceLocatorValue(updateFields.owner); + if (owner !== undefined) { + body.OwnerId = owner; + } } if (updateFields.subject !== undefined) { body.Subject = updateFields.subject as string; @@ -2633,8 +2658,11 @@ export class Salesforce implements INodeType { if (additionalFields.whatId !== undefined) { body.WhatId = additionalFields.whatId as string; } - if (additionalFields.owner !== undefined) { - body.OwnerId = additionalFields.owner as string; + { + const owner = getResourceLocatorValue(additionalFields.owner); + if (owner !== undefined) { + body.OwnerId = owner; + } } if (additionalFields.subject !== undefined) { body.Subject = additionalFields.subject as string; @@ -2725,8 +2753,11 @@ export class Salesforce implements INodeType { if (updateFields.whatId !== undefined) { body.WhatId = updateFields.whatId as string; } - if (updateFields.owner !== undefined) { - body.OwnerId = updateFields.owner as string; + { + const owner = getResourceLocatorValue(updateFields.owner); + if (owner !== undefined) { + body.OwnerId = owner; + } } if (updateFields.subject !== undefined) { body.Subject = updateFields.subject as string; @@ -2883,8 +2914,11 @@ export class Salesforce implements INodeType { if (additionalFields.description !== undefined) { body.Description = additionalFields.description as string; } - if (additionalFields.owner !== undefined) { - body.OwnerId = additionalFields.owner as string; + { + const owner = getResourceLocatorValue(additionalFields.owner); + if (owner !== undefined) { + body.OwnerId = owner; + } } if (additionalFields.isPrivate !== undefined) { body.IsPrivate = additionalFields.isPrivate as boolean; @@ -2920,8 +2954,11 @@ export class Salesforce implements INodeType { if (updateFields.description !== undefined) { body.Description = updateFields.description as string; } - if (updateFields.owner !== undefined) { - body.OwnerId = updateFields.owner as string; + { + const owner = getResourceLocatorValue(updateFields.owner); + if (owner !== undefined) { + body.OwnerId = owner; + } } if (updateFields.isPrivate !== undefined) { body.IsPrivate = updateFields.isPrivate as boolean; diff --git a/packages/nodes-base/nodes/Salesforce/TaskDescription.ts b/packages/nodes-base/nodes/Salesforce/TaskDescription.ts index 9b7ff4dcfea..1d5b3f223bd 100644 --- a/packages/nodes-base/nodes/Salesforce/TaskDescription.ts +++ b/packages/nodes-base/nodes/Salesforce/TaskDescription.ts @@ -183,15 +183,38 @@ export const taskFields: INodeProperties[] = [ description: 'Whether a popup reminder has been set for the task (true) or not (false)', }, { - displayName: 'Owner Name or ID', + displayName: 'Owner', name: 'owner', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - default: '', - description: - 'ID of the User who owns the record. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user...', + typeOptions: { + searchListMethod: 'searchUsers', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '0051700000ABCDE', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?:[a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})$', + errorMessage: 'User ID must be 15 or 18 alphanumeric characters', + }, + }, + ], + }, + ], + description: 'The user who owns the record', }, { displayName: 'Priority Name or ID', @@ -526,15 +549,38 @@ export const taskFields: INodeProperties[] = [ description: 'Whether a popup reminder has been set for the task (true) or not (false)', }, { - displayName: 'Owner Name or ID', + displayName: 'Owner', name: 'owner', - type: 'options', - typeOptions: { - loadOptionsMethod: 'getUsers', - }, - default: '', - description: - 'ID of the User who owns the record. Choose from the list, or specify an ID using an expression.', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a user...', + typeOptions: { + searchListMethod: 'searchUsers', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '0051700000ABCDE', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?:[a-zA-Z0-9]{15}|[a-zA-Z0-9]{18})$', + errorMessage: 'User ID must be 15 or 18 alphanumeric characters', + }, + }, + ], + }, + ], + description: 'The user who owns the record', }, { displayName: 'Priority Name or ID', diff --git a/packages/nodes-base/nodes/Salesforce/__test__/GenericFunctions.test.ts b/packages/nodes-base/nodes/Salesforce/__test__/GenericFunctions.test.ts index 4986e90b4f2..efb99c1019f 100644 --- a/packages/nodes-base/nodes/Salesforce/__test__/GenericFunctions.test.ts +++ b/packages/nodes-base/nodes/Salesforce/__test__/GenericFunctions.test.ts @@ -11,6 +11,7 @@ import { filterAndManageProcessedItems, salesforceApiRequest, escapeSoqlString, + getResourceLocatorValue, validateSoqlFieldName, validateSoqlOperator, validateSoqlObjectName, @@ -839,6 +840,40 @@ describe('Salesforce -> GenericFunctions', () => { }); }); + describe('getResourceLocatorValue', () => { + it('returns a raw string unchanged (legacy options-field shape)', () => { + expect(getResourceLocatorValue('0051700000ABCDE')).toBe('0051700000ABCDE'); + }); + + it('extracts the value from a resourceLocator object', () => { + expect( + getResourceLocatorValue({ __rl: true, mode: 'list', value: '0051700000ABCDE' }), + ).toBe('0051700000ABCDE'); + expect(getResourceLocatorValue({ __rl: true, mode: 'id', value: '0051700000ABCDE' })).toBe( + '0051700000ABCDE', + ); + }); + + it('returns undefined for missing or empty values', () => { + expect(getResourceLocatorValue(undefined)).toBeUndefined(); + expect(getResourceLocatorValue(null)).toBeUndefined(); + expect(getResourceLocatorValue('')).toBeUndefined(); + expect(getResourceLocatorValue({ __rl: true, mode: 'list', value: '' })).toBeUndefined(); + expect(getResourceLocatorValue({ __rl: true, mode: 'list', value: null })).toBeUndefined(); + }); + + it('coerces non-string inner values to strings', () => { + expect(getResourceLocatorValue({ __rl: true, mode: 'id', value: 12345 })).toBe('12345'); + }); + + it('returns undefined for unrecognised shapes', () => { + expect(getResourceLocatorValue({})).toBeUndefined(); + expect(getResourceLocatorValue({ foo: 'bar' })).toBeUndefined(); + expect(getResourceLocatorValue(42)).toBeUndefined(); + expect(getResourceLocatorValue(true)).toBeUndefined(); + }); + }); + describe('Security Functions: escaping', () => { describe('escapeSoqlString', () => { it('should escape single quotes', () => { diff --git a/packages/nodes-base/nodes/Salesforce/__test__/Salesforce.node.test.ts b/packages/nodes-base/nodes/Salesforce/__test__/Salesforce.node.test.ts index cc4e95a844d..51b87693971 100644 --- a/packages/nodes-base/nodes/Salesforce/__test__/Salesforce.node.test.ts +++ b/packages/nodes-base/nodes/Salesforce/__test__/Salesforce.node.test.ts @@ -121,98 +121,180 @@ describe('Salesforce', () => { }); }); - describe('getUsers', () => { - it('should return users with sorted options', async () => { - const mockUsers = [ - { Id: 'user1', Name: 'John Doe' }, - { Id: 'user2', Name: 'Jane Smith' }, - ]; - - salesforceApiRequestAllItemsSpy.mockResolvedValue(mockUsers); - sortOptionsSpy.mockImplementation((options) => { - return options.sort((a, b) => a.name.localeCompare(b.name)); + describe('searchUsers (listSearch)', () => { + it('should run a filtered SOQL query with a batchSize header and map results', async () => { + salesforceApiRequestSpy.mockResolvedValue({ + records: [ + { Id: 'user1', Name: 'John Doe' }, + { Id: 'user2', Name: 'Jane Smith' }, + ], + nextRecordsUrl: '/services/data/v59.0/query/01g-2000', }); - const result = await node.methods.loadOptions.getUsers.call(mockLoadOptionsFunctions); + const result = await node.methods.listSearch.searchUsers.call( + mockLoadOptionsFunctions, + 'jo', + ); - expect(salesforceApiRequestAllItemsSpy).toHaveBeenCalledWith( - 'records', + expect(salesforceApiRequestAllItemsSpy).not.toHaveBeenCalled(); + expect(salesforceApiRequestSpy).toHaveBeenCalledWith( 'GET', '/query', {}, - { q: 'SELECT id, Name FROM User' }, + { q: "SELECT Id, Name FROM User WHERE Name LIKE '%jo%' ORDER BY Name" }, + undefined, + { headers: { 'Sforce-Query-Options': 'batchSize=200' } }, + ); + expect(result).toEqual({ + results: [ + { name: 'John Doe', value: 'user1' }, + { name: 'Jane Smith', value: 'user2' }, + ], + paginationToken: '/services/data/v59.0/query/01g-2000', + }); + }); + + it('should omit the WHERE clause when no filter is given', async () => { + salesforceApiRequestSpy.mockResolvedValue({ records: [] }); + + const result = await node.methods.listSearch.searchUsers.call(mockLoadOptionsFunctions); + + expect(salesforceApiRequestSpy).toHaveBeenCalledWith( + 'GET', + '/query', + {}, + { q: 'SELECT Id, Name FROM User ORDER BY Name' }, + undefined, + { headers: { 'Sforce-Query-Options': 'batchSize=200' } }, + ); + expect(result).toEqual({ results: [], paginationToken: undefined }); + }); + + it('should escape single quotes in the filter without applying a LIMIT', async () => { + salesforceApiRequestSpy.mockResolvedValue({ records: [] }); + + await node.methods.listSearch.searchUsers.call(mockLoadOptionsFunctions, "O'Brien"); + + const qs = salesforceApiRequestSpy.mock.calls[0][3] as { q: string }; + expect(qs.q).toBe("SELECT Id, Name FROM User WHERE Name LIKE '%O\\'Brien%' ORDER BY Name"); + expect(qs.q).not.toContain('LIMIT'); + }); + + it('should follow the pagination cursor instead of re-querying', async () => { + salesforceApiRequestSpy.mockResolvedValue({ records: [{ Id: 'user3', Name: 'Zoe' }] }); + + const result = await node.methods.listSearch.searchUsers.call( + mockLoadOptionsFunctions, + '', + '/services/data/v59.0/query/01g-2000', ); - expect(result).toEqual([ - { name: 'Jane Smith', value: 'user2' }, - { name: 'John Doe', value: 'user1' }, - ]); + expect(salesforceApiRequestSpy).toHaveBeenCalledTimes(1); + expect(salesforceApiRequestSpy).toHaveBeenCalledWith('GET', '/query/01g-2000'); + expect(result).toEqual({ + results: [{ name: 'Zoe', value: 'user3' }], + paginationToken: undefined, + }); }); }); - describe('getCaseOwners', () => { - it('should return case owners with queues and users', async () => { - const mockQueues = [ + describe('searchCaseOwners (listSearch)', () => { + it('should combine case queues and users with prefixed labels', async () => { + salesforceApiRequestAllItemsSpy.mockResolvedValue([ { Queue: { Id: 'queue1', Name: 'Support Queue' } }, { Queue: { Id: 'queue2', Name: 'Sales Queue' } }, - ]; - const mockUsers = [ - { Id: 'user1', Name: 'John Doe' }, - { Id: 'user2', Name: 'Jane Smith' }, - ]; + ]); + salesforceApiRequestSpy.mockResolvedValue({ records: [{ Id: 'user1', Name: 'John Doe' }] }); - salesforceApiRequestAllItemsSpy - .mockResolvedValueOnce(mockQueues) - .mockResolvedValueOnce(mockUsers); - - const result = await node.methods.loadOptions.getCaseOwners.call(mockLoadOptionsFunctions); + const result = await node.methods.listSearch.searchCaseOwners.call( + mockLoadOptionsFunctions, + '', + ); + // Queues are fetched in full (legacy behaviour), users via a capped search. expect(salesforceApiRequestAllItemsSpy).toHaveBeenCalledWith( 'records', 'GET', '/query', {}, { - q: "SELECT Queue.Id, Queue.Name FROM QueuesObject where Queue.Type='Queue' and SobjectType = 'Case'", + q: "SELECT Queue.Id, Queue.Name FROM QueuesObject WHERE Queue.Type = 'Queue' AND SobjectType = 'Case'", }, ); - expect(salesforceApiRequestAllItemsSpy).toHaveBeenCalledWith( - 'records', + expect(salesforceApiRequestSpy).toHaveBeenCalledWith( 'GET', '/query', {}, - { q: 'SELECT id, Name FROM User' }, + { q: 'SELECT Id, Name FROM User ORDER BY Name' }, + undefined, + { headers: { 'Sforce-Query-Options': 'batchSize=200' } }, ); - - expect(result).toEqual([ - { name: 'Queue: Support Queue', value: 'queue1' }, + // Queues are sorted by label; users follow in their SOQL order. + expect(result.results).toEqual([ { name: 'Queue: Sales Queue', value: 'queue2' }, + { name: 'Queue: Support Queue', value: 'queue1' }, { name: 'User: John Doe', value: 'user1' }, - { name: 'User: Jane Smith', value: 'user2' }, ]); }); - it('should handle users without queue prefix when no queues exist', async () => { - salesforceApiRequestAllItemsSpy - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ Id: 'user1', Name: 'John Doe' }]); + it('should not prefix users when the filter matches no queues', async () => { + salesforceApiRequestAllItemsSpy.mockResolvedValue([ + { Queue: { Id: 'queue1', Name: 'Support Queue' } }, + ]); + salesforceApiRequestSpy.mockResolvedValue({ records: [{ Id: 'user1', Name: 'John Doe' }] }); - const result = await node.methods.loadOptions.getCaseOwners.call(mockLoadOptionsFunctions); + const result = await node.methods.listSearch.searchCaseOwners.call( + mockLoadOptionsFunctions, + 'doe', + ); - expect(result).toEqual([{ name: 'John Doe', value: 'user1' }]); + // No queue matched 'doe', so the users render without the "User: " prefix. + expect(result.results).toEqual([{ name: 'John Doe', value: 'user1' }]); + }); + + it('should filter queues in-memory by the search term', async () => { + salesforceApiRequestAllItemsSpy.mockResolvedValue([ + { Queue: { Id: 'queue1', Name: 'Support Queue' } }, + { Queue: { Id: 'queue2', Name: 'Sales Queue' } }, + ]); + salesforceApiRequestSpy.mockResolvedValue({ records: [] }); + + const result = await node.methods.listSearch.searchCaseOwners.call( + mockLoadOptionsFunctions, + 'support', + ); + + expect(result.results).toEqual([{ name: 'Queue: Support Queue', value: 'queue1' }]); + }); + + it('should skip the queue query when following a pagination cursor', async () => { + salesforceApiRequestSpy.mockResolvedValue({ records: [{ Id: 'user1', Name: 'John Doe' }] }); + + const result = await node.methods.listSearch.searchCaseOwners.call( + mockLoadOptionsFunctions, + '', + '/services/data/v59.0/query/01g-2000', + ); + + expect(salesforceApiRequestAllItemsSpy).not.toHaveBeenCalled(); + expect(salesforceApiRequestSpy).toHaveBeenCalledTimes(1); + expect(salesforceApiRequestSpy).toHaveBeenCalledWith('GET', '/query/01g-2000'); + // Paginated pages carry no queues, so users stay unprefixed. + expect(result.results).toEqual([{ name: 'John Doe', value: 'user1' }]); }); }); - describe('getLeadOwners', () => { - it('should return lead owners with queues and users', async () => { - const mockQueues = [{ Queue: { Id: 'queue1', Name: 'Lead Queue' } }]; - const mockUsers = [{ Id: 'user1', Name: 'John Doe' }]; + describe('searchLeadOwners (listSearch)', () => { + it('should query the Lead queues and prefix users', async () => { + salesforceApiRequestAllItemsSpy.mockResolvedValue([ + { Queue: { Id: 'queue1', Name: 'Lead Queue' } }, + ]); + salesforceApiRequestSpy.mockResolvedValue({ records: [{ Id: 'user1', Name: 'John Doe' }] }); - salesforceApiRequestAllItemsSpy - .mockResolvedValueOnce(mockQueues) - .mockResolvedValueOnce(mockUsers); - - const result = await node.methods.loadOptions.getLeadOwners.call(mockLoadOptionsFunctions); + const result = await node.methods.listSearch.searchLeadOwners.call( + mockLoadOptionsFunctions, + '', + ); expect(salesforceApiRequestAllItemsSpy).toHaveBeenCalledWith( 'records', @@ -220,11 +302,10 @@ describe('Salesforce', () => { '/query', {}, { - q: "SELECT Queue.Id, Queue.Name FROM QueuesObject where Queue.Type='Queue' and SobjectType = 'Lead'", + q: "SELECT Queue.Id, Queue.Name FROM QueuesObject WHERE Queue.Type = 'Queue' AND SobjectType = 'Lead'", }, ); - - expect(result).toEqual([ + expect(result.results).toEqual([ { name: 'Queue: Lead Queue', value: 'queue1' }, { name: 'User: John Doe', value: 'user1' }, ]); @@ -1291,6 +1372,31 @@ describe('Salesforce', () => { ); }); + it('should resolve a resourceLocator owner value to OwnerId', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation((param: string): any => { + const params: Record = { + resource: 'lead', + operation: 'create', + company: 'ACME Corp', + lastname: 'Doe', + additionalFields: { + owner: { __rl: true, mode: 'id', value: 'user123' }, + }, + }; + return params[param]; + }); + + salesforceApiRequestSpy.mockResolvedValue({ id: 'lead456', success: true }); + + await node.execute.call(mockExecuteFunctions); + + expect(salesforceApiRequestSpy).toHaveBeenCalledWith( + 'POST', + '/sobjects/lead', + expect.objectContaining({ OwnerId: 'user123' }), + ); + }); + it('should handle lead upsert operation', async () => { mockExecuteFunctions.getNodeParameter.mockImplementation( (param: string, index?: number): any => { @@ -3288,6 +3394,39 @@ describe('Salesforce', () => { }); describe('Execute Method - Account Resource Extended Fields', () => { + describe('Account Add Note Operation', () => { + it('should set the note owner from the ownerId option', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation((param: string): any => { + const params: Record = { + resource: 'account', + operation: 'addNote', + accountId: 'acc123', + title: 'Important Note', + options: { + body: 'Note body', + ownerId: { __rl: true, mode: 'id', value: 'user789' }, + }, + }; + return params[param]; + }); + + salesforceApiRequestSpy.mockResolvedValue({ id: 'note123', success: true }); + + await node.execute.call(mockExecuteFunctions); + + expect(salesforceApiRequestSpy).toHaveBeenCalledWith( + 'POST', + '/sobjects/note', + expect.objectContaining({ + Title: 'Important Note', + ParentId: 'acc123', + Body: 'Note body', + OwnerId: 'user789', + }), + ); + }); + }); + describe('Account Create Operation - Additional Fields', () => { it('should handle account create with all additional fields', async () => { mockExecuteFunctions.getNodeParameter.mockImplementation((param: string): any => {