fix(Salesforce Node): Use server-side search for owner selectors (#32815)

Co-authored-by: Alexander Gekov <40495748+alexander-gekov@users.noreply.github.com>
This commit is contained in:
Bernhard Wittmann
2026-06-29 12:09:23 +02:00
committed by GitHub
parent 06ceccbb23
commit 8027463c34
12 changed files with 1046 additions and 367 deletions
@@ -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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
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',
},
],
},
@@ -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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
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',
},
],
},
@@ -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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
},
{
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
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',
@@ -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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
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',
},
],
},
@@ -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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
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',
},
],
},
@@ -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
*
@@ -307,15 +307,38 @@ export const leadFields: INodeProperties[] = [
description: 'Number of employees at the leads 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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
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 leads 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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
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',
},
],
},
@@ -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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
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',
},
],
},
@@ -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<INodeListSearchResult> {
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/<locator>` 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<INodePropertyOptions[]> {
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<INodePropertyOptions[]> {
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<INodePropertyOptions[]> {
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<INodePropertyOptions[]> {
@@ -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<INodeListSearchResult> {
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<INodeListSearchResult> {
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<INodeListSearchResult> {
return await searchOwners.call(this, 'Lead', filter, paginationToken);
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
@@ -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;
@@ -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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
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',
@@ -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', () => {
@@ -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<string, unknown> = {
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<string, unknown> = {
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 => {