Introduction
What are SmartLite Components?
SmartLite Components are how you build your own screens and record-page panels in SmartLite. A component is a small bundle of files: a template for the layout, a JavaScript controller for clicks and typing, and optional styles. Your Custom Classes do the real work on the server, and the component shows what they return.
Overview #
You write three kinds of code, and each has one job:
| Part | Runs | Job |
|---|---|---|
| Custom Class (Groovy) | On the server | Reads and saves records, applies your rules, works out totals and labels, and decides what the user may do. |
Template (.html) | In the browser | The layout: shows values with {name}, repeats rows, shows and hides parts, connects buttons. |
Controller (.js) | In the browser | Holds what the class returned and reacts to the user. Usually a few short methods. |
The recommended pattern is simple: the class computes, the component shows. A class method returns everything a screen needs, ready to display. Every button calls another class method that does the work and returns the updated screen. Your rules live in one place, and the component stays small.
Where a component can go #
- As an app page, with its own address
/c/dealDeskthat you can put on a tab. - On a record page, placed with the Page Layout Editor, where it knows which record it is on.
- Inside another component, as a tag like
<c-contact-card>. Every component can be used this way.
Built on web standards
Every component is a real custom element with its own shadow DOM, so its styles stay inside it. It runs in a sandboxed frame with no network access, and it can only reach the server through the class methods it imports.
Availability
Custom code is currently switched off in production while its sandbox is being strengthened. Components run wherever Custom Classes can run.
Introduction
Getting Started
Let's build a component, see it on the screen, and then connect it to a class. It takes about five minutes.
Your first component #
- Go to Setup → Customization → SmartLite Components and click New Component.
- Enter the API name
helloWorldand the label Hello World. Under Where it can go, tick App page. - Paste the two files below into the Template and JavaScript tabs, then click Save.
- Open
/c/helloWorld. You'll see Hello, world!
<template>
<h1>Hello, {name}!</h1>
</template>export default class HelloWorld extends SmartLiteComponent {
name = 'world';
}{name} in the template shows the controller's name field. Change the field and the page changes with it.
Make it react #
Templates connect events to methods with on<event>={method}. When a method assigns a field, the component redraws itself.
<template>
<p>You clicked {count} times.</p>
<button onclick={handleClick}>Click me</button>
<template if:true={isBig}><p>That's a lot of clicks.</p></template>
</template>export default class Counter extends SmartLiteComponent {
count = 0;
get isBig() {
return this.count >= 10;
}
handleClick() {
this.count = this.count + 1; // assigning a field redraws
}
}Getters
get isBig() works out a value from other fields. You can use it in the template like any field: {isBig}.
Ask your class #
Now let a Custom Class do the work. Create this class under Setup → Customization → Classes first:
class Greeter {
Map hello(Map params) {
String name = params?.name?.trim() ?: 'world'
return [ message: 'Hello, ' + name + '!', length: name.size() ]
}
}Then import its method in the component. Each import names one class and one method, and the import becomes an ordinary async function you can call:
<template>
<input value={name} oninput={handleInput} placeholder="Your name">
<button class="sl-primary" onclick={handleGreet}>Greet me</button>
<p class="message">{reply.message}</p>
</template>import hello from '@class/Greeter.hello';
export default class Greet extends SmartLiteComponent {
name = '';
reply = {};
handleInput(event) {
this.name = event.target.value;
}
async handleGreet() {
this.reply = await hello({ name: this.name });
}
}That's the whole model. The rest of this guide shows each part in more detail, then builds complete examples.
Save the class first
Saving a component checks every import: the class must exist, be active and have that method. A typo like @class/Greeter.helo stops the save with Greeter has no method helo.
When something is wrong
Click Check in the editor at any time: it compiles your files without saving and lists every problem with its line. See Compiling and Errors.
Foundations
JavaScript You Need
You don't need to be a JavaScript developer to build components. A controller uses a small part of the language, and this page covers all of it: names and values, the class and its fields, methods, lists, and waiting for the server. If you already write JavaScript, skip to Built-in Values.
The class and its fields #
Every controller is one class. Its fields are the component's data, declared at the top of the class with a starting value. Inside the class you reach them through this: this.title is the title field of this component.
export default class TaskBasics extends SmartLiteComponent {
title = 'My tasks'; // a String
count = 0; // a Number
busy = false; // a Boolean: true or false
view = {}; // an Object: named values, e.g. { name: 'Acme', count: 3 }
tasks = []; // an Array: a list, e.g. [ 'a', 'b' ]
picked = null; // null: nothing yet
}| Write | Means |
|---|---|
export default class X extends SmartLiteComponent { … } | This file's component. The class name is up to you; extends SmartLiteComponent is required. |
title = 'My tasks'; (in the class body) | A field with its starting value. Watched: assigning it later redraws the component. |
this.title = 'Done'; (in a method) | Change a field. The template shows the new value. |
{title} (in the template) | Show a field. The braces are only in the template, never in JavaScript. |
Declare every field you show
Give each field a starting value in the class body, even if it is just '', [], {} or null. A field created later is not watched, and the compiler warns you when the template shows a name the class never declares.
Names inside a method: const and let #
Inside a method you often need a name for a value for a moment. Use const when the name will keep pointing at the same value, and let when you'll change it. These are local: they disappear when the method ends, and the template can't see them.
get summary() {
let open = 0; // will change: let
const total = this.tasks.length; // won't change: const
for (const t of this.tasks) {
if (!t.done) open = open + 1;
}
return `${open} of ${total} open`; // a template string: ${…} inserts a value
}Field (title = …) | Local name (const x = …) | |
|---|---|---|
| Where | Class body | Inside a method |
| Lives | As long as the component | Until the method returns |
| Template can show it | Yes, {title} | No: return it from a getter instead |
| Assigning redraws | Yes | No |
Methods and getters #
A method is something the component can do: showOpen() { … }. The template calls methods from events (onclick={showOpen}), and methods call each other as this.showOpen(). A getter, written get name() { … }, is a value worked out from other fields; the template uses it like a field, {shown}, and it is worked out again on every redraw.
<template>
<h3>{title}</h3>
<p class="count">{summary}</p>
<button onclick={showOpen}>Open only</button>
<button onclick={showAll}>All</button>
<ul>
<template for:each={shown} for:item="t">
<li key={t.id}>{t.name}</li>
</template>
</ul>
</template>export default class TaskBasics extends SmartLiteComponent {
// Fields: the component's data. Assigning one redraws the component.
title = 'My tasks';
filter = 'all';
tasks = [
{ id: 1, name: 'Call Acme', done: false },
{ id: 2, name: 'Send quote', done: true },
{ id: 3, name: 'Book demo', done: false },
];
// A getter works a value out from the fields; the template uses it like a field
get shown() {
const all = this.tasks; // const: a name you won't point elsewhere
if (this.filter === 'open') {
return all.filter(t => !t.done); // t => !t.done is a small function
}
return all;
}
get summary() {
let open = 0; // let: a name you will change
for (const t of this.tasks) {
if (!t.done) open = open + 1;
}
return `${open} of ${this.tasks.length} open`;
}
// Methods: called from the template (onclick={showOpen}) or from other methods
showOpen() {
this.filter = 'open';
}
showAll() {
this.filter = 'all';
}
}Values and comparisons #
| Write | Means |
|---|---|
'text', "text", `Hi ${name}` | Text. Backticks let you insert values. |
a + b, a - b, a * b, a / b | Arithmetic; + also joins text. |
a === b, a !== b, a > b | Equal, not equal, greater. Always use three equals signs. |
a && b, a || b, !a | And, or, not. |
x ?? 'default' | x, or the default when x is null/undefined. |
row?.name | row.name, or undefined instead of an error when row is empty. |
if (x) { … } else { … } | Do one thing or another. |
Lists and objects #
Most component code turns one list into another. These are the methods you'll use; none of them changes the original list, they return a new one, which is exactly what makes the component redraw.
| Write | Returns |
|---|---|
list.filter(c => c.active) | The items for which the test is true. |
list.map(c => c.name) | A new list made from each item. |
list.find(c => c.id === id) | The first matching item, or undefined. |
list.some(c => c.overdue) | true if any item matches. |
list.length | How many items. |
[...list, item] | A new list with item added at the end. |
{ ...row, done: true } | A copy of row with done changed. |
text.includes('acme'), text.toLowerCase(), text.trim() | Search, lower-case and tidy text. |
markDone(event, task) {
// a new list where one task is replaced by a changed copy: assigning it redraws
this.tasks = this.tasks.map(t => (t.id === task.id ? { ...t, done: true } : t));
}Changing a list in place
this.tasks.push(x) or this.tasks[0].done = true change the list without assigning the field, so nothing redraws. Assign a new list (as above), or call this.refresh() afterwards.
Events #
An event handler gets the browser event as its first value. For an input, event.target.value is what the user typed. In a for:each row, the handler also gets the row as its second value, so you know which one was clicked.
handleSearch(event) {
this.query = event.target.value; // what the user typed
}
openContact(event, contact) { // contact: the for:each row that was clicked
this.openRecord(contact.id);
}Waiting for the server: async and await #
A class method runs on the server, so its answer takes a moment. Mark the method async, and put await before the call: the method pauses there until the answer arrives. Wrap it in try / catch / finally to handle a failure and always clear a busy flag.
async connectedCallback() {
this.loading = true;
try {
this.view = await getView({ accountId: this.recordId }); // pauses here
} catch (e) {
this.problem = e.message; // the class's error
} finally {
this.loading = false; // always runs
}
}Forgetting await
Without await, this.view = getView(…) stores a promise (a pending answer), not the data, and the template shows nothing. If a class call seems to return nothing, check for a missing await and a missing async on the method.
Foundations
Built-in Values: this.recordId and More
Some names are always there, without declaring them. The most important is this.recordId: on a record page it is the ID of the record the user is looking at, which you pass to your class to load that record's data.
Values a component is given #
| Name | Is | Set where |
|---|---|---|
this.recordId | The ID of the record being shown, e.g. ACC00002f00000001HAA. | Always on a record page. On an app page, from the address (/c/x?recordId=…). A child gets it only if the parent passes it. |
this.objectApiName | That record's object, e.g. account. | Same as recordId. |
this.yourProperty | Each property in the component's Properties tab, with its type. | The address, a tab's Parameters, the Page Layout Editor, or a parent's tag. See Properties. |
All of them are set before connectedCallback runs, so you can use them there to load data. When the user opens a different record, the record page starts the component again, so connectedCallback runs again with the new recordId.
<template>
<p class="where">{where}</p>
<template if:true={recordId}>
<button onclick={handleOpen}>Open this record</button>
</template>
</template>export default class RecordInfo extends SmartLiteComponent {
// this.recordId and this.objectApiName are set by the record page before
// connectedCallback runs. Don't declare them as fields: that would overwrite them.
get where() {
if (!this.recordId) return 'Not on a record page';
return 'On ' + this.objectApiName + ' ' + this.recordId;
}
handleOpen() {
this.openRecord(this.recordId);
}
}Don't declare recordId as a field
recordId = ''; in the class body would replace the value the record page set. Read it, don't declare it. The same goes for your properties: declare them in the Properties tab, not as fields.
Things a component can do #
| Write | Does |
|---|---|
this.openRecord(id) | Opens a record's page, e.g. a contact from a list. The record is looked up first, with the user's access. |
this.navigate('dealDesk', { stage: 'Won' }) | Opens another SmartLite Component's app page with its properties set. |
this.refresh() | Redraws now, after changing a list or object in place. |
this.template.querySelector('input') | Finds an element the component drew, e.g. to focus it. |
this.dispatchEvent(new CustomEvent('saved', { detail: { id } })) | Tells the parent component something happened; the parent listens with onsaved={handleSaved}. |
Methods SmartLite calls for you #
| Method | Called |
|---|---|
connectedCallback() | Once, when the component appears. Load data here; it may be async. |
renderedCallback() | After every redraw. |
disconnectedCallback() | When it goes away. Stop timers here. |
propertyChangedCallback(name, oldValue) | When a property is changed from outside, e.g. a parent passes a new value. |
Passing recordId to a child #
A component used inside another one isn't on a record page of its own, so it doesn't get recordId automatically. Declare a recordId property (type RecordId) on the child, and pass it down in the parent's template:
<c-account-contact-list record-id={recordId}></c-account-contact-list>Read the record in the class
recordId is only an ID. Pass it to your class and let the class read the record: it does so with the user's own permissions, and returns only what the component should show.
Foundations
Writing the Class
The class is where the real work happens: it reads and saves records and decides what the screen shows. Classes are written in Groovy, under Setup → Customization → Classes. This page covers what a class needs to serve a component.
The shape of a class #
class AccountContacts {
def getView(params) {
def accountId = params?.accountId // what the component passed
def rows = Platform.query('contact', 'account_id', accountId)
return [ count: rows.size() ] // what the component receives
}
}- The class name is its API name:
class AccountContactsis imported as@class/AccountContacts.getView. - A method the component calls takes one value,
params: the object the component passed, as a map.getView({ accountId: this.recordId })arrives asparams.accountId. - Whatever the method returns arrives in the component as plain data: maps become objects, lists become arrays, numbers, text and true/false stay as they are.
- Write
params?.accountId(with?.) so a call without parameters doesn't fail.
Reading and saving records #
Platform is always available in a class. Every call runs with the permissions of the user who is using the component.
| Write | Does |
|---|---|
Platform.query('contact') | Up to 500 records of an object the user can see, each a map of field API names to values plus hex_id. |
Platform.query('contact', 'account_id', id) | Records where a field equals a value: here, the contacts of one account. |
Platform.getRecord('account', id) | One record as a map, or null if it doesn't exist or the user can't see it. |
Platform.insert('contact', [last_name: 'Lee']) | Creates a record; returns its ID. |
Platform.update(id, [title: 'CFO']) | Changes fields on a record. |
Platform.delete(id) | Deletes a record (to the Recycle Bin). |
Platform.log('message') | Writes to the class's debug log. |
Field names
Records are maps keyed by each field's API name: first_name, email, account_id; custom fields end in __c. A lookup field such as account_id holds the related record's ID, which is why query('contact', 'account_id', recordId) finds an account's contacts.
Groovy you'll use #
| Write | Means |
|---|---|
def name = 'Acme' | A local name (like let in JavaScript). |
[name: 'Acme', count: 3], [], [:] | A map, an empty list, an empty map. |
a ?: 'default' | a, or the default when a is empty, null or false. |
row?.name | row.name, or null when row is null. |
rows.collect { r -> [id: r.hex_id] } | A new list made from each item (JavaScript's map). |
rows.findAll { it.email } | The items that match (JavaScript's filter); it is the item. |
rows.sort { it.name } | Sorted by a value. |
rows.size(), rows.isEmpty() | How many; whether there are none. |
throw new RuntimeException('Email is required') | Stop with an error; the component's await fails, with a message such as RuntimeException at line 12: Email is required (the line in your class). For messages users should read, return [ ok: false, message: '…' ] instead. |
The full class reference
This page covers what a component needs. The Custom Class Code Guide teaches the whole language step by step, with what a method receives and returns, the errors a component sees, testing a method from the Developer Console, and what saving checks.
Return the screen, not the records
Return exactly what the template shows: names already joined, dates and amounts already formatted, flags like canEdit already decided. The component then needs no logic, and the rules live in one place on the server.
Save and activate the class first
A component's imports are checked when you save it: the class must exist, be active and declare the method. Change a method's name and every component importing it must be updated.
Essentials
Templates
A template is one <template> element containing ordinary HTML, plus a few additions for showing data.
Showing values #
{field} shows a field or getter of the controller, and {view.total} reaches into an object. Values are always inserted as text, so data can never turn into markup.
Lists #
Repeat part of the template with for:each. Name each item with for:item, and optionally its position with for:index. Give the first element inside a key that is unique in the list, so SmartLite can keep each row's element when the list changes.
Conditions #
<template if:true={x}> shows its content when x is true, and if:false when it is false. An empty list counts as false, which makes empty states easy.
<template>
<p class="summary">{summary}</p>
<ul>
<template for:each={tasks} for:item="task" for:index="i">
<li key={task.id} class={task.state}>
{i}. {task.title}
<button onclick={handleDone} disabled={task.done}>Done</button>
</li>
</template>
</ul>
<template if:false={tasks}><p class="empty">Nothing to do.</p></template>
</template>export default class TaskList extends SmartLiteComponent {
tasks = [
{ id: 1, title: 'Call Acme', done: false, state: 'open' },
{ id: 2, title: 'Send quote', done: true, state: 'done' },
];
get summary() {
const open = this.tasks.filter(t => !t.done).length;
return open + ' of ' + this.tasks.length + ' open';
}
// In a for:each, a handler also gets the row it belongs to
handleDone(event, task) {
this.tasks = this.tasks.map(t => (t.id === task.id ? { ...t, done: true, state: 'done' } : t));
}
}.done { color: var(--sl-color-muted); text-decoration: line-through; }Attributes #
Any attribute can take a value: class={task.state}, href={link}, title={hint}. For true/false attributes such as disabled and hidden, true adds the attribute and false leaves it out.
No calculations in braces
{a + b}, {!done} or {count > 3} are refused. Work the value out in the class (best) or in a getter, and show that. This keeps templates readable and logic testable.
Essentials
The Controller
The controller is a JavaScript class that extends SmartLiteComponent. It is a real custom element, so everything a web component can do, it can do.
Fields redraw the component #
Every field you declare is watched. Assigning a new value redraws the component; several assignments in a row are combined into one redraw, and assigning the same value does nothing.
export default class Basket extends SmartLiteComponent {
items = [];
total = 0;
add(item) {
this.items = [...this.items, item]; // a new array: redraws
this.total = this.total + item.price; // combined into the same redraw
}
addQuickly(item) {
this.items.push(item); // changed in place: tell it
this.refresh();
}
}Declare every field
Give each field a starting value in the class body (items = [];). A field you only create later is not watched.
Lifecycle #
| Method | Runs |
|---|---|
constructor() | When the element is created. Call super() first; don't touch the DOM yet. |
connectedCallback() | When it is added to the page, before the first redraw. Load data here. It may be async. |
renderedCallback() | After every redraw, e.g. to focus an input. Assigning a field here doesn't cause another redraw. |
disconnectedCallback() | When it leaves the page. Stop timers here. |
propertyChangedCallback(name, old) | When one of its properties changes from outside. |
Useful members #
| Member | Does |
|---|---|
this.template | The component's own rendered nodes: this.template.querySelector('input'). |
this.refresh() | Redraw now, after changing a list or object in place. |
this.dispatchEvent(new CustomEvent('saved', { detail })) | Tell the parent component something happened. |
this.navigate('dealDesk', { stage: 'Won' }) | Open another app page with its properties set. |
this.openRecord(id) | Open a record's own page. |
Errors are shown, not swallowed
If a method throws, a class call fails or a template can't be read, the message appears in a red box at the top of the component, and in the browser console.
Essentials
Calling Your Classes
A component reaches the server only through Custom Class methods it imports. One line per method:
import getView from '@class/DealDesk.getView';
import advance from '@class/DealDesk.advance';Each import is an async function. Pass it an object of parameters; the class method receives it as its params map, and whatever it returns comes back as plain data.
async handleAdvance(event, row) {
this.view = await advance({ id: row.id });
}The view pattern #
Most screens follow the same shape, and it keeps the controller tiny:
- A
getView(params)method in the class returns everything the screen shows: formatted text, sorted rows, and flags likecanAdvance. - Each action is another class method that does the work and ends with
return getView(params). - The controller keeps the result in one field,
view, and every button doesthis.view = await action(...).
Busy states and errors #
Calls take a moment. Set a field while you wait, and use try/finally so it is always cleared:
async save() {
this.busy = true;
try {
this.view = await saveDeal({ id: this.dealId });
} catch (e) {
this.problem = e.message; // the class's error message
} finally {
this.busy = false;
}
}Let the class report problems
For expected problems (a missing field, a duplicate), return them from the class, e.g. [ errors: ['Email looks wrong'] ], and show them in the template. Keep exceptions for the unexpected.
Only imported methods can be called
The server refuses any call from a component to a method it doesn't import (or that a component inside it doesn't import), even if the component's code tried. This limits what a component can reach if its code were ever subverted.
Essentials
Forms and Input
Inputs work in one direction, as in most modern frameworks: the template shows a field with value={name}, and an event handler writes the field.
<template>
<form onsubmit={handleSubmit}>
<input name="name" value={name} oninput={handleName} placeholder="Name">
<select name="source" onchange={handleSource}>
<template for:each={sourceOptions} for:item="o">
<option key={o.value} value={o.value} selected={o.selected}>{o.value}</option>
</template>
</select>
<label><input type="checkbox" checked={vip} onchange={handleVip}> VIP</label>
<button class="sl-primary" disabled={cannotSave}>Save</button>
</form>
<p class="saved">{saved}</p>
</template>export default class LeadForm extends SmartLiteComponent {
name = '';
source = 'Web';
vip = false;
saved = '';
sources = ['Web', 'Referral', 'Event'];
get cannotSave() { return !this.name.trim(); }
// Comparisons can't go in braces, so the selected option comes from a getter
get sourceOptions() {
return this.sources.map(s => ({ value: s, selected: s === this.source }));
}
handleName(event) { this.name = event.target.value; }
handleSource(event) { this.source = event.target.value; }
handleVip(event) { this.vip = event.target.checked; }
handleSubmit(event) {
// The form never reloads the page; read values from fields or from the form itself
const data = Object.fromEntries(new FormData(event.target));
this.saved = data.name + ' from ' + data.source + (this.vip ? ' (VIP)' : '');
}
}- Use
oninputfor text as the user types, andonchangefor selects and checkboxes. selected={…}andchecked={…}set the option and the tick.onsubmitnever reloads the page, so you don't needevent.preventDefault().- Inputs keep their focus and cursor while the component redraws around them.
Selected options
Comparisons can't go in braces, so selected={s === source} won't work. Build the options in a getter, as sourceOptions does above, or let the class return them with a selected flag.
Essentials
Compiling and Errors
When you save a component, SmartLite compiles it: it reads all three files strictly, checks them against each other and against your classes, and stores a compiled copy, which is what runs on the page. A component that doesn't compile is not saved, so a broken template or script can never reach your users.
Check and Save #
- Check compiles every file without saving and lists what it finds. Use it as often as you like.
- Save compiles too. If there are errors, nothing is saved and the same list appears.
- Before anything is sent, the editor also checks your JavaScript's full syntax, e.g. a missing comma inside an object.
Each problem shows its file, line and column, like Template line 2:3. Click it to open that file at that line; the line is also underlined in the editor, and each file's tab shows how many errors it has. Editing a file clears its marks; click Check again to see what is left.
| Kind | Means |
|---|---|
| Error (red) | The component can't work as written, so it isn't saved. Fix every error. |
| Warning (amber) | Saved, but probably a mistake, e.g. {heding} when the field is heading. Warnings stay on screen after saving. |
Template errors #
| Message | Fix |
|---|---|
| <div> is never closed | Add the missing </div>. The line points at the opening tag. |
| <template> is never closed (add </template> at the end) | The file's last lines are missing, often lost while copying. The file must end with </template>. |
| <span> is never closed (</div> on line 9 closes an outer element first) | Tags must close in order: <div><span></span></div>. |
| </p> has no matching <p> | Remove the extra closing tag. |
| the file must be one <template> … </template> | Put everything inside one outer <template>; nothing before or after it. |
| {a + b}: only field names and dotted paths are allowed in braces | Work the value out in a getter or the class and show that: {total}. |
| onclick must name a method of the class, like onclick={handleClick} | Write onclick={save}, not onclick="save()". |
| {save}: the class has no method save | Add save(event) { … } to the JavaScript, or fix the name. |
| <li> is the first element in a for:each, so it needs a key | Add key={c.id} to the first element inside the loop. |
| if:true only works on a <template> element | Wrap the part: <template if:true={x}>…</template>. |
| a <template> takes one of for:each, if:true or if:false | Nest a second <template> for the second directive. |
| the value of value opens a { that is never closed | Close the braces: value={query}. |
| <script> isn't allowed, <style> doesn't work in a template | Code goes in the JavaScript file, styles in the Styles file. |
| there is no component for <c-contact-card> | Save that child component first, or fix the tag. |
JavaScript errors #
| Message | Fix |
|---|---|
| syntax error: Unexpected token | The line and column point at the mistake: often a missing comma, bracket or quote just before it. |
| '{' is never closed | A } is missing; the line points at the { that was opened. Check the end of the file first. |
| '(' is never closed (')' … expected) | Brackets must close in order: (1, [2]). |
| a string starting with ' is never closed on its line | Add the closing quote. Use backticks for text over several lines. |
| the file must contain 'export default class … extends SmartLiteComponent' | The component's class must be export default class Name extends SmartLiteComponent. |
| only the component's class is exported | Remove other exports; helper functions just stay in the file. |
| there is no active class X (save and activate the class first) | Create and activate the class, or fix the import's spelling. |
| X has no method y | The class must declare a public method y. |
| write imports as import name from '@class/ClassName.methodName'; | One method per import, no braces: import getView from '@class/AccountContacts.getView'; |
Style errors #
| Message | Fix |
|---|---|
| '{' is never closed, '}' has nothing to close | Every rule is selector { … } with matching braces. |
| 'color red' isn't a declaration; write property: value; | Add the colon: color: red;. |
| 'color:' has no value | Give it a value or remove it. |
| '.b' has no { … } block | A selector needs its block: .b { … }. |
| @import isn't allowed | A component can't load other files; put the styles in this file. |
Properties and placement #
Property problems (an invalid name, a default that doesn't fit its type) and placement problems (Record page without an object) are listed too, without a line number.
What gets stored #
Your files are saved exactly as you wrote them, for editing. The compiled copy is stored next to them: the template rebuilt from what was parsed (comments removed, every attribute quoted, <c-x/> written out as <c-x></c-x>) and the JavaScript with its imports turned into calls to your classes. Pages run the compiled copy.
Components saved before compiling
A component saved before compiling existed is compiled when it is shown, and runs as before. Open and save it once to store its compiled copy; if it no longer compiles, the list shows what to fix.
Building Components
Properties
Properties are values a component gets from outside, so one component can be reused with different settings. You declare them in the component's Properties tab, and read them in the controller as this.name.
| Name | Type | Label | Default |
|---|---|---|---|
stage | String | Stage | Negotiation |
maxRows | Number | Rows to show | 20 |
compact | Boolean | Compact | false |
<template>
<p>Showing {stage} deals, {maxRows} at a time{compactText}.</p>
<button onclick={handleOpen}>Open the deal desk</button>
</template>export default class ReportLink extends SmartLiteComponent {
get compactText() {
return this.compact ? ', compact' : '';
}
handleOpen() {
// Opens another app page with its properties set
this.navigate('dealDesk', { stage: this.stage });
}
}| Name | Type | Label | Default |
|---|---|---|---|
stage | String | Stage | Negotiation |
maxRows | Number | Rows to show | 20 |
compact | Boolean | Compact | false |
Where values come from #
| Where it is used | How you set them |
|---|---|
| An app page | The address: /c/reportLink?stage=Won&maxRows=50. Any link works: an email, a URL field, a report. |
| A tab | Tab Manager → New Custom Tab, Page Tab, page /c/reportLink, Parameters stage=Won. Two tabs can open the same component differently. |
| A record page | Page Layout Editor → Add Custom Component shows a settings form built from the Properties tab. recordId and objectApiName are always set too. |
| A parent component | Attributes on the tag: <c-contact-card contact={c}>. |
| Code | this.navigate('reportLink', { stage: 'Won' }) |
Types are converted for you: ?maxRows=50 arrives as the number 50, and ?compact=true as true. A value that doesn't fit (letters for a Number, a malformed record ID) is ignored and the default is used. Anything that isn't a declared property is ignored.
React to changes
If a property can change while the component is open, for example when a parent passes a new value, handle it in propertyChangedCallback(name).
Values from outside are just input
Treat properties like anything a user typed. The class still checks permissions: a recordId for a record the user can't see simply returns no data. Never put secrets in an address.
Building Components
Components in Components
Big screens are easier to build from small components. A parent uses a child as a tag, passes data down through the child's properties, and listens for the child's events. Data goes down, events come up.
Using a child #
A component's tag is c- followed by its API name in lower case with dashes: contactCard becomes <c-contact-card>. Close the tag explicitly. Attributes in kebab case set the child's properties in camel case: max-rows={5} sets maxRows.
<template>
<div class="card">
<strong>{contact.name}</strong> <span class="sl-error">{contact.warning}</span><br>
<span class="sl-muted">{contact.detail}</span>
<slot></slot>
<button onclick={handleRemove}>Remove</button>
</div>
</template>export default class ContactCard extends SmartLiteComponent {
// contact is a property (type Object), set by the parent
handleRemove() {
// Tell the parent; the parent decides what removing means
this.dispatchEvent(new CustomEvent('remove', { detail: { id: this.contact.id } }));
}
}.card { padding: 8px 0; border-bottom: 1px solid var(--sl-color-border); }| Name | Type | Label | Default |
|---|---|---|---|
contact | Object | Contact | — |
<template>
<h3>{heading}</h3>
<p class="sl-muted note">{view.heading} · {view.note}</p>
<template for:each={view.rows} for:item="c">
<c-contact-card key={c.id} contact={c} onremove={handleRemove}></c-contact-card>
</template>
<p class="sl-muted">{view.more}</p>
<form onsubmit={handleAdd}>
<input name="firstName" placeholder="First name">
<input name="lastName" placeholder="Last name">
<input name="email" placeholder="Email">
<button class="sl-primary">Add contact</button>
</form>
<template for:each={view.errors} for:item="e"><p key={e} class="sl-error error">{e}</p></template>
<template if:true={view.saved}><p class="sl-success ok">{view.saved}</p></template>
</template>import getView from '@class/AccountContactsPanel.getView';
import addContact from '@class/AccountContactsPanel.addContact';
import unlink from '@class/AccountContactsPanel.unlink';
export default class AccountContacts extends SmartLiteComponent {
// heading and maxRows are properties set in the Page Layout Editor;
// recordId is the Account this component sits on
view = {};
async connectedCallback() {
this.view = await getView(this.params());
}
async handleAdd(event) {
const form = event.target;
this.view = await addContact({ ...Object.fromEntries(new FormData(form)), ...this.params() });
if (!this.view.errors.length) form.reset();
}
// The child sent 'remove' with the contact's id
async handleRemove(event) {
this.view = await unlink({ contactId: event.detail.id, ...this.params() });
}
params() {
return { accountId: this.recordId, maxRows: this.maxRows };
}
}| Name | Type | Label | Default |
|---|---|---|---|
heading | String | Heading | Contacts |
maxRows | Number | Rows to show | 10 |
The child doesn't know what removing means. It sends a remove event with the contact's id; the parent calls its class and the new list flows back down. Each card keeps its element when others are removed, because of key={c.id}.
Slots #
Content you put between a child's tags is shown wherever the child has a <slot>, the standard web-component way to pass markup in.
<c-contact-card contact={c}>
<em>Primary contact</em>
</c-contact-card>Calling a child's method #
Sometimes a parent needs a child to act: clear a form, focus a field, reload. Any method of the child can be called on its element:
<template>
<input class="name" value={name} oninput={handleName} placeholder="Name">
</template>export default class NameInput extends SmartLiteComponent {
name = '';
handleName(event) { this.name = event.target.value; }
// Any method can be called by a parent
clear() {
this.name = '';
this.template.querySelector('input').focus();
}
}<template>
<c-name-input></c-name-input>
<button onclick={handleNew}>New contact</button>
</template>export default class NewContact extends SmartLiteComponent {
handleNew() {
this.template.querySelector('c-name-input').clear();
}
}Checked when you save
Every c- tag must name a saved, active component. A component can't contain itself, directly or through others, and components can be nested at most 5 levels deep. Save children before the components that use them.
Building Components
Styles and Branding
Each component's .css applies only inside that component. A parent's styles never leak into a child, and a child's never leak out.
Plain elements already look like SmartLite: buttons, inputs, selects and tables get the app's styles, and these helper classes are available:
| Class | Use |
|---|---|
sl-primary | The main action button. |
sl-danger | A destructive action. |
sl-card | A bordered box. |
sl-badge | A small pill. |
sl-muted, sl-error, sl-success | Secondary, error and success text. |
Branding variables #
Use the org's colours through CSS variables, so components follow the Branding settings automatically:
.highlight {
border-left: 3px solid var(--sl-color-brand);
color: var(--sl-color-title);
background: var(--sl-color-bg);
border-radius: var(--sl-radius);
}| Variable | Is |
|---|---|
--sl-color-brand | The org's brand colour. |
--sl-color-title, --sl-color-text, --sl-color-muted | Heading, body and secondary text. |
--sl-color-border, --sl-color-bg, --sl-color-hover | Borders and backgrounds. |
--sl-radius, --sl-font | Corner radius and font. |
Building Components
Putting It in the App
A component's Where it can go setting decides where admins can place it.
As an app page #
- Tick App page and save. The component opens at
/c/{apiName}. - Go to Setup → Configuration → Tab Manager → New Custom Tab, choose Page Tab, and pick it under SmartLite Components in the Page list. Add Parameters if it has properties.
- Add the tab to an app in App Manager.
On a record page #
- Tick Record page of and choose the object, then save.
- Go to Object Manager → the object → Page Layouts, open a layout, and click Add Custom Component (in the main body) or add a Custom Component widget to the right sidebar.
- Pick the component and fill in its settings. Each layout can set its own values. Save the layout.
On a record page the component always gets recordId and objectApiName. Pass recordId to your class rather than reading fields in the browser: the class reads the record with the user's permissions and returns what to show.
Inside other components #
Nothing to configure: any active SmartLite Component can be used as a tag in another one.
Examples
Tutorial: Account Contacts, Step by Step
Let's build a panel for the Account page that lists the account's contacts, with a search box, and opens a contact when you click it. You'll write the class, then the component one piece at a time, and place it on the page. It uses everything from the Foundations pages.
1. The class #
Create this class under Setup → Customization → Classes and activate it. It finds the contacts whose account_id is the account's ID, and returns them ready to show: full name, initials, and a lower-case searchText for the search box.
class AccountContacts {
def getView(params) {
def accountId = params?.accountId
if (!accountId) {
return [contacts: [], count: 0, hasContacts: false]
}
def rows = Platform.query('contact', 'account_id', accountId)
def contacts = rows.collect { c ->
def first = c.first_name ?: ''
def last = c.last_name ?: ''
def name = (first + ' ' + last).trim() ?: '(no name)'
[
id: c.hex_id,
name: name,
initials: ((first ? first.take(1) : '') + (last ? last.take(1) : '')).toUpperCase() ?: '?',
title: c.title ?: '',
email: c.email ?: '',
phone: c.phone ?: '',
searchText: (name + ' ' + (c.email ?: '') + ' ' + (c.phone ?: '')).toLowerCase()
]
}.sort { it.name.toLowerCase() }
return [
contacts: contacts,
count: contacts.size(),
hasContacts: !contacts.isEmpty()
]
}
}2. The component #
- Go to Setup → Customization → SmartLite Components → New Component.
- API name
accountContactList, label Account Contacts. - Under Where it can go, tick Record page of and choose Account.
- In the Properties tab, add
heading, type String, label Heading, default Contacts.
3. Load the data #
The controller declares its fields, and loads the view as soon as the component appears, passing this.recordId: the account being shown.
import getView from '@class/AccountContacts.getView';
export default class AccountContactList extends SmartLiteComponent {
view = { contacts: [], count: 0, hasContacts: false };
loading = true;
async connectedCallback() {
try {
this.view = await getView({ accountId: this.recordId });
} finally {
this.loading = false;
}
}
}4. Show the list #
The template shows the heading and count, a loading line, an empty state, and one row per contact. key={c.id} lets each row keep its element when the list changes.
<template>
<h3>{heading} ({view.count})</h3>
<template if:true={loading}><p>Loading contacts…</p></template>
<template if:false={view.hasContacts}><p>No contacts on this account yet.</p></template>
<ul>
<template for:each={view.contacts} for:item="c">
<li key={c.id}>{c.name} · {c.email}</li>
</template>
</ul>
</template>Click Check. With no problems, this is already a working panel.
5. Add search #
Add a query field and a shown getter that filters the contacts; loop over shown instead of view.contacts. The search box shows query and writes it on every keystroke.
query = '';
get shown() {
const q = this.query.trim().toLowerCase();
if (!q) return this.view.contacts;
return this.view.contacts.filter(c => c.searchText.includes(q));
}
handleSearch(event) {
this.query = event.target.value;
}6. Open a contact #
Put onclick={openContact} on the row. In a for:each, the handler gets the row as its second value:
openContact(event, contact) {
this.openRecord(contact.id);
}7. The finished component #
Here are all the files, with styles and a no matches message. Paste them in, click Check, then Save.
<template>
<div class="card">
<div class="head">
<h3>{heading} <span class="count">({view.count})</span></h3>
<input type="search" placeholder="Search contacts" value={query} oninput={handleSearch}>
</div>
<template if:true={loading}>
<p class="muted">Loading contacts…</p>
</template>
<template if:false={loading}>
<template if:false={view.hasContacts}>
<p class="muted">No contacts on this account yet.</p>
</template>
<template if:true={noMatches}>
<p class="muted">No contacts match your search.</p>
</template>
<ul class="list">
<template for:each={shown} for:item="c">
<li key={c.id} class="row" onclick={openContact}>
<span class="avatar">{c.initials}</span>
<span class="main">
<span class="name">{c.name}</span>
<span class="sub">{c.title}</span>
</span>
<span class="meta">
<span>{c.email}</span>
<span>{c.phone}</span>
</span>
</li>
</template>
</ul>
</template>
</div>
</template>import getView from '@class/AccountContacts.getView';
export default class AccountContactList extends SmartLiteComponent {
view = { contacts: [], count: 0, hasContacts: false };
loading = true;
query = '';
async connectedCallback() {
try {
this.view = await getView({ accountId: this.recordId });
} finally {
this.loading = false;
}
}
get shown() {
const q = this.query.trim().toLowerCase();
if (!q) return this.view.contacts;
return this.view.contacts.filter(c => c.searchText.includes(q));
}
get noMatches() {
return this.view.hasContacts && this.shown.length === 0;
}
handleSearch(event) {
this.query = event.target.value;
}
openContact(event, contact) {
this.openRecord(contact.id);
}
}.card { font-size: 13px; }
.head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
h3 { margin: 0; font-size: 14px; font-weight: 600; }
.count { color: #6b7280; font-weight: 400; }
input { padding: 4px 8px; border: 1px solid #d1d5db; border-radius: 4px; font-size: 12px; width: 160px; }
.muted { color: #6b7280; margin: 8px 0; }
.list { list-style: none; margin: 0; padding: 0; }
.row { display: flex; align-items: center; gap: 10px; padding: 8px 4px; border-top: 1px solid #f3f4f6; cursor: pointer; }
.row:hover { background: #f9fafb; }
.avatar { width: 30px; height: 30px; border-radius: 50%; flex: none; display: flex; align-items: center; justify-content: center;
background: var(--sl-color-brand, #0176d3); color: #fff; font-size: 11px; font-weight: 600; }
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.name { font-weight: 600; color: var(--sl-color-brand, #0176d3); }
.sub, .meta { color: #6b7280; font-size: 12px; }
.meta { display: flex; flex-direction: column; align-items: flex-end; }| Name | Type | Label | Default |
|---|---|---|---|
heading | String | Heading | Contacts |
8. Put it on the Account page #
- Go to Object Manager → Account → Page Layouts and open the layout.
- Click Add Custom Component (main body), or add a Custom Component widget to the right sidebar.
- Choose Account Contacts, set its Heading if you like, and save the layout.
Open any account: its contacts are listed by name, the search box narrows them as you type, and clicking one opens it.
If no contacts appear
Click Check first: a broken template is the most common cause, and the list says exactly where. Then check that the contacts' Account field is filled in: the class finds contacts by account_id.
Examples
Example: Contact Search
A search screen: type a name, click Search, see matching contacts. It shows a busy state and lets the class decide what to say when nothing matches.
The class #
class ContactFinder {
Map find(Map params) {
String q = (params?.q ?: '').trim().toLowerCase()
if (q.size() < 2) return [ rows: [], status: 'Type at least 2 letters' ]
List rows = Platform.query('contact')
.findAll { Map c -> [c.first_name, c.last_name, c.email].any { it?.toLowerCase()?.contains(q) } }
.take(25)
.collect { Map c -> [ id: c.hex_id, name: c.first_name + ' ' + c.last_name, email: c.email ?: '-' ] }
return [ rows: rows, status: rows ? rows.size() + ' found' : 'Nobody matches "' + q + '"' ]
}
}The component #
<template>
<input value={q} oninput={handleInput} placeholder="Name or email">
<button class="sl-primary" onclick={handleSearch} disabled={busy}>Search</button>
<p class="sl-muted status">{result.status}</p>
<table>
<template for:each={result.rows} for:item="c">
<tr key={c.id}><td>{c.name}</td><td>{c.email}</td></tr>
</template>
</table>
</template>import find from '@class/ContactFinder.find';
export default class ContactSearch extends SmartLiteComponent {
q = '';
result = { rows: [], status: '' };
busy = false;
handleInput(event) {
this.q = event.target.value;
}
async handleSearch() {
this.busy = true;
try {
this.result = await find({ q: this.q });
} finally {
this.busy = false;
}
}
}Tick App page, save, and open /c/contactSearch.
Examples
Example: Account Contacts
A panel for the Account page: the account's contacts as cards, a warning for missing emails, a form to add a contact (checked by the class), and a Remove button on each card. It combines a record page, properties, a form, a child component and events.
The class #
getView builds the cards; addContact and unlink do the work and return the new view. The form's rules live here, so they apply wherever the class is called from.
class AccountContactsPanel {
Map getView(Map params) {
List contacts = Platform.query('contact', 'account_id', params.accountId)
List rows = contacts
.sort { Map a, Map b -> (a.last_name ?: '') <=> (b.last_name ?: '') }
.collect { Map c ->
[ id : c.hex_id,
name : ((c.first_name ?: '') + ' ' + (c.last_name ?: '')).trim(),
detail : [ c.title, c.email ].findAll { it }.join(' · ') ?: 'No title or email',
warning : c.email ? '' : 'Missing email' ]
}
int missing = rows.count { Map r -> r.warning }
int limit = params?.maxRows ? params.maxRows.toString().toInteger() : rows.size()
return [
rows : rows.take(limit),
more : rows.size() > limit ? (rows.size() - limit) + ' more not shown' : '',
heading : rows.size() + (rows.size() == 1 ? ' contact' : ' contacts'),
note : missing ? missing + ' without an email' : 'Everyone has an email',
errors : params?.errors ?: [],
saved : params?.saved ?: ''
]
}
Map addContact(Map params) {
String email = params.email?.trim()?.toLowerCase()
List errors = []
if (!params.firstName) errors << 'First name is required'
if (!params.lastName) errors << 'Last name is required'
if (email && !(email ==~ /[^@\s]+@[^@\s]+\.[^@\s]+/)) errors << 'Email looks wrong'
if (errors) return getView(params + [ errors: errors ])
Platform.insert('contact', [
first_name: params.firstName, last_name: params.lastName,
email: email, title: params.title,
account_id: params.accountId ])
return getView(params + [ saved: params.firstName + ' ' + params.lastName + ' added' ])
}
Map unlink(Map params) {
Platform.update(params.contactId, [ account_id: null ])
return getView(params + [ saved: 'Contact removed from this account' ])
}
}The child: one contact #
<template>
<div class="card">
<strong>{contact.name}</strong> <span class="sl-error">{contact.warning}</span><br>
<span class="sl-muted">{contact.detail}</span>
<slot></slot>
<button onclick={handleRemove}>Remove</button>
</div>
</template>export default class ContactCard extends SmartLiteComponent {
// contact is a property (type Object), set by the parent
handleRemove() {
// Tell the parent; the parent decides what removing means
this.dispatchEvent(new CustomEvent('remove', { detail: { id: this.contact.id } }));
}
}.card { padding: 8px 0; border-bottom: 1px solid var(--sl-color-border); }| Name | Type | Label | Default |
|---|---|---|---|
contact | Object | Contact | — |
The panel #
Tick Record page of Account. Its properties appear in the Page Layout Editor as Heading and Rows to show.
<template>
<h3>{heading}</h3>
<p class="sl-muted note">{view.heading} · {view.note}</p>
<template for:each={view.rows} for:item="c">
<c-contact-card key={c.id} contact={c} onremove={handleRemove}></c-contact-card>
</template>
<p class="sl-muted">{view.more}</p>
<form onsubmit={handleAdd}>
<input name="firstName" placeholder="First name">
<input name="lastName" placeholder="Last name">
<input name="email" placeholder="Email">
<button class="sl-primary">Add contact</button>
</form>
<template for:each={view.errors} for:item="e"><p key={e} class="sl-error error">{e}</p></template>
<template if:true={view.saved}><p class="sl-success ok">{view.saved}</p></template>
</template>import getView from '@class/AccountContactsPanel.getView';
import addContact from '@class/AccountContactsPanel.addContact';
import unlink from '@class/AccountContactsPanel.unlink';
export default class AccountContacts extends SmartLiteComponent {
// heading and maxRows are properties set in the Page Layout Editor;
// recordId is the Account this component sits on
view = {};
async connectedCallback() {
this.view = await getView(this.params());
}
async handleAdd(event) {
const form = event.target;
this.view = await addContact({ ...Object.fromEntries(new FormData(form)), ...this.params() });
if (!this.view.errors.length) form.reset();
}
// The child sent 'remove' with the contact's id
async handleRemove(event) {
this.view = await unlink({ contactId: event.detail.id, ...this.params() });
}
params() {
return { accountId: this.recordId, maxRows: this.maxRows };
}
}| Name | Type | Label | Default |
|---|---|---|---|
heading | String | Heading | Contacts |
maxRows | Number | Rows to show | 10 |
Examples
Example: Deal Desk
A full screen for a sales manager: open deals sorted by amount, overdue ones highlighted, a stage filter that can come from the address or a tab, and buttons to move a deal forward or mark it lost. Every rule, label and colour is decided by the class; the controller is one helper and a few one-line handlers.
The class #
class DealDesk {
List stages = ['Prospecting', 'Qualification', 'Needs Analysis', 'Value Proposition',
'Negotiation', 'Verbal Commitment', 'Closed Won']
// Everything the page shows, already worked out: labels, totals, colours, which buttons apply
Map getView(Map params) {
String stage = params?.stage ?: ''
List open = Platform.query('opportunity')
.findAll { Map o -> !(o.stage in ['Closed Won', 'Closed Lost']) }
List shown = stage ? open.findAll { Map o -> o.stage == stage } : open
String today = day(0)
List rows = shown
.sort { Map a, Map b -> num(b.amount) <=> num(a.amount) }
.collect { Map o ->
boolean overdue = o.close_date && o.close_date.toString().take(10) < today
int next = stages.indexOf(o.stage) + 1
[ id : o.hex_id,
name : o.name,
stage : o.stage ?: 'No stage',
amountText : money(o.amount),
closeText : o.close_date ? o.close_date.toString().take(10) : 'no date',
rowClass : overdue ? 'row overdue' : 'row',
badge : overdue ? 'Overdue' : '',
canAdvance : next > 0 && next < stages.size(),
nextLabel : next > 0 && next < stages.size() ? 'Move to ' + stages[next] : '' ]
}
return [
stageOptions: [[ value: '', label: 'All open stages', selected: stage == '' ]] +
stages.take(6).collect { String s -> [ value: s, label: s, selected: s == stage ] },
rows : rows,
hasRows : !rows.isEmpty(),
summary : rows.size() + ' deals · ' + money(shown.sum { Map o -> num(o.amount) } ?: 0),
message : params?.message ?: ''
]
}
// Actions do the work and hand back the new view, so the page just shows it
Map advance(Map params) {
Map o = Platform.getRecord('opportunity', params.id)
int next = stages.indexOf(o.stage) + 1
if (next <= 0 || next >= stages.size()) return getView(params + [ message: o.name + ' cannot move further' ])
Platform.update(params.id, [ stage: stages[next] ])
return getView(params + [ message: o.name + ' moved to ' + stages[next] ])
}
Map markLost(Map params) {
Map o = Platform.getRecord('opportunity', params.id)
Platform.update(params.id, [ stage: 'Closed Lost', close_reason: 'Other' ])
return getView(params + [ message: o.name + ' marked as lost' ])
}
private BigDecimal num(Object v) { return v ? v.toString().toBigDecimal() : 0 }
private String money(Object v) {
String whole = num(v).setScale(0, 4).toPlainString()
return '$' + whole.replaceAll(/\B(?=(\d{3})+(?!\d))/, ',')
}
private String day(int offset) {
return new Date(new Date().getTime() + offset * 86400000L).toInstant().toString().substring(0, 10)
}
}The component #
Declare one property, stage (String, label Stage filter), tick App page, and save. Then try /c/dealDesk?stage=Negotiation, or a tab with Parameters stage=Negotiation.
<template>
<h2>Deal desk</h2>
<select onchange={handleStage}>
<template for:each={view.stageOptions} for:item="o">
<option key={o.value} value={o.value} selected={o.selected}>{o.label}</option>
</template>
</select>
<span class="summary">{view.summary}</span>
<template if:true={view.message}><p class="sl-success message">{view.message}</p></template>
<table>
<tr><th>Deal</th><th>Stage</th><th>Amount</th><th>Closes</th><th></th></tr>
<template for:each={view.rows} for:item="r">
<tr key={r.id} class={r.rowClass}>
<td>{r.name} <span class="badge">{r.badge}</span></td>
<td>{r.stage}</td>
<td>{r.amountText}</td>
<td>{r.closeText}</td>
<td>
<template if:true={r.canAdvance}>
<button class="advance" onclick={handleAdvance} disabled={busy}>{r.nextLabel}</button>
</template>
<button class="lost sl-danger" onclick={handleLost} disabled={busy}>Lost</button>
</td>
</tr>
</template>
</table>
<template if:false={view.hasRows}><p class="empty">No open deals here.</p></template>
</template>import getView from '@class/DealDesk.getView';
import advance from '@class/DealDesk.advance';
import markLost from '@class/DealDesk.markLost';
export default class DealDesk extends SmartLiteComponent {
// stage is a property: /c/dealDesk?stage=Negotiation, or a tab's Parameters
view = {};
busy = false;
connectedCallback() {
this.show(getView({ stage: this.stage }));
}
propertyChangedCallback(name) {
if (name === 'stage') this.show(getView({ stage: this.stage }));
}
handleStage(event) {
this.stage = event.target.value;
}
handleAdvance(event, row) {
this.show(advance({ id: row.id, stage: this.stage }));
}
handleLost(event, row) {
this.show(markLost({ id: row.id, stage: this.stage }));
}
// One helper: wait for the class, show the view it returns
async show(call) {
this.busy = true;
try {
this.view = await call;
} finally {
this.busy = false;
}
}
}.overdue td { background: #FEF2F2; }
.badge { color: #B91C1C; font-size: 12px; }| Name | Type | Label | Default |
|---|---|---|---|
stage | String | Stage filter | — |
| The class works out | The template just shows |
|---|---|
| Which deals are open and match the filter | for:each={view.rows} |
| $125,000, 2026-01-15, Overdue | {r.amountText}, {r.closeText}, {r.badge} |
| The row colour | class={r.rowClass} |
| Whether a deal can move, and the button's label | if:true={r.canAdvance}, {r.nextLabel} |
| The summary and the success message | {view.summary}, {view.message} |
Reference
Reference
Template #
| Write | Does |
|---|---|
{name}, {a.b.c} | A field, getter or nested value, as text. |
<template for:each={list} for:item="x" for:index="i"> | Repeats its content; the first element needs key={x.id}. |
<template if:true={x}>, if:false | Shows its content or not; an empty list is false. |
onclick={method}, any on<event> | Calls method(event, item); item is the for:each row. |
disabled={busy} | True adds the attribute, false leaves it out. |
value={v}, checked={b}, selected={b} | Set as the element's property. |
<c-my-card prop-name={v}></c-my-card> | A child component; kebab-case attributes set camelCase properties. |
<slot> | Where a parent's content between the tags appears. |
JavaScript #
| Write | Does |
|---|---|
import f from '@class/Class.method'; | One class method, as an async function. The only kind of import. |
export default class X extends SmartLiteComponent | The component. |
field = value; | Watched: assigning redraws. |
get total() { … } | A computed value for the template. |
| Lifecycle | connectedCallback, renderedCallback, disconnectedCallback, propertyChangedCallback(name, old). |
| Members | this.template, this.refresh(), this.dispatchEvent(), this.navigate(page, props), this.openRecord(id). |
| Always set on a record page | this.recordId, this.objectApiName. |
Property types #
| Type | Holds | From |
|---|---|---|
| String | Text | Address, tab, layout, parent |
| Number | A number; anything else is ignored | Address, tab, layout, parent |
| Boolean | true / false | Address, tab, layout, parent |
| RecordId | A record ID, checked for its shape | Address, tab, layout, parent |
| Object | Any data, such as one row | A parent component only |
What compiling checks #
- Template: one
<template>; every element closed, in order; bindings are names or dotted paths; directives only on<template>, one each;keyon the first element of afor:each; handlers areon…={method}; no<script>,<style>,<base>,<meta>orsrcdoc. - JavaScript: full syntax (in the editor); every bracket, string, comment and template literal closed; one
export default class … extends SmartLiteComponentand no other export; only@classimports, each naming an active class that has that method. - Styles: braces matched, every rule has a block, every declaration is
property: value, no@import. - Together: every handler in the template is a method of the class; names the template shows but the class never declares are warnings.
- Every
c-tag names an active SmartLite Component; no loops; at most 5 levels deep. - Property names are valid and unique, types are known, defaults fit their type. Record page has an object. Each file is at most 200,000 characters.
- A component used by others can't be deleted, switched off or turned back into a classic one.
Limits #
| Limit | Value |
|---|---|
| Nesting | 5 levels of components inside one another |
| File size | 200,000 characters per file |
| Class calls | Each call is a class run: the usual limits of 150 record changes, 10 web calls and 15 seconds apply to it |
| Components per org | Counted with your plan's custom component limit |
Reference
Security
Components run code an admin wrote, next to data users trust. SmartLite keeps the two apart in several layers:
- A sandboxed frame. Each page or panel runs in a sandboxed iframe with its own origin. It can't read the app's cookies, storage or pages, and it has no network access: no requests, no external scripts, no external images.
- No code from strings. The frame forbids
evalandnew Function, and SmartLite's own runtime uses neither. - Data stays text. Values are inserted with DOM calls, never as HTML.
javascript:and non-imagedata:links are blocked.<script>,<base>,<meta>,srcdocand inlineonclick="…"code in templates are never output. - One way out. The frame can only send messages to the app, and the app only accepts messages from its own frame. It never passes the user's session into the frame.
- Declared calls only. The app adds which component is calling, and the server refuses methods the component doesn't import.
- The user's own permissions. Classes read and write through the same permission, sharing and field-level security checks as the rest of the app.
- Checked input. Only declared properties are read from addresses, tabs and layouts, converted to their types.
What the import check doesn't do
A signed-in user can still call an active class directly with their own session, outside any component. The import check limits what a component can reach; who may run a class at all is decided by the class's own logic and the user's permissions.
Reference
Performance
Components are fast by default. Here's what happens, and how to help.
- Batched redraws. Assignments made together cause one redraw, after your method finishes.
- Patching, not rebuilding. A redraw changes only what is different. In a list of 1,000 rows, changing one row touches one text node.
- Keys keep rows. With
key={row.id}, removing, inserting, sorting or reversing rows moves the existing elements instead of recreating them. - Children redraw only when their properties change.
- Shared styles. A component's styles are parsed once and shared by all its copies.
Make fewer, bigger calls
Each class call is a round trip to the server and counts as a class run. Return everything a screen needs from one getView call instead of calling once per row.
Keep keys stable
Use the record's ID as the key, not the row's position, or rows will be recreated when the list changes.
Reference
Classic Components and Pages
Before SmartLite Components, SmartLite had two kinds of single-file custom UI: Custom Pages and Custom Components. They keep working, unchanged.
- They are listed in the Classic section at the bottom of SmartLite Components, and can still be edited there.
- New screens and panels are SmartLite Components; the classic screens no longer create new items.
- Classic code uses
SmartLiteComponent.callClass(...)andrenderClass(...)in one HTML file. In a SmartLite Component you import class methods instead.
Moving a classic page over #
- Create a SmartLite Component with App page ticked.
- Move the markup into the template, replacing hand-written DOM code with
{fields},for:eachandon…handlers. - Move the script into the controller, and replace each
callClass('Class', 'method', …)with an import. - Point the tab at
/c/{apiName}, then deactivate the classic page.