Skip to content

Commit b77a851

Browse files
jrupeshShubham Gupta
andauthored
feat: add support for Task/Event List View Buttons in Search Layouts (#746)
Metadata API is not supported to Add list view buttons for Tasks and Events on Aloha Search Layouts. The association of these buttons are done from Activity page. Signed-off-by: Rupesh J <j.rupesh@gmail.com> Co-authored-by: Shubham Gupta <shubham.gupta@health.telstra.com>
1 parent 5915002 commit b77a851

11 files changed

Lines changed: 374 additions & 0 deletions

File tree

src/plugins/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { HighVelocitySalesSettings as highVelocitySalesSettings } from './high-v
88
import { HistoryTracking as historyTracking } from './history-tracking/index.js';
99
import { HomePageLayouts as homePageLayouts } from './home-page-layouts/index.js';
1010
import { LightningExperienceSettings as lightningExperienceSettings } from './lightning-experience-settings/index.js';
11+
import { ListViewCustomButtons as listViewCustomButtons } from './list-view-custom-buttons/index.js';
1112
import { LinkedInSalesNavigatorSettings as linkedInSalesNavigatorSettings } from './linkedin-sales-navigator-settings/index.js';
1213
import { OmniChannelSettings as omniChannelSettings } from './omni-channel-settings/index.js';
1314
import { OpportunitySplits as opportunitySplits } from './opportunity-splits/index.js';
@@ -34,6 +35,7 @@ export {
3435
historyTracking,
3536
homePageLayouts,
3637
lightningExperienceSettings,
38+
listViewCustomButtons,
3739
linkedInSalesNavigatorSettings,
3840
omniChannelSettings,
3941
opportunitySplits,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"settings": {
3+
"listViewCustomButtons": [
4+
{
5+
"objectApiName": "Task",
6+
"buttons": []
7+
},
8+
{
9+
"objectApiName": "Event",
10+
"buttons": []
11+
}
12+
]
13+
}
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"settings": {
3+
"listViewCustomButtons": [
4+
{
5+
"objectApiName": "Task",
6+
"buttons": ["TestListButton", "TestListButton2"]
7+
},
8+
{
9+
"objectApiName": "Event",
10+
"buttons": ["TestListButton", "TestListButton2"]
11+
}
12+
]
13+
}
14+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import assert from 'assert';
2+
import * as child from 'child_process';
3+
import { fileURLToPath } from 'node:url';
4+
import * as path from 'path';
5+
import { ListViewCustomButtons } from './index.js';
6+
7+
const __dirname = fileURLToPath(new URL('.', import.meta.url));
8+
9+
describe(ListViewCustomButtons.name, function () {
10+
let plugin: ListViewCustomButtons;
11+
before(() => {
12+
plugin = new ListViewCustomButtons(global.browserforce);
13+
});
14+
15+
const enableButtons = [
16+
{
17+
objectApiName: 'Task',
18+
buttons: ['TestListButton', 'TestListButton2'],
19+
removeOtherButtons: false,
20+
},
21+
{
22+
objectApiName: 'Event',
23+
buttons: ['TestListButton', 'TestListButton2'],
24+
removeOtherButtons: false,
25+
},
26+
];
27+
28+
const enableSingleButton = [
29+
{
30+
objectApiName: 'Task',
31+
buttons: ['TestListButton'],
32+
removeOtherButtons: false,
33+
},
34+
];
35+
36+
const disableButtons = [
37+
{
38+
objectApiName: 'Task',
39+
buttons: [],
40+
removeOtherButtons: true,
41+
},
42+
];
43+
44+
const missingButton = [
45+
{
46+
objectApiName: 'Task',
47+
buttons: ['TestListButton', 'NonExistentButton'],
48+
removeOtherButtons: false,
49+
},
50+
];
51+
52+
it('should deploy test WebLink buttons as a prerequisite', () => {
53+
const sourceDeployCmd = child.spawnSync('sf', [
54+
'project',
55+
'deploy',
56+
'start',
57+
'-d',
58+
path.join(__dirname, 'sfdx-source'),
59+
'--json',
60+
]);
61+
assert.deepStrictEqual(sourceDeployCmd.status, 0, sourceDeployCmd.output.toString());
62+
});
63+
64+
it('should add both custom buttons to the list view', async () => {
65+
await plugin.apply(enableButtons);
66+
const res = await plugin.retrieve(enableButtons);
67+
assert.deepStrictEqual(res, enableButtons);
68+
});
69+
70+
it('should be idempotent when buttons are already selected', async () => {
71+
const result = await plugin.run(enableButtons);
72+
assert.deepStrictEqual(result, { message: 'no action necessary' });
73+
});
74+
75+
it('should remove other buttons when removeOtherButtons is true', async () => {
76+
await plugin.apply([{ objectApiName: 'Task', buttons: ['TestListButton'], removeOtherButtons: true }]);
77+
const res = await plugin.retrieve([
78+
{ objectApiName: 'Task', buttons: ['TestListButton'], removeOtherButtons: true },
79+
]);
80+
assert.deepStrictEqual(res, [{ objectApiName: 'Task', buttons: ['TestListButton'], removeOtherButtons: true }]);
81+
});
82+
83+
it('should remove one button and keep the other', async () => {
84+
await plugin.apply(enableSingleButton);
85+
const res = await plugin.retrieve(enableSingleButton);
86+
assert.deepStrictEqual(res, enableSingleButton);
87+
});
88+
89+
it('should remove all custom buttons', async () => {
90+
await plugin.apply(disableButtons);
91+
const res = await plugin.retrieve(disableButtons);
92+
assert.deepStrictEqual(res, disableButtons);
93+
});
94+
95+
it('should add found button and warn about missing button without failing', async () => {
96+
await plugin.apply(missingButton);
97+
const res = await plugin.retrieve([{ objectApiName: 'Task', buttons: ['TestListButton'] }]);
98+
assert.strictEqual(res[0].buttons.length, 1);
99+
assert.strictEqual(res[0].buttons[0], 'TestListButton');
100+
});
101+
});
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import type { Record } from '@jsforce/jsforce-node';
2+
import { type SalesforceUrlPath, waitForPageErrors } from '../../browserforce.js';
3+
import { BrowserforcePlugin } from '../../plugin.js';
4+
5+
const AVAILABLE_LISTBOX_NAME = 'Available Buttons';
6+
const SELECTED_LISTBOX_NAME = 'Selected Buttons';
7+
const SAVE_BUTTON_SELECTOR = 'input[name="save"]';
8+
9+
interface WebLinkRecord extends Record {
10+
Id: string;
11+
Name: string;
12+
NamespacePrefix: string | null;
13+
}
14+
15+
type ListViewCustomButtonsConfig = {
16+
objectApiName: string;
17+
buttons: string[];
18+
removeOtherButtons?: boolean | false;
19+
};
20+
21+
function buildPagePath(objectApiName: string): SalesforceUrlPath {
22+
let pageObjApiName = objectApiName;
23+
if (objectApiName === 'Task' || objectApiName === 'Event') {
24+
pageObjApiName = 'Activity';
25+
}
26+
27+
return `/p/setup/layout/ListButtonsEdit?LayoutEntity=${pageObjApiName}&retURL=${encodeURIComponent('/setup/forcecomHomepage.apexp')}`;
28+
}
29+
30+
export class ListViewCustomButtons extends BrowserforcePlugin {
31+
public async retrieve(definition?: ListViewCustomButtonsConfig[]): Promise<ListViewCustomButtonsConfig[]> {
32+
const results: ListViewCustomButtonsConfig[] = [];
33+
34+
for (const entry of definition) {
35+
const page = await this.browserforce.openPage(buildPagePath(entry.objectApiName));
36+
const selectedListbox = page.getByRole('listbox', { name: SELECTED_LISTBOX_NAME });
37+
await selectedListbox.waitFor();
38+
39+
const buttonIds = (
40+
await Promise.all((await selectedListbox.locator('option').all()).map((opt) => opt.getAttribute('value')))
41+
)
42+
.filter((b) => b?.trim() !== '')
43+
.map((b) => `'${b?.trim()}'`);
44+
45+
const result =
46+
buttonIds.length > 0
47+
? await this.browserforce.connection.query<WebLinkRecord>(
48+
`SELECT Id, Name, NamespacePrefix FROM WebLink WHERE Id IN (${buttonIds.join(',')}) AND PageOrSobjectType = '${entry.objectApiName}'`,
49+
)
50+
: { records: [] };
51+
52+
const buttons = [];
53+
54+
for (const record of result.records) {
55+
const fullApiName = record.NamespacePrefix ? `${record.NamespacePrefix}__${record.Name}` : record.Name;
56+
buttons.push(fullApiName);
57+
}
58+
59+
results.push({
60+
objectApiName: entry.objectApiName,
61+
buttons: buttons.sort(),
62+
removeOtherButtons: entry.removeOtherButtons,
63+
});
64+
}
65+
66+
return results;
67+
}
68+
69+
public async apply(plan: ListViewCustomButtonsConfig[]): Promise<void> {
70+
for (const entry of plan) {
71+
const buttons = await this.queryWebLinks(entry.buttons, entry.objectApiName);
72+
73+
const buttonApiNames = new Set(
74+
buttons.map((b) => (b.NamespacePrefix ? `${b.NamespacePrefix}__${b.Name}` : b.Name)),
75+
);
76+
const missingButtons = entry.buttons.filter((b) => !buttonApiNames.has(b));
77+
78+
if (missingButtons.length > 0) {
79+
this.browserforce.logger?.warn(
80+
`[${entry.objectApiName}] WebLink(s) not found for button API name(s): ${missingButtons.map((b) => `"${b}"`).join(', ')}. Skipping missing buttons.`,
81+
);
82+
}
83+
84+
const targetButtonIds = buttons.map((b) => b.Id.slice(0, 15)!);
85+
86+
const page = await this.browserforce.openPage(buildPagePath(entry.objectApiName));
87+
const availableListbox = page.getByRole('listbox', { name: AVAILABLE_LISTBOX_NAME });
88+
const selectedListbox = page.getByRole('listbox', { name: SELECTED_LISTBOX_NAME });
89+
await availableListbox.waitFor();
90+
91+
const availableButtonIds = (
92+
await Promise.all((await availableListbox.locator('option').all()).map((opt) => opt.getAttribute('value')))
93+
).map((b) => b?.trim() ?? '');
94+
95+
const selectedButtonIds = (
96+
await Promise.all((await selectedListbox.locator('option').all()).map((opt) => opt.getAttribute('value')))
97+
)
98+
.map((b) => b?.trim() ?? '')
99+
.filter((b) => b !== '');
100+
101+
const addButton = page.locator('img.rightArrowIcon');
102+
const removeButton = page.locator('img.leftArrowIcon');
103+
104+
for (const buttonId of targetButtonIds) {
105+
if (availableButtonIds.includes(buttonId) && !selectedButtonIds.includes(buttonId)) {
106+
await availableListbox.selectOption({ value: buttonId });
107+
await addButton.click();
108+
await selectedListbox.locator(`option[value="${buttonId}"]`).waitFor();
109+
}
110+
}
111+
112+
if (entry.removeOtherButtons) {
113+
for (const buttonId of selectedButtonIds) {
114+
if (!targetButtonIds.includes(buttonId)) {
115+
await selectedListbox.selectOption({ value: buttonId });
116+
await removeButton.click();
117+
await availableListbox.locator(`option[value="${buttonId}"]`).waitFor();
118+
}
119+
}
120+
}
121+
122+
await page.locator(SAVE_BUTTON_SELECTOR).click();
123+
await Promise.race([
124+
page.waitForURL((url) => url.pathname === '/setup/forcecomHomepage.apexp'),
125+
waitForPageErrors(page),
126+
]);
127+
}
128+
}
129+
130+
private async queryWebLinks(apiNames: string[], objectApiName: string): Promise<WebLinkRecord[]> {
131+
if (!apiNames?.length) {
132+
return [];
133+
}
134+
135+
const conditions: string[] = [];
136+
137+
for (const name of apiNames) {
138+
const parts = name.split('__');
139+
if (parts.length > 1) {
140+
const namespace = parts[0];
141+
const devName = parts.slice(1).join('__');
142+
conditions.push(`(Name = '${devName}' AND NamespacePrefix = '${namespace}')`);
143+
} else {
144+
conditions.push(`(Name = '${name}' AND NamespacePrefix = NULL)`);
145+
}
146+
}
147+
148+
const result = await this.browserforce.connection.query<WebLinkRecord>(
149+
`SELECT Id, Name, NamespacePrefix FROM WebLink WHERE (${conditions.join(' OR ')}) AND PageOrSobjectType = '${objectApiName}'`,
150+
);
151+
152+
return result.records;
153+
}
154+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema",
3+
"$id": "https://github.com/amtrack/sfdx-browserforce-plugin/src/plugins/list-view-custom-buttons/schema.json",
4+
"title": "List View Custom Buttons",
5+
"description": "Manage the selected custom buttons in the list view button layout (Aloha Search Layout) for any object. Buttons are identified by their WebLink API Name (DeveloperName), with optional namespace prefix (e.g. 'AssignTask' or 'th_dev__AssignTask').",
6+
"type": "array",
7+
"items": { "$ref": "#/definitions/listViewCustomButtons" },
8+
"default": [],
9+
"definitions": {
10+
"listViewCustomButtons": {
11+
"type": "object",
12+
"properties": {
13+
"objectApiName": {
14+
"title": "Object API Name",
15+
"description": "The API name of the object whose list view custom buttons to manage (e.g. Activity, Account, Contact).",
16+
"type": "string"
17+
},
18+
"buttons": {
19+
"title": "Buttons",
20+
"description": "WebLink API Names (DeveloperName) of the custom buttons to select. Use namespace__Name for namespaced buttons (e.g. 'th_dev__AssignTask') or just Name for unpackaged buttons (e.g. 'AssignTask').",
21+
"type": "array",
22+
"items": {
23+
"type": "string",
24+
"description": "WebLink API Name (DeveloperName) of the custom button, optionally prefixed with namespace (e.g. 'AssignTask' or 'th_dev__AssignTask')."
25+
},
26+
"default": []
27+
},
28+
"removeOtherButtons": {
29+
"title": "Remove Other Buttons",
30+
"description": "Whether to remove other buttons from the list view. If true, all buttons except the ones specified in the buttons array will be removed.",
31+
"type": "boolean",
32+
"default": false
33+
}
34+
},
35+
"required": ["objectApiName", "buttons"]
36+
}
37+
}
38+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<WebLink xmlns="http://soap.sforce.com/2006/04/metadata">
3+
<fullName>TestListButton</fullName>
4+
<availability>online</availability>
5+
<displayType>massActionButton</displayType>
6+
<encodingKey>UTF-8</encodingKey>
7+
<linkType>javascript</linkType>
8+
<masterLabel>Test List Button</masterLabel>
9+
<openType>onClickJavaScript</openType>
10+
<protected>false</protected>
11+
<url>alert(&apos;test&apos;);</url>
12+
</WebLink>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<WebLink xmlns="http://soap.sforce.com/2006/04/metadata">
3+
<fullName>TestListButton2</fullName>
4+
<availability>online</availability>
5+
<displayType>massActionButton</displayType>
6+
<encodingKey>UTF-8</encodingKey>
7+
<linkType>javascript</linkType>
8+
<masterLabel>Test List Button 2</masterLabel>
9+
<openType>onClickJavaScript</openType>
10+
<protected>false</protected>
11+
<url>alert(&apos;test2&apos;);</url>
12+
</WebLink>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<WebLink xmlns="http://soap.sforce.com/2006/04/metadata">
3+
<fullName>TestListButton</fullName>
4+
<availability>online</availability>
5+
<displayType>massActionButton</displayType>
6+
<encodingKey>UTF-8</encodingKey>
7+
<linkType>javascript</linkType>
8+
<masterLabel>Test List Button</masterLabel>
9+
<openType>onClickJavaScript</openType>
10+
<protected>false</protected>
11+
<url>alert(&apos;test&apos;);</url>
12+
</WebLink>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<WebLink xmlns="http://soap.sforce.com/2006/04/metadata">
3+
<fullName>TestListButton2</fullName>
4+
<availability>online</availability>
5+
<displayType>massActionButton</displayType>
6+
<encodingKey>UTF-8</encodingKey>
7+
<linkType>javascript</linkType>
8+
<masterLabel>Test List Button 2</masterLabel>
9+
<openType>onClickJavaScript</openType>
10+
<protected>false</protected>
11+
<url>alert(&apos;test2&apos;);</url>
12+
</WebLink>

0 commit comments

Comments
 (0)