
Generating Lwc Components
- 2.6k installs
- 787 repo stars
- Updated August 5, 2026
- forcedotcom/sf-skills
generating-lwc-components is a Salesforce agent skill for Lightning Web Component scaffolding, wire patterns, SLDS 2 styling, accessibility, and Jest tests using PICKLES.
About
generating-lwc-components is a Salesforce agent skill for creating and editing Lightning Web Components across lwc JavaScript, HTML, CSS, and js-meta.xml bundles. It applies the PICKLES methodology covering prototype, integrate, compose, layout, libraries, execution, and security, plus a 165-point scoring rubric across eight quality categories. The skill chooses data access patterns such as LDS getRecord, base record forms, cacheable Apex, GraphQL wire adapters, or Lightning Message Service based on the UI need. It ships asset templates for datatables, modals, Flow screen components, GraphQL pagination, LMS publishers, and Jest test scaffolds, with reference guides for SLDS 2, dark mode, accessibility, performance, and template anti-patterns. Triggers include LWC file paths, wire service questions, and Jest LWC tests, while Apex-only or Aura work delegates to sibling sf-skills. Local dev preview via scripts supports hot reload without deployment. Output reports components changed, pattern chosen, quality notes, and next deploy or controller steps.
- PICKLES methodology plus 165-point scoring rubric across eight LWC quality categories.
- Data pattern table maps UI needs to LDS, Apex, GraphQL, LMS, or base record forms.
- Asset templates for datatable, modal, Flow screen, GraphQL, LMS, and Jest test bundles.
- SLDS 2, dark mode, accessibility, and performance guides with template anti-pattern references.
- Delegates Apex controllers to generating-apex and deployment to deploying-metadata sibling skills.
Generating Lwc Components by the numbers
- 2,588 all-time installs (skills.sh)
- +4 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #199 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
generating-lwc-components capabilities & compatibility
- Capabilities
- lwc bundle scaffolding across js html css meta · data access pattern selection for lds apex graph · slds 2 and dark mode compliant styling guidance · accessibility and performance validation checkli · jest test templates and async rendering helpers · cross skill delegation to apex flow and deploy s
- Works with
- salesforce
- Use cases
- frontend · testing · ui design
What generating-lwc-components says it does
Lightning Web Components with PICKLES methodology and 165-point scoring.
prefer platform base components over reinventing controls
npx skills add https://github.com/forcedotcom/sf-skills --skill generating-lwc-componentsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.6k |
|---|---|
| repo stars | ★ 787 |
| Security audit | 3 / 3 scanners passed |
| Last updated | August 5, 2026 |
| Repository | forcedotcom/sf-skills ↗ |
How do I implement a Salesforce LWC with the right data pattern, accessible SLDS UI, and test coverage without reinventing platform primitives?
Scaffold Lightning Web Components with PICKLES methodology, wire service patterns, SLDS 2 styling, accessibility checks, and Jest tests.
Who is it for?
Salesforce developers editing lwc bundles, wire services, Flow screen components, or Jest unit tests on the platform.
Skip if: Pure Apex server work, Aura components, Visualforce pages, or metadata deploy orchestration without UI changes.
When should I use this skill?
User creates or edits LWC files, asks about wire service, SLDS, GraphQL LWC, or Jest tests under lwc paths.
What you get
Production-ready LWC bundles with documented data access choice, quality checks, and clear handoff to Apex or deploy steps.
- Apex controller classes
- @AuraEnabled wire methods
- Bulk-safe mutation handlers
Files
generating-lwc-components: 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 generating-lwc-components 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 → generating-apex
- building Flow XML rather than an LWC screen component → generating-flow
- deploying metadata → deploying-metadata
---
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:
- generating-apex for controllers / services
- deploying-metadata for deployment
- running-apex-tests 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. Run the commands in scripts/local-dev-preview.sh to start a local dev session for a component, app, or Experience Cloud site.
Local Dev commands install just-in-time on first run. 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 | generating-apex | backend logic |
| embed in Flow screens | generating-flow | declarative orchestration |
| deploy component bundle | deploying-metadata | org rollout |
| create supporting metadata (message channels, objects) | deploying-metadata | metadata deployment |
---
Reference File Index
Start here
- references/component-patterns.md — component architecture patterns and bundle design
- references/slds-design-guide.md — SLDS 2 styling, dark mode, CSS hooks
- references/lwc-best-practices.md — high-signal rules and anti-patterns
- references/scoring-and-testing.md — 165-point scoring rubric across 8 categories
- references/jest-testing.md — Jest unit test patterns and async rendering helpers
- references/slds-blueprints.json — machine-readable SLDS component blueprints
- references/cli-commands.md — SF CLI commands for LWC development
Accessibility / performance / state
- references/accessibility-guide.md — WCAG, ARIA, keyboard navigation patterns
- references/performance-guide.md — lazy loading, debouncing, rerender safety
- references/state-management.md — reactive state patterns and LMS
- references/template-anti-patterns.md — common HTML template mistakes to avoid
Integration / advanced features
- references/lms-guide.md — Lightning Message Service patterns
- references/flow-integration-guide.md — Flow screen component design
- references/advanced-features.md — Spring '26 features: TypeScript, lwc:on, GraphQL mutations
- references/async-notification-patterns.md — toast, notifications, async flows
- references/triangle-pattern.md — parent-child-sibling communication triangle
Asset templates
- assets/basic-component/basicComponent.js — wire service, error/loading states, event dispatching
- assets/datatable-component/datatableComponent.js — datatable with inline editing
- assets/flow-screen-component/flowScreenComponent.js — Flow screen with input/output properties
- assets/form-component/formComponent.js — form validation and DML patterns
- assets/graphql-component/graphqlComponent.js — GraphQL wire adapter with cursor-based pagination
- assets/jest-test/componentName.test.js.example — Jest test template (copy and rename, remove
.examplesuffix) - assets/message-channel/lmsPublisher.js — LMS publisher pattern
- assets/message-channel/lmsSubscriber.js — LMS subscriber pattern
- assets/modal-component/modalComponent.js — modal with focus trap and ESC handling
- assets/record-picker/recordPicker.js — record picker with search
- assets/state-store/store.js — reactive state store for cross-component state
- assets/typescript-component/typescriptComponent.ts — TypeScript-enabled component (Spring '26)
- assets/workspace-api/workspaceComponent.js — workspace API for tab and focus management
- assets/apex-controller/LwcController.cls — Apex controller with
@AuraEnabled(cacheable=true)patterns
Scripts
- scripts/local-dev-preview.sh — local dev server commands for component, app, and site preview
---
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 (lwc:if / lwc:elseif / lwc:else)
- 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 lwc:if={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 lwc:elseif={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 lwc:elseif={isEmpty}>
<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>
<!-- Records List -->
<template lwc:else>
<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">
<button
type="button"
data-id={record.Id}
data-name={record.Name}
onclick={handleRecordClick}
class="slds-button slds-button_reset 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 lwc:if={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>
</button>
</div>
</article>
</li>
</template>
</ul>
</template>
<!-- Footer Slot -->
<div slot="footer">
<template lwc:if={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 isEmpty() {
return !this.isLoading && !this.error && !this.hasRecords;
}
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);
}
}
/**
* 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);
}
Credits & Acknowledgments
This skill is built on official Salesforce LWC platform patterns and established component development practices.
---
Related skills
Forks & variants (1)
Generating Lwc Components has 1 known copy in the catalog totaling 541 installs. They canonicalize to this original listing.
- forcedotcom - 541 installs
FAQ
When should I use @wire versus imperative Apex?
Use @wire for reactive read-only cases; use imperative calls for explicit actions and DML paths per the skill table.
Does this skill write Apex controllers?
No. Delegate controller and service work to the generating-apex sibling skill in the sf-skills repo.
How is LWC quality scored?
A 165-point rubric across eight categories is documented in references/scoring-and-testing.md.
Is Generating Lwc Components safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.