Extend the Platform · For Developers & Admins
Custom Classes & Pages
Build Your Own Screens, Backed by Governed Code
When a standard record page, related list, or Workflow Rule action just isn't enough, Custom Classes and SmartLite Components let you write your own small scripts and screens — safely boxed in and limited, so they can never affect anything outside your own company's data. Heads up: unlike most other trails, this one involves writing some real code, so it's best suited to admins who are comfortable with that.
Screens are SmartLite Components
Every screen and record-page panel on this trail is a SmartLite Component, built under Setup → Customization → SmartLite Components: a template, a small JavaScript controller, and an @class import for each class method it calls. To learn how they work, start with the SmartLite Components Guide; for the language behind the classes, the Custom Class Code Guide. Classic single-file Custom Pages made earlier keep working.
About & Business Case #
"We need one screen that searches across objects — not just one record's related list." #
"Our support desk wants to type an account name and instantly see every contact under it — before they've even opened the account record. A related list only shows contacts once you're already on that one account's page. On the old platform we'd write an Apex controller and a Lightning page for this. Can we do that here without standing up a whole dev environment?"
Most day-to-day work fits the standard patterns you already know — record detail pages, related lists, list views, Reports. But some needs genuinely don't: a search screen that spans multiple objects, a dashboard-style widget with custom logic, or a lookup a rep needs before a record even exists.
SmartLite CRM has a safe answer for exactly this gap: Custom Classes (small scripts that run behind the scenes, safely boxed in) and SmartLite Components (screens and record-page panels you build yourself that call a Custom Class to fetch data). The same Custom Class can also be attached to a Workflow Rule or Journey Builder step, so the script you write once can run automatically whenever a record is saved, not just when a user opens a screen.
What you'll be able to do
Build a real cross-object search screen — reachable from your app's nav like any other tab — backed by a governed script you write yourself, with zero platform code changes.
Implementation #
Two building blocks, one governed sandbox #
Custom Classes and SmartLite Components are separate pieces that plug together — you'll usually build a Class first, then a component (or a Workflow Rule action) that calls it.
params map. A class run from a Workflow Rule or the Run button gets nothing (its execute() takes no argument), and a batch job gets its records instead: those are different ways of triggering the same class.await x({ … }), and you get back whatever the method returns, as plain data the template shows.params, since there's no user typing anything in.| Where a Custom Class can run | Gets params? | Triggered by |
|---|---|---|
| SmartLite Component as an app page (on demand) | Yes | A user action on the screen, e.g. clicking Search, or the screen opening |
| SmartLite Component on a record page (on demand) | Yes | The component calling a method it imports, usually with this.recordId — see the SmartLite Components Guide |
| Workflow Rule / Journey Builder — "Run Custom Class" | No | Record created/updated (after save) |
| Batch mode (from Developer Console) | No | Manually started, processes records in chunks |
Same script, different doors
Write a class defensively (read params?.x, so a call with no parameters doesn't fail) and you can reuse the same class from a component and a Workflow Rule, instead of writing it twice.
Everything has a usage limit
Every class run — whether from a component, a Workflow Rule, or a batch job — counts against your company's daily script-run limit, and each run has to finish within 15 seconds. A component that calls a class on every keystroke will use up that limit fast; only call it on an explicit action like a Search button.
🌱 Beginner: the three verbs every script uses #
Before any of the bigger builds below, every Custom Class leans on the same three data operations — all of them permission-checked, org-isolated, and impossible to bypass with raw SQL (there isn't any).
Platform.insert('lead', [first_name: 'Priya', last_name: 'Nair', email: 'priya@x.com']) — returns the new record's ID.Platform.update(leadId, [status: 'Contacted']) — only the fields you pass are changed; everything else is left alone.Platform.query('lead', 'status', 'New') — filtered by one field, or Platform.getRecord('lead', leadId) for a single record by ID.Try it now
Setup → Customization → Developer Console → Execute Script. Paste CRM.log(CRM.insert('lead', [first_name: 'Test', last_name: 'Lead', email: 'test@example.com'])) and click Execute: the output shows the new Lead's ID, and the Lead is there. In this tab the toolbox is called CRM; inside a saved class it is Platform.
1 Intermediate: build the account → contacts search screen #
You'll build exactly the screen Ravi asked for in the About tab: type part of an account name, see every contact under every matching account.
AccountContactSearch class from example 21 below: its search(params) method reads params.q, finds matching Accounts with Platform.query('account'), then each one's Contacts with Platform.query('contact', 'account_id', a.hex_id), and returns them.accountContactSearch, tick App page, and paste example 21's template and JavaScript: a search box and button, and import search from '@class/AccountContactSearch.search' called with { q: this.q }. Click Check, then Save./c/accountContactSearch). Assign the new tab to whichever app your team lives in.AccountContactSearch. It'll now also fire automatically whenever a matching Account is saved — remember, in that context the class gets no params, so guard for that (params?.q) if you're reusing the same class.2 Advanced: process thousands of records with Batch Mode #
Some jobs are too big for one 15-second run — like backfilling a field across every Lead your company has ever created. Batch Mode splits the work into chunks and runs them automatically, one after another, until every record has been through the script.
lead and Chunk Size to 200 (the default). The class's execute(batch) method gets the current chunk's records as a list: class LeadSourceFix { def execute(batch) { batch.each { r -> if (!r.source) Platform.update(r.hex_id, [source: 'Other']) } } } (example 1 below is the complete version).Fresh budget every chunk
The 150-row/15-second limits reset for every chunk, not once for the whole job — a batch job over 10,000 records just means 50 chunks of 200, each with its own full budget, not one shared one.
3 Pro: run a batch script automatically, every day #
Turn the manual "Run as Batch" click from the previous step into a standing job that fires on its own — no one has to remember to start it.
It never stacks runs
If yesterday's scheduled run is still going when today's trigger time arrives, today's run is quietly skipped rather than starting a second one on top of it — you'll never get two overlapping jobs fighting over the same records.
4 Syntax reference: declaring a variable, building a class, calling it #
Every Custom Class is Groovy — close to Java, forgiving about types. Here's the three things you'll write over and over.
def or with a type: def name = 'Priya' or String name = 'Priya', int count = 0, List rows = []. Both work. Use def for a variable holding one of your own model classes (below) and for a caught error, because the sandbox refuses calls on those types. The Code Guide explains why, with the whole language from variables to a complete class.class ClassName { def methodName(params) { ...your logic...; return someValue } } — the class's own name in the code is its API name; there's no separate name field to fill in anywhere. Whatever the method's last line evaluates to (or an explicit return) becomes the result. Platform — the toolbox with insert/update/query/getRecord/log/httpGet etc. — is simply available inside any method, with nothing to import or declare.helper()), or call a different, already-saved class by name: Platform.callClass('OtherClassName', 'someMethod', [key: 'value']) — same governed budget as the caller, just one extra level of nesting (capped at 5 deep, so a class can't accidentally call itself in an infinite loop).Minimal working example
class LeadService {
def getSummary(params) {
def lead = Platform.getRecord('lead', params.leadId)
return [ name: lead.first_name + ' ' + lead.last_name, status: lead.status ]
}
}import getSummary from '@class/LeadService.getSummary';, then await getSummary({ leadId: this.recordId }).5 Typed model classes — a real class with getters and setters #
A method doesn't have to return a plain list of loose key/value pairs. It can build and return a list of a proper model class you define yourself, with real fields and real get/set methods — closer to how you'd shape data in a typed language.
defdef b = new Breed() — not Breed b = new Breed(). As above, this is what lets the safety sandbox allow it.class DogBreeds {
def getBreeds(params) {
def list = []
def b = new Breed()
b.setBreedName('Husky')
b.setSubBreeds(['sub-breed'])
list.add(b)
return [ breeds: list ]
}
}
class Breed {
private String breedName
private List subBreeds
String getBreedName() { return breedName }
void setBreedName(String v) { this.breedName = v }
List getSubBreeds() { return subBreeds }
void setSubBreeds(List v) { this.subBreeds = v }
String getSubBreedsText() { return subBreeds ? subBreeds.join(', ') : '-' } // computed
}6 Call a third-party web service (Named Credentials) #
A Class can reach out to an external API — a shipping-rate lookup, a Slack post, a firmographic enrichment service — but it never sees a raw URL or a secret. Both live in a Named Credential, resolved automatically at call time.
shippingApi), the base URL, and how to authenticate (None / API Key Header / Bearer Token / Basic).def res = Platform.httpGet('shippingApi', '/rates?zip=94105') — also httpPost/httpPut/httpDelete, all returning [status, body, headers]. The auth header is injected for you.https://, only public addresses (no reaching your own internal network), no more than 10 outbound calls per script run, and a strict response-size/time cap — the same governed-sandbox philosophy as everything else in this trail.7 Render results with an LWC-style template #
A SmartLite Component doesn't need hand-written DOM code to show a list or a value — its template uses the same directive names as a Lightning Web Component template (for:each, if:true, {value}), so if you already know LWC, you already know this. This screen shows DogBreeds from step 5 when it opens: tick App page, paste both files, and add a tab for it.
<template>
<template if:true={loading}><p class="sl-muted">Loading…</p></template>
<template if:false={loading}>
<table>
<template for:each={view.breeds} for:item="breed">
<tr key={breed.breedName}><td>{breed.breedName}</td><td>{breed.subBreedsText}</td></tr>
</template>
</table>
<template if:false={view.breeds}><p>No breeds found.</p></template>
</template>
</template>import getBreeds from '@class/DogBreeds.getBreeds';
export default class DogBreeds extends SmartLiteComponent {
view = { breeds: [] };
loading = true;
async connectedCallback() {
try {
this.view = await getBreeds({});
} finally {
this.loading = false;
}
}
}No innerHTML, ever
The template's markup is read natively by the browser and bound with real DOM calls — nothing here is built by concatenating an HTML string, so data can never accidentally be parsed as markup.
8 Example library: seven classes you can copy #
Complete classes, one for each common job. Every one of them was compiled and run in the real sandbox against the standard Lead, Account, Contact, Opportunity and Case fields, so you can paste one under Setup → Classes and it will save as is. Change the field names if your org uses custom fields (they end in __c).
What each row looks like
Platform.query(...) returns up to 500 records, each a map of field name → value plus hex_id (the record's ID). Platform.getRecord(...) returns one record with its ID under id, or null if it doesn't exist or you can't see it. Field values come back as text, so convert numbers before adding them up.
A. Read and summarise: pipeline by stage #
Called from a SmartLite Component. Groups every Opportunity by stage and totals the amounts, largest stage first. Shows groupBy, collect, sum and a private helper method.
class PipelineSummary {
def getByStage(params) {
def opps = Platform.query('opportunity')
def byStage = opps.groupBy { it.stage ?: 'No stage' }
def rows = byStage.collect { stage, list ->
[ stage: stage, count: list.size(), total: list.sum { toAmount(it.amount) } ]
}
return [ stages: rows.sort { a, b -> b.total <=> a.total } ]
}
// Amounts are stored as text; blank counts as 0
private toAmount(value) {
return value ? value.toString().toBigDecimal() : 0
}
}The SmartLite Component (an app page) that shows it, built like step 7:
<template>
<template if:true={loading}><p class="sl-muted">Loading…</p></template>
<template if:false={loading}>
<table>
<tr><th>Stage</th><th>Deals</th><th>Total</th></tr>
<template for:each={view.stages} for:item="s">
<tr key={s.stage}><td>{s.stage}</td><td>{s.count}</td><td>{s.total}</td></tr>
</template>
</table>
</template>
</template>import getByStage from '@class/PipelineSummary.getByStage';
export default class PipelineSummary extends SmartLiteComponent {
view = { stages: [] };
loading = true;
async connectedCallback() {
try {
this.view = await getByStage({});
} finally {
this.loading = false;
}
}
}B. Create related records: onboard a new customer #
Called from a SmartLite Component form. Checks its input, skips companies that already exist, then creates an Account and its first Contact linked by account_id. Throwing an exception stops the class and shows the message to the caller.
class AccountOnboarding {
def create(params) {
if (!params?.company) throw new IllegalArgumentException('company is required')
def existing = Platform.query('account', 'name', params.company)
if (existing) return [ accountId: existing[0].hex_id, created: false ]
def accountId = Platform.insert('account', [ name: params.company, industry: params.industry ])
def contactId = Platform.insert('contact', [
first_name: params.firstName ?: 'Primary',
last_name : params.lastName ?: 'Contact',
email : params.email,
account_id: accountId
])
Platform.log('onboarded ' + params.company)
return [ accountId: accountId, contactId: contactId, created: true ]
}
}A form that calls it, as an app page. When the class throws, the component's await fails and shows the message:
<template>
<form onsubmit={handleSubmit}>
<input name="company" placeholder="Company" value={form.company} oninput={handleInput}>
<input name="lastName" placeholder="Contact last name" value={form.lastName} oninput={handleInput}>
<input name="email" placeholder="Contact email" value={form.email} oninput={handleInput}>
<button class="sl-primary" disabled={busy}>Onboard</button>
</form>
<p class="message">{message}</p>
</template>import create from '@class/AccountOnboarding.create';
export default class OnboardCustomer extends SmartLiteComponent {
form = { company: '', lastName: '', email: '' };
message = '';
busy = false;
handleInput(event) {
this.form = { ...this.form, [event.target.name]: event.target.value };
}
async handleSubmit() {
this.busy = true;
try {
const r = await create(this.form);
this.message = r.created ? 'Account and contact created.' : 'That company already exists.';
if (r.created) this.openRecord(r.accountId);
} catch (e) {
this.message = e.message; // e.g. "company is required"
} finally {
this.busy = false;
}
}
}C. Clean data in bulk: a batch class #
Save it with Batch Mode on and Object API Name lead. Each chunk of leads arrives as the batch argument. The second execute(), with no argument, lets the Run button reuse the same logic on today's new leads.
class LeadCleanup {
// Run as Batch: called once for every chunk of leads
def execute(batch) {
def fixed = 0
batch.each { lead ->
def changes = [:]
def email = lead.email?.trim()?.toLowerCase()
if (email && email != lead.email) changes.email = email
if (!lead.source) changes.source = 'Other'
if (lead.company) changes.company = lead.company.trim()
if (changes) {
Platform.update(lead.hex_id, changes) // only the fields in changes are touched
fixed++
}
}
Platform.log('cleaned ' + fixed + ' of ' + batch.size() + ' leads')
return fixed
}
// Run button: the same clean-up over new leads only
def execute() {
return execute(Platform.query('lead', 'status', 'New'))
}
}D. One class calling another: score and route leads #
Save LeadScorer first, then HotLeadRouter. The router can run from the Run button or from a Workflow Rule's "Run Custom Class" action. It asks the scorer for each new lead's score, saves the score, and moves leads scoring 50 or more to Working. The call shares the router's limits, and calls can go at most 5 classes deep.
class LeadScorer {
def score(params) {
def lead = Platform.getRecord('lead', params.leadId)
if (!lead) return 0
def points = 0
if (lead.email) points += 20
if (lead.phone) points += 10
if (lead.company) points += 20
if (lead.source in ['Referral', 'Partner']) points += 30
return points
}
}class HotLeadRouter {
// Run button, or a Workflow Rule's "Run Custom Class" action
def execute() {
def hot = 0
Platform.query('lead', 'status', 'New').each { lead ->
def points = Platform.callClass('LeadScorer', 'score', [ leadId: lead.hex_id ])
def changes = [ lead_score: points ]
if (points >= 50) {
changes.status = 'Working'
hot++
}
Platform.update(lead.hex_id, changes)
}
Platform.log(hot + ' hot leads moved to Working')
return hot
}
}A workflow run doesn't know which record fired it
A class run by a Workflow Rule gets no arguments, so it can't see the record that was just saved. Write it to look records up itself, as HotLeadRouter does with Platform.query. Also keep each run under 150 inserts, updates and deletes: a big job belongs in Batch Mode (example C).
E. Call an outside service: enrich an Account #
Needs a Named Credential with API name enrichmentApi (step 6). Reads the Account's website, asks the service about that domain, and copies back the industry and employee count. Always check res.status before using res.body.
class CompanyEnrichment {
def enrich(params) {
def account = Platform.getRecord('account', params.accountId)
if (!account?.website) return [ ok: false, message: 'Account has no website' ]
def domain = account.website.replaceAll('^https?://', '').replaceAll('/.*$', '')
def res = Platform.httpGet('enrichmentApi', '/companies/find?domain=' + domain)
if (res.status != 200) {
Platform.log('enrichment failed: HTTP ' + res.status)
return [ ok: false, message: 'Lookup failed (' + res.status + ')' ]
}
def changes = [:]
if (res.body.industry) changes.industry = res.body.industry
if (res.body.employees) changes.employees = res.body.employees
if (changes) Platform.update(params.accountId, changes)
return [ ok: true, updated: changes.keySet().toList() ]
}
}The path and the industry/employees names in the response depend on the service you use: change them to match its documentation.
F. Return a model class: an open-cases board #
Builds one CaseRow per open Case and sorts High priority first. The getters become the names the page binds to (getUrgent() → urgent), including the computed ones.
class CaseBoard {
def getOpenCases(params) {
def rows = []
Platform.query('case').findAll { it.status != 'Closed' }.each { c ->
def row = new CaseRow() // def, not CaseRow row = ...
row.setSubject(c.subject)
row.setPriority(c.priority)
row.setStatus(c.status)
rows.add(row)
}
return [ cases: rows.sort { a, b -> a.getRank() <=> b.getRank() } ]
}
}
class CaseRow {
private String subject
private String priority
private String status
String getSubject() { return subject }
void setSubject(String v) { this.subject = v }
String getPriority() { return priority ?: 'Medium' }
void setPriority(String v) { this.priority = v }
String getStatus() { return status }
void setStatus(String v) { this.status = v }
int getRank() { return ['High': 0, 'Medium': 1, 'Low': 2][getPriority()] ?: 1 } // computed
boolean getUrgent() { return getPriority() == 'High' } // computed
}Things the sandbox won't accept
Calls on a class name, such as Math.round(x), String.valueOf(x) or LocalDate.now(), are refused when you save. Call the method on a value instead: x.toString(), x.toBigDecimal(), new Date(). Use Platform.log(...) for output, not println.
"Temporarily unavailable" when you save or run?
Custom code is switched off on some environments while its sandbox is being strengthened. The examples are still correct: they will work once it is switched back on.
How to Use #
Day-to-day: regular users (non-technical) #
50 things you can build #
Every idea below is a working example: click one to open its click-by-click steps and the complete code. All of the classes were checked in the real sandbox, using the standard Lead, Account, Contact, Opportunity, Case, Contract, Product and Campaign fields. New to the language? Start with the Custom Class Code Guide.
🗂️ Batch Classes — process thousands of records unattended #
Batch classes are saved in the Developer Console, because that is where Batch Mode is. Each chunk of records arrives as the batch argument of execute(batch); every chunk gets its own fresh limits (150 inserts/updates/deletes, 10 web calls, 15 seconds).
Backfill a missing field
Give every Lead with a blank Lead Source the value "Other".Steps
- Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
- Paste the
LeadSourceBackfillcode below into Source Code. - Tick Batch Mode, set Object API Name to
leadand Chunk Size to200. - Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
- To repeat it every night, tick Run Daily, set Time (UTC) to
02:00and save again.
Code
class LeadSourceBackfill {
def execute(batch) {
def fixed = 0
batch.each { lead ->
if (!lead.source) {
Platform.update(lead.hex_id, [ source: 'Other' ])
fixed++
}
}
Platform.log('filled ' + fixed + ' of ' + batch.size())
return fixed
}
}Nightly currency conversion
Store every Opportunity's amount in your home currency.Steps
- Go to Setup → Configuration → Object Manager → Opportunity → Fields → New Field. Create a Text field labelled Currency; its API name becomes
currency__c. - Go to Setup → Configuration → Object Manager → Opportunity → Fields → New Field. Create a Currency field labelled Amount (Home Currency); its API name becomes
amount_home__c. - Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
- Paste the
HomeCurrencyBatchcode below into Source Code. - Tick Batch Mode, set Object API Name to
opportunityand Chunk Size to200. - Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
- To repeat it every night, tick Run Daily, set Time (UTC) to
01:00and save again.
Code
class HomeCurrencyBatch {
// Rate to convert 1 unit of each currency into the home currency (USD here).
// For live rates, fetch them once per chunk instead: see example 18.
def rates = [ USD: 1.0, EUR: 1.08, GBP: 1.27, INR: 0.012 ]
def execute(batch) {
def updated = 0
batch.each { opp ->
def rate = rates[opp.currency__c ?: 'USD']
if (rate == null || !opp.amount) return
def home = (num(opp.amount) * rate).setScale(2, 4)
Platform.update(opp.hex_id, [ amount_home__c: home ])
updated++
}
return updated
}
// Numbers come back as text; blank counts as 0
private num(value) {
return value ? value.toString().toBigDecimal() : 0
}
}Note: setScale(2, 4) rounds to 2 decimals, half up.
Mass re-score
Recalculate Lead Score on every open Lead after you change the scoring rules.Steps
- Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
- Paste the
LeadRescoreBatchcode below into Source Code. - Tick Batch Mode, set Object API Name to
leadand Chunk Size to200. - Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
Code
class LeadRescoreBatch {
def execute(batch) {
def changed = 0
batch.findAll { it.status != 'Converted' && it.status != 'Unqualified' }.each { lead ->
def score = 0
if (lead.email) score += 20
if (lead.phone) score += 10
if (lead.company) score += 20
if (lead.rating == 'Hot') score += 30
if (lead.source in ['Referral', 'Partner']) score += 20
if (num(lead.lead_score) != score) {
Platform.update(lead.hex_id, [ lead_score: score ])
changed++
}
}
Platform.log('rescored ' + changed + ' leads')
return changed
}
// Numbers come back as text; blank counts as 0
private num(value) {
return value ? value.toString().toBigDecimal() : 0
}
}Archive stale Contacts
Flag Contacts nobody has changed in two years.Steps
- Go to Setup → Configuration → Object Manager → Contact → Fields → New Field. Create a Checkbox field labelled Archived; its API name becomes
archived__c. - Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
- Paste the
StaleContactBatchcode below into Source Code. - Tick Batch Mode, set Object API Name to
contactand Chunk Size to200. - Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
- To repeat it every night, tick Run Daily, set Time (UTC) to
03:00and save again.
Code
class StaleContactBatch {
def execute(batch) {
def cutoff = day(-730) // two years ago
def archived = 0
batch.each { c ->
def modified = c.sys_modified_date?.toString()?.take(10)
if (modified && modified < cutoff && !c.archived__c) {
Platform.update(c.hex_id, [ archived__c: true ])
archived++
}
}
return archived
}
// Today's date as yyyy-MM-dd (UTC), plus or minus some days
private day(int offset) {
return new Date(new Date().getTime() + offset * 86400000L).toInstant().toString().substring(0, 10)
}
}Note: Classes can't read Activities yet, so "stale" here means the Contact record itself hasn't changed. Dates are compared as yyyy-MM-dd text, which sorts correctly.
Recalculate totals after an import
Recompute each Account's open pipeline once a bulk import of Opportunities finishes.Steps
- Go to Setup → Configuration → Object Manager → Account → Fields → New Field. Create a Currency field labelled Open Pipeline; its API name becomes
open_pipeline__c. - Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
- Paste the
AccountPipelineBatchcode below into Source Code. - Tick Batch Mode, set Object API Name to
accountand Chunk Size to50. - Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
Code
class AccountPipelineBatch {
def execute(batch) {
batch.each { account ->
def open = Platform.query('opportunity', 'account_id', account.hex_id)
.findAll { !(it.stage in ['Closed Won', 'Closed Lost']) }
def total = open.sum { num(it.amount) } ?: 0
if (num(account.open_pipeline__c) != total) {
Platform.update(account.hex_id, [ open_pipeline__c: total ])
}
}
return batch.size()
}
// Numbers come back as text; blank counts as 0
private num(value) {
return value ? value.toString().toBigDecimal() : 0
}
}Note: A chunk size of 50 keeps each chunk well inside 15 seconds, since every Account runs its own query.
Daily closed-won digest
Post yesterday's wins to Slack every morning.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
slack, Base URLhttps://slack.com/api, Auth Type Bearer Token, and paste the key into the secret field. Save. - In Slack, create an app with the
chat:writescope, install it, invite it to the channel, and use its bot token (xoxb-…) as the secret. - Go to Setup → Customization → Classes and click New Class.
- Paste the
SendSlackAlertcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
- Paste the
ClosedWonDigestcode below into Source Code. - Tick Batch Mode, set Object API Name to
opportunityand Chunk Size to1000. A large chunk size means one message per day for most teams; each chunk posts its own message. - Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
- To repeat it every night, tick Run Daily, set Time (UTC) to
07:00and save again.
Code
class SendSlackAlert {
// Post a message to a Slack channel. Called by other classes:
// Platform.callClass('SendSlackAlert', 'post', [ channel: '#sales', text: 'Hello' ])
def post(params) {
def res = Platform.httpPost('slack', '/chat.postMessage',
[ channel: params.channel ?: '#sales', text: params.text ])
if (res.status != 200 || !res.body.ok) {
Platform.log('Slack post failed: ' + res.status + ' ' + res.body)
return false
}
return true
}
}class ClosedWonDigest {
def execute(batch) {
def yesterday = day(-1)
def wins = batch.findAll { it.stage == 'Closed Won' && it.close_date?.toString()?.take(10) == yesterday }
if (!wins) return 0
def total = wins.sum { num(it.amount) }
def lines = wins.collect { '• ' + it.name + ' — ' + (it.amount ?: 0) }.join('\n')
Platform.callClass('SendSlackAlert', 'post', [
channel: '#sales',
text: ':trophy: ' + wins.size() + ' deals won yesterday, total ' + total + '\n' + lines ])
return wins.size()
}
// Today's date as yyyy-MM-dd (UTC), plus or minus some days
private day(int offset) {
return new Date(new Date().getTime() + offset * 86400000L).toInstant().toString().substring(0, 10)
}
// Numbers come back as text; blank counts as 0
private num(value) {
return value ? value.toString().toBigDecimal() : 0
}
}Nightly duplicate sweep
Flag Accounts that share a name with another Account.Steps
- Go to Setup → Configuration → Object Manager → Account → Fields → New Field. Create a Checkbox field labelled Possible Duplicate; its API name becomes
possible_duplicate__c. - Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
- Paste the
DuplicateAccountSweepcode below into Source Code. - Tick Batch Mode, set Object API Name to
accountand Chunk Size to100. - Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
- To repeat it every night, tick Run Daily, set Time (UTC) to
04:00and save again.
Code
class DuplicateAccountSweep {
def execute(batch) {
def flagged = 0
batch.each { account ->
if (!account.name || account.possible_duplicate__c) return
def sameName = Platform.query('account', 'name', account.name)
if (sameName.size() > 1) {
Platform.update(account.hex_id, [ possible_duplicate__c: true ])
flagged++
}
}
Platform.log('flagged ' + flagged + ' possible duplicates')
return flagged
}
}Note: Then build a list view on Account filtered by Possible Duplicate = true for someone to review.
Nightly price sync
Refresh every Product's price from your supplier's price list.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
priceApi, Base URLhttps://api.your-supplier.com, Auth Type API Key Header, Header NameX-Api-Key, and paste the key into the secret field. Save. - Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
- Paste the
ProductPriceSynccode below into Source Code. - Tick Batch Mode, set Object API Name to
productand Chunk Size to10. Keep it at 10: a run may make at most 10 web calls, one per product here. - Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
- To repeat it every night, tick Run Daily, set Time (UTC) to
02:00and save again.
Code
class ProductPriceSync {
def execute(batch) {
def updated = 0
batch.findAll { it.sku && it.active != false }.each { product ->
def res = Platform.httpGet('priceApi', '/prices/' + product.sku)
if (res.status != 200) {
Platform.log('no price for ' + product.sku + ': HTTP ' + res.status)
return
}
def price = res.body.price
if (price != null && price.toString() != product.unit_price?.toString()) {
Platform.update(product.hex_id, [ unit_price: price ])
updated++
}
}
return updated
}
}Note: Change /prices/ and res.body.price to match your supplier's API.
Auto-expire stale Quotes
Move Draft Quotes past their expiration date to Expired.Steps
- Add Expired to the Quote Status picklist: Setup → Configuration → Object Manager → Quote → Fields → Status → add the value.
- Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
- Paste the
QuoteExpiryBatchcode below into Source Code. - Tick Batch Mode, set Object API Name to
quoteand Chunk Size to200. - Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
- To repeat it every night, tick Run Daily, set Time (UTC) to
00:00and save again.
Code
class QuoteExpiryBatch {
def execute(batch) {
def today = day(0)
def expired = 0
batch.each { quote ->
def expires = quote.expiration_date?.toString()?.take(10)
if (quote.status == 'Draft' && expires && expires < today) {
Platform.update(quote.hex_id, [ status: 'Expired' ])
expired++
}
}
return expired
}
// Today's date as yyyy-MM-dd (UTC), plus or minus some days
private day(int offset) {
return new Date(new Date().getTime() + offset * 86400000L).toInstant().toString().substring(0, 10)
}
}Bulk-update Leads from one source
Move every new Lead from an event into Nurturing in one run.Steps
- Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
- Paste the
EventLeadNurturecode below into Source Code. - Tick Batch Mode, set Object API Name to
leadand Chunk Size to200. - Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
Code
class EventLeadNurture {
def source = 'Event' // change to the source you want
def execute(batch) {
def moved = 0
batch.findAll { it.source == source && it.status == 'New' }.each { lead ->
Platform.update(lead.hex_id, [ status: 'Nurturing' ])
moved++
}
return moved
}
}🌐 Web Service Callouts — reach a third-party API through a Named Credential #
Every outside call goes through a Named Credential: the class names the credential, and SmartLite adds the address and the key. Only https, at most 10 calls per run, and the reply comes back as [status, body, headers]. The paths and reply fields below are typical; change them to match the service you sign up for.
Firmographic enrichment
Fill in industry, size and website for new Leads as they are created.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
enrichApi, Base URLhttps://api.your-enrichment.com, Auth Type Bearer Token, and paste the key into the secret field. Save. - Go to Setup → Customization → Classes and click New Class.
- Paste the
LeadEnrichmentcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Lead and Trigger to On Create.
- Add an action of type Run Custom Class, choose
LeadEnrichment, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved.
Code
class LeadEnrichment {
def execute() {
def todo = Platform.query('lead', 'status', 'New')
.findAll { it.email && !it.industry && it.email.contains('@') }
.take(10) // at most 10 web calls per run
todo.each { lead ->
def domain = lead.email.substring(lead.email.indexOf('@') + 1)
def res = Platform.httpGet('enrichApi', '/companies?domain=' + domain)
if (res.status != 200) return
def changes = [ industry: res.body.industry ?: 'Other' ]
if (res.body.employees) changes.no_of_employees = res.body.employees
if (res.body.website) changes.website = res.body.website
Platform.update(lead.hex_id, changes)
}
return todo.size()
}
}Note: A class run by a Workflow Rule doesn't receive the saved record, so it looks up the records that still need work and marks each one done. That also means a failed call is simply retried on the next save. Here "done" means Industry is filled, so a lead the service doesn't know is set to Other.
Address validation
Check an Account's billing address when it is saved.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
addressApi, Base URLhttps://api.your-address-service.com, Auth Type API Key Header, Header NameX-Api-Key, and paste the key into the secret field. Save. - Go to Setup → Configuration → Object Manager → Account → Fields → New Field. Create a Checkbox field labelled Address Verified; its API name becomes
address_verified__c. - Go to Setup → Customization → Classes and click New Class.
- Paste the
AddressCheckcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Account and Trigger to On Create or Update.
- Add an action of type Run Custom Class, choose
AddressCheck, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved.
Code
class AddressCheck {
def execute() {
def todo = Platform.query('account')
.findAll { it.billing_zip && !it.address_verified__c }
.take(10)
todo.each { account ->
def res = Platform.httpPost('addressApi', '/verify', [
street: account.billing_street, city: account.billing_city,
state: account.billing_state, zip: account.billing_zip,
country: account.billing_country ])
if (res.status != 200) return
def changes = [ address_verified__c: res.body.valid == true ]
if (res.body.valid && res.body.zip) changes.billing_zip = res.body.zip // corrected ZIP
Platform.update(account.hex_id, changes)
}
return todo.size()
}
}Note: A class run by a Workflow Rule doesn't receive the saved record, so it looks up the records that still need work and marks each one done. That also means a failed call is simply retried on the next save. Clear Address Verified when someone edits the address (a Field Update action) so it is checked again.
Live shipping rates
A button on the Quote that fetches carrier rates for its shipping address.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
shippingApi, Base URLhttps://api.your-shipping.com, Auth Type Bearer Token, and paste the key into the secret field. Save. - Go to Setup → Customization → Classes and click New Class.
- Paste the
ShippingRatescode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → SmartLite Components and click New Component. API Name
shippingRates, Label Shipping Rates. Under Where it can go, tick Record page of and choose Quote. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save. - Go to Setup → Configuration → Object Manager → Quote → Page Layouts, open the layout, click Add Custom Component, choose Shipping Rates and save the layout. The button now shows on every Quote record.
Code
class ShippingRates {
def getRates(params) {
def quote = Platform.getRecord('quote', params.quoteId)
if (!quote?.shipping_zip) return [ ok: false, message: 'Add a shipping ZIP first' ]
def res = Platform.httpPost('shippingApi', '/rates', [
toZip: quote.shipping_zip, toCountry: quote.shipping_country ?: 'US' ])
if (res.status != 200) return [ ok: false, message: 'Rate lookup failed (' + res.status + ')' ]
def options = res.body.rates.collect { [ carrier: it.carrier, price: it.amount, days: it.days ] }
return [ ok: true, options: options.sort { a, b -> a.price <=> b.price } ]
}
// Second button: save the chosen price on the quote
def choose(params) {
Platform.update(params.quoteId, [ shipping_handling: params.price ])
return [ ok: true ]
}
}<template>
<button class="sl-primary" onclick={handleGetRates} disabled={busy}>Get shipping rates</button>
<p class="message">{message}</p>
<template for:each={options} for:item="o">
<button key={o.carrier} class="option" onclick={handleChoose}>{o.label}</button>
</template>
</template>import getRates from '@class/ShippingRates.getRates';
import choose from '@class/ShippingRates.choose';
export default class ShippingRates extends SmartLiteComponent {
options = [];
message = '';
busy = false;
async handleGetRates() {
this.busy = true;
this.message = '';
try {
const r = await getRates({ quoteId: this.recordId });
if (!r.ok) {
this.message = r.message;
this.options = [];
return;
}
this.options = r.options.map(o => ({ ...o, label: o.carrier + ' — ' + o.price + ' (' + o.days + ' days)' }));
} finally {
this.busy = false;
}
}
// In a for:each, the handler also gets the option that was clicked
async handleChoose(event, option) {
await choose({ quoteId: this.recordId, price: option.price });
this.options = [];
this.message = 'Saved ' + option.carrier + ' to the quote.';
}
}.option { display: block; margin-top: 6px; }Slack alert on a big win
Post to Slack the moment a deal over $50,000 is won.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
slack, Base URLhttps://slack.com/api, Auth Type Bearer Token, and paste the key into the secret field. Save. - In Slack, create an app with the
chat:writescope, install it, invite it to the channel, and use its bot token (xoxb-…) as the secret. - Go to Setup → Configuration → Object Manager → Opportunity → Fields → New Field. Create a Checkbox field labelled Win Announced; its API name becomes
win_announced__c. - Go to Setup → Customization → Classes and click New Class.
- Paste the
SendSlackAlertcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → Classes and click New Class.
- Paste the
BigWinAlertcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Opportunity and Trigger to On Create or Update. Add the criteria Stage equals Closed Won and Amount greater than 50000.
- Add an action of type Run Custom Class, choose
BigWinAlert, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved.
Code
class SendSlackAlert {
// Post a message to a Slack channel. Called by other classes:
// Platform.callClass('SendSlackAlert', 'post', [ channel: '#sales', text: 'Hello' ])
def post(params) {
def res = Platform.httpPost('slack', '/chat.postMessage',
[ channel: params.channel ?: '#sales', text: params.text ])
if (res.status != 200 || !res.body.ok) {
Platform.log('Slack post failed: ' + res.status + ' ' + res.body)
return false
}
return true
}
}class BigWinAlert {
def execute() {
def wins = Platform.query('opportunity', 'stage', 'Closed Won')
.findAll { num(it.amount) > 50000 && !it.win_announced__c }
.take(10)
wins.each { opp ->
def ok = Platform.callClass('SendSlackAlert', 'post', [
channel: '#wins',
text: ':tada: ' + opp.name + ' just closed for ' + opp.amount + ' (owner: ' + opp.owner + ')' ])
if (ok) Platform.update(opp.hex_id, [ win_announced__c: true ])
}
return wins.size()
}
// Numbers come back as text; blank counts as 0
private num(value) {
return value ? value.toString().toBigDecimal() : 0
}
}Note: A class run by a Workflow Rule doesn't receive the saved record, so it looks up the records that still need work and marks each one done. That also means a failed call is simply retried on the next save.
SMS when a Case is resolved
Text the customer through an SMS provider when their Case is resolved.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
sms, Base URLhttps://api.your-sms-provider.com, Auth Type Basic Auth, and paste the key into the secret field. Save. - Go to Setup → Configuration → Object Manager → Case → Fields → New Field. Create a Checkbox field labelled SMS Sent; its API name becomes
sms_sent__c. - Go to Setup → Customization → Classes and click New Class.
- Paste the
CaseResolvedSmscode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Case and Trigger to On Update. Add the criteria Status equals Resolved.
- Add an action of type Run Custom Class, choose
CaseResolvedSms, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved.
Code
class CaseResolvedSms {
def execute() {
def resolved = Platform.query('case', 'status', 'Resolved')
.findAll { !it.sms_sent__c && it.contact_id }
.take(10)
resolved.each { c ->
def contact = Platform.getRecord('contact', c.contact_id)
def phone = contact?.mobile ?: contact?.phone
if (!phone) return
def res = Platform.httpPost('sms', '/messages', [
to: phone,
body: 'Hi ' + (contact.first_name ?: '') + ', your case ' + c.name + ' (' + c.subject + ') is resolved.' ])
if (res.status in [200, 201, 202]) Platform.update(c.hex_id, [ sms_sent__c: true ])
}
return resolved.size()
}
}Note: A class run by a Workflow Rule doesn't receive the saved record, so it looks up the records that still need work and marks each one done. That also means a failed call is simply retried on the next save. For Basic Auth the secret is username:password.
Credit-risk check
A button on the Opportunity that checks the customer's credit before a big discount.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
creditApi, Base URLhttps://api.your-credit-bureau.com, Auth Type Bearer Token, and paste the key into the secret field. Save. - Go to Setup → Customization → Classes and click New Class.
- Paste the
CreditCheckcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → SmartLite Components and click New Component. API Name
creditCheck, Label Credit Check. Under Where it can go, tick Record page of and choose Opportunity. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save. - Go to Setup → Configuration → Object Manager → Opportunity → Page Layouts, open the layout, click Add Custom Component, choose Credit Check and save the layout. The button now shows on every Opportunity record.
Code
class CreditCheck {
def check(params) {
def opp = Platform.getRecord('opportunity', params.opportunityId)
if (!opp?.account_id) return [ ok: false, message: 'The opportunity has no account' ]
def account = Platform.getRecord('account', opp.account_id)
def res = Platform.httpGet('creditApi', '/score?company=' + account.name.replaceAll(' ', '+'))
if (res.status != 200) return [ ok: false, message: 'Credit check failed (' + res.status + ')' ]
def score = res.body.score as Integer
def verdict = score >= 700 ? 'Low risk' : score >= 550 ? 'Medium risk: manager approval' : 'High risk: no discount'
return [ ok: true, company: account.name, score: score, verdict: verdict ]
}
}<template>
<button class="sl-primary" onclick={handleClick} disabled={busy}>Check credit</button>
<p class="result">{resultText}</p>
</template>import check from '@class/CreditCheck.check';
export default class CreditCheck extends SmartLiteComponent {
result = null;
busy = false;
async handleClick() {
this.busy = true;
try {
// this.recordId: the record this panel is on
this.result = await check({ opportunityId: this.recordId });
} catch (e) {
this.result = { ok: false, message: e.message };
} finally {
this.busy = false;
}
}
get resultText() {
const r = this.result;
if (!r) return '';
return r.ok ? r.company + ': ' + r.score + ' — ' + r.verdict : r.message;
}
}Mailing-list sync
Add each new Contact to your email marketing list.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
mailingApi, Base URLhttps://api.your-email-platform.com, Auth Type API Key Header, Header NameX-Api-Key, and paste the key into the secret field. Save. - Go to Setup → Configuration → Object Manager → Contact → Fields → New Field. Create a Checkbox field labelled On Mailing List; its API name becomes
on_mailing_list__c. - Go to Setup → Customization → Classes and click New Class.
- Paste the
MailingListSynccode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Contact and Trigger to On Create.
- Add an action of type Run Custom Class, choose
MailingListSync, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved.
Code
class MailingListSync {
def execute() {
def todo = Platform.query('contact')
.findAll { it.email && !it.on_mailing_list__c && !it.email_opt_out }
.take(10)
todo.each { c ->
def res = Platform.httpPost('mailingApi', '/lists/main/members', [
email: c.email, firstName: c.first_name, lastName: c.last_name ])
if (res.status in [200, 201, 409]) { // 409: already on the list
Platform.update(c.hex_id, [ on_mailing_list__c: true ])
}
}
return todo.size()
}
}Note: A class run by a Workflow Rule doesn't receive the saved record, so it looks up the records that still need work and marks each one done. That also means a failed call is simply retried on the next save. Contacts who opted out of email are skipped.
Live exchange rates
Fetch today's exchange rates for any page or class that needs them.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
fxApi, Base URLhttps://api.your-fx-provider.com, Auth Type API Key Header, Header Nameapikey, and paste the key into the secret field. Save. - Go to Setup → Customization → Classes and click New Class.
- Paste the
ExchangeRatescode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Use it from another class:
def rates = Platform.callClass('ExchangeRates', 'latest', [ base: 'USD' ]), thenrates.EUR. Example 2 can use this instead of its fixed table.
Code
class ExchangeRates {
def latest(params) {
def base = params?.base ?: 'USD'
def res = Platform.httpGet('fxApi', '/latest?base=' + base)
if (res.status != 200) throw new IllegalStateException('Rates unavailable: HTTP ' + res.status)
return res.body.rates.subMap(['USD', 'EUR', 'GBP', 'INR', 'AUD', 'CAD'])
}
}Email deliverability check
A button on the Lead that verifies its email address before you qualify it.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
emailCheckApi, Base URLhttps://api.your-email-verifier.com, Auth Type API Key Header, Header NameX-Api-Key, and paste the key into the secret field. Save. - Go to Setup → Configuration → Object Manager → Lead → Fields → New Field. Create a Checkbox field labelled Email Verified; its API name becomes
email_verified__c. - Go to Setup → Customization → Classes and click New Class.
- Paste the
EmailVerifiercode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → SmartLite Components and click New Component. API Name
verifyEmail, Label Verify Email. Under Where it can go, tick Record page of and choose Lead. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save. - Go to Setup → Configuration → Object Manager → Lead → Page Layouts, open the layout, click Add Custom Component, choose Verify Email and save the layout. The button now shows on every Lead record.
- Optional: in Setup → Configuration → Object Manager → Lead → Validation Rules, block Status = Qualified unless Email Verified is ticked.
Code
class EmailVerifier {
def verify(params) {
def lead = Platform.getRecord('lead', params.leadId)
if (!lead?.email) return [ ok: false, message: 'The lead has no email' ]
def res = Platform.httpGet('emailCheckApi', '/verify?email=' + lead.email)
if (res.status != 200) return [ ok: false, message: 'Check failed (' + res.status + ')' ]
def deliverable = res.body.result == 'deliverable'
Platform.update(params.leadId, [ email_verified__c: deliverable ])
return [ ok: true, deliverable: deliverable, result: res.body.result ]
}
}<template>
<button class="sl-primary" onclick={handleClick} disabled={busy}>Verify email</button>
<p class="result">{resultText}</p>
</template>import verify from '@class/EmailVerifier.verify';
export default class VerifyEmail extends SmartLiteComponent {
result = null;
busy = false;
async handleClick() {
this.busy = true;
try {
// this.recordId: the record this panel is on
this.result = await verify({ leadId: this.recordId });
} catch (e) {
this.result = { ok: false, message: e.message };
} finally {
this.busy = false;
}
}
get resultText() {
const r = this.result;
if (!r) return '';
if (!r.ok) return r.message;
return r.deliverable ? '✓ Deliverable' : '✗ ' + r.result;
}
}Mirror a Case to another tool
Open a matching ticket in your engineering tracker for High and Critical Cases.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
ticketApi, Base URLhttps://api.your-tracker.com, Auth Type Bearer Token, and paste the key into the secret field. Save. - Go to Setup → Configuration → Object Manager → Case → Fields → New Field. Create a Text field labelled External Ticket; its API name becomes
external_ticket__c. - Go to Setup → Customization → Classes and click New Class.
- Paste the
CaseMirrorcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Case and Trigger to On Create or Update. Add the criteria Priority equals High or Priority equals Critical.
- Add an action of type Run Custom Class, choose
CaseMirror, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved.
Code
class CaseMirror {
def execute() {
def todo = Platform.query('case')
.findAll { it.priority in ['High', 'Critical'] && !it.external_ticket__c && !(it.status in ['Resolved', 'Closed']) }
.take(10)
todo.each { c ->
def res = Platform.httpPost('ticketApi', '/issues', [
title: '[' + c.priority + '] ' + c.subject,
description: c.description ?: '',
labels: [ 'from-crm', c.priority.toLowerCase() ] ])
if (res.status in [200, 201]) Platform.update(c.hex_id, [ external_ticket__c: res.body.id.toString() ])
}
return todo.size()
}
}Note: A class run by a Workflow Rule doesn't receive the saved record, so it looks up the records that still need work and marks each one done. That also means a failed call is simply retried on the next save.
🖥️ Custom Screens — a standalone page your team reaches from the nav #
Each screen is one class that returns data plus one SmartLite Component that shows it (an app page), then a tab so people can find it. The component imports the class method, calls it when the screen opens or when the user submits, and its template shows what came back.
Cross-object search
Type part of an Account name and see every Contact under every match.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
AccountContactSearchcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → SmartLite Components and click New Component. API Name
accountContactSearch, Label Account Search. Under Where it can go, tick App page. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save. - Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick Account Search from the SmartLite Components group under Page (its address is
/c/accountContactSearch). Add the tab to your app in App Manager. - Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class AccountContactSearch {
def search(params) {
def q = (params?.q ?: '').trim().toLowerCase()
if (q.size() < 2) return [ results: [] ]
def accounts = Platform.query('account').findAll { it.name?.toLowerCase()?.contains(q) }.take(20)
def results = accounts.collect { a ->
def contacts = Platform.query('contact', 'account_id', a.hex_id)
[ id: a.hex_id, account: a.name, city: a.billing_city ?: '',
contacts: contacts.collect { [ id: it.hex_id, name: it.first_name + ' ' + it.last_name, email: it.email ?: '' ] } ]
}
return [ results: results ]
}
}<template>
<form onsubmit={handleSubmit}>
<input value={q} oninput={handleInput} placeholder="Account name…">
<button class="sl-primary" disabled={busy}>Search</button>
</form>
<template if:true={view}>
<template for:each={view.results} for:item="r">
<div key={r.id}>
<strong>{r.account}</strong> {r.city}
<ul>
<template for:each={r.contacts} for:item="c">
<li key={c.id}>{c.name} — {c.email}</li>
</template>
</ul>
</div>
</template>
<template if:false={view.results}><p>No matching accounts.</p></template>
</template>
</template>import search from '@class/AccountContactSearch.search';
export default class AccountContactSearch extends SmartLiteComponent {
q = '';
view = null; // nothing to show until the first search
busy = false;
handleInput(event) {
this.q = event.target.value;
}
async handleSubmit() {
this.busy = true;
try {
this.view = await search({ q: this.q });
} finally {
this.busy = false;
}
}
}New-rep onboarding checklist
See at a glance how far a new rep has got: their first account, contact, deal and case.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
RepOnboardingcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → SmartLite Components and click New Component. API Name
repOnboarding, Label Rep Onboarding. Under Where it can go, tick App page. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save. - Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick Rep Onboarding from the SmartLite Components group under Page (its address is
/c/repOnboarding). Add the tab to your app in App Manager. - Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class RepOnboarding {
def checklist(params) {
def rep = params?.username
if (!rep) return [ steps: [] ]
def steps = [
[ label: 'Created an account', done: Platform.query('account', 'owner', rep).size() > 0 ],
[ label: 'Added a contact', done: Platform.query('contact', 'owner', rep).size() > 0 ],
[ label: 'Opened an opportunity', done: Platform.query('opportunity', 'owner', rep).size() > 0 ],
[ label: 'Won a deal', done: Platform.query('opportunity', 'owner', rep).any { it.stage == 'Closed Won' } ],
[ label: 'Handled a case', done: Platform.query('case', 'owner', rep).size() > 0 ]
]
steps.each { it.icon = it.done ? '✅' : '⬜' }
return [ rep: rep, steps: steps, doneCount: steps.count { it.done }, total: steps.size() ]
}
}<template>
<form onsubmit={handleSubmit}>
<input value={username} oninput={handleInput} placeholder="Username">
<button class="sl-primary" disabled={busy}>Show</button>
</form>
<template if:true={view}>
<h3>{view.rep}: {view.doneCount} of {view.total} done</h3>
<template for:each={view.steps} for:item="s">
<div key={s.label}>{s.icon} {s.label}</div>
</template>
</template>
</template>import checklist from '@class/RepOnboarding.checklist';
export default class RepOnboarding extends SmartLiteComponent {
username = '';
view = null; // nothing to show until the first search
busy = false;
handleInput(event) {
this.username = event.target.value;
}
async handleSubmit() {
this.busy = true;
try {
this.view = await checklist({ username: this.username });
} finally {
this.busy = false;
}
}
}Territory coverage view
Open Leads grouped by state, busiest first.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
LeadsByRegioncode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → SmartLite Components and click New Component. API Name
leadsByRegion, Label Leads by Region. Under Where it can go, tick App page. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save. - Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick Leads by Region from the SmartLite Components group under Page (its address is
/c/leadsByRegion). Add the tab to your app in App Manager. - Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class LeadsByRegion {
def regions(params) {
def open = Platform.query('lead').findAll { !(it.status in ['Converted', 'Unqualified']) }
def rows = open.groupBy { it.state ?: 'Unknown' }.collect { state, leads ->
[ region: state, count: leads.size(), hot: leads.count { it.rating == 'Hot' } ]
}
return [ regions: rows.sort { a, b -> b.count <=> a.count } ]
}
}<template>
<template if:true={loading}><p class="sl-muted">Loading…</p></template>
<template if:false={loading}>
<table>
<tr><th>Region</th><th>Open leads</th><th>Hot</th></tr>
<template for:each={view.regions} for:item="r">
<tr key={r.region}><td>{r.region}</td><td>{r.count}</td><td>{r.hot}</td></tr>
</template>
</table>
</template>
</template>import regions from '@class/LeadsByRegion.regions';
export default class LeadsByRegion extends SmartLiteComponent {
view = { regions: [] };
loading = true;
async connectedCallback() {
try {
this.view = await regions({});
} finally {
this.loading = false;
}
}
}Renewal radar
Every Active Contract ending in the next 30 days.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
RenewalRadarcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → SmartLite Components and click New Component. API Name
renewalRadar, Label Renewal Radar. Under Where it can go, tick App page. In the Properties tab, adddays(Number, default 30). Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save. - Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick Renewal Radar from the SmartLite Components group under Page (its address is
/c/renewalRadar). Add the tab to your app in App Manager. - Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class RenewalRadar {
def upcoming(params) {
def days = (params?.days ?: 30) as Integer
def today = day(0)
def until = day(days)
def due = Platform.query('contract', 'status', 'Active').findAll {
def end = it.end_date?.toString()?.take(10)
end && end >= today && end <= until
}
def rows = due.sort { it.end_date.toString() }.collect {
[ id: it.hex_id, name: it.name, ends: it.end_date.toString().take(10), value: it.contract_value ?: 0,
autoRenews: it.auto_renewal ? 'Yes' : 'No' ]
}
return [ contracts: rows, days: days ]
}
// Today's date as yyyy-MM-dd (UTC), plus or minus some days
private day(int offset) {
return new Date(new Date().getTime() + offset * 86400000L).toInstant().toString().substring(0, 10)
}
}<template>
<template if:true={loading}><p class="sl-muted">Loading…</p></template>
<template if:false={loading}>
<h3>Ending in the next {view.days} days</h3>
<table>
<tr><th>Contract</th><th>Ends</th><th>Value</th><th>Auto-renews</th></tr>
<template for:each={view.contracts} for:item="c">
<tr key={c.id}><td>{c.name}</td><td>{c.ends}</td><td>{c.value}</td><td>{c.autoRenews}</td></tr>
</template>
</table>
<template if:false={view.contracts}><p>Nothing ends soon.</p></template>
</template>
</template>import upcoming from '@class/RenewalRadar.upcoming';
export default class RenewalRadar extends SmartLiteComponent {
view = { contracts: [], days: 30 };
loading = true;
async connectedCallback() {
try {
this.view = await upcoming({ days: this.days });
} finally {
this.loading = false;
}
}
}Discount approval calculator
Before submitting a Quote, see whether its discount will need sign-off and from whom.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
DiscountCalculatorcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → SmartLite Components and click New Component. API Name
discountCalculator, Label Discount Calculator. Under Where it can go, tick App page. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save. - Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick Discount Calculator from the SmartLite Components group under Page (its address is
/c/discountCalculator). Add the tab to your app in App Manager. - Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class DiscountCalculator {
// Keep these in line with your Approval Process steps
def rules = [
[ upTo: 10, approver: 'None — you can submit it' ],
[ upTo: 20, approver: 'Sales Manager' ],
[ upTo: 30, approver: 'Sales Director' ],
[ upTo: 100, approver: 'VP Sales and Finance' ]
]
def check(params) {
def list = (params.listPrice ?: 0).toString().toBigDecimal()
def pct = (params.discountPct ?: 0).toString().toBigDecimal()
def net = (list * (100 - pct) / 100).setScale(2, 4)
def rule = rules.find { pct <= it.upTo } ?: rules[-1]
return [ net: net, saving: list - net, approver: rule.approver ]
}
}<template>
<form onsubmit={handleSubmit}>
<label>List price <input type="number" name="listPrice" value={listPrice} oninput={handleInput}></label>
<label>Discount % <input type="number" name="discountPct" value={discountPct} oninput={handleInput}></label>
<button class="sl-primary" disabled={busy}>Check</button>
</form>
<template if:true={view}>
<p>Net price <strong>{view.net}</strong> (saving {view.saving})</p>
<p>Approval: <strong>{view.approver}</strong></p>
</template>
</template>import check from '@class/DiscountCalculator.check';
export default class DiscountCalculator extends SmartLiteComponent {
listPrice = '10000';
discountPct = '15';
view = null;
busy = false;
// Each input has the name of the field it fills in
handleInput(event) {
this[event.target.name] = event.target.value;
}
async handleSubmit() {
this.busy = true;
try {
this.view = await check({ listPrice: this.listPrice, discountPct: this.discountPct });
} finally {
this.busy = false;
}
}
}"My Day" screen
Your deals closing this week, your hot Leads and your open Cases on one page.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
MyDaycode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → SmartLite Components and click New Component. API Name
myDay, Label My Day. Under Where it can go, tick App page. In the Properties tab, addusername(String). Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save. - Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick My Day from the SmartLite Components group under Page (its address is
/c/myDay). Add the tab to your app in App Manager. - Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class MyDay {
def summary(params) {
def me = params?.username
def today = day(0)
def weekEnd = day(7)
def closing = Platform.query('opportunity', 'owner', me).findAll {
def d = it.close_date?.toString()?.take(10)
d && d >= today && d <= weekEnd && !(it.stage in ['Closed Won', 'Closed Lost'])
}.collect { [ id: it.hex_id, name: it.name, stage: it.stage, closes: it.close_date.toString().take(10) ] }
def hotLeads = Platform.query('lead', 'owner', me).findAll { it.rating == 'Hot' && it.status in ['New', 'Working'] }
.collect { [ id: it.hex_id, name: it.first_name + ' ' + it.last_name, company: it.company ?: '' ] }
def cases = Platform.query('case', 'owner', me).findAll { !(it.status in ['Resolved', 'Closed']) }
.collect { [ id: it.hex_id, subject: it.subject, priority: it.priority ?: '' ] }
return [ closing: closing, hotLeads: hotLeads, cases: cases ]
}
// Today's date as yyyy-MM-dd (UTC), plus or minus some days
private day(int offset) {
return new Date(new Date().getTime() + offset * 86400000L).toInstant().toString().substring(0, 10)
}
}<template>
<form onsubmit={handleSubmit}>
<input value={username} oninput={handleInput} placeholder="Username">
<button class="sl-primary" disabled={busy}>Show my day</button>
</form>
<template if:true={view}>
<h3>Closing this week</h3>
<template for:each={view.closing} for:item="o">
<div key={o.id}>{o.name} — {o.stage}, {o.closes}</div>
</template>
<h3>Hot leads</h3>
<template for:each={view.hotLeads} for:item="l">
<div key={l.id}>{l.name} ({l.company})</div>
</template>
<h3>My open cases</h3>
<template for:each={view.cases} for:item="c">
<div key={c.id}>[{c.priority}] {c.subject}</div>
</template>
</template>
</template>import summary from '@class/MyDay.summary';
export default class MyDay extends SmartLiteComponent {
view = null;
busy = false;
// username is a property: a tab with Parameters username=priya opens it ready for Priya
async connectedCallback() {
if (this.username) await this.handleSubmit();
}
handleInput(event) {
this.username = event.target.value;
}
async handleSubmit() {
this.busy = true;
try {
this.view = await summary({ username: this.username });
} finally {
this.busy = false;
}
}
}Note: A page can't yet tell who is signed in, so it asks for the username once and remembers it in the browser. Classes can't read Tasks yet (they live in Activities), so the page shows deals, leads and cases.
Partner lead-submission form
A form that checks a partner's lead and refuses duplicates before creating it.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
PartnerLeadFormcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → SmartLite Components and click New Component. API Name
partnerLeadForm, Label Submit a Lead. Under Where it can go, tick App page. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save. - Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick Partner Lead Form from the SmartLite Components group under Page (its address is
/c/partnerLeadForm). Add the tab to your app in App Manager. - Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class PartnerLeadForm {
def submit(params) {
def errors = []
if (!params.firstName) errors << 'First name is required'
if (!params.lastName) errors << 'Last name is required'
if (!params.company) errors << 'Company is required'
if (!(params.email ==~ /[^@\s]+@[^@\s]+\.[^@\s]+/)) errors << 'Enter a valid email'
if (errors) return [ ok: false, errors: errors ]
def email = params.email.trim().toLowerCase()
if (Platform.query('lead', 'email', email)) return [ ok: false, errors: [ 'We already have this lead' ] ]
def id = Platform.insert('lead', [
first_name: params.firstName, last_name: params.lastName, company: params.company,
email: email, phone: params.phone, source: 'Partner', status: 'New' ])
return [ ok: true, id: id ]
}
}<template>
<form onsubmit={handleSubmit}>
<input name="firstName" placeholder="First name" value={form.firstName} oninput={handleInput}>
<input name="lastName" placeholder="Last name" value={form.lastName} oninput={handleInput}>
<input name="company" placeholder="Company" value={form.company} oninput={handleInput}>
<input name="email" placeholder="Email" value={form.email} oninput={handleInput}>
<input name="phone" placeholder="Phone" value={form.phone} oninput={handleInput}>
<button class="sl-primary" disabled={busy}>Submit</button>
</form>
<p class="sl-success">{message}</p>
<ul class="sl-error">
<template for:each={errors} for:item="e">
<li key={e}>{e}</li>
</template>
</ul>
</template>import submit from '@class/PartnerLeadForm.submit';
const EMPTY = { firstName: '', lastName: '', company: '', email: '', phone: '' };
export default class PartnerLeadForm extends SmartLiteComponent {
form = { ...EMPTY };
errors = [];
message = '';
busy = false;
handleInput(event) {
this.form = { ...this.form, [event.target.name]: event.target.value };
}
async handleSubmit() {
this.busy = true;
this.message = '';
try {
const r = await submit(this.form);
this.errors = r.ok ? [] : r.errors;
if (r.ok) {
this.message = 'Thanks — lead created.';
this.form = { ...EMPTY };
}
} catch (e) {
// the platform refused the lead, e.g. a phone number in the wrong format
this.errors = [e.message];
} finally {
this.busy = false;
}
}
}Live stock lookup
Check a Product's stock in your warehouse system in real time.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
stockApi, Base URLhttps://api.your-warehouse.com, Auth Type API Key Header, Header NameX-Api-Key, and paste the key into the secret field. Save. - Go to Setup → Customization → Classes and click New Class.
- Paste the
StockLookupcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → SmartLite Components and click New Component. API Name
stockLookup, Label Stock Lookup. Under Where it can go, tick App page. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save. - Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick Stock Lookup from the SmartLite Components group under Page (its address is
/c/stockLookup). Add the tab to your app in App Manager. - Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class StockLookup {
def lookup(params) {
def q = (params?.q ?: '').trim().toLowerCase()
def products = Platform.query('product')
.findAll { it.sku && (it.name?.toLowerCase()?.contains(q) || it.sku.toLowerCase() == q) }
.take(10) // one web call per product
def rows = products.collect { p ->
def res = Platform.httpGet('stockApi', '/stock/' + p.sku)
[ name: p.name, sku: p.sku,
onHand: res.status == 200 ? res.body.available : 'n/a',
eta: res.status == 200 ? (res.body.nextDelivery ?: '') : '' ]
}
return [ products: rows ]
}
}<template>
<form onsubmit={handleSubmit}>
<input value={q} oninput={handleInput} placeholder="Product name or SKU">
<button class="sl-primary" disabled={busy}>Check stock</button>
</form>
<template if:true={view}>
<table>
<tr><th>Product</th><th>SKU</th><th>Available</th><th>Next delivery</th></tr>
<template for:each={view.products} for:item="p">
<tr key={p.sku}><td>{p.name}</td><td>{p.sku}</td><td>{p.onHand}</td><td>{p.eta}</td></tr>
</template>
</table>
</template>
</template>import lookup from '@class/StockLookup.lookup';
export default class StockLookup extends SmartLiteComponent {
q = '';
view = null; // nothing to show until the first search
busy = false;
handleInput(event) {
this.q = event.target.value;
}
async handleSubmit() {
this.busy = true;
try {
this.view = await lookup({ q: this.q });
} finally {
this.busy = false;
}
}
}Note: If you use SmartLite's own Inventory package, stock is already on the Product; this example is for an outside warehouse system.
Executive summary screen
Open pipeline, win rate and case backlog on one page.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
ExecSummarycode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → SmartLite Components and click New Component. API Name
execSummary, Label Executive Summary. Under Where it can go, tick App page. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save. - Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick Executive Summary from the SmartLite Components group under Page (its address is
/c/execSummary). Add the tab to your app in App Manager. - Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class ExecSummary {
def summary(params) {
def opps = Platform.query('opportunity')
def open = opps.findAll { !(it.stage in ['Closed Won', 'Closed Lost']) }
def won = opps.count { it.stage == 'Closed Won' }
def lost = opps.count { it.stage == 'Closed Lost' }
def cases = Platform.query('case').findAll { !(it.status in ['Resolved', 'Closed']) }
return [
openPipeline: open.sum { num(it.amount) } ?: 0,
openDeals: open.size(),
winRate: (won + lost) ? ((won * 100) / (won + lost)).setScale(0, 4) + '%' : '-',
caseBacklog: cases.size(),
urgentCases: cases.count { it.priority in ['High', 'Critical'] }
]
}
// Numbers come back as text; blank counts as 0
private num(value) {
return value ? value.toString().toBigDecimal() : 0
}
}<template>
<template if:true={loading}><p class="sl-muted">Loading…</p></template>
<template if:false={loading}>
<div class="tiles">
<div><small>Open pipeline</small><h2>{view.openPipeline}</h2><small>{view.openDeals} deals</small></div>
<div><small>Win rate</small><h2>{view.winRate}</h2></div>
<div><small>Case backlog</small><h2>{view.caseBacklog}</h2><small>{view.urgentCases} urgent</small></div>
</div>
</template>
</template>import summary from '@class/ExecSummary.summary';
export default class ExecSummary extends SmartLiteComponent {
view = { openPipeline: 0, openDeals: 0, winRate: '-', caseBacklog: 0, urgentCases: 0 };
loading = true;
async connectedCallback() {
try {
this.view = await summary({});
} finally {
this.loading = false;
}
}
}.tiles { display: flex; gap: 24px; flex-wrap: wrap; }"Nearest Account" finder
For field reps: Accounts closest to a ZIP code.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
NearbyAccountscode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → SmartLite Components and click New Component. API Name
nearbyAccounts, Label Nearby Accounts. Under Where it can go, tick App page. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save. - Go to Setup → Configuration → Tab Manager → New Custom Tab. Set Tab Type to Page Tab and pick Nearest Accounts from the SmartLite Components group under Page (its address is
/c/nearbyAccounts). Add the tab to your app in App Manager. - Open the tab: any user who has it in their navigation can use the screen, no admin role needed.
Code
class NearbyAccounts {
def near(params) {
def zip = (params?.zip ?: '').trim()
if (!(zip ==~ /\d{5}/)) return [ accounts: [], message: 'Enter a 5-digit ZIP code' ]
def target = zip.toInteger()
def rows = Platform.query('account')
.findAll { it.billing_zip?.toString()?.take(5) ==~ /\d{5}/ }
.collect { a -> [ id: a.hex_id, name: a.name, city: a.billing_city ?: '', zip: a.billing_zip.toString().take(5),
gap: (a.billing_zip.toString().take(5).toInteger() - target).abs() ] }
.sort { it.gap }
.take(15)
return [ accounts: rows, message: '' ]
}
}<template>
<form onsubmit={handleSubmit}>
<input value={zip} oninput={handleInput} placeholder="ZIP code">
<button class="sl-primary" disabled={busy}>Find</button>
</form>
<template if:true={view}>
<p class="sl-error">{view.message}</p>
<template for:each={view.accounts} for:item="a">
<div key={a.id}>{a.name} — {a.city} {a.zip}</div>
</template>
</template>
</template>import near from '@class/NearbyAccounts.near';
export default class NearbyAccounts extends SmartLiteComponent {
zip = '';
view = null; // nothing to show until the first search
busy = false;
handleInput(event) {
this.zip = event.target.value;
}
async handleSubmit() {
this.busy = true;
try {
this.view = await near({ zip: this.zip });
} finally {
this.busy = false;
}
}
}Note: Nearby ZIP codes are close in number, which is good enough for a quick list. For real distances, call a geocoding service through a Named Credential (example 12 shows the pattern).
🧩 Typed Model Classes — return proper objects with computed properties #
The main class comes first; one or more model classes follow it in the same source. Create them with def x = new Model(), never Model x = …. Every getSomething() becomes a something property the page can show, including computed ones.
Deal health score
A risk level for each open deal, worked out from several fields.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
DealHealthBoardcode below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Show it with a SmartLite Component (an app page): import the method with
import getDeals from '@class/DealHealthBoard.getDeals';, load it withthis.view = await getDeals({})inconnectedCallback, and bind the getters in the template, e.g.{row.riskLevel}forgetRiskLevel()(see step 7 and example 23).
Code
class DealHealthBoard {
def getDeals(params) {
def today = day(0)
def deals = Platform.query('opportunity')
.findAll { !(it.stage in ['Closed Won', 'Closed Lost']) }
.collect { o ->
def d = new DealHealth()
d.setName(o.name)
d.setStage(o.stage)
d.setProbability(o.probability ? o.probability.toString().toBigDecimal() : 0)
d.setOverdue(o.close_date && o.close_date.toString().take(10) < today)
d.setHasNextStep(o.next_step ? true : false)
d
}
return [ deals: deals.sort { a, b -> b.getRiskScore() <=> a.getRiskScore() } ]
}
// Today's date as yyyy-MM-dd (UTC), plus or minus some days
private day(int offset) {
return new Date(new Date().getTime() + offset * 86400000L).toInstant().toString().substring(0, 10)
}
}
class DealHealth {
private String name
private String stage
private BigDecimal probability
private boolean overdue
private boolean hasNextStep
String getName() { return name }
void setName(String v) { name = v }
String getStage() { return stage }
void setStage(String v) { stage = v }
void setProbability(BigDecimal v) { probability = v }
void setOverdue(boolean v) { overdue = v }
void setHasNextStep(boolean v) { hasNextStep = v }
int getRiskScore() {
def score = 0
if (overdue) score += 50
if (!hasNextStep) score += 20
if (probability < 30) score += 30
return score
}
String getRiskLevel() {
def s = getRiskScore()
return s >= 60 ? 'High' : s >= 30 ? 'Medium' : 'Low'
}
}Shipping quote model
Turn a carrier API's reply into clean carrier, price and delivery-date properties.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
shippingApi, Base URLhttps://api.your-shipping.com, Auth Type Bearer Token, and paste the key into the secret field. Save. - Go to Setup → Customization → Classes and click New Class.
- Paste the
ShippingQuotescode below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Show it with a SmartLite Component (an app page): import the method with
import quote from '@class/ShippingQuotes.quote';, load it withthis.view = await quote({ zip: '94105', weightKg: 2 })inconnectedCallback, and bind the getters in the template, e.g.{row.riskLevel}forgetRiskLevel()(see step 7 and example 23).
Code
class ShippingQuotes {
def quote(params) {
def res = Platform.httpPost('shippingApi', '/rates', [ toZip: params.zip, weightKg: params.weightKg ?: 1 ])
if (res.status != 200) return [ options: [], message: 'No rates (' + res.status + ')' ]
def options = res.body.rates.collect { r ->
def o = new ShippingOption()
o.setCarrier(r.carrier)
o.setPrice(r.amount.toString().toBigDecimal())
o.setDays(r.days as Integer)
o
}
return [ options: options.sort { a, b -> a.getPrice() <=> b.getPrice() }, message: '' ]
}
}
class ShippingOption {
private String carrier
private BigDecimal price
private Integer days
String getCarrier() { return carrier }
void setCarrier(String v) { carrier = v }
BigDecimal getPrice() { return price }
void setPrice(BigDecimal v) { price = v }
Integer getDays() { return days }
void setDays(Integer v) { days = v }
String getEta() { return days == 1 ? 'Next day' : days + ' days' }
String getLabel() { return carrier + ' — $' + price.setScale(2, 4) + ' (' + getEta() + ')' }
}Lead summary
One object with a Lead's key facts and whether its company is already a customer.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
LeadSummaryServicecode below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Show it with a SmartLite Component (an app page): import the method with
import summarise from '@class/LeadSummaryService.summarise';, load it withthis.view = await summarise({ leadId: 'LEA…' })inconnectedCallback, and bind the getters in the template, e.g.{row.riskLevel}forgetRiskLevel()(see step 7 and example 23).
Code
class LeadSummaryService {
def summarise(params) {
def lead = Platform.getRecord('lead', params.leadId)
if (!lead) return null
def s = new LeadSummary()
s.setName(lead.first_name + ' ' + lead.last_name)
s.setCompany(lead.company)
s.setStatus(lead.status)
s.setScore(lead.lead_score ? lead.lead_score.toString().toBigDecimal().intValue() : 0)
def accounts = lead.company ? Platform.query('account', 'name', lead.company) : []
s.setExistingCustomer(accounts.any { it.type == 'Customer' })
return s
}
}
class LeadSummary {
private String name
private String company
private String status
private int score
private boolean existingCustomer
String getName() { return name }
void setName(String v) { name = v }
String getCompany() { return company ?: '-' }
void setCompany(String v) { company = v }
String getStatus() { return status }
void setStatus(String v) { status = v }
int getScore() { return score }
void setScore(int v) { score = v }
boolean getExistingCustomer() { return existingCustomer }
void setExistingCustomer(boolean v) { existingCustomer = v }
String getHeadline() {
return name + (existingCustomer ? ' (existing customer)' : '') + ' — ' + (score >= 70 ? 'hot' : score >= 40 ? 'warm' : 'cold')
}
}Note: Classes can't read Activities yet, so this summary doesn't include the latest call or email.
Account rollup
Each Account with its open pipeline worked out from its Opportunities.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
AccountRollupscode below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Show it with a SmartLite Component (an app page): import the method with
import getAccounts from '@class/AccountRollups.getAccounts';, load it withthis.view = await getAccounts({})inconnectedCallback, and bind the getters in the template, e.g.{row.riskLevel}forgetRiskLevel()(see step 7 and example 23).
Code
class AccountRollups {
def getAccounts(params) {
def byAccount = Platform.query('opportunity').findAll { it.account_id }.groupBy { it.account_id }
def rows = Platform.query('account').take(50).collect { a ->
def r = new AccountRollup()
r.setName(a.name)
r.setOpportunities(byAccount[a.hex_id] ?: [])
r
}
return [ accounts: rows.sort { x, y -> y.getTotalOpenPipeline() <=> x.getTotalOpenPipeline() } ]
}
}
class AccountRollup {
private String name
private List opportunities
String getName() { return name }
void setName(String v) { name = v }
void setOpportunities(List v) { opportunities = v }
BigDecimal getTotalOpenPipeline() {
def open = opportunities.findAll { !(it.stage in ['Closed Won', 'Closed Lost']) }
return open.sum { it.amount ? it.amount.toString().toBigDecimal() : 0 } ?: 0
}
int getOpenCount() { return opportunities.count { !(it.stage in ['Closed Won', 'Closed Lost']) } }
}Note: All Opportunities are read once and grouped, rather than one query per Account.
Contract status
Turn Contract end dates into "days until expiry" and an "expiring soon" flag.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
ContractStatusListcode below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Show it with a SmartLite Component (an app page): import the method with
import getContracts from '@class/ContractStatusList.getContracts';, load it withthis.view = await getContracts({})inconnectedCallback, and bind the getters in the template, e.g.{row.riskLevel}forgetRiskLevel()(see step 7 and example 23).
Code
class ContractStatusList {
def getContracts(params) {
def rows = Platform.query('contract', 'status', 'Active').findAll { it.end_date }.collect { c ->
def s = new ContractStatus()
s.setName(c.name)
s.setDaysUntilExpiry(daysUntil(c.end_date))
s
}
return [ contracts: rows.sort { a, b -> a.getDaysUntilExpiry() <=> b.getDaysUntilExpiry() } ]
}
// Whole days from now until a yyyy-MM-dd date (negative when it has passed)
private daysUntil(value) {
if (!value) return null
def p = value.toString().substring(0, 10).split('-')
def end = new Date(p[0].toInteger() - 1900, p[1].toInteger() - 1, p[2].toInteger())
return ((end.getTime() - new Date().getTime()) / 86400000).intValue() + 1
}
}
class ContractStatus {
private String name
private Integer daysUntilExpiry
String getName() { return name }
void setName(String v) { name = v }
Integer getDaysUntilExpiry() { return daysUntilExpiry }
void setDaysUntilExpiry(Integer v) { daysUntilExpiry = v }
boolean getIsExpiringSoon() { return daysUntilExpiry != null && daysUntilExpiry >= 0 && daysUntilExpiry <= 30 }
String getLabel() {
if (daysUntilExpiry < 0) return 'Expired ' + (-daysUntilExpiry) + ' days ago'
if (daysUntilExpiry == 0) return 'Ends today'
return 'Ends in ' + daysUntilExpiry + ' days'
}
}Rep scorecard
Each rep's quota, closed-won total and attainment percentage.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
RepScorecardscode below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Show it with a SmartLite Component (an app page): import the method with
import getScorecards from '@class/RepScorecards.getScorecards';, load it withthis.view = await getScorecards({ quotas: { alice: 250000, bob: 180000 } })inconnectedCallback, and bind the getters in the template, e.g.{row.riskLevel}forgetRiskLevel()(see step 7 and example 23).
Code
class RepScorecards {
// params.quotas: { username: quota }, e.g. { alice: 250000, bob: 180000 }
def getScorecards(params) {
def quotas = params?.quotas ?: [:]
def won = Platform.query('opportunity', 'stage', 'Closed Won').groupBy { it.owner ?: 'unassigned' }
def cards = quotas.collect { rep, quota ->
def c = new RepScorecard()
c.setRep(rep)
c.setQuota(quota.toString().toBigDecimal())
c.setClosedWon((won[rep] ?: []).sum { it.amount ? it.amount.toString().toBigDecimal() : 0 } ?: 0)
c
}
return [ reps: cards.sort { a, b -> b.getAttainmentPct() <=> a.getAttainmentPct() } ]
}
}
class RepScorecard {
private String rep
private BigDecimal quota
private BigDecimal closedWon
String getRep() { return rep }
void setRep(String v) { rep = v }
BigDecimal getQuota() { return quota }
void setQuota(BigDecimal v) { quota = v }
BigDecimal getClosedWon() { return closedWon }
void setClosedWon(BigDecimal v) { closedWon = v }
BigDecimal getAttainmentPct() { return quota ? (closedWon * 100 / quota).setScale(1, 4) : 0 }
String getBand() { def p = getAttainmentPct(); return p >= 100 ? 'On target' : p >= 70 ? 'Close' : 'Behind' }
}Note: Quotas are passed in by the page; keep them in a custom object if you want admins to edit them.
Case escalation
Which open Cases have missed their resolution deadline.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
CaseEscalationscode below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Show it with a SmartLite Component (an app page): import the method with
import getCases from '@class/CaseEscalations.getCases';, load it withthis.view = await getCases({})inconnectedCallback, and bind the getters in the template, e.g.{row.riskLevel}forgetRiskLevel()(see step 7 and example 23).
Code
class CaseEscalations {
def getCases(params) {
def now = new Date().toInstant().toString().take(19) // yyyy-MM-ddTHH:mm:ss (UTC)
def rows = Platform.query('case').findAll { !(it.status in ['Resolved', 'Closed']) }.collect { c ->
def e = new CaseEscalation()
e.setSubject(c.subject)
e.setPriority(c.priority)
e.setDue(c.sla_resolution_due?.toString()?.take(19))
e.setNow(now)
e.setBreached(c.sla_breached == true)
e
}
return [ cases: rows.findAll { it.getIsOverdue() } ]
}
}
class CaseEscalation {
private String subject
private String priority
private String due
private String now
private boolean breached
String getSubject() { return subject }
void setSubject(String v) { subject = v }
String getPriority() { return priority ?: 'Medium' }
void setPriority(String v) { priority = v }
String getDue() { return due ? due.replace('T', ' ') : '-' }
void setDue(String v) { due = v }
void setNow(String v) { now = v }
void setBreached(boolean v) { breached = v }
boolean getIsOverdue() { return breached || (due && due < now) }
}Note: SLA times are compared as text in UTC, the way they are stored.
Price-book line
Show Price Book entries with a display-ready price such as $12,500.00.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
PriceListcode below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Show it with a SmartLite Component (an app page): import the method with
import getLines from '@class/PriceList.getLines';, load it withthis.view = await getLines({})inconnectedCallback, and bind the getters in the template, e.g.{row.riskLevel}forgetRiskLevel()(see step 7 and example 23).
Code
class PriceList {
def getLines(params) {
def products = Platform.query('product').collectEntries { [ (it.hex_id): it.name ] }
def lines = Platform.query('pricebook_entry').findAll { it.is_active != false }.collect { e ->
def l = new PriceBookLine()
l.setProduct(products[e.product_id] ?: e.name)
l.setPrice(e.unit_price ? e.unit_price.toString().toBigDecimal() : 0)
l.setCurrency(e.currency_iso_code ?: 'USD')
l
}
return [ lines: lines.sort { it.getProduct() } ]
}
}
class PriceBookLine {
private String product
private BigDecimal price
private String currency
String getProduct() { return product }
void setProduct(String v) { product = v }
void setPrice(BigDecimal v) { price = v }
void setCurrency(String v) { currency = v }
String getFormattedPrice() {
def parts = price.setScale(2, 4).toPlainString().split('\\.')
def whole = parts[0].replaceAll(/\B(?=(\d{3})+(?!\d))/, ',')
def symbol = [ USD: '$', EUR: '€', GBP: '£', INR: '₹' ][currency] ?: currency + ' '
return symbol + whole + '.' + parts[1]
}
}Campaign performance
Responses and cost for each Campaign, with cost per lead worked out.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
CampaignStatscode below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Show it with a SmartLite Component (an app page): import the method with
import getCampaigns from '@class/CampaignStats.getCampaigns';, load it withthis.view = await getCampaigns({})inconnectedCallback, and bind the getters in the template, e.g.{row.riskLevel}forgetRiskLevel()(see step 7 and example 23).
Code
class CampaignStats {
def getCampaigns(params) {
def rows = Platform.query('campaign').collect { c ->
def p = new CampaignPerformance()
p.setName(c.name)
p.setLeads(c.num_responses ? c.num_responses.toString().toBigDecimal().intValue() : 0)
p.setCost(c.actual_cost ? c.actual_cost.toString().toBigDecimal() : 0)
p
}
return [ campaigns: rows.sort { a, b -> (a.getCostPerLead() ?: 999999) <=> (b.getCostPerLead() ?: 999999) } ]
}
}
class CampaignPerformance {
private String name
private int leads
private BigDecimal cost
String getName() { return name }
void setName(String v) { name = v }
int getLeads() { return leads }
void setLeads(int v) { leads = v }
BigDecimal getCost() { return cost }
void setCost(BigDecimal v) { cost = v }
BigDecimal getCostPerLead() { return leads ? (cost / leads).setScale(2, 4) : null }
String getCostPerLeadText() { return getCostPerLead() == null ? 'no leads yet' : getCostPerLead().toPlainString() }
}Note: Uses each Campaign's Responses count as its leads.
Territory coverage
Each region with its count of open Leads and who covers it.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
TerritoryCoverageListcode below into Source Code. The class name is its API name, so there is nothing else to name. The model class goes after the main class in the same source. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Show it with a SmartLite Component (an app page): import the method with
import getRegions from '@class/TerritoryCoverageList.getRegions';, load it withthis.view = await getRegions({})inconnectedCallback, and bind the getters in the template, e.g.{row.riskLevel}forgetRiskLevel()(see step 7 and example 23).
Code
class TerritoryCoverageList {
def getRegions(params) {
def open = Platform.query('lead').findAll { !(it.status in ['Converted', 'Unqualified']) }
def rows = open.groupBy { it.state ?: 'Unknown' }.collect { region, leads ->
def t = new TerritoryCoverage()
t.setRegion(region)
t.setLeads(leads)
t
}
return [ regions: rows.sort { a, b -> b.getOpenLeadCount() <=> a.getOpenLeadCount() } ]
}
}
class TerritoryCoverage {
private String region
private List leads
String getRegion() { return region }
void setRegion(String v) { region = v }
void setLeads(List v) { leads = v }
int getOpenLeadCount() { return leads.size() }
String getOwners() { return leads.collect { it.owner ?: 'unassigned' }.unique().sort().join(', ') }
boolean getIsUncovered() { return leads.every { !it.owner } }
}🔗 Classes Calling Classes — compose small pieces into bigger flows #
Platform.callClass('Name', 'method', [ … ]) runs another saved, active class inside the same run: same limits, no extra daily quota, at most 5 calls deep. Save the called classes first.
Validate-then-notify chain
When an Order is activated, one class checks it and, if it passes, another tells the team.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
slack, Base URLhttps://slack.com/api, Auth Type Bearer Token, and paste the key into the secret field. Save. - In Slack, create an app with the
chat:writescope, install it, invite it to the channel, and use its bot token (xoxb-…) as the secret. - Go to Setup → Customization → Classes and click New Class.
- Paste the
SendSlackAlertcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → Classes and click New Class.
- Paste the
OrderValidatorcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → Classes and click New Class.
- Paste the
OrderActivatedcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Configuration → Object Manager → Order → Fields → New Field. Create a Checkbox field labelled Team Notified; its API name becomes
team_notified__c. - Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Order and Trigger to On Create or Update. Add the criteria Status equals Activated.
- Add an action of type Run Custom Class, choose
OrderActivated, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved.
Code
class SendSlackAlert {
// Post a message to a Slack channel. Called by other classes:
// Platform.callClass('SendSlackAlert', 'post', [ channel: '#sales', text: 'Hello' ])
def post(params) {
def res = Platform.httpPost('slack', '/chat.postMessage',
[ channel: params.channel ?: '#sales', text: params.text ])
if (res.status != 200 || !res.body.ok) {
Platform.log('Slack post failed: ' + res.status + ' ' + res.body)
return false
}
return true
}
}class OrderValidator {
// Returns a list of problems; empty means the order is fine
def check(params) {
def order = Platform.getRecord('order', params.orderId)
def problems = []
if (!order.account_id) problems << 'no account'
if (!order.shipping_street || !order.shipping_city) problems << 'no shipping address'
def items = Platform.query('order_item', 'order_id', params.orderId)
if (!items) problems << 'no products'
if (items.any { !it.quantity || it.quantity.toString().toBigDecimal() <= 0 }) problems << 'a line with zero quantity'
return problems
}
}class OrderActivated {
def execute() {
def orders = Platform.query('order', 'status', 'Activated').findAll { !it.team_notified__c }.take(10)
orders.each { o ->
def problems = Platform.callClass('OrderValidator', 'check', [ orderId: o.hex_id ])
def text = problems
? ':warning: Order ' + o.name + ' was activated but has ' + problems.join(', ')
: ':package: Order ' + o.name + ' is ready to ship'
Platform.callClass('SendSlackAlert', 'post', [ channel: '#fulfilment', text: text ])
Platform.update(o.hex_id, [ team_notified__c: true ])
}
return orders.size()
}
}Note: Check the Order Status picklist for the exact name of your activated value.
Scheduled finance sync
Every night, send yesterday's won deals to your finance system.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
financeApi, Base URLhttps://api.your-finance-system.com, Auth Type Bearer Token, and paste the key into the secret field. Save. - Go to Setup → Customization → Classes and click New Class.
- Paste the
FinanceSynccode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
- Paste the
FinanceSyncBatchcode below into Source Code. - Tick Batch Mode, set Object API Name to
opportunityand Chunk Size to500. - Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
- To repeat it every night, tick Run Daily, set Time (UTC) to
05:00and save again.
Code
class FinanceSync {
// Sends one deal; returns true when the finance system accepted it
def push(params) {
def opp = Platform.getRecord('opportunity', params.opportunityId)
def account = opp.account_id ? Platform.getRecord('account', opp.account_id) : null
def res = Platform.httpPost('financeApi', '/invoices/draft', [
externalId: params.opportunityId, customer: account?.name ?: opp.name,
amount: opp.amount, closeDate: opp.close_date ])
return res.status in [200, 201, 409] // 409: already sent
}
}class FinanceSyncBatch {
def execute(batch) {
def yesterday = day(-1)
def wins = batch.findAll { it.stage == 'Closed Won' && it.close_date?.toString()?.take(10) == yesterday }.take(10)
def sent = wins.count { Platform.callClass('FinanceSync', 'push', [ opportunityId: it.hex_id ]) }
Platform.log('sent ' + sent + ' of ' + wins.size() + ' deals to finance')
return sent
}
// Today's date as yyyy-MM-dd (UTC), plus or minus some days
private day(int offset) {
return new Date(new Date().getTime() + offset * 86400000L).toInstant().toString().substring(0, 10)
}
}Note: At most 10 deals per chunk are sent, because each is one web call. If you win more than that in a day, lower the chunk size so more chunks share the work.
Component button chain
A button on the Opportunity that checks the discount and, if it is large, alerts the sales manager in Slack.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
slack, Base URLhttps://slack.com/api, Auth Type Bearer Token, and paste the key into the secret field. Save. - In Slack, create an app with the
chat:writescope, install it, invite it to the channel, and use its bot token (xoxb-…) as the secret. - Go to Setup → Customization → Classes and click New Class.
- Paste the
SendSlackAlertcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → Classes and click New Class.
- Paste the
DiscountGuardcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → SmartLite Components and click New Component. API Name
discountGuard, Label Discount Check. Under Where it can go, tick Record page of and choose Opportunity. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save. - Go to Setup → Configuration → Object Manager → Opportunity → Page Layouts, open the layout, click Add Custom Component, choose Discount Check and save the layout. The button now shows on every Opportunity record.
Code
class SendSlackAlert {
// Post a message to a Slack channel. Called by other classes:
// Platform.callClass('SendSlackAlert', 'post', [ channel: '#sales', text: 'Hello' ])
def post(params) {
def res = Platform.httpPost('slack', '/chat.postMessage',
[ channel: params.channel ?: '#sales', text: params.text ])
if (res.status != 200 || !res.body.ok) {
Platform.log('Slack post failed: ' + res.status + ' ' + res.body)
return false
}
return true
}
}class DiscountGuard {
def review(params) {
def opp = Platform.getRecord('opportunity', params.opportunityId)
def lines = Platform.query('opportunity_line_item', 'opportunity_id', params.opportunityId)
if (!lines) return [ ok: false, message: 'Add products first' ]
def biggest = lines.collect { it.discount ? it.discount.toString().toBigDecimal() : 0 }.max()
if (biggest <= 15) return [ ok: true, message: 'Discounts are within policy (max ' + biggest + '%)' ]
Platform.callClass('SendSlackAlert', 'post', [
channel: '#sales-managers',
text: ':eyes: ' + opp.name + ' has a ' + biggest + '% discount and needs a review' ])
return [ ok: true, message: 'A ' + biggest + '% discount needs approval: your manager has been told' ]
}
}<template>
<button class="sl-primary" onclick={handleClick} disabled={busy}>Check discount</button>
<p class="result">{resultText}</p>
</template>import review from '@class/DiscountGuard.review';
export default class DiscountGuard extends SmartLiteComponent {
result = null;
busy = false;
async handleClick() {
this.busy = true;
try {
// this.recordId: the record this panel is on
this.result = await review({ opportunityId: this.recordId });
} catch (e) {
this.result = { ok: false, message: e.message };
} finally {
this.busy = false;
}
}
get resultText() {
const r = this.result;
if (!r) return '';
return r.message;
}
}Volume-aware case assignment
Give each new unassigned Case to the agent with the fewest open Cases.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
CaseLoadBalancercode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → Classes and click New Class.
- Paste the
NewCaseAssignercode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Case and Trigger to On Create.
- Add an action of type Run Custom Class, choose
NewCaseAssigner, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved.
Code
class CaseLoadBalancer {
def agents = [ 'alice', 'bob', 'carol' ] // usernames of your support agents
// Returns the agent with the fewest open cases right now
def pickAgent(params) {
def open = Platform.query('case').findAll { !(it.status in ['Resolved', 'Closed']) }
def load = agents.collectEntries { a -> [ (a): open.count { it.owner == a } ] }
return agents.min { load[it] }
}
}class NewCaseAssigner {
def execute() {
def unassigned = Platform.query('case', 'status', 'New').findAll { !it.owner }
unassigned.each { c ->
def agent = Platform.callClass('CaseLoadBalancer', 'pickAgent', [:])
Platform.update(c.hex_id, [ owner: agent ])
}
return unassigned.size()
}
}Note: If you only need round-robin, Case assignment rules already do that without code; this is for sharing by current load.
Personalised discount step
In a Journey, work out a discount for the customer before the quote email goes out.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
QuoteDiscountcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Automation → Workflow Rules → New Rule. Set Object to Quote, Trigger to On Create.
- Add a Run Custom Class step with
QuoteDiscountfirst, then your email step after it, so the email shows the new discount. Save the rule as active.
Code
class QuoteDiscount {
def execute() {
def drafts = Platform.query('quote', 'status', 'Draft').findAll { !it.discount && it.account_id }
drafts.each { q ->
def account = Platform.getRecord('account', q.account_id)
def pct = 0
if (account?.type == 'Customer') pct += 5 // loyalty
if (account?.rating == 'Hot') pct += 3
if (num(account?.annual_revenue) > 10000000) pct += 2
if (pct) Platform.update(q.hex_id, [ discount: pct ])
}
return drafts.size()
}
// Numbers come back as text; blank counts as 0
private num(value) {
return value ? value.toString().toBigDecimal() : 0
}
}Note: Change the rules to your own pricing policy. Large discounts should still go through an Approval Process.
One enrichment class, two callers
A nightly batch and an on-demand page both use the same enrichment class.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
enrichApi, Base URLhttps://api.your-enrichment.com, Auth Type Bearer Token, and paste the key into the secret field. Save. - Go to Setup → Customization → Classes and click New Class.
- Paste the
AccountEnrichercode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → Developer Console, open the Saved Scripts tab and click New Script.
- Paste the
AccountEnrichBatchcode below into Source Code. - Tick Batch Mode, set Object API Name to
accountand Chunk Size to10. 10 per chunk, since each account is one web call. - Click Save, then Run as Batch. Follow it under Recent Batch Jobs: PENDING → RUNNING → COMPLETED, one chunk every 30 seconds.
- To repeat it every night, tick Run Daily, set Time (UTC) to
03:00and save again. - For the on-demand side, import it in a SmartLite Component:
import enrich from '@class/AccountEnricher.enrich';, thenawait enrich({ accountId: id }). Example 50 does this.
Code
class AccountEnricher {
// Fills industry, employees and revenue from the account's website. Returns what changed.
def enrich(params) {
def account = Platform.getRecord('account', params.accountId)
if (!account?.website) return [:]
def domain = account.website.replaceAll('^https?://', '').replaceAll('/.*$', '')
def res = Platform.httpGet('enrichApi', '/companies?domain=' + domain)
if (res.status != 200) return [:]
def changes = [:]
if (res.body.industry && !account.industry) changes.industry = res.body.industry
if (res.body.employees && !account.employees) changes.employees = res.body.employees
if (res.body.revenue && !account.annual_revenue) changes.annual_revenue = res.body.revenue
if (changes) Platform.update(params.accountId, changes)
return changes
}
}class AccountEnrichBatch {
def execute(batch) {
def todo = batch.findAll { it.website && !it.industry }
def changed = todo.count { Platform.callClass('AccountEnricher', 'enrich', [ accountId: it.hex_id ]) }
Platform.log('enriched ' + changed + ' of ' + todo.size())
return changed
}
}Three-class trigger chain
On an Opportunity save: score it, tell the owner, and send it to another system.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
hooks, Base URLhttps://hooks.your-system.com, Auth Type API Key Header, Header NameX-Api-Key, and paste the key into the secret field. Save. - Go to Setup → Customization → Named Credentials and click New: API Name
slack, Base URLhttps://slack.com/api, Auth Type Bearer Token, and paste the key into the secret field. Save. - In Slack, create an app with the
chat:writescope, install it, invite it to the channel, and use its bot token (xoxb-…) as the secret. - Go to Setup → Customization → Classes and click New Class.
- Paste the
SendSlackAlertcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → Classes and click New Class.
- Paste the
OpportunityScorercode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → Classes and click New Class.
- Paste the
OpportunityWebhookcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → Classes and click New Class.
- Paste the
OpportunityNotifiercode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Configuration → Object Manager → Opportunity → Fields → New Field. Create a Number field labelled Score; its API name becomes
score__c. - Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Opportunity and Trigger to On Create or Update.
- Add an action of type Run Custom Class, choose
OpportunityNotifier, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved.
Code
class SendSlackAlert {
// Post a message to a Slack channel. Called by other classes:
// Platform.callClass('SendSlackAlert', 'post', [ channel: '#sales', text: 'Hello' ])
def post(params) {
def res = Platform.httpPost('slack', '/chat.postMessage',
[ channel: params.channel ?: '#sales', text: params.text ])
if (res.status != 200 || !res.body.ok) {
Platform.log('Slack post failed: ' + res.status + ' ' + res.body)
return false
}
return true
}
}class OpportunityScorer {
def score(params) {
def o = Platform.getRecord('opportunity', params.opportunityId)
def s = 0
if (o.amount && o.amount.toString().toBigDecimal() > 25000) s += 30
if (o.contact_id) s += 20
if (o.next_step) s += 20
s += ((o.probability ?: 0).toString().toBigDecimal() * 0.3).intValue()
return s
}
}class OpportunityWebhook {
def send(params) {
def res = Platform.httpPost('hooks', '/crm/opportunity', params)
return res.status in [200, 201, 202, 204]
}
}class OpportunityNotifier {
// The workflow runs this class; it calls the other three
def execute() {
def open = Platform.query('opportunity').findAll { !(it.stage in ['Closed Won', 'Closed Lost']) }.take(5)
open.each { o ->
def score = Platform.callClass('OpportunityScorer', 'score', [ opportunityId: o.hex_id ])
if (score == (o.score__c ?: -1).toString().toBigDecimal().intValue()) return // nothing changed
Platform.update(o.hex_id, [ score__c: score ])
if (score >= 70) {
Platform.callClass('SendSlackAlert', 'post', [ channel: '#sales', text: o.name + ' now scores ' + score ])
}
Platform.callClass('OpportunityWebhook', 'send', [ id: o.hex_id, name: o.name, stage: o.stage, score: score ])
}
}
}Note: Up to 5 opportunities per run, because each can make 2 web calls and a run allows 10. Each depth of callClass counts toward the limit of 5.
Reusable alert class
One SendSlackAlert class, called from rules on five different objects.Steps
- Go to Setup → Customization → Named Credentials and click New: API Name
slack, Base URLhttps://slack.com/api, Auth Type Bearer Token, and paste the key into the secret field. Save. - In Slack, create an app with the
chat:writescope, install it, invite it to the channel, and use its bot token (xoxb-…) as the secret. - Go to Setup → Customization → Classes and click New Class.
- Paste the
SendSlackAlertcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Customization → Classes and click New Class.
- Paste the
CriticalCaseAlertcode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Go to Setup → Automation → Workflow Rules and click New Rule (it opens Journey Builder). Set Object to Case and Trigger to On Create. Add the criteria Priority equals Critical.
- Add an action of type Run Custom Class, choose
CriticalCaseAlert, and save the rule as active. From now on every matching save runs the class, a moment after the record is saved. - Repeat with a small class for each other rule (big win, lost deal, contract terminated, new partner lead): each is a few lines that build the message and call
SendSlackAlert. Change the Slack channel or token once, in one place.
Code
class SendSlackAlert {
// Post a message to a Slack channel. Called by other classes:
// Platform.callClass('SendSlackAlert', 'post', [ channel: '#sales', text: 'Hello' ])
def post(params) {
def res = Platform.httpPost('slack', '/chat.postMessage',
[ channel: params.channel ?: '#sales', text: params.text ])
if (res.status != 200 || !res.body.ok) {
Platform.log('Slack post failed: ' + res.status + ' ' + res.body)
return false
}
return true
}
}class CriticalCaseAlert {
def execute() {
def critical = Platform.query('case', 'priority', 'Critical')
.findAll { it.status == 'New' && !it.owner }
.take(10)
critical.each { c ->
Platform.callClass('SendSlackAlert', 'post', [
channel: '#support-urgent', text: ':rotating_light: Critical case ' + c.name + ': ' + c.subject ])
}
return critical.size()
}
}Note: A Workflow Rule can't pass arguments to a class, which is why each rule gets its own tiny class instead of calling SendSlackAlert directly. Unassigned New cases are re-announced on later saves until someone takes them.
Helper that calls itself
Walk Account → its Opportunities → their line items with one helper that calls itself for each level.Steps
- Go to Setup → Customization → Classes and click New Class.
- Paste the
AccountTreecode below into Source Code. The class name is its API name, so there is nothing else to name. - Leave Active ticked and click Save. A typing mistake or a blocked call is reported here, before anything runs.
- Call it from a SmartLite Component:
import build from '@class/AccountTree.build';, thenthis.tree = await build({ accountId: this.recordId }). The result is nested: each node has alabelandchildren; show each level with a nestedfor:each, or with a child component that shows one node and uses itself for the children.
Code
class AccountTree {
// Each level: which object holds the children, and which field points at the parent
def levels = [
[ object: 'opportunity', parentField: 'account_id', label: { it.name + ' (' + (it.stage ?: '') + ')' } ],
[ object: 'opportunity_line_item', parentField: 'opportunity_id', label: { it.name + ' × ' + it.quantity } ]
]
def build(params) {
def account = Platform.getRecord('account', params.accountId)
return [ label: account.name, children: walk(params.accountId, 0) ]
}
// Calls itself once per level until there are no more levels
private walk(parentId, int depth) {
if (depth >= levels.size()) return []
def level = levels[depth]
return Platform.query(level.object, level.parentField, parentId).collect { row ->
[ label: level.label.call(row), children: walk(row.hex_id, depth + 1) ]
}
}
}Note: Add a third level (for example quote line items) by adding one entry to levels; the helper doesn't change.
Search-then-enrich screen
Search Accounts on a page, then enrich any result with one click, reusing two classes from above.Steps
- Build examples 21 (
AccountContactSearch) and 46 (AccountEnricher) first. - Go to Setup → Customization → SmartLite Components and click New Component. API Name
accountEnrich, Label Account Enrichment. Under Where it can go, tick App page. Paste the files below into the Template and JavaScript tabs (and Styles if there is one), click Check, then Save. - Add a tab for Account Enrichment (
/c/accountEnrich) in Setup → Configuration → Tab Manager, as in example 21. - Search for a company, then click Enrich on a row. The search class finds it, the enrichment class calls the web service and saves what it learns.
Code
class AccountSearch {
// Like example 21 but returns the account IDs, so the page can enrich a row
def search(params) {
def q = (params?.q ?: '').trim().toLowerCase()
if (q.size() < 2) return []
return Platform.query('account').findAll { it.name?.toLowerCase()?.contains(q) }.take(20).collect {
[ id: it.hex_id, name: it.name, website: it.website ?: '', industry: it.industry ?: '' ]
}
}
}<template>
<form onsubmit={handleSearch}>
<input value={q} oninput={handleInput} placeholder="Company">
<button class="sl-primary">Search</button>
</form>
<table>
<template for:each={rows} for:item="a">
<tr key={a.id}>
<td>{a.name}</td>
<td>{a.website}</td>
<td>{a.industryText}</td>
<td><button onclick={handleEnrich} disabled={a.locked}>{a.buttonText}</button></td>
</tr>
</template>
</table>
</template>import search from '@class/AccountSearch.search';
import enrich from '@class/AccountEnricher.enrich';
export default class AccountEnrich extends SmartLiteComponent {
q = '';
rows = [];
handleInput(event) {
this.q = event.target.value;
}
async handleSearch() {
const accounts = await search({ q: this.q });
this.rows = accounts.map(a => ({
...a,
industryText: a.industry || '—',
buttonText: 'Enrich',
locked: !a.website, // nothing to look up without a website
}));
}
async handleEnrich(event, account) {
this.update(account.id, { locked: true });
const changes = await enrich({ accountId: account.id });
this.update(account.id, {
industryText: changes.industry || account.industryText,
buttonText: Object.keys(changes).length ? 'Updated' : 'Nothing new',
});
}
// Replace one row with a changed copy: assigning the list redraws that row
update(id, patch) {
this.rows = this.rows.map(r => (r.id === id ? { ...r, ...patch } : r));
}
}Note: Save AccountSearch under Classes like any other class. Enrichment runs only when someone clicks, so the page never uses web calls just by being opened.
Day-to-day: admins maintaining a script #
What to try next
Look at the Developer Console trail to get comfortable with the query and script tools this trail relies on.