Appearance
Salesforce Action Launcher Feature Documentation
Overview
Implementation guide for adding Prepare and Present action buttons in Salesforce that launch Pitcher applications using custom settings for domain and connection configuration, with full record context.
Setup Requirements
1. Create Custom Settings (Hierarchical)
Navigate to Setup → Custom Settings → New
Name: Pitcher_Settings__c
Setting Type: Hierarchy
Visibility: Public
Fields:
1. Pitcher_Domain__c (Text, 255) - Example: "demo" (for demo.my.pitcher.com)
2. Connection_ID__c (Text, 255) - Provided by Pitcher
2. Configure Custom Settings
apex
// Via Anonymous Apex or Setup UI
Pitcher_Settings__c settings = Pitcher_Settings__c.getOrgDefaults();
settings.Pitcher_Domain__c = 'your-domain'; // provided by Pitcher
settings.Connection_ID__c = 'your-connection-id'; // provided by Pitcher
upsert settings;
Complete Implementation with Record Context
Apex Controller with Full Context
PitcherActionController.cls
apex
public with sharing class PitcherActionController {
@AuraEnabled(cacheable=true)
public static Map<String, Object> getPitcherSettings() {
try {
Pitcher_Settings__c settings = Pitcher_Settings__c.getOrgDefaults();
// Check if settings exist and are configured
Boolean isConfigured = settings != null &&
String.isNotBlank(settings.Pitcher_Domain__c) &&
String.isNotBlank(settings.Connection_ID__c);
return new Map<String, Object>{
'domain' => settings?.Pitcher_Domain__c,
'connectionId' => settings?.Connection_ID__c,
'isConfigured' => isConfigured,
'orgId' => UserInfo.getOrganizationId()
};
} catch (Exception e) {
throw new AuraHandledException('Error retrieving Pitcher settings: ' + e.getMessage());
}
}
@AuraEnabled(cacheable=true)
public static RecordWithInfo getRecordWithInfo(Id recordId) {
// Get the object type of the record
String objectType = recordId.getSObjectType().getDescribe().getName();
String accountIdField = null;
String accountId = null;
if (objectType == 'Event') {
// Handle Event object exceptional case
accountIdField = 'WhatId';
SObject record = [SELECT WhatId FROM Event WHERE Id = :recordId LIMIT 1];
accountId = (String) record.get(accountIdField);
} else if (objectType == 'Account') {
// Handle Account object exceptional case
accountIdField = 'Id';
accountId = recordId;
} else {
// Describe the object to get its fields
Schema.SObjectType sObjectType = Schema.getGlobalDescribe().get(objectType);
Map<String, Schema.SObjectField> fieldMap = sObjectType.getDescribe().fields.getMap();
for (String fieldName : fieldMap.keySet()) {
Schema.SObjectField field = fieldMap.get(fieldName);
if (field.getDescribe().getType() == Schema.DisplayType.REFERENCE &&
field.getDescribe().getReferenceTo().toString().contains('Account')) {
accountIdField = fieldName;
break;
}
}
// Query the record to get the AccountId field value
if (accountIdField != null) {
String query = 'SELECT ' + accountIdField + ' FROM ' + objectType + ' WHERE Id = :recordId';
SObject record = Database.query(query);
accountId = (String) record.get(accountIdField);
}
}
RecordWithInfo result = new RecordWithInfo();
result.recordId = recordId;
result.accountIdField = accountIdField;
result.accountId = accountId;
result.objectType = objectType;
return result;
}
public class RecordWithInfo {
@AuraEnabled public Id recordId;
@AuraEnabled public String accountIdField;
@AuraEnabled public String accountId;
@AuraEnabled public String objectType;
}
}
Lightning Web Component with Full Parameters
actionLauncher.html
html
<template>
<lightning-card title="Pitcher Actions" icon-name="action:flow">
<div class="slds-p-around_medium">
<template if:true={isReady}>
<div class="slds-button-group-row">
<lightning-button
label="Prepare"
variant="brand"
onclick={handlePrepare}
icon-name="utility:setup"
disabled={!isReady}>
</lightning-button>
<lightning-button
label="Present"
variant="success"
onclick={handlePresent}
icon-name="utility:call"
class="slds-m-left_x-small"
disabled={!isReady}>
</lightning-button>
</div>
</template>
<template if:false={isConfigured}>
<div class="slds-text-color_error">
Pitcher settings not configured. Please contact your administrator.
</div>
</template>
<template if:true={isLoading}>
<lightning-spinner alternative-text="Loading" size="small"></lightning-spinner>
</template>
</div>
</lightning-card>
</template>
actionLauncher.js
javascript
import { LightningElement, api, wire } from 'lwc';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import getPitcherSettings from '@salesforce/apex/PitcherActionController.getPitcherSettings';
import getRecordWithInfo from '@salesforce/apex/PitcherActionController.getRecordWithInfo';
export default class ActionLauncher extends LightningElement {
@api recordId;
// Settings data
pitcherDomain;
connectionId;
orgId;
isConfigured = false;
// Record data
recordInfo;
// UI state
isLoading = true;
settingsLoaded = false;
recordLoaded = false;
@wire(getPitcherSettings)
wiredSettings({ error, data }) {
if (data) {
this.pitcherDomain = data.domain;
this.connectionId = data.connectionId;
this.orgId = data.orgId;
this.isConfigured = data.isConfigured;
this.settingsLoaded = true;
this.checkIfReady();
} else if (error) {
console.error('Error loading Pitcher settings:', error);
this.showToast('Error', 'Failed to load Pitcher settings', 'error');
this.isLoading = false;
}
}
@wire(getRecordWithInfo, { recordId: '$recordId' })
wiredRecordInfo({ error, data }) {
if (data) {
this.recordInfo = data;
this.recordLoaded = true;
this.checkIfReady();
} else if (error) {
console.error('Error loading record info:', error);
this.showToast('Error', 'Failed to load record information', 'error');
this.isLoading = false;
}
}
checkIfReady() {
if (this.settingsLoaded && this.recordLoaded) {
this.isLoading = false;
}
}
get isReady() {
return this.isConfigured && !this.isLoading && this.recordInfo;
}
handlePrepare() {
this.launchPitcher('pitcherai', false);
}
handlePresent() {
this.launchPitcher('start_call', true);
}
launchPitcher(action, isStart) {
if (!this.isReady) {
this.showToast('Configuration Error', 'System not ready or configured', 'error');
return;
}
// Build comprehensive parameters matching Pitcher's expected format
const params = new URLSearchParams({
PostcallId: this.recordId || '',
AccountId: this.recordInfo.accountId || '',
Type: this.recordInfo.objectType || '',
orgId: this.orgId,
timestamp: Date.now().toString(),
start: isStart.toString()
});
// Build redirect URL following Pitcher format
const redirectUrl = `https://${this.pitcherDomain}.my.pitcher.com/?connection=${this.connectionId}&next=/${action}?${params.toString()}`;
console.log('Launching Pitcher with URL:', redirectUrl);
window.open(
redirectUrl,
isStart ? 'pitcherPresent' : 'pitcherPrepare',
'width=1600,height=900,toolbar=no,location=no,menubar=no'
);
const actionLabel = isStart ? 'Presentation' : 'Preparation';
this.showToast('Launching', `Opening Pitcher ${actionLabel}...`, 'success');
}
showToast(title, message, variant) {
this.dispatchEvent(
new ShowToastEvent({
title: title,
message: message,
variant: variant
})
);
}
}
actionLauncher.js-meta.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>59.0</apiVersion>
<isExposed>true</isExposed>
<masterLabel>Pitcher Action Launcher</masterLabel>
<description>Launch Pitcher AI and presentation tools with full context</description>
<targets>
<target>lightning__RecordPage</target>
<target>lightning__AppPage</target>
<target>lightning__HomePage</target>
<target>lightning__UtilityBar</target>
</targets>
</LightningComponentBundle>
Alternative: Visualforce Implementation with Full Context
PitcherActionPage.page
html
<apex:page controller="PitcherActionPageController" showHeader="false" sidebar="false">
<script>
var pitcherDomain = '{!JSENCODE(pitcherDomain)}';
var connectionId = '{!JSENCODE(connectionId)}';
var orgId = '{!JSENCODE(orgId)}';
var recordId = '{!$CurrentPage.parameters.recordId}';
var recordInfo = null;
// Load record info on page load using vanilla JavaScript
window.onload = function() {
if (recordId) {
Visualforce.remoting.Manager.invokeAction(
'{!$RemoteAction.PitcherActionPageController.getRecordInfo}',
recordId,
function(result, event) {
if (event.status) {
recordInfo = result;
enableButtons();
} else {
console.error('Failed to load record info:', event.message);
}
}
);
}
};
function enableButtons() {
document.getElementById('prepareBtn').disabled = false;
document.getElementById('presentBtn').disabled = false;
}
function launchPrepare() {
launchPitcher('pitcherai', false);
}
function launchPresent() {
launchPitcher('start_call', true);
}
function buildQueryString(params) {
var queryString = '';
for (var key in params) {
if (params.hasOwnProperty(key)) {
if (queryString.length > 0) queryString += '&';
queryString += encodeURIComponent(key) + '=' + encodeURIComponent(params[key]);
}
}
return queryString;
}
function launchPitcher(action, isStart) {
if (!pitcherDomain || !connectionId) {
alert('Pitcher settings not configured');
return;
}
var params = buildQueryString({
PostcallId: recordId || '',
AccountId: recordInfo ? recordInfo.accountId : '',
Type: recordInfo ? recordInfo.objectType : '',
orgId: orgId,
timestamp: Date.now(),
start: isStart
});
var redirectUrl = 'https://' + pitcherDomain + '.my.pitcher.com/?connection=' +
connectionId + '&next=/' + action + '?' + params;
window.open(redirectUrl, isStart ? 'pitcherPresent' : 'pitcherPrepare',
'width=1600,height=900');
}
</script>
<div style="padding: 20px; text-align: center;">
<button id="prepareBtn" onclick="launchPrepare()" disabled="true"
style="padding: 10px 20px; margin: 5px; background: #0070d2; color: white;
border: none; border-radius: 4px; cursor: pointer;">
Prepare
</button>
<button id="presentBtn" onclick="launchPresent()" disabled="true"
style="padding: 10px 20px; margin: 5px; background: #04844b; color: white;
border: none; border-radius: 4px; cursor: pointer;">
Present
</button>
</div>
</apex:page>
PitcherActionPageController.cls
apex
public with sharing class PitcherActionPageController {
public String pitcherDomain { get; private set; }
public String connectionId { get; private set; }
public String orgId { get; private set; }
public PitcherActionPageController() {
loadPitcherSettings();
orgId = UserInfo.getOrganizationId();
}
private void loadPitcherSettings() {
try {
Pitcher_Settings__c settings = Pitcher_Settings__c.getOrgDefaults();
if (settings != null) {
pitcherDomain = settings.Pitcher_Domain__c;
connectionId = settings.Connection_ID__c;
}
} catch (Exception e) {
ApexPages.addMessage(new ApexPages.Message(
ApexPages.Severity.ERROR,
'Error loading Pitcher settings: ' + e.getMessage()
));
}
}
@RemoteAction
public static RecordWithInfo getRecordInfo(Id recordId) {
return PitcherActionController.getRecordWithInfo(recordId);
}
public class RecordWithInfo {
public Id recordId;
public String accountIdField;
public String accountId;
public String objectType;
}
}
Test Class with Full Coverage
apex
@isTest
public class PitcherActionControllerTest {
@testSetup
static void setup() {
// Create test custom settings
Pitcher_Settings__c settings = new Pitcher_Settings__c();
settings.Pitcher_Domain__c = 'test-domain';
settings.Connection_ID__c = 'test-connection-123';
insert settings;
// Create test Account
Account testAccount = new Account(Name = 'Test Account');
insert testAccount;
// Create test Opportunity
Opportunity testOpp = new Opportunity(
Name = 'Test Opportunity',
AccountId = testAccount.Id,
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30)
);
insert testOpp;
// Create test Event
Event testEvent = new Event(
Subject = 'Test Event',
WhatId = testAccount.Id,
StartDateTime = DateTime.now(),
EndDateTime = DateTime.now().addHours(1)
);
insert testEvent;
}
@isTest
static void testGetPitcherSettings() {
Test.startTest();
Map<String, Object> result = PitcherActionController.getPitcherSettings();
Test.stopTest();
System.assertEquals('test-domain', result.get('domain'));
System.assertEquals('test-connection-123', result.get('connectionId'));
System.assertEquals(true, result.get('isConfigured'));
System.assertNotEquals(null, result.get('orgId'));
}
@isTest
static void testGetRecordWithInfoOpportunity() {
Opportunity opp = [SELECT Id, AccountId FROM Opportunity LIMIT 1];
Test.startTest();
PitcherActionController.RecordWithInfo result =
PitcherActionController.getRecordWithInfo(opp.Id);
Test.stopTest();
System.assertEquals(opp.Id, result.recordId);
System.assertEquals('Opportunity', result.objectType);
System.assertEquals(opp.AccountId, result.accountId);
System.assertEquals('AccountId', result.accountIdField);
}
@isTest
static void testGetRecordWithInfoAccount() {
Account acc = [SELECT Id FROM Account LIMIT 1];
Test.startTest();
PitcherActionController.RecordWithInfo result =
PitcherActionController.getRecordWithInfo(acc.Id);
Test.stopTest();
System.assertEquals(acc.Id, result.recordId);
System.assertEquals('Account', result.objectType);
System.assertEquals(acc.Id, result.accountId);
System.assertEquals('Id', result.accountIdField);
}
@isTest
static void testGetRecordWithInfoEvent() {
Event evt = [SELECT Id, WhatId FROM Event LIMIT 1];
Test.startTest();
PitcherActionController.RecordWithInfo result =
PitcherActionController.getRecordWithInfo(evt.Id);
Test.stopTest();
System.assertEquals(evt.Id, result.recordId);
System.assertEquals('Event', result.objectType);
System.assertEquals(evt.WhatId, result.accountId);
System.assertEquals('WhatId', result.accountIdField);
}
}
URL Format with Complete Parameters
Prepare URL Example
https://demo.my.pitcher.com/?connection=abc123&next=/pitcherai?PostcallId=0061234567890AB&AccountId=0011234567890AB&Type=Opportunity&orgId=00D1234567890AB×tamp=1704067200000&start=false
Present URL Example
https://demo.my.pitcher.com/?connection=abc123&next=/start_call?PostcallId=0061234567890AB&AccountId=0011234567890AB&Type=Opportunity&orgId=00D1234567890AB×tamp=1704067200000&start=true
URL Parameters Reference
Parameter | Description | Example |
---|---|---|
PostcallId | Current record ID | 0061234567890AB |
AccountId | Related Account ID | 0011234567890AB |
Type | Object type of current record | Opportunity, Lead, Account, Event |
orgId | Salesforce Organization ID | 00D1234567890AB |
timestamp | Current timestamp in milliseconds | 1704067200000 |
start | Boolean for Present action | true/false |
Deployment Checklist
- [ ] Create Pitcher_Settings__c custom setting
- [ ] Deploy PitcherActionController.cls with test class
- [ ] Deploy Lightning Web Component files
- [ ] Configure custom settings with Pitcher domain and connection ID
- [ ] Add Remote Site Settings for *.my.pitcher.com
- [ ] Add CSP Trusted Sites for *.my.pitcher.com
- [ ] Add component to page layouts
- [ ] Test with different record types (Account, Opportunity, Event)
- [ ] Verify popup blocker settings
- [ ] Validate URL parameters in browser developer tools
Security Considerations
Field Level Security: Ensure users have read access to:
- Pitcher_Settings__c custom setting
- Account lookup fields on objects
- Record IDs they're accessing
Apex Security: Classes use
with sharing
to respect record accessRemote Sites: Configure for HTTPS only
Parameter Validation: All parameters are encoded to prevent injection
Troubleshooting Guide
Debug JavaScript Console
javascript
// Add to LWC for debugging
connectedCallback() {
console.log('=== Pitcher Debug Info ===');
console.log('Org ID:', this.orgId);
console.log('Record ID:', this.recordId);
console.log('Pitcher Domain:', this.pitcherDomain);
console.log('Connection ID:', this.connectionId);
console.log('Record Info:', this.recordInfo);
console.log('Is Configured:', this.isConfigured);
console.log('Is Ready:', this.isReady);
}
Common Issues and Solutions
Buttons Disabled
- Check if custom settings are populated
- Verify record ID is being passed
- Check browser console for errors
Invalid Integration Error
- Verify connection ID with Pitcher support
- Check domain spelling and format
- Test URL directly in browser
Missing Account ID
- Verify object has Account lookup field
- Check field-level security
- Review special cases (Event, Account objects)