
Sf Lwc
- 1.5k installs
- 423 repo stars
- Updated April 27, 2026
- jaganpro/sf-skills
When and how to build Lightning Web Components with wire service, data integration, SLDS 2 styling, accessibility, and Jest tests.
About
sf-lwc guides developers through Lightning Web Component creation using the PICKLES methodology (prototype, integrate, compose, define, libraries, execute, security). Covers component scaffolding, data access patterns (LDS, Apex, GraphQL), SLDS 2 and dark mode compliance, accessibility, performance optimization, and Jest unit testing. Developers use this skill when touching lwc/**/*.js, .html, .css, or .js-meta.xml files, integrating wire service or Apex, or reviewing component quality. Includes local dev server commands for hot-reload preview without deployment. Delegates Apex backend work to sf-apex, Flow orchestration to sf-flow, and org deployment to sf-deploy.
- PICKLES methodology: prototype, integrate, compose, define, libraries, execute, security framework
- Data access patterns: LDS, Apex @AuraEnabled(cacheable=true), GraphQL wire adapter, Lightning Message Service
- 165-point scoring system across 8 categories including SLDS 2 and dark mode compliance
- Local dev preview with hot reload for .js, .html, .css changes without deployment
- Jest testing, accessibility audit, performance rerender safety, and event contract validation
Sf Lwc by the numbers
- 1,457 all-time installs (skills.sh)
- +4 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #297 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
sf-lwc capabilities & compatibility
- Capabilities
- component scaffolding and bundle design · wire service and apex integration · graphql data fetching · slds 2 and dark mode styling · jest unit testing · accessibility audit · local dev preview with hot reload
- Works with
- salesforce
- Use cases
- frontend · testing · api development
- Platforms
- macOS · Windows · Linux
- Runs
- Hosted SaaS
- Pricing
- Free
npx skills add https://github.com/jaganpro/sf-skills --skill sf-lwcAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.5k |
|---|---|
| repo stars | ★ 423 |
| Security audit | 3 / 3 scanners passed |
| Last updated | April 27, 2026 |
| Repository | jaganpro/sf-skills ↗ |
What it does
Build Lightning Web Components with wire service integration, SLDS 2 styling, and Jest tests for Salesforce orgs.
Who is it for?
Frontend developers building interactive UI components, data-driven dashboards, Flow screen components, and Experience Cloud embeds in Salesforce.
Skip if: Apex business logic, Flow declarative logic, metadata deployment, or Aura/Visualforce component migration.
When should I use this skill?
User creates/edits lwc/**/*.js, .html, .css, .js-meta.xml files, or asks about wire service, SLDS, Jest LWC tests, or component architecture.
What you get
Component achieves 150+ point score (production-ready) with SLDS 2 compliance, dark mode support, accessibility audit pass, and Jest coverage.
- LWC bundle (JS, HTML, CSS, JS-meta.xml)
- Jest test suite with coverage
- Accessibility audit notes
By the numbers
- 165-point scoring system across 8 categories
- Production-ready threshold: 150+ points
- Local dev commands support hot reload for .js, .html, .css files
Files
sf-lwc: Lightning Web Components Development
Use this skill when the user needs Lightning Web Components: LWC bundles, wire patterns, Apex/GraphQL integration, SLDS 2 styling, accessibility, performance work, or Jest unit tests.
When This Skill Owns the Task
Use sf-lwc when the work involves:
lwc/**/*.js,.html,.css,.js-meta.xml- component scaffolding and bundle design
- wire service, Apex integration, GraphQL integration
- SLDS 2, dark mode, and accessibility work
- Jest unit tests for LWC
Delegate elsewhere when the user is:
- writing Apex controllers or business logic first → sf-apex
- building Flow XML rather than an LWC screen component → sf-flow
- deploying metadata → sf-deploy
---
Required Context to Gather First
Ask for or infer:
- component purpose and target surface
- data source: LDS, Apex, GraphQL, LMS, or external system via Apex
- whether the user needs tests
- whether the component must run in Flow, App Builder, Experience Cloud, or dashboard contexts
- accessibility and styling expectations
---
Recommended Workflow
1. Choose the right architecture
Use the PICKLES mindset:
- prototype
- integrate the right data source
- compose component boundaries
- define interaction model
- use platform libraries
- optimize execution
- enforce security
2. Choose the right data access pattern
| Need | Default pattern |
|---|---|
| single-record UI | LDS / getRecord |
| simple CRUD form | base record form components |
| complex server query | Apex @AuraEnabled(cacheable=true) |
| related graph data | GraphQL wire adapter |
| cross-DOM communication | Lightning Message Service |
3. Start from an asset when useful
Use provided assets for:
- basic component bundles
- datatables
- modal patterns
- Flow screen components
- GraphQL components
- LMS message channels
- Jest tests
- TypeScript-enabled components
4. Validate for frontend quality
Check:
- accessibility
- SLDS 2 / dark mode compliance
- event contracts
- performance / rerender safety
- Jest coverage when required
5. Hand off supporting backend or deploy work
Use:
- sf-apex for controllers / services
- sf-deploy for deployment
- sf-testing only for Apex-side test loops, not Jest
---
High-Signal Rules
- prefer platform base components over reinventing controls
- use
@wirefor reactive read-only use cases; imperative calls for explicit actions and DML paths - do not introduce inaccessible custom UI
- avoid hardcoded colors; use SLDS 2-compatible styling hooks / variables
- avoid rerender loops in
renderedCallback() - keep component communication patterns explicit and minimal
---
Output Format
When finishing, report in this order: 1. Component(s) created or updated 2. Data access pattern chosen 3. Files changed 4. Accessibility / styling / testing notes 5. Next implementation or deploy step
Suggested shape:
LWC work: <summary>
Pattern: <wire / apex / graphql / lms / flow-screen>
Files: <paths>
Quality: <a11y, SLDS2, dark mode, Jest>
Next step: <deploy, add controller, or run tests>---
Local Development Server
Preview LWC components locally with hot reload — no deployment needed:
# Preview LWC components in isolation
sf lightning dev component --target-org <alias>
# Preview a Lightning Experience app locally
sf lightning dev app --target-org <alias>
# Preview an Experience Cloud site locally
sf lightning dev site --target-org <alias>In current SF CLI releases, these Local Dev commands are installed just-in-time the first time you run them. They are long-running processes that open a browser with live preview. Changes to .js, .html, and .css files auto-reload instantly. Requires an active org connection for data and Apex callouts.
---
Cross-Skill Integration
| Need | Delegate to | Reason |
|---|---|---|
| Apex controller or service | sf-apex | backend logic |
| embed in Flow screens | sf-flow | declarative orchestration |
| deploy component bundle | sf-deploy | org rollout |
| create metadata like message channels | sf-metadata | supporting metadata |
---
Reference Map
Start here
- references/component-patterns.md
- references/slds-design-guide.md
- references/lwc-best-practices.md
- references/scoring-and-testing.md
- references/jest-testing.md
Accessibility / performance / state
- references/accessibility-guide.md
- references/performance-guide.md
- references/state-management.md
- references/template-anti-patterns.md
Integration / advanced features
- references/lms-guide.md
- references/flow-integration-guide.md
- references/advanced-features.md
- references/async-notification-patterns.md
- references/triangle-pattern.md
- assets/
---
Score Guide
| Score | Meaning |
|---|---|
| 150+ | production-ready LWC bundle |
| 125–149 | strong component with minor polish left |
| 100–124 | functional but review recommended |
| < 100 | needs significant improvement |
/**
* APEX CONTROLLER TEMPLATE FOR LWC
*
* This template demonstrates @AuraEnabled methods for LWC with:
* - Cacheable methods for wire service
* - Non-cacheable methods for data mutations
* - Proper error handling
* - Security enforcement
* - Bulk-safe patterns
*
* Replace: LwcController → YourControllerName
*/
public with sharing class LwcController {
// ═══════════════════════════════════════════════════════════════════════
// CACHEABLE METHODS (For @wire service)
// ═══════════════════════════════════════════════════════════════════════
/**
* Get records for display in LWC
* Use with @wire decorator for automatic caching and refresh
*
* @param parentId - Parent record ID (optional filter)
* @param searchTerm - Search filter (optional)
* @param limitSize - Max records to return
* @return List of Account records
*/
@AuraEnabled(cacheable=true)
public static List<Account> getAccounts(Id parentId, String searchTerm, Integer limitSize) {
try {
Integer recordLimit = limitSize != null ? limitSize : 50;
String searchKey = String.isNotBlank(searchTerm)
? '%' + String.escapeSingleQuotes(searchTerm) + '%'
: '%';
return [
SELECT Id, Name, Industry, AnnualRevenue, Phone, CreatedDate
FROM Account
WHERE Name LIKE :searchKey
WITH SECURITY_ENFORCED
ORDER BY Name
LIMIT :recordLimit
];
} catch (Exception e) {
throw new AuraHandledException(e.getMessage());
}
}
/**
* Get single record by ID
*/
@AuraEnabled(cacheable=true)
public static Account getAccountById(Id accountId) {
try {
List<Account> accounts = [
SELECT Id, Name, Industry, AnnualRevenue, Phone, BillingAddress,
(SELECT Id, FirstName, LastName, Email FROM Contacts LIMIT 5)
FROM Account
WHERE Id = :accountId
WITH SECURITY_ENFORCED
LIMIT 1
];
if (accounts.isEmpty()) {
throw new AuraHandledException('Account not found');
}
return accounts[0];
} catch (Exception e) {
throw new AuraHandledException(e.getMessage());
}
}
/**
* Get picklist values for a field
*/
@AuraEnabled(cacheable=true)
public static List<PicklistOption> getIndustryOptions() {
List<PicklistOption> options = new List<PicklistOption>();
Schema.DescribeFieldResult fieldResult = Account.Industry.getDescribe();
List<Schema.PicklistEntry> entries = fieldResult.getPicklistValues();
for (Schema.PicklistEntry entry : entries) {
if (entry.isActive()) {
options.add(new PicklistOption(entry.getLabel(), entry.getValue()));
}
}
return options;
}
// ═══════════════════════════════════════════════════════════════════════
// NON-CACHEABLE METHODS (For data mutations)
// ═══════════════════════════════════════════════════════════════════════
/**
* Create a new account
*
* @param accountData - JSON string of account fields
* @return Created Account with Id
*/
@AuraEnabled
public static Account createAccount(String accountData) {
try {
// Parse JSON to Map
Map<String, Object> fieldMap = (Map<String, Object>) JSON.deserializeUntyped(accountData);
// Create Account record
Account newAccount = new Account();
newAccount.Name = (String) fieldMap.get('Name');
newAccount.Industry = (String) fieldMap.get('Industry');
newAccount.Phone = (String) fieldMap.get('Phone');
// Validate required fields
if (String.isBlank(newAccount.Name)) {
throw new AuraHandledException('Account name is required');
}
// Insert with security check
SObjectAccessDecision decision = Security.stripInaccessible(
AccessType.CREATABLE,
new List<Account>{ newAccount }
);
insert decision.getRecords();
return (Account) decision.getRecords()[0];
} catch (DmlException e) {
throw new AuraHandledException(e.getDmlMessage(0));
} catch (Exception e) {
throw new AuraHandledException(e.getMessage());
}
}
/**
* Update an existing account
*
* @param accountId - Record ID to update
* @param accountData - JSON string of fields to update
* @return Updated Account
*/
@AuraEnabled
public static Account updateAccount(Id accountId, String accountData) {
try {
// Verify record exists and user has access
List<Account> existing = [
SELECT Id FROM Account
WHERE Id = :accountId
WITH SECURITY_ENFORCED
LIMIT 1
];
if (existing.isEmpty()) {
throw new AuraHandledException('Account not found or access denied');
}
// Parse and apply updates
Map<String, Object> fieldMap = (Map<String, Object>) JSON.deserializeUntyped(accountData);
Account accountToUpdate = new Account(Id = accountId);
if (fieldMap.containsKey('Name')) {
accountToUpdate.Name = (String) fieldMap.get('Name');
}
if (fieldMap.containsKey('Industry')) {
accountToUpdate.Industry = (String) fieldMap.get('Industry');
}
if (fieldMap.containsKey('Phone')) {
accountToUpdate.Phone = (String) fieldMap.get('Phone');
}
// Update with security check
SObjectAccessDecision decision = Security.stripInaccessible(
AccessType.UPDATABLE,
new List<Account>{ accountToUpdate }
);
update decision.getRecords();
// Return refreshed record
return getAccountById(accountId);
} catch (DmlException e) {
throw new AuraHandledException(e.getDmlMessage(0));
} catch (Exception e) {
throw new AuraHandledException(e.getMessage());
}
}
/**
* Delete records
*
* @param recordIds - List of record IDs to delete
*/
@AuraEnabled
public static void deleteAccounts(List<Id> recordIds) {
try {
if (recordIds == null || recordIds.isEmpty()) {
throw new AuraHandledException('No records specified for deletion');
}
// Query records to verify access
List<Account> accountsToDelete = [
SELECT Id FROM Account
WHERE Id IN :recordIds
WITH SECURITY_ENFORCED
];
if (accountsToDelete.size() != recordIds.size()) {
throw new AuraHandledException('Some records not found or access denied');
}
delete accountsToDelete;
} catch (DmlException e) {
throw new AuraHandledException(e.getDmlMessage(0));
} catch (Exception e) {
throw new AuraHandledException(e.getMessage());
}
}
/**
* Bulk update records (for datatable inline editing)
*
* @param records - List of records with Id and fields to update
*/
@AuraEnabled
public static void updateRecords(List<Account> records) {
try {
if (records == null || records.isEmpty()) {
return;
}
// Security check
SObjectAccessDecision decision = Security.stripInaccessible(
AccessType.UPDATABLE,
records
);
update decision.getRecords();
} catch (DmlException e) {
throw new AuraHandledException(e.getDmlMessage(0));
} catch (Exception e) {
throw new AuraHandledException(e.getMessage());
}
}
// ═══════════════════════════════════════════════════════════════════════
// PAGINATED QUERY (For infinite scrolling)
// ═══════════════════════════════════════════════════════════════════════
/**
* Get paginated records for infinite scrolling
*
* @param limitSize - Records per page
* @param offset - Starting position
* @param sortBy - Field to sort by
* @param sortDirection - ASC or DESC
* @return PagedResult with records and total count
*/
@AuraEnabled(cacheable=true)
public static PagedResult getPagedAccounts(
Integer limitSize,
Integer offset,
String sortBy,
String sortDirection
) {
try {
Integer recordLimit = limitSize != null ? limitSize : 50;
Integer recordOffset = offset != null ? offset : 0;
String sortField = String.isNotBlank(sortBy) ? sortBy : 'Name';
String sortDir = sortDirection == 'desc' ? 'DESC' : 'ASC';
// Whitelist allowed sort fields to prevent SOQL injection
Set<String> allowedSortFields = new Set<String>{
'Name', 'Industry', 'AnnualRevenue', 'CreatedDate', 'Phone'
};
if (!allowedSortFields.contains(sortField)) {
sortField = 'Name';
}
// Build dynamic query
String query = 'SELECT Id, Name, Industry, AnnualRevenue, Phone, CreatedDate ' +
'FROM Account ' +
'WITH SECURITY_ENFORCED ' +
'ORDER BY ' + sortField + ' ' + sortDir + ' NULLS LAST ' +
'LIMIT :recordLimit OFFSET :recordOffset';
List<Account> records = Database.query(query);
// Get total count
Integer totalCount = [SELECT COUNT() FROM Account WITH SECURITY_ENFORCED];
return new PagedResult(records, totalCount);
} catch (Exception e) {
throw new AuraHandledException(e.getMessage());
}
}
// ═══════════════════════════════════════════════════════════════════════
// WRAPPER CLASSES
// ═══════════════════════════════════════════════════════════════════════
/**
* Wrapper for picklist options
*/
public class PicklistOption {
@AuraEnabled public String label;
@AuraEnabled public String value;
public PicklistOption(String label, String value) {
this.label = label;
this.value = value;
}
}
/**
* Wrapper for paginated results
*/
public class PagedResult {
@AuraEnabled public List<Account> records;
@AuraEnabled public Integer totalCount;
public PagedResult(List<Account> records, Integer totalCount) {
this.records = records;
this.totalCount = totalCount;
}
}
}
/**
* BASIC LWC COMPONENT TEMPLATE - CSS
*
* CSS in LWC is automatically scoped to the component.
* Use SLDS (Salesforce Lightning Design System) classes when possible.
*
* CSS Custom Properties (variables) can be exposed for theming.
*/
/* Host element styling */
:host {
display: block;
}
/* Custom properties for theming */
:host {
--card-background: var(--lwc-colorBackground, #ffffff);
--card-border-radius: var(--lwc-borderRadiusMedium, 0.25rem);
--item-hover-background: var(--lwc-colorBackgroundRowHover, #f3f3f3);
}
/* List item hover effect */
.slds-item:hover {
background-color: var(--item-hover-background);
cursor: pointer;
}
/* Smooth transitions */
.slds-tile {
transition: background-color 0.2s ease;
}
/* Loading state fade */
.slds-spinner {
opacity: 0.8;
}
/* Error state styling */
.slds-alert_error {
border-radius: var(--card-border-radius);
}
/* Empty state styling */
.empty-state {
padding: 2rem;
text-align: center;
}
/* Responsive adjustments */
@media (max-width: 480px) {
.slds-tile__detail {
padding: 0.5rem;
}
.slds-text-heading_small {
font-size: 0.875rem;
}
}
/* Focus states for accessibility */
a:focus {
outline: 2px solid var(--lwc-colorBorderBrand, #1589ee);
outline-offset: 2px;
border-radius: 0.25rem;
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.slds-item:hover {
background-color: highlight;
}
}
<!--
BASIC LWC COMPONENT TEMPLATE - HTML
Template demonstrates:
- Conditional rendering (if:true/if:false)
- Iteration (for:each)
- Event binding (onclick)
- Data binding ({property})
- Slots for composition
-->
<template>
<lightning-card title={cardTitle} icon-name="standard:record">
<!-- Card Actions Slot -->
<lightning-button-icon
slot="actions"
icon-name="utility:refresh"
alternative-text="Refresh"
onclick={handleRefresh}>
</lightning-button-icon>
<!-- Loading State -->
<template if:true={isLoading}>
<div class="slds-p-around_medium slds-align_absolute-center">
<lightning-spinner
alternative-text="Loading"
size="small">
</lightning-spinner>
</div>
</template>
<!-- Error State -->
<template if:true={error}>
<div class="slds-p-around_medium">
<div class="slds-notify slds-notify_alert slds-alert_error" role="alert">
<lightning-icon
icon-name="utility:error"
alternative-text="Error"
size="x-small"
class="slds-m-right_x-small">
</lightning-icon>
<span class="slds-assistive-text">Error</span>
<h2>{error}</h2>
</div>
</div>
</template>
<!-- Empty State -->
<template if:false={isLoading}>
<template if:false={hasRecords}>
<template if:false={error}>
<div class="slds-p-around_medium slds-text-align_center">
<lightning-icon
icon-name="utility:info"
alternative-text="Info"
size="small"
class="slds-m-bottom_small">
</lightning-icon>
<p class="slds-text-body_regular slds-text-color_weak">
No records found
</p>
</div>
</template>
</template>
</template>
<!-- Records List -->
<template if:true={hasRecords}>
<ul class="slds-has-dividers_around-space">
<template for:each={records} for:item="record">
<li key={record.Id} class="slds-item">
<article class="slds-tile slds-tile_board">
<div class="slds-tile__detail">
<a
href="javascript:void(0)"
data-id={record.Id}
data-name={record.Name}
onclick={handleRecordClick}
class="slds-text-link_reset">
<div class="slds-grid slds-grid_align-spread">
<div class="slds-col">
<p class="slds-text-heading_small">
{record.Name}
</p>
<template if:true={record.Description}>
<p class="slds-text-body_small slds-text-color_weak">
{record.Description}
</p>
</template>
</div>
<div class="slds-col slds-no-flex">
<lightning-icon
icon-name="utility:chevronright"
alternative-text="View"
size="x-small">
</lightning-icon>
</div>
</div>
</a>
</div>
</article>
</li>
</template>
</ul>
</template>
<!-- Footer Slot -->
<div slot="footer">
<template if:true={hasRecords}>
<p class="slds-text-body_small slds-text-color_weak">
Showing {recordCount} records
</p>
</template>
</div>
</lightning-card>
</template>
/**
* BASIC LWC COMPONENT TEMPLATE
*
* This template demonstrates a standard LWC component with:
* - Wire service for data fetching
* - Loading and error states
* - Event dispatching
* - Proper lifecycle management
*
* Replace: basicComponent → yourComponentName
* Replace: BasicComponent → YourComponentName
* Replace: Account → YourObject
*/
import { LightningElement, api, wire } from 'lwc';
import getRecords from '@salesforce/apex/BasicComponentController.getRecords';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
export default class BasicComponent extends LightningElement {
// ═══════════════════════════════════════════════════════════════════════
// PUBLIC API (@api) - Properties set by parent or App Builder
// ═══════════════════════════════════════════════════════════════════════
@api recordId; // Automatically populated on record pages
@api objectApiName; // Automatically populated on record pages
@api title = 'Records'; // Configurable in App Builder
// ═══════════════════════════════════════════════════════════════════════
// PRIVATE REACTIVE PROPERTIES
// ═══════════════════════════════════════════════════════════════════════
records = [];
error;
isLoading = true;
// ═══════════════════════════════════════════════════════════════════════
// WIRE SERVICE - Reactive data fetching
// ═══════════════════════════════════════════════════════════════════════
/**
* Wire service automatically calls Apex when recordId changes.
* The '$recordId' syntax makes it reactive to changes.
*/
@wire(getRecords, { parentId: '$recordId' })
wiredRecords({ error, data }) {
this.isLoading = false;
if (data) {
this.records = data;
this.error = undefined;
} else if (error) {
this.error = this.reduceErrors(error);
this.records = [];
}
}
// ═══════════════════════════════════════════════════════════════════════
// GETTERS - Computed properties for template
// ═══════════════════════════════════════════════════════════════════════
get hasRecords() {
return this.records && this.records.length > 0;
}
get recordCount() {
return this.records ? this.records.length : 0;
}
get cardTitle() {
return `${this.title} (${this.recordCount})`;
}
// ═══════════════════════════════════════════════════════════════════════
// LIFECYCLE HOOKS
// ═══════════════════════════════════════════════════════════════════════
connectedCallback() {
// Component inserted into DOM
// Good for: event listeners, subscriptions
console.log('BasicComponent connected');
}
renderedCallback() {
// DOM has been rendered
// Warning: Runs after every render - avoid heavy operations
// Use a flag to run once if needed
}
disconnectedCallback() {
// Component removed from DOM
// Good for: cleanup, unsubscribe, remove event listeners
console.log('BasicComponent disconnected');
}
// ═══════════════════════════════════════════════════════════════════════
// EVENT HANDLERS
// ═══════════════════════════════════════════════════════════════════════
handleRecordClick(event) {
const recordId = event.currentTarget.dataset.id;
const recordName = event.currentTarget.dataset.name;
// Dispatch custom event for parent components
this.dispatchEvent(new CustomEvent('recordselected', {
detail: {
recordId,
recordName
},
bubbles: true, // Event bubbles up through DOM
composed: true // Event crosses shadow DOM boundary
}));
}
handleRefresh() {
// Force refresh by triggering wire service
// Note: Wire with cacheable=true caches results
// Use refreshApex() for explicit refresh
this.isLoading = true;
// Wire will re-execute when recordId changes
}
// ═══════════════════════════════════════════════════════════════════════
// UTILITY METHODS
// ═══════════════════════════════════════════════════════════════════════
showToast(title, message, variant = 'info') {
this.dispatchEvent(new ShowToastEvent({
title,
message,
variant // 'success', 'warning', 'error', 'info'
}));
}
reduceErrors(errors) {
if (!Array.isArray(errors)) {
errors = [errors];
}
return errors
.filter(error => !!error)
.map(error => {
if (typeof error === 'string') {
return error;
}
if (error.body) {
if (typeof error.body.message === 'string') {
return error.body.message;
}
if (Array.isArray(error.body)) {
return error.body.map(e => e.message).join(', ');
}
}
if (error.message) {
return error.message;
}
return JSON.stringify(error);
})
.join(', ');
}
}
<?xml version="1.0" encoding="UTF-8"?>
<!--
BASIC LWC COMPONENT TEMPLATE - METADATA
This file configures where and how the component can be used.
targets: Where the component is available
targetConfigs: Configuration for each target
properties: Configurable attributes in App Builder
-->
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>66.0</apiVersion>
<isExposed>true</isExposed>
<masterLabel>Basic Component</masterLabel>
<description>A reusable component that displays a list of records with loading, error, and empty states.</description>
<!-- Where this component can be used -->
<targets>
<!-- Standard Salesforce pages -->
<target>lightning__RecordPage</target>
<target>lightning__AppPage</target>
<target>lightning__HomePage</target>
<!-- Flow integration -->
<target>lightning__FlowScreen</target>
<!-- Experience Cloud (Communities) -->
<target>lightningCommunity__Page</target>
<target>lightningCommunity__Default</target>
<!-- Utility Bar -->
<target>lightning__UtilityBar</target>
</targets>
<!-- Configuration for each target -->
<targetConfigs>
<!-- Record Page Configuration -->
<targetConfig targets="lightning__RecordPage">
<!-- Limit to specific objects (optional) -->
<objects>
<object>Account</object>
<object>Contact</object>
<object>Opportunity</object>
</objects>
<!-- Configurable properties in App Builder -->
<property
name="title"
type="String"
label="Card Title"
description="Title displayed on the card header"
default="Related Records"/>
<property
name="maxRecords"
type="Integer"
label="Maximum Records"
description="Maximum number of records to display"
default="10"
min="1"
max="100"/>
<property
name="showRefreshButton"
type="Boolean"
label="Show Refresh Button"
description="Display the refresh button in the header"
default="true"/>
</targetConfig>
<!-- App Page / Home Page Configuration -->
<targetConfig targets="lightning__AppPage,lightning__HomePage">
<property
name="title"
type="String"
label="Card Title"
default="My Records"/>
<property
name="objectApiName"
type="String"
label="Object API Name"
description="API name of the object to display"
default="Account"
datasource="apex://ObjectPicklistProvider"/>
</targetConfig>
<!-- Flow Screen Configuration -->
<targetConfig targets="lightning__FlowScreen">
<!-- Input properties (from Flow to component) -->
<property
name="recordId"
type="String"
label="Record ID"
description="ID of the parent record"
role="inputOnly"/>
<property
name="title"
type="String"
label="Title"
role="inputOnly"
default="Select a Record"/>
<!-- Output properties (from component to Flow) -->
<property
name="selectedRecordId"
type="String"
label="Selected Record ID"
description="ID of the selected record"
role="outputOnly"/>
<property
name="selectedRecordName"
type="String"
label="Selected Record Name"
role="outputOnly"/>
</targetConfig>
<!-- Experience Cloud Configuration -->
<targetConfig targets="lightningCommunity__Default">
<property
name="title"
type="String"
label="Title"
default="Records"/>
<!-- Community-specific theme property -->
<property
name="variant"
type="String"
label="Visual Variant"
default="standard"
datasource="standard,compact,minimal"/>
</targetConfig>
</targetConfigs>
</LightningComponentBundle>
<!--
DATATABLE LWC COMPONENT TEMPLATE - HTML
Features demonstrated:
- Column sorting
- Row selection
- Inline editing
- Row actions
- Infinite scrolling
- Bulk actions
-->
<template>
<lightning-card title={tableTitle} icon-name="standard:account">
<!-- Card Actions -->
<div slot="actions">
<!-- Bulk Delete Button (shown when rows selected) -->
<template if:true={hasSelection}>
<lightning-button
variant="destructive"
label={selectedCount}
icon-name="utility:delete"
onclick={handleBulkDelete}
class="slds-m-right_x-small">
</lightning-button>
</template>
<!-- Refresh Button -->
<lightning-button-icon
icon-name="utility:refresh"
alternative-text="Refresh"
onclick={handleRefresh}>
</lightning-button-icon>
</div>
<!-- Loading Spinner -->
<template if:true={isLoading}>
<div class="slds-is-relative" style="height: 100px;">
<lightning-spinner
alternative-text="Loading"
size="medium">
</lightning-spinner>
</div>
</template>
<!-- Error State -->
<template if:true={error}>
<div class="slds-p-around_medium">
<div class="slds-notify slds-notify_alert slds-alert_error" role="alert">
<span class="slds-assistive-text">Error</span>
<h2>{error}</h2>
</div>
</div>
</template>
<!-- Data Table -->
<template if:true={hasData}>
<div class="slds-p-around_small">
<lightning-datatable
key-field="Id"
data={data}
columns={columns}
sorted-by={sortBy}
sorted-direction={sortDirection}
onsort={handleSort}
onrowselection={handleRowSelection}
onrowaction={handleRowAction}
oncellchange={handleCellChange}
onsave={handleSave}
oncancel={handleCancel}
draft-values={draftValues}
show-row-number-column={showRowNumbers}
enable-infinite-loading={enableInfiniteScroll}
onloadmore={loadMoreData}
max-row-selection="100"
hide-checkbox-column="false">
</lightning-datatable>
<!-- Load More Status -->
<template if:true={loadMoreStatus}>
<div class="slds-text-align_center slds-p-top_small">
<span class="slds-text-color_weak">{loadMoreStatus}</span>
</div>
</template>
</div>
</template>
<!-- Empty State -->
<template if:false={hasData}>
<template if:false={isLoading}>
<template if:false={error}>
<div class="slds-p-around_medium slds-text-align_center">
<div class="slds-illustration slds-illustration_small">
<lightning-icon
icon-name="utility:info"
size="large"
alternative-text="No data"
class="slds-m-bottom_small">
</lightning-icon>
<div class="slds-text-longform">
<h3 class="slds-text-heading_medium">No Records Found</h3>
<p class="slds-text-body_regular slds-text-color_weak">
There are no records to display.
</p>
</div>
</div>
</div>
</template>
</template>
</template>
</lightning-card>
</template>
/**
* DATATABLE LWC COMPONENT TEMPLATE
*
* This template demonstrates lightning-datatable with:
* - Column configuration
* - Row selection (single/multi)
* - Inline editing
* - Sorting
* - Infinite scrolling
* - Row actions
*
* Replace: datatableComponent → yourComponentName
*/
import { LightningElement, api, wire, track } from 'lwc';
import { refreshApex } from '@salesforce/apex';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import { NavigationMixin } from 'lightning/navigation';
import getRecords from '@salesforce/apex/DatatableController.getRecords';
import updateRecords from '@salesforce/apex/DatatableController.updateRecords';
import deleteRecords from '@salesforce/apex/DatatableController.deleteRecords';
// Row actions
const actions = [
{ label: 'View', name: 'view' },
{ label: 'Edit', name: 'edit' },
{ label: 'Delete', name: 'delete' }
];
// Column definitions
const COLUMNS = [
{
label: 'Account Name',
fieldName: 'nameUrl',
type: 'url',
typeAttributes: {
label: { fieldName: 'Name' },
target: '_blank'
},
sortable: true
},
{
label: 'Industry',
fieldName: 'Industry',
type: 'text',
sortable: true,
editable: true
},
{
label: 'Annual Revenue',
fieldName: 'AnnualRevenue',
type: 'currency',
typeAttributes: {
currencyCode: 'USD',
minimumFractionDigits: 0
},
sortable: true,
editable: true,
cellAttributes: { alignment: 'right' }
},
{
label: 'Phone',
fieldName: 'Phone',
type: 'phone',
editable: true
},
{
label: 'Created Date',
fieldName: 'CreatedDate',
type: 'date',
typeAttributes: {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
},
sortable: true
},
{
label: 'Active',
fieldName: 'IsActive__c',
type: 'boolean',
editable: true
},
{
type: 'action',
typeAttributes: { rowActions: actions }
}
];
export default class DatatableComponent extends NavigationMixin(LightningElement) {
// ═══════════════════════════════════════════════════════════════════════
// PUBLIC API
// ═══════════════════════════════════════════════════════════════════════
@api recordId;
@api maxRows = 50;
@api enableInfiniteScroll = false;
@api showRowNumbers = false;
// ═══════════════════════════════════════════════════════════════════════
// PRIVATE PROPERTIES
// ═══════════════════════════════════════════════════════════════════════
columns = COLUMNS;
@track data = [];
error;
isLoading = true;
// Sorting
sortBy;
sortDirection = 'asc';
// Selection
selectedRows = [];
draftValues = [];
// Infinite scroll
offset = 0;
loadMoreStatus;
totalRecords = 0;
// Wire result for refresh
wiredRecordsResult;
// ═══════════════════════════════════════════════════════════════════════
// WIRE SERVICE
// ═══════════════════════════════════════════════════════════════════════
@wire(getRecords, {
parentId: '$recordId',
limitSize: '$maxRows',
offset: '$offset',
sortBy: '$sortBy',
sortDirection: '$sortDirection'
})
wiredRecords(result) {
this.wiredRecordsResult = result;
this.isLoading = false;
const { data, error } = result;
if (data) {
// Add URL field for navigation
this.data = data.records.map(record => ({
...record,
nameUrl: `/${record.Id}`
}));
this.totalRecords = data.totalCount;
this.error = undefined;
} else if (error) {
this.error = this.reduceErrors(error);
this.data = [];
}
}
// ═══════════════════════════════════════════════════════════════════════
// GETTERS
// ═══════════════════════════════════════════════════════════════════════
get hasData() {
return this.data && this.data.length > 0;
}
get hasSelection() {
return this.selectedRows && this.selectedRows.length > 0;
}
get selectedCount() {
return this.selectedRows.length;
}
get tableTitle() {
return `Accounts (${this.totalRecords})`;
}
// ═══════════════════════════════════════════════════════════════════════
// SORTING
// ═══════════════════════════════════════════════════════════════════════
handleSort(event) {
this.sortBy = event.detail.fieldName;
this.sortDirection = event.detail.sortDirection;
this.isLoading = true;
// Wire will automatically refetch with new sort params
}
// ═══════════════════════════════════════════════════════════════════════
// SELECTION
// ═══════════════════════════════════════════════════════════════════════
handleRowSelection(event) {
this.selectedRows = event.detail.selectedRows;
// Dispatch event for parent
this.dispatchEvent(new CustomEvent('selection', {
detail: {
selectedRows: this.selectedRows,
selectedIds: this.selectedRows.map(row => row.Id)
}
}));
}
// ═══════════════════════════════════════════════════════════════════════
// ROW ACTIONS
// ═══════════════════════════════════════════════════════════════════════
handleRowAction(event) {
const actionName = event.detail.action.name;
const row = event.detail.row;
switch (actionName) {
case 'view':
this.navigateToView(row.Id);
break;
case 'edit':
this.navigateToEdit(row.Id);
break;
case 'delete':
this.handleDeleteRow(row);
break;
default:
break;
}
}
navigateToView(recordId) {
this[NavigationMixin.Navigate]({
type: 'standard__recordPage',
attributes: {
recordId: recordId,
objectApiName: 'Account',
actionName: 'view'
}
});
}
navigateToEdit(recordId) {
this[NavigationMixin.Navigate]({
type: 'standard__recordPage',
attributes: {
recordId: recordId,
objectApiName: 'Account',
actionName: 'edit'
}
});
}
async handleDeleteRow(row) {
if (!confirm(`Delete ${row.Name}?`)) {
return;
}
this.isLoading = true;
try {
await deleteRecords({ recordIds: [row.Id] });
this.showToast('Success', 'Record deleted', 'success');
await this.refresh();
} catch (error) {
this.showToast('Error', this.reduceErrors(error), 'error');
} finally {
this.isLoading = false;
}
}
// ═══════════════════════════════════════════════════════════════════════
// INLINE EDITING
// ═══════════════════════════════════════════════════════════════════════
handleCellChange(event) {
this.draftValues = event.detail.draftValues;
}
async handleSave(event) {
const updatedFields = event.detail.draftValues;
this.isLoading = true;
try {
await updateRecords({ records: updatedFields });
this.showToast('Success', 'Records updated', 'success');
this.draftValues = [];
await this.refresh();
} catch (error) {
this.showToast('Error', this.reduceErrors(error), 'error');
} finally {
this.isLoading = false;
}
}
handleCancel() {
this.draftValues = [];
}
// ═══════════════════════════════════════════════════════════════════════
// INFINITE SCROLLING
// ═══════════════════════════════════════════════════════════════════════
loadMoreData(event) {
if (!this.enableInfiniteScroll) return;
const { target } = event;
target.isLoading = true;
if (this.data.length >= this.totalRecords) {
target.enableInfiniteLoading = false;
this.loadMoreStatus = 'All records loaded';
target.isLoading = false;
return;
}
this.offset = this.data.length;
// Wire will load more data
}
// ═══════════════════════════════════════════════════════════════════════
// BULK ACTIONS
// ═══════════════════════════════════════════════════════════════════════
async handleBulkDelete() {
if (!this.hasSelection) return;
const selectedIds = this.selectedRows.map(row => row.Id);
if (!confirm(`Delete ${selectedIds.length} records?`)) {
return;
}
this.isLoading = true;
try {
await deleteRecords({ recordIds: selectedIds });
this.showToast('Success', `${selectedIds.length} records deleted`, 'success');
this.selectedRows = [];
await this.refresh();
} catch (error) {
this.showToast('Error', this.reduceErrors(error), 'error');
} finally {
this.isLoading = false;
}
}
// ═══════════════════════════════════════════════════════════════════════
// UTILITIES
// ═══════════════════════════════════════════════════════════════════════
async refresh() {
this.isLoading = true;
await refreshApex(this.wiredRecordsResult);
this.isLoading = false;
}
handleRefresh() {
this.refresh();
}
showToast(title, message, variant) {
this.dispatchEvent(new ShowToastEvent({ title, message, variant }));
}
reduceErrors(errors) {
if (!Array.isArray(errors)) errors = [errors];
return errors
.filter(e => !!e)
.map(e => e.body?.message || e.message || JSON.stringify(e))
.join(', ');
}
}
/**
* Flow Screen Component Styles
* Uses SLDS 2 global styling hooks for dark mode compatibility
*/
/* Record Tile - Interactive Selection */
.record-tile {
cursor: pointer;
transition: all 0.2s ease;
position: relative;
border: 2px solid var(--slds-g-color-border-base-1, #e5e5e5);
background-color: var(--slds-g-color-neutral-base-100, #ffffff);
}
.record-tile:hover {
border-color: var(--slds-g-color-brand-base-50, #0176d3);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.record-tile:focus {
outline: none;
border-color: var(--slds-g-color-brand-base-50, #0176d3);
box-shadow: 0 0 0 3px var(--slds-g-color-brand-base-30, #aacbff);
}
/* Selected State */
.record-tile[aria-pressed="true"],
.record-tile.slds-is-selected {
border-color: var(--slds-g-color-brand-base-50, #0176d3);
background-color: var(--slds-g-color-brand-base-10, #e5f2ff);
}
/* Selection Check Icon */
.selected-icon {
position: absolute;
top: 0.5rem;
right: 0.5rem;
color: var(--slds-g-color-brand-base-50, #0176d3);
}
/* Card Footer Spacing */
.slds-card__footer {
border-top: 1px solid var(--slds-g-color-border-base-1, #e5e5e5);
padding: var(--slds-g-spacing-medium, 1rem);
background-color: var(--slds-g-color-neutral-base-95, #f3f3f3);
}
/* Selection Summary Box */
.slds-theme_info {
background-color: var(--slds-g-color-brand-base-10, #e5f2ff);
border: 1px solid var(--slds-g-color-brand-base-30, #aacbff);
}
/* Loading Spinner Container */
.slds-align_absolute-center {
min-height: 200px;
}
/* Keyboard Focus Visible */
.record-tile:focus-visible {
outline: 2px solid var(--slds-g-color-brand-base-50, #0176d3);
outline-offset: 2px;
}
<template>
<div class="slds-card">
<!-- Header -->
<div class="slds-card__header slds-grid">
<header class="slds-media slds-media_center slds-has-flexi-truncate">
<div class="slds-media__body">
<h2 class="slds-card__header-title">
<span>{inputLabel}</span>
</h2>
</div>
</header>
</div>
<!-- Body -->
<div class="slds-card__body slds-card__body_inner">
<!-- Loading State -->
<template lwc:if={isLoading}>
<div class="slds-align_absolute-center slds-p-around_large">
<lightning-spinner alternative-text="Loading" size="medium"></lightning-spinner>
</div>
</template>
<!-- Error State -->
<template lwc:if={error}>
<div class="slds-notify slds-notify_alert slds-alert_error" role="alert">
<span class="slds-assistive-text">Error</span>
<lightning-icon icon-name="utility:error" size="small" alternative-text="Error"></lightning-icon>
<h2 class="slds-m-left_x-small">{error}</h2>
</div>
</template>
<!-- Validation Error -->
<template lwc:if={errorMessage}>
<div class="slds-notify slds-notify_alert slds-alert_warning slds-m-bottom_small" role="alert">
<span class="slds-assistive-text">Warning</span>
<lightning-icon icon-name="utility:warning" size="small" alternative-text="Warning"></lightning-icon>
<h2 class="slds-m-left_x-small">{errorMessage}</h2>
</div>
</template>
<!-- Empty State -->
<template lwc:if={!isLoading}>
<template lwc:if={!hasRecords}>
<div class="slds-illustration slds-illustration_small">
<div class="slds-text-longform">
<h3 class="slds-text-heading_medium">No Records Found</h3>
<p class="slds-text-body_regular">There are no records to display.</p>
</div>
</div>
</template>
<!-- Record List -->
<template lwc:if={hasRecords}>
<div class="slds-grid slds-wrap slds-gutters_small">
<template for:each={records} for:item="record">
<div key={record.id} class="slds-col slds-size_1-of-1 slds-medium-size_1-of-2 slds-large-size_1-of-3 slds-m-bottom_small">
<!-- Record Tile (clickable) -->
<div
class="record-tile slds-box slds-box_link"
data-id={record.id}
data-name={record.name}
onclick={handleRecordSelect}
role="button"
tabindex="0"
aria-pressed={record.isSelected}>
<article class="slds-tile">
<h3 class="slds-tile__title slds-truncate" title={record.name}>
<span>{record.name}</span>
</h3>
<div class="slds-tile__detail">
<p class="slds-text-body_small slds-text-color_weak">
{record.description}
</p>
</div>
</article>
<!-- Selection Indicator -->
<template lwc:if={record.isSelected}>
<lightning-icon
icon-name="utility:check"
size="small"
class="selected-icon"
alternative-text="Selected">
</lightning-icon>
</template>
</div>
</div>
</template>
</div>
<!-- Selection Summary -->
<template lwc:if={hasSelection}>
<div class="slds-m-top_medium slds-p-around_small slds-theme_info slds-box">
<p class="slds-text-body_regular">
<lightning-icon icon-name="utility:check" size="x-small" alternative-text="Selected" class="slds-m-right_x-small"></lightning-icon>
<strong>Selected:</strong> {selectedRecordName}
</p>
</div>
</template>
</template>
</template>
</div>
<!-- Footer with Navigation Buttons -->
<footer class="slds-card__footer">
<div class="slds-grid slds-grid_align-spread">
<!-- Back Button (left aligned) -->
<div>
<template lwc:if={showBackButton}>
<lightning-button
label="Back"
onclick={handleBack}
icon-name="utility:back">
</lightning-button>
</template>
</div>
<!-- Next/Finish Buttons (right aligned) -->
<div class="slds-grid slds-grid_align-end slds-gutters_x-small">
<template lwc:if={canPause}>
<lightning-button
label="Pause"
onclick={handlePause}
icon-name="utility:pause">
</lightning-button>
</template>
<template lwc:if={showNextButton}>
<lightning-button
label="Next"
variant="brand"
onclick={handleNext}
icon-name="utility:forward"
icon-position="right"
disabled={!hasSelection}>
</lightning-button>
</template>
<template lwc:if={showFinishButton}>
<lightning-button
label="Finish"
variant="brand"
onclick={handleFinish}
icon-name="utility:check"
disabled={!hasSelection}>
</lightning-button>
</template>
</div>
</div>
</footer>
</div>
</template>
/**
* Flow Screen Component Template
*
* Demonstrates complete Flow integration patterns:
* - @api properties with inputOnly/outputOnly roles
* - FlowAttributeChangeEvent for output binding
* - FlowNavigationFinishEvent for programmatic navigation
* - availableActions for navigation state awareness
* - @AuraEnabled Apex integration for data operations
*
* @see https://developer.salesforce.com/docs/component-library/bundle/lightning-flow-support
*/
import { LightningElement, api, wire } from 'lwc';
import { FlowAttributeChangeEvent, FlowNavigationFinishEvent } from 'lightning/flowSupport';
// Apex imports (uncomment and modify as needed)
// import getRecords from '@salesforce/apex/FlowScreenController.getRecords';
// import processRecord from '@salesforce/apex/FlowScreenController.processRecord';
export default class FlowScreenComponent extends LightningElement {
// ═══════════════════════════════════════════════════════════════════════
// FLOW INPUT PROPERTIES (inputOnly - Flow → Component)
// These receive values FROM the Flow. Changes in Flow update the component.
// ═══════════════════════════════════════════════════════════════════════
/**
* Record ID from Flow context (e.g., from Record-Triggered Flow)
* Maps to: Flow variable or $Record.Id
*/
@api recordId;
/**
* Object API Name for context-aware behavior
* Maps to: Flow variable or $Record.Object
*/
@api objectApiName;
/**
* Custom label configurable in Flow Builder
* Maps to: Flow constant, variable, or direct text
*/
@api inputLabel = 'Select a Record';
/**
* Available navigation actions - automatically populated by Flow runtime
* Possible values: ['NEXT', 'BACK', 'FINISH', 'PAUSE']
* Use this to conditionally show/hide navigation buttons
*/
@api availableActions = [];
// ═══════════════════════════════════════════════════════════════════════
// FLOW OUTPUT PROPERTIES (outputOnly - Component → Flow)
// These send values BACK to the Flow. Use FlowAttributeChangeEvent to update.
// ═══════════════════════════════════════════════════════════════════════
/**
* Selected record ID - output to Flow
* IMPORTANT: Use FlowAttributeChangeEvent to notify Flow of changes
*/
@api selectedRecordId;
/**
* Selected record name - output to Flow
*/
@api selectedRecordName;
/**
* Boolean flag indicating completion - useful for Flow decisions
*/
@api isComplete = false;
/**
* Error message if validation fails - Flow can use in fault path
*/
@api errorMessage;
// ═══════════════════════════════════════════════════════════════════════
// PRIVATE REACTIVE PROPERTIES
// ═══════════════════════════════════════════════════════════════════════
records = [];
error;
isLoading = true;
_selectedId;
// ═══════════════════════════════════════════════════════════════════════
// LIFECYCLE HOOKS
// ═══════════════════════════════════════════════════════════════════════
connectedCallback() {
// Initialize component when added to DOM
this.loadData();
}
// ═══════════════════════════════════════════════════════════════════════
// DATA LOADING
// ═══════════════════════════════════════════════════════════════════════
/**
* Load data - replace with @wire or imperative Apex call
*/
async loadData() {
this.isLoading = true;
try {
// Example: Load records from Apex
// this.records = await getRecords({ parentId: this.recordId });
// Placeholder data for template
this.records = [
{ id: '001xx000000001', name: 'Sample Record 1', description: 'Description 1' },
{ id: '001xx000000002', name: 'Sample Record 2', description: 'Description 2' },
{ id: '001xx000000003', name: 'Sample Record 3', description: 'Description 3' }
];
this.error = undefined;
} catch (err) {
this.error = this.reduceErrors(err);
this.records = [];
} finally {
this.isLoading = false;
}
}
// ═══════════════════════════════════════════════════════════════════════
// FLOW OUTPUT - FlowAttributeChangeEvent
// CRITICAL: This is how you send values back to Flow variables
// ═══════════════════════════════════════════════════════════════════════
/**
* Handle record selection and notify Flow
* @param {Event} event - Click event from record tile
*/
handleRecordSelect(event) {
const recordId = event.currentTarget.dataset.id;
const recordName = event.currentTarget.dataset.name;
// Update local state
this._selectedId = recordId;
this.selectedRecordId = recordId;
this.selectedRecordName = recordName;
// ─────────────────────────────────────────────────────────────────
// CRITICAL: Dispatch FlowAttributeChangeEvent for EACH output
// This notifies the Flow runtime that output values have changed
// The first parameter is the @api property name (case-sensitive)
// The second parameter is the new value
// ─────────────────────────────────────────────────────────────────
this.dispatchEvent(new FlowAttributeChangeEvent(
'selectedRecordId',
this.selectedRecordId
));
this.dispatchEvent(new FlowAttributeChangeEvent(
'selectedRecordName',
this.selectedRecordName
));
}
/**
* Update completion status and notify Flow
* @param {Boolean} complete - Whether the screen is complete
*/
setComplete(complete) {
this.isComplete = complete;
this.dispatchEvent(new FlowAttributeChangeEvent('isComplete', complete));
}
/**
* Set error message and notify Flow
* @param {String} message - Error message
*/
setError(message) {
this.errorMessage = message;
this.dispatchEvent(new FlowAttributeChangeEvent('errorMessage', message));
}
// ═══════════════════════════════════════════════════════════════════════
// FLOW NAVIGATION - FlowNavigationFinishEvent
// Programmatically trigger Flow navigation (Next, Back, Finish, Pause)
// ═══════════════════════════════════════════════════════════════════════
/**
* Navigate to next screen
* Only works if 'NEXT' is in availableActions
*/
handleNext() {
// Validate before navigation
if (!this.selectedRecordId) {
this.setError('Please select a record before proceeding.');
return;
}
// Clear any previous errors
this.setError(null);
// Mark as complete
this.setComplete(true);
// Check if NEXT action is available
if (this.canGoNext) {
// Dispatch navigation event - Flow will handle the rest
this.dispatchEvent(new FlowNavigationFinishEvent('NEXT'));
}
}
/**
* Navigate to previous screen
* Only works if 'BACK' is in availableActions
*/
handleBack() {
if (this.canGoBack) {
this.dispatchEvent(new FlowNavigationFinishEvent('BACK'));
}
}
/**
* Finish the flow (for final screens)
* Only works if 'FINISH' is in availableActions
*/
handleFinish() {
// Validate before finishing
if (!this.selectedRecordId) {
this.setError('Please select a record before finishing.');
return;
}
this.setComplete(true);
if (this.canFinish) {
this.dispatchEvent(new FlowNavigationFinishEvent('FINISH'));
}
}
/**
* Pause the flow (for pausable flows)
* Only works if 'PAUSE' is in availableActions
*/
handlePause() {
if (this.canPause) {
this.dispatchEvent(new FlowNavigationFinishEvent('PAUSE'));
}
}
// ═══════════════════════════════════════════════════════════════════════
// APEX INTEGRATION (Optional)
// Use @AuraEnabled methods for complex operations
// ═══════════════════════════════════════════════════════════════════════
/**
* Example: Process selection with Apex before navigation
*/
async handleProcessAndContinue() {
this.isLoading = true;
try {
// Call Apex to process the selection
// const result = await processRecord({
// recordId: this.selectedRecordId,
// operation: 'validate'
// });
// Simulate successful processing
const result = { success: true, message: 'Validated successfully' };
if (result.success) {
this.setComplete(true);
this.handleNext();
} else {
this.setError(result.message);
}
} catch (err) {
this.setError(this.reduceErrors(err));
} finally {
this.isLoading = false;
}
}
// ═══════════════════════════════════════════════════════════════════════
// COMPUTED PROPERTIES
// ═══════════════════════════════════════════════════════════════════════
get hasRecords() {
return this.records && this.records.length > 0;
}
get hasSelection() {
return !!this._selectedId;
}
get canGoBack() {
return this.availableActions.includes('BACK');
}
get canGoNext() {
return this.availableActions.includes('NEXT');
}
get canFinish() {
return this.availableActions.includes('FINISH');
}
get canPause() {
return this.availableActions.includes('PAUSE');
}
get showBackButton() {
return this.canGoBack;
}
get showNextButton() {
return this.canGoNext;
}
get showFinishButton() {
return this.canFinish && !this.canGoNext;
}
/**
* Compute selected class for record tiles
*/
getRecordClass(recordId) {
return this._selectedId === recordId
? 'slds-box slds-box_link slds-theme_shade slds-is-selected'
: 'slds-box slds-box_link';
}
// ═══════════════════════════════════════════════════════════════════════
// UTILITIES
// ═══════════════════════════════════════════════════════════════════════
/**
* Reduce various error formats to a string
* @param {*} errors - Error object(s)
* @returns {String} - Formatted error message
*/
reduceErrors(errors) {
if (!Array.isArray(errors)) {
errors = [errors];
}
return errors
.filter(error => !!error)
.map(error => {
if (error.body?.message) return error.body.message;
if (error.message) return error.message;
return JSON.stringify(error);
})
.join('; ');
}
}
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>66.0</apiVersion>
<isExposed>true</isExposed>
<masterLabel>Flow Screen Component</masterLabel>
<description>Component demonstrating complete Flow integration patterns with FlowAttributeChangeEvent and FlowNavigationFinishEvent</description>
<targets>
<!-- Primary target: Flow Screens -->
<target>lightning__FlowScreen</target>
</targets>
<targetConfigs>
<targetConfig targets="lightning__FlowScreen">
<!--
═══════════════════════════════════════════════════════════════════════════
INPUT PROPERTIES (Flow → Component)
These receive values FROM the Flow.
Roles:
- inputOnly: Flow can set, component cannot change (default for inputs)
- outputOnly: Component sets, Flow receives
- (no role): Bidirectional - not recommended, use explicit roles
═══════════════════════════════════════════════════════════════════════════
-->
<property
name="recordId"
type="String"
label="Record ID"
description="ID of the parent record for context (e.g., from $Record.Id)"
role="inputOnly"/>
<property
name="objectApiName"
type="String"
label="Object API Name"
description="API name of the object (e.g., from $Record.Object)"
role="inputOnly"/>
<property
name="inputLabel"
type="String"
label="Display Label"
description="Label to display in the component header"
default="Select a Record"
role="inputOnly"/>
<!--
═══════════════════════════════════════════════════════════════════════════
OUTPUT PROPERTIES (Component → Flow)
These send values BACK to the Flow.
Component must dispatch FlowAttributeChangeEvent to update these.
Flow accesses these via: {!Get_Screen.outputPropertyName}
═══════════════════════════════════════════════════════════════════════════
-->
<property
name="selectedRecordId"
type="String"
label="Selected Record ID"
description="ID of the record selected by the user"
role="outputOnly"/>
<property
name="selectedRecordName"
type="String"
label="Selected Record Name"
description="Name of the selected record"
role="outputOnly"/>
<property
name="isComplete"
type="Boolean"
label="Is Complete"
description="Whether the user has completed their selection"
default="false"
role="outputOnly"/>
<property
name="errorMessage"
type="String"
label="Error Message"
description="Error message if validation fails"
role="outputOnly"/>
</targetConfig>
</targetConfigs>
</LightningComponentBundle>
<!--
FORM LWC COMPONENT TEMPLATE - HTML
This template shows two approaches:
1. lightning-record-edit-form (standard, recommended)
2. Custom form with lightning-input (for complex scenarios)
Choose based on your requirements.
-->
<template>
<lightning-card title={cardTitle} icon-name="standard:account">
<!-- Loading Overlay -->
<template if:true={isLoading}>
<lightning-spinner
alternative-text="Loading"
size="medium"
class="slds-is-absolute">
</lightning-spinner>
</template>
<!--
═══════════════════════════════════════════════════════════════════════
OPTION 1: LIGHTNING-RECORD-EDIT-FORM (Recommended)
Pros:
- Automatic FLS enforcement
- Built-in validation
- Handles record types
- Less code
Cons:
- Less control over layout
- Limited custom validation
═══════════════════════════════════════════════════════════════════════
-->
<lightning-record-edit-form
object-api-name={objectApiName}
record-id={recordId}
record-type-id={recordTypeId}
onload={handleLoad}
onsubmit={handleSubmit}
onsuccess={handleSuccess}
onerror={handleError}>
<!-- Error Messages Display -->
<lightning-messages></lightning-messages>
<div class="slds-p-around_medium">
<!-- Form Fields -->
<div class="slds-grid slds-wrap slds-gutters">
<div class="slds-col slds-size_1-of-1 slds-medium-size_1-of-2">
<lightning-input-field
field-name={nameField}
required>
</lightning-input-field>
</div>
<div class="slds-col slds-size_1-of-1 slds-medium-size_1-of-2">
<lightning-input-field
field-name={industryField}>
</lightning-input-field>
</div>
<div class="slds-col slds-size_1-of-1 slds-medium-size_1-of-2">
<lightning-input-field
field-name={phoneField}>
</lightning-input-field>
</div>
</div>
<!-- Form Buttons -->
<div class="slds-m-top_medium">
<lightning-button
variant="neutral"
label="Cancel"
onclick={handleCancel}
class="slds-m-right_x-small">
</lightning-button>
<lightning-button
variant="neutral"
label="Reset"
onclick={handleReset}
class="slds-m-right_x-small">
</lightning-button>
<lightning-button
variant="brand"
type="submit"
label={submitButtonLabel}>
</lightning-button>
</div>
</div>
</lightning-record-edit-form>
<!--
═══════════════════════════════════════════════════════════════════════
OPTION 2: CUSTOM FORM (Alternative - uncomment to use)
Pros:
- Full control over layout
- Complex validation logic
- Multiple objects in one form
Cons:
- Manual FLS checking
- More code to maintain
═══════════════════════════════════════════════════════════════════════
-->
<!--
<div class="slds-p-around_medium custom-form">
<div class="slds-grid slds-wrap slds-gutters">
<div class="slds-col slds-size_1-of-1 slds-medium-size_1-of-2">
<lightning-input
type="text"
label="Account Name"
name="Name"
value={formData.Name}
onchange={handleInputChange}
required
message-when-value-missing="Account name is required">
</lightning-input>
</div>
<div class="slds-col slds-size_1-of-1 slds-medium-size_1-of-2">
<lightning-combobox
label="Industry"
name="Industry"
value={formData.Industry}
options={industryOptions}
onchange={handleInputChange}>
</lightning-combobox>
</div>
<div class="slds-col slds-size_1-of-1 slds-medium-size_1-of-2">
<lightning-input
type="tel"
label="Phone"
name="Phone"
value={formData.Phone}
onchange={handleInputChange}
pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}"
message-when-pattern-mismatch="Enter phone as: 123-456-7890">
</lightning-input>
</div>
</div>
<div class="slds-m-top_medium">
<lightning-button
variant="neutral"
label="Cancel"
onclick={handleCancel}
class="slds-m-right_x-small">
</lightning-button>
<lightning-button
variant="brand"
label={submitButtonLabel}
onclick={handleCustomSubmit}>
</lightning-button>
</div>
</div>
-->
</lightning-card>
</template>
/**
* FORM LWC COMPONENT TEMPLATE
*
* This template demonstrates a form component with:
* - lightning-record-edit-form for standard forms
* - Custom validation
* - Imperative Apex submission
* - Toast notifications
*
* Replace: formComponent → yourComponentName
* Replace: FormComponent → YourComponentName
*/
import { LightningElement, api, wire } from 'lwc';
import { getObjectInfo } from 'lightning/uiObjectInfoApi';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import { NavigationMixin } from 'lightning/navigation';
import saveRecord from '@salesforce/apex/FormComponentController.saveRecord';
// Import schema for field references
import ACCOUNT_OBJECT from '@salesforce/schema/Account';
import NAME_FIELD from '@salesforce/schema/Account.Name';
import INDUSTRY_FIELD from '@salesforce/schema/Account.Industry';
import PHONE_FIELD from '@salesforce/schema/Account.Phone';
export default class FormComponent extends NavigationMixin(LightningElement) {
// ═══════════════════════════════════════════════════════════════════════
// PUBLIC API
// ═══════════════════════════════════════════════════════════════════════
@api recordId; // For edit mode
@api objectApiName = 'Account';
// ═══════════════════════════════════════════════════════════════════════
// PRIVATE PROPERTIES
// ═══════════════════════════════════════════════════════════════════════
isLoading = false;
recordTypeId;
// Field values for custom form
formData = {
Name: '',
Industry: '',
Phone: ''
};
// ═══════════════════════════════════════════════════════════════════════
// SCHEMA REFERENCES
// ═══════════════════════════════════════════════════════════════════════
accountObject = ACCOUNT_OBJECT;
nameField = NAME_FIELD;
industryField = INDUSTRY_FIELD;
phoneField = PHONE_FIELD;
// ═══════════════════════════════════════════════════════════════════════
// WIRE SERVICE
// ═══════════════════════════════════════════════════════════════════════
@wire(getObjectInfo, { objectApiName: ACCOUNT_OBJECT })
objectInfo({ data, error }) {
if (data) {
// Get default record type
this.recordTypeId = data.defaultRecordTypeId;
}
}
// ═══════════════════════════════════════════════════════════════════════
// GETTERS
// ═══════════════════════════════════════════════════════════════════════
get isEditMode() {
return !!this.recordId;
}
get cardTitle() {
return this.isEditMode ? 'Edit Account' : 'New Account';
}
get submitButtonLabel() {
return this.isEditMode ? 'Save' : 'Create';
}
// ═══════════════════════════════════════════════════════════════════════
// LIGHTNING-RECORD-EDIT-FORM HANDLERS
// ═══════════════════════════════════════════════════════════════════════
/**
* Called when the form loads successfully
*/
handleLoad(event) {
this.isLoading = false;
console.log('Form loaded', event.detail);
}
/**
* Called when the record is saved successfully
*/
handleSuccess(event) {
this.isLoading = false;
const recordId = event.detail.id;
const recordName = event.detail.fields.Name.value;
this.showToast(
'Success',
`Account "${recordName}" ${this.isEditMode ? 'updated' : 'created'} successfully`,
'success'
);
// Dispatch event for parent components
this.dispatchEvent(new CustomEvent('save', {
detail: { recordId, recordName }
}));
// Navigate to record page
if (!this.isEditMode) {
this.navigateToRecord(recordId);
}
}
/**
* Called when there's an error saving
*/
handleError(event) {
this.isLoading = false;
const errorMessage = event.detail.message || 'An error occurred';
this.showToast('Error', errorMessage, 'error');
console.error('Form error:', event.detail);
}
/**
* Called before the form submits - opportunity to validate
*/
handleSubmit(event) {
event.preventDefault(); // Stop default submission
// Custom validation
const fields = event.detail.fields;
if (!this.validateForm(fields)) {
return;
}
this.isLoading = true;
// Modify fields if needed before submission
// fields.Custom_Field__c = 'Computed Value';
// Submit the form
this.template.querySelector('lightning-record-edit-form').submit(fields);
}
// ═══════════════════════════════════════════════════════════════════════
// CUSTOM FORM HANDLERS (Alternative to lightning-record-edit-form)
// ═══════════════════════════════════════════════════════════════════════
handleInputChange(event) {
const field = event.target.name || event.target.dataset.field;
const value = event.target.value;
this.formData = { ...this.formData, [field]: value };
}
async handleCustomSubmit() {
if (!this.validateCustomForm()) {
return;
}
this.isLoading = true;
try {
const result = await saveRecord({
recordData: JSON.stringify(this.formData),
recordId: this.recordId
});
this.showToast('Success', 'Record saved successfully', 'success');
this.dispatchEvent(new CustomEvent('save', {
detail: { recordId: result.Id }
}));
} catch (error) {
const errorMessage = error.body?.message || error.message || 'Unknown error';
this.showToast('Error', errorMessage, 'error');
} finally {
this.isLoading = false;
}
}
// ═══════════════════════════════════════════════════════════════════════
// VALIDATION
// ═══════════════════════════════════════════════════════════════════════
validateForm(fields) {
// Custom validation logic
if (fields.Name && fields.Name.length < 2) {
this.showToast('Validation Error', 'Name must be at least 2 characters', 'error');
return false;
}
// Validate all input fields
const allValid = [...this.template.querySelectorAll('lightning-input-field')]
.reduce((validSoFar, inputField) => {
inputField.reportValidity();
return validSoFar && inputField.checkValidity();
}, true);
return allValid;
}
validateCustomForm() {
const inputFields = this.template.querySelectorAll('lightning-input, lightning-combobox');
let isValid = true;
inputFields.forEach(field => {
if (!field.checkValidity()) {
field.reportValidity();
isValid = false;
}
});
return isValid;
}
// ═══════════════════════════════════════════════════════════════════════
// BUTTON HANDLERS
// ═══════════════════════════════════════════════════════════════════════
handleCancel() {
// Reset form
const inputFields = this.template.querySelectorAll('lightning-input-field');
inputFields.forEach(field => {
field.reset();
});
this.dispatchEvent(new CustomEvent('cancel'));
}
handleReset() {
const inputFields = this.template.querySelectorAll('lightning-input-field');
inputFields.forEach(field => {
field.reset();
});
}
// ═══════════════════════════════════════════════════════════════════════
// NAVIGATION
// ═══════════════════════════════════════════════════════════════════════
navigateToRecord(recordId) {
this[NavigationMixin.Navigate]({
type: 'standard__recordPage',
attributes: {
recordId: recordId,
objectApiName: this.objectApiName,
actionName: 'view'
}
});
}
// ═══════════════════════════════════════════════════════════════════════
// UTILITIES
// ═══════════════════════════════════════════════════════════════════════
showToast(title, message, variant) {
this.dispatchEvent(new ShowToastEvent({
title,
message,
variant
}));
}
}
<template>
<lightning-card title="Contacts (GraphQL)" icon-name="standard:contact">
<!-- Actions slot -->
<div slot="actions">
<lightning-button-icon
icon-name="utility:refresh"
alternative-text="Refresh"
onclick={handleRefresh}>
</lightning-button-icon>
</div>
<div class="slds-p-horizontal_medium">
<!-- Loading State -->
<template lwc:if={isLoading}>
<div class="slds-align_absolute-center slds-p-around_large">
<lightning-spinner alternative-text="Loading" size="medium"></lightning-spinner>
</div>
</template>
<!-- Error State -->
<template lwc:if={error}>
<div class="slds-notify slds-notify_alert slds-alert_error" role="alert">
<span class="slds-assistive-text">Error</span>
<h2>{error}</h2>
</div>
</template>
<!-- Empty State -->
<template lwc:if={hasNoData}>
<div class="slds-illustration slds-illustration_small">
<div class="slds-text-longform">
<h3 class="slds-text-heading_medium">No contacts found</h3>
<p class="slds-text-body_regular">
There are no contacts to display. Try adjusting your filters or creating new contacts.
</p>
</div>
</div>
</template>
<!-- Data Display -->
<template lwc:if={hasData}>
<!-- Page Info -->
<div class="slds-m-bottom_small slds-text-body_small slds-text-color_weak">
{currentPageInfo}
</div>
<!-- Contact List -->
<ul class="slds-has-dividers_bottom-space">
<template for:each={contacts} for:item="contact">
<li key={contact.id}
class="slds-item slds-p-vertical_small slds-is-relative"
data-id={contact.id}
onclick={handleRowClick}>
<article class="slds-tile slds-media">
<div class="slds-media__figure">
<lightning-icon
icon-name="standard:contact"
size="small">
</lightning-icon>
</div>
<div class="slds-media__body">
<h3 class="slds-tile__title">
<a href="javascript:void(0);">{contact.name}</a>
</h3>
<div class="slds-tile__detail">
<dl class="slds-list_horizontal slds-wrap">
<template lwc:if={contact.title}>
<dt class="slds-item_label slds-truncate">Title:</dt>
<dd class="slds-item_detail slds-truncate">{contact.title}</dd>
</template>
<template lwc:if={contact.email}>
<dt class="slds-item_label slds-truncate">Email:</dt>
<dd class="slds-item_detail slds-truncate">{contact.email}</dd>
</template>
<template lwc:if={contact.accountName}>
<dt class="slds-item_label slds-truncate">Account:</dt>
<dd class="slds-item_detail slds-truncate">{contact.accountName}</dd>
</template>
</dl>
</div>
</div>
</article>
</li>
</template>
</ul>
<!-- Load More Button -->
<template lwc:if={hasNextPage}>
<div class="slds-align_absolute-center slds-m-top_medium">
<lightning-button
label="Load More"
onclick={handleLoadMore}
disabled={isLoading}>
</lightning-button>
</div>
</template>
</template>
</div>
</lightning-card>
</template>
/**
* GraphQL Component Template
*
* Demonstrates GraphQL patterns for LWC with:
* - GraphQL query definition using gql tagged template
* - GraphQL mutations (Create, Update, Delete) - Spring '26 GA
* - Cursor-based pagination
* - Reactive variables
* - Error handling
* - Data transformation
*
* Module: lightning/graphql supersedes lightning/uiGraphQLApi
* Requires: API 66.0+ for mutations (GA in Spring '26)
*
* @see https://developer.salesforce.com/docs/platform/lwc/guide/data-graphql.html
*/
import { LightningElement, wire, track } from 'lwc';
import { gql, graphql, refreshGraphQL, executeMutation } from 'lightning/graphql';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
// ═══════════════════════════════════════════════════════════════════════════
// GRAPHQL QUERY
// ═══════════════════════════════════════════════════════════════════════════
const CONTACTS_QUERY = gql`
query ContactsWithAccount($first: Int!, $after: String, $orderBy: ContactOrderByInput) {
uiapi {
query {
Contact(first: $first, after: $after, orderBy: $orderBy) {
edges {
node {
Id
Name { value }
Email { value }
Phone { value }
Title { value }
Account {
Id
Name { value }
}
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
endCursor
startCursor
}
totalCount
}
}
}
}
`;
// ═══════════════════════════════════════════════════════════════════════════
// GRAPHQL MUTATIONS (Spring '26 - API 66.0+)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Create a new Contact record
* Returns the newly created record fields
*/
const CREATE_CONTACT = gql`
mutation CreateContact($firstName: String, $lastName: String!, $email: String, $accountId: ID) {
uiapi {
ContactCreate(input: {
Contact: {
FirstName: $firstName
LastName: $lastName
Email: $email
AccountId: $accountId
}
}) {
Record {
Id
Name { value }
Email { value }
}
}
}
}
`;
/**
* Update an existing Contact record
* Note: Cannot query fields in update response
*/
const UPDATE_CONTACT = gql`
mutation UpdateContact($id: ID!, $firstName: String, $lastName: String, $email: String) {
uiapi {
ContactUpdate(input: {
Contact: {
Id: $id
FirstName: $firstName
LastName: $lastName
Email: $email
}
}) {
Record {
Id
}
}
}
}
`;
/**
* Delete a Contact record
*/
const DELETE_CONTACT = gql`
mutation DeleteContact($id: ID!) {
uiapi {
ContactDelete(input: { Contact: { Id: $id } }) {
Id
}
}
}
`;
export default class GraphqlComponent extends LightningElement {
// Data state
@track contacts = [];
pageInfo;
totalCount = 0;
error;
isLoading = true;
// Pagination state
_pageSize = 10;
_cursor = null;
// Store the wire result for refresh
_wiredResult;
/**
* Wire the GraphQL query with reactive variables
* Variables are recalculated whenever queryVariables getter returns new values
*/
@wire(graphql, {
query: CONTACTS_QUERY,
variables: '$queryVariables'
})
wiredContacts(result) {
this._wiredResult = result;
const { data, error } = result;
this.isLoading = false;
if (data) {
const queryResult = data.uiapi.query.Contact;
// Transform GraphQL response to flat structure
this.contacts = queryResult.edges.map(edge => ({
id: edge.node.Id,
name: edge.node.Name?.value,
email: edge.node.Email?.value,
phone: edge.node.Phone?.value,
title: edge.node.Title?.value,
accountId: edge.node.Account?.Id,
accountName: edge.node.Account?.Name?.value,
cursor: edge.cursor
}));
this.pageInfo = queryResult.pageInfo;
this.totalCount = queryResult.totalCount;
this.error = undefined;
} else if (error) {
this.error = this._reduceErrors(error);
this.contacts = [];
}
}
get queryVariables() {
return {
first: this._pageSize,
after: this._cursor,
orderBy: { Name: { order: 'ASC' } }
};
}
get hasData() {
return this.contacts.length > 0;
}
get hasNoData() {
return !this.isLoading && !this.error && this.contacts.length === 0;
}
get hasNextPage() {
return this.pageInfo?.hasNextPage;
}
get currentPageInfo() {
return `Showing ${this.contacts.length} of ${this.totalCount} contacts`;
}
handleLoadMore() {
if (this.hasNextPage) {
this.isLoading = true;
this._cursor = this.pageInfo.endCursor;
}
}
handleRefresh() {
this.isLoading = true;
this._cursor = null;
refreshGraphQL(this._wiredResult);
}
handleRowClick(event) {
const contactId = event.currentTarget.dataset.id;
this.dispatchEvent(new CustomEvent('contactselected', {
detail: { contactId },
bubbles: true,
composed: true
}));
}
_reduceErrors(errors) {
if (!Array.isArray(errors)) {
errors = [errors];
}
return errors
.filter(error => !!error)
.map(error => {
if (typeof error === 'string') return error;
if (error.body?.message) return error.body.message;
if (error.message) return error.message;
return 'Unknown error';
})
.join('; ');
}
// ═══════════════════════════════════════════════════════════════════════
// MUTATION METHODS (Spring '26 - API 66.0+)
// ═══════════════════════════════════════════════════════════════════════
/**
* Create a new Contact via GraphQL mutation
* @param {Object} contactData - { firstName, lastName, email, accountId }
* @returns {Promise<Object>} - Created record
*/
async createContact(contactData) {
try {
const result = await executeMutation(CREATE_CONTACT, {
variables: {
firstName: contactData.firstName || null,
lastName: contactData.lastName,
email: contactData.email || null,
accountId: contactData.accountId || null
}
});
const newRecord = result.data.uiapi.ContactCreate.Record;
this._showToast('Success', `Contact "${newRecord.Name.value}" created`, 'success');
// Refresh the query to show new record
await refreshGraphQL(this._wiredResult);
return newRecord;
} catch (error) {
this._handleMutationError(error);
throw error;
}
}
/**
* Update an existing Contact via GraphQL mutation
* @param {String} contactId - ID of contact to update
* @param {Object} updates - { firstName, lastName, email }
* @returns {Promise<Object>} - Updated record ID
*/
async updateContact(contactId, updates) {
try {
const result = await executeMutation(UPDATE_CONTACT, {
variables: {
id: contactId,
firstName: updates.firstName,
lastName: updates.lastName,
email: updates.email
}
});
this._showToast('Success', 'Contact updated successfully', 'success');
// Refresh the query to show updated data
await refreshGraphQL(this._wiredResult);
return result.data.uiapi.ContactUpdate.Record;
} catch (error) {
this._handleMutationError(error);
throw error;
}
}
/**
* Delete a Contact via GraphQL mutation
* @param {String} contactId - ID of contact to delete
*/
async deleteContact(contactId) {
try {
await executeMutation(DELETE_CONTACT, {
variables: { id: contactId }
});
this._showToast('Success', 'Contact deleted successfully', 'success');
// Refresh the query to remove deleted record
await refreshGraphQL(this._wiredResult);
} catch (error) {
this._handleMutationError(error);
throw error;
}
}
/**
* Handle mutation errors with proper GraphQL error parsing
*/
_handleMutationError(error) {
let message;
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
message = error.graphQLErrors.map(e => e.message).join(', ');
} else {
message = error.message || 'An unknown error occurred';
}
this._showToast('Error', message, 'error');
}
/**
* Display toast notification
*/
_showToast(title, message, variant) {
this.dispatchEvent(new ShowToastEvent({ title, message, variant }));
}
}
/**
* LIGHTNING MESSAGE SERVICE - PUBLISHER TEMPLATE
*
* This component demonstrates publishing messages via LMS.
* Use LMS when components need to communicate but:
* - Don't have a parent-child relationship
* - Are in different parts of the page
* - Need to communicate between LWC, Aura, and Visualforce
*
* Replace: lmsPublisher → yourComponentName
*/
import { LightningElement, wire } from 'lwc';
import { publish, MessageContext } from 'lightning/messageService';
import RECORD_SELECTED_CHANNEL from '@salesforce/messageChannel/RecordSelected__c';
export default class LmsPublisher extends LightningElement {
// ═══════════════════════════════════════════════════════════════════════
// MESSAGE CONTEXT
// ═══════════════════════════════════════════════════════════════════════
/**
* MessageContext provides the scope for publishing/subscribing.
* Wire it to get the current context automatically.
*/
@wire(MessageContext)
messageContext;
// ═══════════════════════════════════════════════════════════════════════
// PUBLISH METHODS
// ═══════════════════════════════════════════════════════════════════════
/**
* Publish a simple message with record selection
*/
handleRecordSelect(event) {
const recordId = event.target.dataset.id;
const recordName = event.target.dataset.name;
// Create message payload
const payload = {
recordId: recordId,
recordName: recordName,
objectApiName: 'Account',
sourceComponent: 'lmsPublisher',
timestamp: new Date().toISOString()
};
// Publish to the message channel
publish(this.messageContext, RECORD_SELECTED_CHANNEL, payload);
console.log('Published message:', payload);
}
/**
* Publish with complex payload (JSON string for nested data)
*/
publishWithComplexPayload(recordData) {
const payload = {
recordId: recordData.Id,
recordName: recordData.Name,
objectApiName: 'Account',
sourceComponent: 'lmsPublisher',
timestamp: new Date().toISOString(),
payload: JSON.stringify({
additionalField1: recordData.field1,
additionalField2: recordData.field2,
nestedObject: {
key1: 'value1',
key2: 'value2'
}
})
};
publish(this.messageContext, RECORD_SELECTED_CHANNEL, payload);
}
/**
* Publish from imperative action (e.g., after Apex call)
*/
async handleSaveAndPublish() {
try {
// Perform some action
const result = await this.saveRecord();
// Publish result to other components
publish(this.messageContext, RECORD_SELECTED_CHANNEL, {
recordId: result.Id,
recordName: result.Name,
objectApiName: 'Account',
sourceComponent: 'lmsPublisher',
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Save failed:', error);
}
}
// Placeholder for actual save logic
async saveRecord() {
return { Id: '001xx000000000', Name: 'Test Account' };
}
}
/**
* LIGHTNING MESSAGE SERVICE - SUBSCRIBER TEMPLATE
*
* This component demonstrates subscribing to messages via LMS.
* The subscriber listens for messages published on a channel
* and reacts to them.
*
* Replace: lmsSubscriber → yourComponentName
*/
import { LightningElement, wire } from 'lwc';
import {
subscribe,
unsubscribe,
APPLICATION_SCOPE,
MessageContext
} from 'lightning/messageService';
import RECORD_SELECTED_CHANNEL from '@salesforce/messageChannel/RecordSelected__c';
export default class LmsSubscriber extends LightningElement {
// ═══════════════════════════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════════════════════════
subscription = null;
receivedMessage = null;
// Store received data for display
selectedRecordId;
selectedRecordName;
sourceComponent;
timestamp;
// ═══════════════════════════════════════════════════════════════════════
// MESSAGE CONTEXT
// ═══════════════════════════════════════════════════════════════════════
@wire(MessageContext)
messageContext;
// ═══════════════════════════════════════════════════════════════════════
// LIFECYCLE HOOKS
// ═══════════════════════════════════════════════════════════════════════
/**
* Subscribe to the message channel when component connects
*/
connectedCallback() {
this.subscribeToMessageChannel();
}
/**
* Unsubscribe when component disconnects to prevent memory leaks
*/
disconnectedCallback() {
this.unsubscribeToMessageChannel();
}
// ═══════════════════════════════════════════════════════════════════════
// SUBSCRIPTION MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════
/**
* Subscribe to the message channel
*
* Options:
* - No scope: Only receive messages from same page
* - APPLICATION_SCOPE: Receive messages across the entire application
*/
subscribeToMessageChannel() {
// Avoid duplicate subscriptions
if (this.subscription) {
return;
}
// Subscribe with APPLICATION_SCOPE to receive messages from anywhere
this.subscription = subscribe(
this.messageContext,
RECORD_SELECTED_CHANNEL,
(message) => this.handleMessage(message),
{ scope: APPLICATION_SCOPE }
);
console.log('Subscribed to RecordSelected channel');
}
/**
* Unsubscribe from the message channel
*/
unsubscribeToMessageChannel() {
if (this.subscription) {
unsubscribe(this.subscription);
this.subscription = null;
console.log('Unsubscribed from RecordSelected channel');
}
}
// ═══════════════════════════════════════════════════════════════════════
// MESSAGE HANDLING
// ═══════════════════════════════════════════════════════════════════════
/**
* Handle incoming messages
* This is called whenever a message is published to the channel
*/
handleMessage(message) {
console.log('Received message:', message);
// Store the raw message for debugging
this.receivedMessage = message;
// Extract fields from the message
this.selectedRecordId = message.recordId;
this.selectedRecordName = message.recordName;
this.sourceComponent = message.sourceComponent;
this.timestamp = message.timestamp;
// Handle complex payload if present
if (message.payload) {
try {
const additionalData = JSON.parse(message.payload);
this.handleAdditionalData(additionalData);
} catch (e) {
console.error('Failed to parse payload:', e);
}
}
// Ignore messages from self (optional)
if (message.sourceComponent === 'lmsSubscriber') {
console.log('Ignoring self-published message');
return;
}
// React to the message
this.onRecordSelected(message.recordId, message.recordName);
}
/**
* Handle additional data from complex payload
*/
handleAdditionalData(data) {
console.log('Additional data:', data);
// Process additional data as needed
}
/**
* Custom logic when a record is selected
*/
onRecordSelected(recordId, recordName) {
// Example: Refresh child component data
// Example: Update URL parameters
// Example: Fetch related records
console.log(`Record selected: ${recordName} (${recordId})`);
// Dispatch event for any parent components
this.dispatchEvent(new CustomEvent('recordchange', {
detail: {
recordId,
recordName
}
}));
}
// ═══════════════════════════════════════════════════════════════════════
// GETTERS FOR TEMPLATE
// ═══════════════════════════════════════════════════════════════════════
get hasSelection() {
return !!this.selectedRecordId;
}
get formattedTimestamp() {
if (!this.timestamp) return '';
return new Date(this.timestamp).toLocaleString();
}
get debugInfo() {
return JSON.stringify(this.receivedMessage, null, 2);
}
}
<?xml version="1.0" encoding="UTF-8"?>
<!--
LIGHTNING MESSAGE CHANNEL TEMPLATE
Message channels enable communication between LWC components
that don't have a parent-child relationship (cross-DOM communication).
Use cases:
- Components on different parts of the page
- LWC to Aura communication
- LWC to Visualforce communication
Replace: RecordSelected → YourChannelName
-->
<LightningMessageChannel xmlns="http://soap.sforce.com/2006/04/metadata">
<!-- Human-readable label shown in Setup -->
<masterLabel>Record Selected</masterLabel>
<!-- Description for documentation -->
<description>
Message channel for communicating record selection events between
components across the page. Used when a user selects a record in
one component and other components need to react.
</description>
<!-- Expose to Lightning web components -->
<isExposed>true</isExposed>
<!--
Message payload fields.
Define all data that can be sent through the channel.
-->
<lightningMessageFields>
<!-- Record ID field -->
<fieldName>recordId</fieldName>
<description>The Id of the selected record</description>
</lightningMessageFields>
<lightningMessageFields>
<!-- Record Name field -->
<fieldName>recordName</fieldName>
<description>The name of the selected record</description>
</lightningMessageFields>
<lightningMessageFields>
<!-- Object API Name field -->
<fieldName>objectApiName</fieldName>
<description>The API name of the object (e.g., Account, Contact)</description>
</lightningMessageFields>
<lightningMessageFields>
<!-- Source Component field -->
<fieldName>sourceComponent</fieldName>
<description>The name of the component that published this message</description>
</lightningMessageFields>
<lightningMessageFields>
<!-- Timestamp field -->
<fieldName>timestamp</fieldName>
<description>When the selection occurred (ISO 8601 format)</description>
</lightningMessageFields>
<!--
Additional fields for complex payloads.
Use JSON strings for nested data.
-->
<lightningMessageFields>
<fieldName>payload</fieldName>
<description>Additional data as JSON string for complex scenarios</description>
</lightningMessageFields>
</LightningMessageChannel>
/**
* TypeScript Component Styles
*
* Uses SLDS 2 global styling hooks for dark mode compatibility.
* All colors are CSS variables - no hardcoded values.
*/
/* Container styling */
:host {
display: block;
}
/* List item hover effect */
.slds-item {
transition: background-color 0.15s ease-in-out;
border-radius: var(--slds-g-radius-border-2, 0.25rem);
}
.slds-item:hover {
background-color: var(--slds-g-color-surface-container-1, #f3f3f3);
}
/* Link reset enhancement */
.slds-text-link_reset:hover {
text-decoration: none;
}
/* Selected state */
.slds-item.is-selected {
background-color: var(--slds-g-color-brand-1, #0176d3);
color: var(--slds-g-color-on-brand, #ffffff);
}
/* Revenue formatting */
.revenue-positive {
color: var(--slds-g-color-success-1, #2e844a);
}
.revenue-negative {
color: var(--slds-g-color-error-1, #ba0517);
}
/* Loading overlay */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--slds-g-color-surface-1, rgba(255, 255, 255, 0.8));
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
/* Description list styling */
.slds-list_horizontal dt {
min-width: 5rem;
}
/* Empty state icon */
.empty-state-icon {
color: var(--slds-g-color-on-surface-2, #706e6b);
}
Related skills
Forks & variants (1)
Sf Lwc has 1 known copy in the catalog totaling 36 installs. They canonicalize to this original listing.
- jaganpro - 36 installs
How it compares
Choose sf-lwc over generic Apex snippets when the target consumer is Lightning Web Components using @wire and @AuraEnabled rather than REST or batch Apex jobs.
FAQ
When should I use @wire vs. imperative Apex calls?
Use @wire for reactive read-only use cases and data subscriptions; use imperative calls for explicit actions and DML operations where control flow matters.
What is the PICKLES methodology?
PICKLES: Prototype, Integrate data source, Compose boundaries, define interaction model, use Libraries, optimize Execution, enforce Security.
What score should my LWC component reach?
Aim for 150+ (production-ready); 125-149 is strong with minor polish; <100 needs significant improvement across 8 categories.
Is Sf Lwc safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.