mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 03:09:50 +08:00
1477 lines
44 KiB
Markdown
1477 lines
44 KiB
Markdown
<!-- Parent: experience-lwc-generate/SKILL.md -->
|
|
# LWC Component Patterns
|
|
|
|
Comprehensive code examples for common Lightning Web Component patterns.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [PICKLES Framework Details](#pickles-framework-details)
|
|
2. [Wire Service Patterns](#wire-service-patterns)
|
|
- [Wire vs Imperative Apex Calls](#wire-vs-imperative-apex-calls)
|
|
3. [GraphQL Patterns](#graphql-patterns)
|
|
4. [Modal Component Pattern](#modal-component-pattern)
|
|
5. [Record Picker Pattern](#record-picker-pattern)
|
|
6. [Workspace API Pattern](#workspace-api-pattern)
|
|
7. [Parent-Child Communication](#parent-child-communication)
|
|
8. [Sibling Communication (via Parent)](#sibling-communication-via-parent)
|
|
9. [Navigation Patterns](#navigation-patterns)
|
|
10. [TypeScript Patterns](#typescript-patterns)
|
|
11. [Apex Controller Patterns](#apex-controller-patterns)
|
|
|
|
---
|
|
|
|
## PICKLES Framework Details
|
|
|
|
### P - Prototype
|
|
|
|
**Purpose**: Validate ideas early before full implementation.
|
|
|
|
| Action | Description |
|
|
|--------|-------------|
|
|
| Wireframe | Create high-level component sketches |
|
|
| Mock Data | Use sample data to test functionality |
|
|
| Stakeholder Review | Gather feedback before development |
|
|
| Separation of Concerns | Break into smaller functional pieces |
|
|
|
|
```javascript
|
|
// Mock data pattern for prototyping
|
|
const MOCK_ACCOUNTS = [
|
|
{ Id: '001MOCK001', Name: 'Acme Corp', Industry: 'Technology' },
|
|
{ Id: '001MOCK002', Name: 'Global Inc', Industry: 'Finance' }
|
|
];
|
|
|
|
export default class AccountPrototype extends LightningElement {
|
|
accounts = MOCK_ACCOUNTS; // Replace with wire/Apex later
|
|
}
|
|
```
|
|
|
|
### I - Integrate
|
|
|
|
**Purpose**: Determine how components interact with data systems.
|
|
|
|
**Integration Checklist**:
|
|
- [ ] Implement error handling with clear user notifications
|
|
- [ ] Add loading spinners to prevent duplicate requests
|
|
- [ ] Use LDS for single-object operations (minimizes DML)
|
|
- [ ] Respect FLS and CRUD in Apex implementations
|
|
- [ ] Store `wiredResult` for `refreshApex()` support
|
|
|
|
### C - Composition
|
|
|
|
**Purpose**: Structure how LWCs nest and communicate.
|
|
|
|
**Best Practices**:
|
|
- Maintain shallow component hierarchies (max 3-4 levels)
|
|
- Single responsibility per component
|
|
- Clean up subscriptions in `disconnectedCallback()`
|
|
- Use custom events purposefully, not for every interaction
|
|
|
|
```javascript
|
|
// Parent-managed composition pattern
|
|
// parent.js
|
|
handleChildEvent(event) {
|
|
this.selectedId = event.detail.id;
|
|
// Update child via @api
|
|
this.template.querySelector('c-child').selectedId = this.selectedId;
|
|
}
|
|
```
|
|
|
|
### K - Kinetics
|
|
|
|
**Purpose**: Manage user interaction and event responsiveness.
|
|
|
|
```javascript
|
|
// Debounce pattern for search
|
|
delayTimeout;
|
|
|
|
handleSearchChange(event) {
|
|
const searchTerm = event.target.value;
|
|
clearTimeout(this.delayTimeout);
|
|
this.delayTimeout = setTimeout(() => {
|
|
this.dispatchEvent(new CustomEvent('search', {
|
|
detail: { searchTerm }
|
|
}));
|
|
}, 300);
|
|
}
|
|
```
|
|
|
|
### L - Libraries
|
|
|
|
**Purpose**: Leverage Salesforce-provided and platform tools.
|
|
|
|
**Recommended Platform Features**:
|
|
|
|
| API/Module | Use Case |
|
|
|------------|----------|
|
|
| `lightning/navigation` | Page/record navigation |
|
|
| `lightning/uiRecordApi` | LDS operations (getRecord, updateRecord) |
|
|
| `lightning/platformShowToastEvent` | User notifications |
|
|
| `lightning/modal` | Native modal dialogs |
|
|
| Base Components | Pre-built UI (button, input, datatable) |
|
|
| `lightning/refresh` | Dispatch refresh events |
|
|
|
|
**Avoid reinventing** what base components already provide!
|
|
|
|
### E - Execution
|
|
|
|
**Purpose**: Optimize performance and resource efficiency.
|
|
|
|
**Performance Checklist**:
|
|
- [ ] Lazy load with `if:true` / `lwc:if`
|
|
- [ ] Use `key` directive in iterations
|
|
- [ ] Cache computed values in getters
|
|
- [ ] Avoid property updates that trigger re-renders
|
|
- [ ] Use browser DevTools Performance tab
|
|
|
|
### S - Security
|
|
|
|
**Purpose**: Enforce access control and data protection.
|
|
|
|
```apex
|
|
// Secure Apex pattern
|
|
@AuraEnabled(cacheable=true)
|
|
public static List<Account> getAccounts(String searchTerm) {
|
|
String searchKey = '%' + String.escapeSingleQuotes(searchTerm) + '%';
|
|
return [
|
|
SELECT Id, Name, Industry
|
|
FROM Account
|
|
WHERE Name LIKE :searchKey
|
|
WITH SECURITY_ENFORCED
|
|
LIMIT 50
|
|
];
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Wire Service Patterns
|
|
|
|
### Wire vs Imperative Apex Calls
|
|
|
|
LWC can interact with Apex in two ways: **@wire** (reactive/declarative) and **imperative calls** (manual/programmatic). Understanding when to use each is critical for building performant, maintainable components.
|
|
|
|
#### Quick Comparison
|
|
|
|
```text
|
|
┌─────────────────────────────────────────────────────────────────────────────────────┐
|
|
│ WIRE vs IMPERATIVE APEX CALLS │
|
|
├──────────────────┬──────────────────────────────┬────────────────────────────────────┤
|
|
│ Aspect │ Wire (@wire) │ Imperative Calls │
|
|
├──────────────────┼──────────────────────────────┼────────────────────────────────────┤
|
|
│ Execution │ Automatic / Reactive │ Manual / Programmatic │
|
|
│ DML Operations │ ❌ Read-Only │ ✅ Insert / Update / Delete │
|
|
│ Data Updates │ ✅ Auto on Parameter Change │ ❌ Manual Refresh Required │
|
|
│ Control │ ⚠️ Low (framework decides) │ ✅ Full (you decide when/how) │
|
|
│ Error Handling │ ✅ Framework Managed │ ⚠️ Developer Managed │
|
|
│ Supported Objects│ ⚠️ UI API Only │ ✅ All Objects │
|
|
│ Caching │ ✅ Built-in (cacheable=true) │ ❌ No automatic caching │
|
|
└──────────────────┴──────────────────────────────┴────────────────────────────────────┘
|
|
```
|
|
|
|
#### Pros & Cons
|
|
|
|
| Wire (@wire) | Imperative Calls |
|
|
|--------------|------------------|
|
|
| ✅ Auto UI sync & caching | ✅ Supports DML & all objects |
|
|
| ✅ Less boilerplate code | ✅ Full control over timing |
|
|
| ✅ Reactive to parameter changes | ✅ Can handle complex logic |
|
|
| ❌ Read-only, limited objects | ❌ Manual handling, no auto refresh |
|
|
| ❌ Can't control execution timing | ❌ More error handling code needed |
|
|
|
|
#### When to Use Each
|
|
|
|
**Use Wire (@wire) when:**
|
|
- 📌 Read-only data display
|
|
- 📌 Auto-refresh UI when parameters change
|
|
- 📌 Stable parameters (recordId, filter values)
|
|
- 📌 Working with UI API supported objects
|
|
|
|
**Use Imperative Calls when:**
|
|
- 📌 User actions (clicks, form submissions)
|
|
- 📌 DML operations (Insert, Update, Delete)
|
|
- 📌 Dynamic parameters determined at runtime
|
|
- 📌 Custom objects or complex queries
|
|
- 📌 Need control over execution timing
|
|
|
|
#### Side-by-Side Code Examples
|
|
|
|
**Wire Example** - Data loads automatically when `selectedIndustry` changes:
|
|
|
|
```javascript
|
|
import { LightningElement, wire } from 'lwc';
|
|
import fetchAccounts from '@salesforce/apex/AccountController.fetchAccounts';
|
|
|
|
export default class WireExample extends LightningElement {
|
|
selectedIndustry = 'Technology';
|
|
accounts;
|
|
error;
|
|
|
|
// Automatically re-fetches when selectedIndustry changes
|
|
@wire(fetchAccounts, { industry: '$selectedIndustry' })
|
|
wiredAccounts({ data, error }) {
|
|
if (data) {
|
|
this.accounts = data;
|
|
this.error = undefined;
|
|
} else if (error) {
|
|
this.error = error;
|
|
this.accounts = undefined;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Imperative Example** - Data loads only when user triggers action:
|
|
|
|
```javascript
|
|
import { LightningElement } from 'lwc';
|
|
import fetchAccounts from '@salesforce/apex/AccountController.fetchAccounts';
|
|
|
|
export default class ImperativeExample extends LightningElement {
|
|
selectedIndustry = 'Technology';
|
|
accounts;
|
|
error;
|
|
isLoading = false;
|
|
|
|
// Called explicitly when user clicks button or submits form
|
|
async fetchAccounts() {
|
|
this.isLoading = true;
|
|
try {
|
|
this.accounts = await fetchAccounts({
|
|
industry: this.selectedIndustry
|
|
});
|
|
this.error = undefined;
|
|
} catch (error) {
|
|
this.error = error;
|
|
this.accounts = undefined;
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Decision Tree
|
|
|
|
```text
|
|
┌─────────────────────────────┐
|
|
│ Need to modify data? │
|
|
│ (Insert/Update/Delete) │
|
|
└─────────────┬───────────────┘
|
|
│
|
|
┌─────────────┴───────────────┐
|
|
│ │
|
|
YES NO
|
|
│ │
|
|
▼ ▼
|
|
┌─────────────────┐ ┌─────────────────────────┐
|
|
│ IMPERATIVE │ │ Should data auto- │
|
|
│ (Use await) │ │ refresh on param │
|
|
└─────────────────┘ │ change? │
|
|
└───────────┬─────────────┘
|
|
│
|
|
┌───────────┴───────────┐
|
|
│ │
|
|
YES NO
|
|
│ │
|
|
▼ ▼
|
|
┌─────────────────┐ ┌─────────────────┐
|
|
│ @WIRE │ │ IMPERATIVE │
|
|
│ (Reactive) │ │ (On-demand) │
|
|
└─────────────────┘ └─────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
### 1. Basic Data Display (Wire Service)
|
|
|
|
```javascript
|
|
// accountCard.js
|
|
import { LightningElement, api, wire } from 'lwc';
|
|
import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
|
|
import NAME_FIELD from '@salesforce/schema/Account.Name';
|
|
import INDUSTRY_FIELD from '@salesforce/schema/Account.Industry';
|
|
|
|
const FIELDS = [NAME_FIELD, INDUSTRY_FIELD];
|
|
|
|
export default class AccountCard extends LightningElement {
|
|
@api recordId;
|
|
|
|
@wire(getRecord, { recordId: '$recordId', fields: FIELDS })
|
|
account;
|
|
|
|
get name() {
|
|
return getFieldValue(this.account.data, NAME_FIELD);
|
|
}
|
|
|
|
get industry() {
|
|
return getFieldValue(this.account.data, INDUSTRY_FIELD);
|
|
}
|
|
|
|
get isLoading() {
|
|
return !this.account.data && !this.account.error;
|
|
}
|
|
}
|
|
```
|
|
|
|
```html
|
|
<!-- accountCard.html -->
|
|
<template>
|
|
<template lwc:if={isLoading}>
|
|
<lightning-spinner alternative-text="Loading"></lightning-spinner>
|
|
</template>
|
|
<template lwc:if={account.data}>
|
|
<div class="slds-box slds-theme_default">
|
|
<h2 class="slds-text-heading_medium">{name}</h2>
|
|
<p class="slds-text-color_weak">{industry}</p>
|
|
</div>
|
|
</template>
|
|
<template lwc:if={account.error}>
|
|
<p class="slds-text-color_error">{account.error.body.message}</p>
|
|
</template>
|
|
</template>
|
|
```
|
|
|
|
### 2. Wire Service with Apex
|
|
|
|
```javascript
|
|
// contactList.js
|
|
import { LightningElement, api, wire } from 'lwc';
|
|
import getContacts from '@salesforce/apex/ContactController.getContacts';
|
|
import { refreshApex } from '@salesforce/apex';
|
|
|
|
export default class ContactList extends LightningElement {
|
|
@api recordId;
|
|
contacts;
|
|
error;
|
|
wiredContactsResult;
|
|
|
|
@wire(getContacts, { accountId: '$recordId' })
|
|
wiredContacts(result) {
|
|
this.wiredContactsResult = result; // Store for refreshApex
|
|
const { error, data } = result;
|
|
if (data) {
|
|
this.contacts = data;
|
|
this.error = undefined;
|
|
} else if (error) {
|
|
this.error = error;
|
|
this.contacts = undefined;
|
|
}
|
|
}
|
|
|
|
async handleRefresh() {
|
|
await refreshApex(this.wiredContactsResult);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## GraphQL Patterns
|
|
|
|
> **Module Note**: `lightning/graphql` supersedes `lightning/uiGraphQLApi` and provides newer features like mutations, optional fields, and dynamic query construction.
|
|
|
|
### GraphQL Query (Wire Adapter)
|
|
|
|
```javascript
|
|
// graphqlContacts.js
|
|
import { LightningElement, wire } from 'lwc';
|
|
import { gql, graphql } from 'lightning/graphql';
|
|
|
|
const CONTACTS_QUERY = gql`
|
|
query ContactsQuery($first: Int, $after: String) {
|
|
uiapi {
|
|
query {
|
|
Contact(first: $first, after: $after) {
|
|
edges {
|
|
node {
|
|
Id
|
|
Name { value }
|
|
Email { value }
|
|
Account {
|
|
Name { value }
|
|
}
|
|
}
|
|
cursor
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
export default class GraphqlContacts extends LightningElement {
|
|
contacts;
|
|
pageInfo;
|
|
error;
|
|
_cursor;
|
|
|
|
@wire(graphql, {
|
|
query: CONTACTS_QUERY,
|
|
variables: '$queryVariables'
|
|
})
|
|
wiredContacts({ data, error }) {
|
|
if (data) {
|
|
const result = data.uiapi.query.Contact;
|
|
this.contacts = result.edges.map(edge => ({
|
|
id: edge.node.Id,
|
|
name: edge.node.Name.value,
|
|
email: edge.node.Email?.value,
|
|
accountName: edge.node.Account?.Name?.value
|
|
}));
|
|
this.pageInfo = result.pageInfo;
|
|
} else if (error) {
|
|
this.error = error;
|
|
}
|
|
}
|
|
|
|
get queryVariables() {
|
|
return { first: 10, after: this._cursor };
|
|
}
|
|
|
|
loadMore() {
|
|
if (this.pageInfo?.hasNextPage) {
|
|
this._cursor = this.pageInfo.endCursor;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### GraphQL Mutations (Spring '26 - GA in API 66.0)
|
|
|
|
Mutations allow create, update, and delete operations via GraphQL. Use `executeMutation` for imperative operations.
|
|
|
|
```javascript
|
|
// graphqlAccountMutation.js
|
|
import { LightningElement, track } from 'lwc';
|
|
import { gql, executeMutation } from 'lightning/graphql';
|
|
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
|
|
|
|
// Create mutation
|
|
const CREATE_ACCOUNT = gql`
|
|
mutation CreateAccount($name: String!, $industry: String) {
|
|
uiapi {
|
|
AccountCreate(input: {
|
|
Account: {
|
|
Name: $name
|
|
Industry: $industry
|
|
}
|
|
}) {
|
|
Record {
|
|
Id
|
|
Name { value }
|
|
Industry { value }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
// Update mutation
|
|
const UPDATE_ACCOUNT = gql`
|
|
mutation UpdateAccount($id: ID!, $name: String!) {
|
|
uiapi {
|
|
AccountUpdate(input: {
|
|
Account: {
|
|
Id: $id
|
|
Name: $name
|
|
}
|
|
}) {
|
|
Record {
|
|
Id
|
|
Name { value }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
// Delete mutation
|
|
const DELETE_ACCOUNT = gql`
|
|
mutation DeleteAccount($id: ID!) {
|
|
uiapi {
|
|
AccountDelete(input: { Account: { Id: $id } }) {
|
|
Id
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
export default class GraphqlAccountMutation extends LightningElement {
|
|
@track accountName = '';
|
|
@track industry = '';
|
|
isLoading = false;
|
|
|
|
handleNameChange(event) {
|
|
this.accountName = event.target.value;
|
|
}
|
|
|
|
handleIndustryChange(event) {
|
|
this.industry = event.target.value;
|
|
}
|
|
|
|
async handleCreate() {
|
|
if (!this.accountName) return;
|
|
|
|
this.isLoading = true;
|
|
try {
|
|
const result = await executeMutation(CREATE_ACCOUNT, {
|
|
variables: {
|
|
name: this.accountName,
|
|
industry: this.industry || null
|
|
}
|
|
});
|
|
|
|
const newRecord = result.data.uiapi.AccountCreate.Record;
|
|
this.showToast('Success', `Account "${newRecord.Name.value}" created`, 'success');
|
|
this.resetForm();
|
|
} catch (error) {
|
|
this.handleError(error);
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
}
|
|
|
|
async handleUpdate(accountId, newName) {
|
|
try {
|
|
const result = await executeMutation(UPDATE_ACCOUNT, {
|
|
variables: { id: accountId, name: newName }
|
|
});
|
|
this.showToast('Success', 'Account updated', 'success');
|
|
return result.data.uiapi.AccountUpdate.Record;
|
|
} catch (error) {
|
|
this.handleError(error);
|
|
}
|
|
}
|
|
|
|
async handleDelete(accountId) {
|
|
try {
|
|
await executeMutation(DELETE_ACCOUNT, {
|
|
variables: { id: accountId }
|
|
});
|
|
this.showToast('Success', 'Account deleted', 'success');
|
|
} catch (error) {
|
|
this.handleError(error);
|
|
}
|
|
}
|
|
|
|
handleError(error) {
|
|
const message = error.graphQLErrors
|
|
? error.graphQLErrors.map(e => e.message).join(', ')
|
|
: error.message || 'Unknown error';
|
|
this.showToast('Error', message, 'error');
|
|
}
|
|
|
|
showToast(title, message, variant) {
|
|
this.dispatchEvent(new ShowToastEvent({ title, message, variant }));
|
|
}
|
|
|
|
resetForm() {
|
|
this.accountName = '';
|
|
this.industry = '';
|
|
}
|
|
}
|
|
```
|
|
|
|
### GraphQL Mutation Operations
|
|
|
|
| Operation | Mutation Type | Notes |
|
|
|-----------|---------------|-------|
|
|
| **Create** | `{Object}Create` | Can request fields from newly created record |
|
|
| **Update** | `{Object}Update` | Cannot query fields in same request |
|
|
| **Delete** | `{Object}Delete` | Cannot query fields in same request |
|
|
|
|
### allOrNone Parameter
|
|
|
|
Control transaction behavior with `allOrNone` (default: `true`):
|
|
|
|
```javascript
|
|
const BATCH_CREATE = gql`
|
|
mutation BatchCreate($allOrNone: Boolean = true) {
|
|
uiapi(allOrNone: $allOrNone) {
|
|
acc1: AccountCreate(input: { Account: { Name: "Account 1" } }) {
|
|
Record { Id }
|
|
}
|
|
acc2: AccountCreate(input: { Account: { Name: "Account 2" } }) {
|
|
Record { Id }
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
// If allOrNone=true: All rollback if any fails
|
|
// If allOrNone=false: Only failed operations rollback
|
|
```
|
|
|
|
---
|
|
|
|
## Modal Component Pattern
|
|
|
|
Based on [James Simone's composable modal pattern](https://www.jamessimone.net/blog/joys-of-apex/lwc-composable-modal/).
|
|
|
|
```javascript
|
|
// composableModal.js
|
|
import { LightningElement, api } from 'lwc';
|
|
|
|
const OUTER_MODAL_CLASS = 'outerModalContent';
|
|
|
|
export default class ComposableModal extends LightningElement {
|
|
@api modalHeader;
|
|
@api modalTagline;
|
|
@api modalSaveHandler;
|
|
|
|
_isOpen = false;
|
|
_focusableElements = [];
|
|
|
|
@api
|
|
toggleModal() {
|
|
this._isOpen = !this._isOpen;
|
|
if (this._isOpen) {
|
|
this._focusableElements = [...this.querySelectorAll('.focusable')];
|
|
this._focusFirstElement();
|
|
window.addEventListener('keyup', this._handleKeyUp);
|
|
} else {
|
|
window.removeEventListener('keyup', this._handleKeyUp);
|
|
}
|
|
}
|
|
|
|
get modalAriaHidden() {
|
|
return !this._isOpen;
|
|
}
|
|
|
|
get modalClass() {
|
|
return this._isOpen
|
|
? 'slds-modal slds-visible slds-fade-in-open'
|
|
: 'slds-modal slds-hidden';
|
|
}
|
|
|
|
get backdropClass() {
|
|
return this._isOpen ? 'slds-backdrop slds-backdrop_open' : 'slds-backdrop';
|
|
}
|
|
|
|
_handleKeyUp = (event) => {
|
|
if (event.code === 'Escape') {
|
|
this.toggleModal();
|
|
} else if (event.code === 'Tab') {
|
|
this._handleTabNavigation(event);
|
|
}
|
|
}
|
|
|
|
_handleTabNavigation(event) {
|
|
// Focus trap logic - keep focus within modal
|
|
const activeEl = this.template.activeElement;
|
|
const lastIndex = this._focusableElements.length - 1;
|
|
const currentIndex = this._focusableElements.indexOf(activeEl);
|
|
|
|
if (event.shiftKey && currentIndex === 0) {
|
|
this._focusableElements[lastIndex]?.focus();
|
|
} else if (!event.shiftKey && currentIndex === lastIndex) {
|
|
this._focusFirstElement();
|
|
}
|
|
}
|
|
|
|
_focusFirstElement() {
|
|
if (this._focusableElements.length > 0) {
|
|
this._focusableElements[0].focus();
|
|
}
|
|
}
|
|
|
|
handleBackdropClick(event) {
|
|
if (event.target.classList.contains(OUTER_MODAL_CLASS)) {
|
|
this.toggleModal();
|
|
}
|
|
}
|
|
|
|
handleSave() {
|
|
if (this.modalSaveHandler) {
|
|
this.modalSaveHandler();
|
|
}
|
|
this.toggleModal();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
window.removeEventListener('keyup', this._handleKeyUp);
|
|
}
|
|
}
|
|
```
|
|
|
|
```html
|
|
<!-- composableModal.html -->
|
|
<template>
|
|
<!-- Backdrop -->
|
|
<div class={backdropClass}></div>
|
|
|
|
<!-- Modal -->
|
|
<div class={modalClass}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-hidden={modalAriaHidden}
|
|
aria-labelledby="modal-heading">
|
|
|
|
<div class="slds-modal__container outerModalContent"
|
|
onclick={handleBackdropClick}>
|
|
|
|
<div class="slds-modal__content slds-p-around_medium">
|
|
<!-- Header -->
|
|
<template lwc:if={modalHeader}>
|
|
<h2 id="modal-heading" class="slds-text-heading_medium">
|
|
{modalHeader}
|
|
</h2>
|
|
</template>
|
|
<template lwc:if={modalTagline}>
|
|
<p class="slds-m-top_x-small slds-text-color_weak">
|
|
{modalTagline}
|
|
</p>
|
|
</template>
|
|
|
|
<!-- Slotted Content -->
|
|
<div class="slds-m-top_medium">
|
|
<slot name="modalContent"></slot>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="slds-m-top_medium slds-text-align_right">
|
|
<lightning-button
|
|
label="Cancel"
|
|
onclick={toggleModal}
|
|
class="slds-m-right_x-small focusable">
|
|
</lightning-button>
|
|
<lightning-button
|
|
variant="brand"
|
|
label="Save"
|
|
onclick={handleSave}
|
|
class="focusable">
|
|
</lightning-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hidden background content -->
|
|
<div aria-hidden={_isOpen}>
|
|
<slot name="body"></slot>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
## Record Picker Pattern
|
|
|
|
```javascript
|
|
// recordPicker.js
|
|
import { LightningElement, api } from 'lwc';
|
|
|
|
export default class RecordPicker extends LightningElement {
|
|
@api label = 'Select Record';
|
|
@api objectApiName = 'Account';
|
|
@api placeholder = 'Search...';
|
|
@api required = false;
|
|
@api multiSelect = false;
|
|
|
|
_selectedIds = [];
|
|
|
|
@api
|
|
get value() {
|
|
return this.multiSelect ? this._selectedIds : this._selectedIds[0];
|
|
}
|
|
|
|
set value(val) {
|
|
this._selectedIds = Array.isArray(val) ? val : val ? [val] : [];
|
|
}
|
|
|
|
handleChange(event) {
|
|
const recordId = event.detail.recordId;
|
|
if (this.multiSelect) {
|
|
if (!this._selectedIds.includes(recordId)) {
|
|
this._selectedIds = [...this._selectedIds, recordId];
|
|
}
|
|
} else {
|
|
this._selectedIds = recordId ? [recordId] : [];
|
|
}
|
|
|
|
this.dispatchEvent(new CustomEvent('select', {
|
|
detail: {
|
|
recordId: this.value,
|
|
recordIds: this._selectedIds
|
|
}
|
|
}));
|
|
}
|
|
|
|
handleRemove(event) {
|
|
const idToRemove = event.target.dataset.id;
|
|
this._selectedIds = this._selectedIds.filter(id => id !== idToRemove);
|
|
this.dispatchEvent(new CustomEvent('select', {
|
|
detail: { recordIds: this._selectedIds }
|
|
}));
|
|
}
|
|
}
|
|
```
|
|
|
|
```html
|
|
<!-- recordPicker.html -->
|
|
<template>
|
|
<lightning-record-picker
|
|
label={label}
|
|
placeholder={placeholder}
|
|
object-api-name={objectApiName}
|
|
onchange={handleChange}
|
|
required={required}>
|
|
</lightning-record-picker>
|
|
|
|
<template lwc:if={multiSelect}>
|
|
<div class="slds-m-top_x-small">
|
|
<template for:each={_selectedIds} for:item="id">
|
|
<lightning-pill
|
|
key={id}
|
|
label={id}
|
|
data-id={id}
|
|
onremove={handleRemove}>
|
|
</lightning-pill>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
## Workspace API Pattern
|
|
|
|
```javascript
|
|
// workspaceTabManager.js
|
|
import { LightningElement, wire } from 'lwc';
|
|
import { IsConsoleNavigation, getFocusedTabInfo, openTab, closeTab,
|
|
setTabLabel, setTabIcon, refreshTab } from 'lightning/platformWorkspaceApi';
|
|
|
|
export default class WorkspaceTabManager extends LightningElement {
|
|
@wire(IsConsoleNavigation) isConsole;
|
|
|
|
async openRecordTab(recordId, objectApiName) {
|
|
if (!this.isConsole) return;
|
|
|
|
await openTab({
|
|
recordId,
|
|
focus: true,
|
|
icon: `standard:${objectApiName.toLowerCase()}`,
|
|
label: 'Loading...'
|
|
});
|
|
}
|
|
|
|
async openSubtab(parentTabId, recordId) {
|
|
if (!this.isConsole) return;
|
|
|
|
await openTab({
|
|
parentTabId,
|
|
recordId,
|
|
focus: true
|
|
});
|
|
}
|
|
|
|
async getCurrentTabInfo() {
|
|
if (!this.isConsole) return null;
|
|
return await getFocusedTabInfo();
|
|
}
|
|
|
|
async updateTabLabel(tabId, label) {
|
|
if (!this.isConsole) return;
|
|
await setTabLabel(tabId, label);
|
|
}
|
|
|
|
async updateTabIcon(tabId, iconName) {
|
|
if (!this.isConsole) return;
|
|
await setTabIcon(tabId, iconName);
|
|
}
|
|
|
|
async refreshCurrentTab() {
|
|
if (!this.isConsole) return;
|
|
const tabInfo = await getFocusedTabInfo();
|
|
await refreshTab(tabInfo.tabId);
|
|
}
|
|
|
|
async closeCurrentTab() {
|
|
if (!this.isConsole) return;
|
|
const tabInfo = await getFocusedTabInfo();
|
|
await closeTab(tabInfo.tabId);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
<a id="parent-child-communication"></a>
|
|
|
|
## Parent-Child Communication
|
|
|
|
```javascript
|
|
// parent.js
|
|
import { LightningElement } from 'lwc';
|
|
|
|
export default class Parent extends LightningElement {
|
|
selectedAccountId;
|
|
|
|
handleAccountSelected(event) {
|
|
this.selectedAccountId = event.detail.accountId;
|
|
}
|
|
}
|
|
```
|
|
|
|
```html
|
|
<!-- parent.html -->
|
|
<template>
|
|
<c-account-list onaccountselected={handleAccountSelected}></c-account-list>
|
|
<template lwc:if={selectedAccountId}>
|
|
<c-account-detail account-id={selectedAccountId}></c-account-detail>
|
|
</template>
|
|
</template>
|
|
```
|
|
|
|
```javascript
|
|
// child.js (accountList)
|
|
import { LightningElement } from 'lwc';
|
|
|
|
export default class AccountList extends LightningElement {
|
|
handleRowAction(event) {
|
|
const accountId = event.detail.row.Id;
|
|
|
|
// Dispatch event to parent
|
|
this.dispatchEvent(new CustomEvent('accountselected', {
|
|
detail: { accountId },
|
|
bubbles: true,
|
|
composed: false // Don't cross shadow DOM boundaries
|
|
}));
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Sibling Communication (via Parent)
|
|
|
|
When two child components need to communicate but share the same parent, use the **parent as middleware**. This is the recommended pattern for master-detail UIs.
|
|
|
|
```text
|
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
│ SIBLING COMMUNICATION FLOW │
|
|
├─────────────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ ┌──────────┐ │
|
|
│ │ Parent │ ← Manages state │
|
|
│ └────┬─────┘ │
|
|
│ ┌─────────┴─────────┐ │
|
|
│ │ │ │
|
|
│ CustomEvent @api property │
|
|
│ (up) (down) │
|
|
│ │ │ │
|
|
│ ┌─────┴─────┐ ┌─────┴─────┐ │
|
|
│ │ Child A │ │ Child B │ │
|
|
│ │ (List) │ │ (Detail) │ │
|
|
│ └───────────┘ └───────────┘ │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
**The flow**:
|
|
1. **Child A** dispatches a custom event (e.g., user selects an account)
|
|
2. **Parent** catches the event and updates its state
|
|
3. **Parent** passes data to **Child B** via `@api` property
|
|
|
|
### Complete Example: Account List → Account Detail
|
|
|
|
```javascript
|
|
// accountContainer.js - Parent orchestrates communication between siblings
|
|
import { LightningElement } from 'lwc';
|
|
|
|
export default class AccountContainer extends LightningElement {
|
|
// State managed at parent level
|
|
selectedAccountId;
|
|
selectedAccountName;
|
|
|
|
// Child A (accountList) fires this event
|
|
handleAccountSelect(event) {
|
|
this.selectedAccountId = event.detail.accountId;
|
|
this.selectedAccountName = event.detail.accountName;
|
|
}
|
|
|
|
// Clear selection (triggered by Child B)
|
|
handleClearSelection() {
|
|
this.selectedAccountId = null;
|
|
this.selectedAccountName = null;
|
|
}
|
|
|
|
get hasSelection() {
|
|
return !!this.selectedAccountId;
|
|
}
|
|
}
|
|
```
|
|
|
|
```html
|
|
<!-- accountContainer.html -->
|
|
<template>
|
|
<div class="slds-grid slds-gutters">
|
|
<!-- Child A: Account List -->
|
|
<div class="slds-col slds-size_1-of-2">
|
|
<c-account-list
|
|
onaccountselect={handleAccountSelect}
|
|
selected-id={selectedAccountId}>
|
|
</c-account-list>
|
|
</div>
|
|
|
|
<!-- Child B: Account Detail (receives data via @api) -->
|
|
<div class="slds-col slds-size_1-of-2">
|
|
<template lwc:if={hasSelection}>
|
|
<c-account-detail
|
|
account-id={selectedAccountId}
|
|
account-name={selectedAccountName}
|
|
onclearselection={handleClearSelection}>
|
|
</c-account-detail>
|
|
</template>
|
|
<template lwc:else>
|
|
<div class="slds-box slds-theme_shade">
|
|
Select an account to view details
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
```javascript
|
|
// accountList.js - Child A: Dispatches events UP to parent
|
|
import { LightningElement, api, wire } from 'lwc';
|
|
import getAccounts from '@salesforce/apex/AccountController.getAccounts';
|
|
|
|
export default class AccountList extends LightningElement {
|
|
@api selectedId; // Highlight selected row (from parent)
|
|
accounts;
|
|
error;
|
|
|
|
@wire(getAccounts)
|
|
wiredAccounts({ data, error }) {
|
|
if (data) {
|
|
this.accounts = data;
|
|
this.error = undefined;
|
|
} else if (error) {
|
|
this.error = error;
|
|
this.accounts = undefined;
|
|
}
|
|
}
|
|
|
|
handleRowClick(event) {
|
|
const accountId = event.currentTarget.dataset.id;
|
|
const accountName = event.currentTarget.dataset.name;
|
|
|
|
// Dispatch event to parent (not bubbles - parent listens directly)
|
|
this.dispatchEvent(new CustomEvent('accountselect', {
|
|
detail: { accountId, accountName }
|
|
}));
|
|
}
|
|
|
|
// Computed: Check if row should be highlighted
|
|
getRowClass(accountId) {
|
|
return accountId === this.selectedId
|
|
? 'slds-item slds-is-selected'
|
|
: 'slds-item';
|
|
}
|
|
}
|
|
```
|
|
|
|
```javascript
|
|
// accountDetail.js - Child B: Receives data via @api from parent
|
|
import { LightningElement, api, wire } from 'lwc';
|
|
import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
|
|
import INDUSTRY_FIELD from '@salesforce/schema/Account.Industry';
|
|
import REVENUE_FIELD from '@salesforce/schema/Account.AnnualRevenue';
|
|
|
|
const FIELDS = [INDUSTRY_FIELD, REVENUE_FIELD];
|
|
|
|
export default class AccountDetail extends LightningElement {
|
|
@api accountId; // Received from parent
|
|
@api accountName; // Received from parent
|
|
|
|
@wire(getRecord, { recordId: '$accountId', fields: FIELDS })
|
|
account;
|
|
|
|
get industry() {
|
|
return getFieldValue(this.account.data, INDUSTRY_FIELD);
|
|
}
|
|
|
|
get revenue() {
|
|
return getFieldValue(this.account.data, REVENUE_FIELD);
|
|
}
|
|
|
|
get isLoading() {
|
|
return !this.account.data && !this.account.error;
|
|
}
|
|
|
|
handleClose() {
|
|
// Dispatch event back to parent to clear selection
|
|
this.dispatchEvent(new CustomEvent('clearselection'));
|
|
}
|
|
}
|
|
```
|
|
|
|
### When to Use Sibling Pattern vs LMS
|
|
|
|
| Scenario | Sibling Pattern | LMS |
|
|
|----------|-----------------|-----|
|
|
| Components share same parent | ✅ Recommended | ❌ Overkill |
|
|
| State is simple (1-2 values) | ✅ | ❌ |
|
|
| Need bidirectional updates | ✅ | ✅ |
|
|
| Components in different DOM trees | ❌ | ✅ Required |
|
|
| Cross-framework (LWC ↔ Aura) | ❌ | ✅ Required |
|
|
| Many consumers need same data | ❌ Consider LMS | ✅ |
|
|
| Component hierarchy is deep (4+ levels) | ❌ Consider LMS | ✅ |
|
|
|
|
**Rule of thumb**: If components share a parent and data flow is simple, use sibling pattern. If components are "far apart" in the DOM or you need pub/sub semantics, use LMS.
|
|
|
|
---
|
|
|
|
## Navigation Patterns
|
|
|
|
```javascript
|
|
// navigator.js
|
|
import { LightningElement } from 'lwc';
|
|
import { NavigationMixin } from 'lightning/navigation';
|
|
|
|
export default class Navigator extends NavigationMixin(LightningElement) {
|
|
|
|
navigateToRecord(recordId, objectApiName = 'Account') {
|
|
this[NavigationMixin.Navigate]({
|
|
type: 'standard__recordPage',
|
|
attributes: {
|
|
recordId,
|
|
objectApiName,
|
|
actionName: 'view'
|
|
}
|
|
});
|
|
}
|
|
|
|
navigateToList(objectApiName, filterName = 'Recent') {
|
|
this[NavigationMixin.Navigate]({
|
|
type: 'standard__objectPage',
|
|
attributes: {
|
|
objectApiName,
|
|
actionName: 'list'
|
|
},
|
|
state: { filterName }
|
|
});
|
|
}
|
|
|
|
navigateToNewRecord(objectApiName, defaultValues = {}) {
|
|
this[NavigationMixin.Navigate]({
|
|
type: 'standard__objectPage',
|
|
attributes: {
|
|
objectApiName,
|
|
actionName: 'new'
|
|
},
|
|
state: {
|
|
defaultFieldValues: Object.entries(defaultValues)
|
|
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
|
.join(',')
|
|
}
|
|
});
|
|
}
|
|
|
|
navigateToRelatedList(recordId, relationshipApiName) {
|
|
this[NavigationMixin.Navigate]({
|
|
type: 'standard__recordRelationshipPage',
|
|
attributes: {
|
|
recordId,
|
|
relationshipApiName,
|
|
actionName: 'view'
|
|
}
|
|
});
|
|
}
|
|
|
|
navigateToNamedPage(pageName, params = {}) {
|
|
this[NavigationMixin.Navigate]({
|
|
type: 'standard__namedPage',
|
|
attributes: {
|
|
pageName
|
|
},
|
|
state: params
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## TypeScript Patterns
|
|
|
|
### TypeScript Component Pattern
|
|
|
|
```typescript
|
|
// accountList.ts
|
|
import { LightningElement, api, wire, track } from 'lwc';
|
|
import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
|
|
import getAccounts from '@salesforce/apex/AccountController.getAccounts';
|
|
import ACCOUNT_NAME_FIELD from '@salesforce/schema/Account.Name';
|
|
|
|
// Define interfaces for type safety
|
|
interface AccountRecord {
|
|
Id: string;
|
|
Name: string;
|
|
Industry?: string;
|
|
AnnualRevenue?: number;
|
|
}
|
|
|
|
interface WireResult<T> {
|
|
data?: T;
|
|
error?: Error;
|
|
}
|
|
|
|
export default class AccountList extends LightningElement {
|
|
// Typed @api properties
|
|
@api recordId: string | undefined;
|
|
|
|
@api
|
|
get maxRecords(): number {
|
|
return this._maxRecords;
|
|
}
|
|
set maxRecords(value: number) {
|
|
this._maxRecords = value;
|
|
}
|
|
|
|
// Typed @track properties
|
|
@track private _accounts: AccountRecord[] = [];
|
|
@track private _error: string | null = null;
|
|
|
|
private _maxRecords: number = 10;
|
|
private _wiredResult: WireResult<AccountRecord[]> | undefined;
|
|
|
|
// Typed wire service
|
|
@wire(getAccounts, { maxRecords: '$maxRecords' })
|
|
wiredAccounts(result: WireResult<AccountRecord[]>): void {
|
|
this._wiredResult = result;
|
|
const { data, error } = result;
|
|
|
|
if (data) {
|
|
this._accounts = data;
|
|
this._error = null;
|
|
} else if (error) {
|
|
this._error = this.reduceErrors(error);
|
|
this._accounts = [];
|
|
}
|
|
}
|
|
|
|
// Typed getters
|
|
get accounts(): AccountRecord[] {
|
|
return this._accounts;
|
|
}
|
|
|
|
get hasAccounts(): boolean {
|
|
return this._accounts.length > 0;
|
|
}
|
|
|
|
// Typed event handlers
|
|
handleSelect(event: CustomEvent<{ accountId: string }>): void {
|
|
const { accountId } = event.detail;
|
|
this.dispatchEvent(new CustomEvent('accountselected', {
|
|
detail: { accountId },
|
|
bubbles: true,
|
|
composed: true
|
|
}));
|
|
}
|
|
|
|
// Typed utility methods
|
|
private reduceErrors(error: Error | Error[]): string {
|
|
const errors = Array.isArray(error) ? error : [error];
|
|
return errors
|
|
.filter((e): e is Error => e !== null)
|
|
.map(e => e.message || 'Unknown error')
|
|
.join('; ');
|
|
}
|
|
}
|
|
```
|
|
|
|
### TypeScript Jest Test Pattern
|
|
|
|
```typescript
|
|
// accountList.test.ts
|
|
import { createElement, LightningElement } from 'lwc';
|
|
import AccountList from 'c/accountList';
|
|
import getAccounts from '@salesforce/apex/AccountController.getAccounts';
|
|
|
|
// Type definitions for tests
|
|
interface AccountRecord {
|
|
Id: string;
|
|
Name: string;
|
|
Industry?: string;
|
|
}
|
|
|
|
// Mock Apex
|
|
jest.mock(
|
|
'@salesforce/apex/AccountController.getAccounts',
|
|
() => ({ default: jest.fn() }),
|
|
{ virtual: true }
|
|
);
|
|
|
|
const MOCK_ACCOUNTS: AccountRecord[] = [
|
|
{ Id: '001xx000003DGQ', Name: 'Acme Corp', Industry: 'Technology' }
|
|
];
|
|
|
|
describe('c-account-list', () => {
|
|
let element: LightningElement & { maxRecords?: number };
|
|
|
|
afterEach(() => {
|
|
while (document.body.firstChild) {
|
|
document.body.removeChild(document.body.firstChild);
|
|
}
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('displays accounts after data loads', async () => {
|
|
(getAccounts as jest.Mock).mockResolvedValue(MOCK_ACCOUNTS);
|
|
|
|
element = createElement('c-account-list', { is: AccountList });
|
|
document.body.appendChild(element);
|
|
|
|
await Promise.resolve();
|
|
|
|
const items = element.shadowRoot?.querySelectorAll('.slds-item');
|
|
expect(items?.length).toBe(MOCK_ACCOUNTS.length);
|
|
});
|
|
});
|
|
```
|
|
|
|
### TypeScript Features for LWC
|
|
|
|
| Feature | LWC Support | Notes |
|
|
|---------|-------------|-------|
|
|
| **Interface definitions** | ✅ | Define shapes for records, events, props |
|
|
| **Typed @api properties** | ✅ | Getter/setter patterns with types |
|
|
| **Typed @wire results** | ✅ | Generic `WireResult<T>` pattern |
|
|
| **Typed event handlers** | ✅ | `CustomEvent<T>` for event detail typing |
|
|
| **Private class fields** | ✅ | Use `private` keyword |
|
|
| **Strict null checking** | ✅ | Optional chaining `?.` and nullish coalescing `??` |
|
|
|
|
---
|
|
|
|
## Apex Controller Patterns
|
|
|
|
### Cacheable Methods (for @wire)
|
|
|
|
```apex
|
|
public with sharing class LwcController {
|
|
|
|
@AuraEnabled(cacheable=true)
|
|
public static List<Account> getAccounts(String searchTerm) {
|
|
String searchKey = '%' + String.escapeSingleQuotes(searchTerm) + '%';
|
|
return [
|
|
SELECT Id, Name, Industry, AnnualRevenue
|
|
FROM Account
|
|
WHERE Name LIKE :searchKey
|
|
WITH SECURITY_ENFORCED
|
|
ORDER BY Name
|
|
LIMIT 50
|
|
];
|
|
}
|
|
|
|
@AuraEnabled(cacheable=true)
|
|
public static List<PicklistOption> getIndustryOptions() {
|
|
List<PicklistOption> options = new List<PicklistOption>();
|
|
Schema.DescribeFieldResult fieldResult =
|
|
Account.Industry.getDescribe();
|
|
for (Schema.PicklistEntry entry : fieldResult.getPicklistValues()) {
|
|
if (entry.isActive()) {
|
|
options.add(new PicklistOption(entry.getLabel(), entry.getValue()));
|
|
}
|
|
}
|
|
return options;
|
|
}
|
|
|
|
public class PicklistOption {
|
|
@AuraEnabled public String label;
|
|
@AuraEnabled public String value;
|
|
|
|
public PicklistOption(String label, String value) {
|
|
this.label = label;
|
|
this.value = value;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Non-Cacheable Methods (for DML)
|
|
|
|
```apex
|
|
@AuraEnabled
|
|
public static Account createAccount(String accountJson) {
|
|
Account acc = (Account) JSON.deserialize(accountJson, Account.class);
|
|
|
|
// FLS check
|
|
SObjectAccessDecision decision = Security.stripInaccessible(
|
|
AccessType.CREATABLE,
|
|
new List<Account>{ acc }
|
|
);
|
|
|
|
insert decision.getRecords();
|
|
return (Account) decision.getRecords()[0];
|
|
}
|
|
|
|
@AuraEnabled
|
|
public static void deleteAccounts(List<Id> accountIds) {
|
|
if (accountIds == null || accountIds.isEmpty()) {
|
|
throw new AuraHandledException('No accounts to delete');
|
|
}
|
|
|
|
List<Account> toDelete = [
|
|
SELECT Id FROM Account
|
|
WHERE Id IN :accountIds
|
|
WITH SECURITY_ENFORCED
|
|
];
|
|
|
|
delete toDelete;
|
|
}
|
|
```
|
|
|
|
### Error Handling Pattern
|
|
|
|
```apex
|
|
@AuraEnabled
|
|
public static List<Contact> getContactsWithErrorHandling(Id accountId) {
|
|
try {
|
|
if (accountId == null) {
|
|
throw new AuraHandledException('Account ID is required');
|
|
}
|
|
|
|
List<Contact> contacts = [
|
|
SELECT Id, Name, Email, Phone
|
|
FROM Contact
|
|
WHERE AccountId = :accountId
|
|
WITH SECURITY_ENFORCED
|
|
ORDER BY Name
|
|
LIMIT 100
|
|
];
|
|
|
|
return contacts;
|
|
} catch (Exception e) {
|
|
throw new AuraHandledException('Error fetching contacts: ' + e.getMessage());
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Related Resources
|
|
|
|
- [lms-guide.md](lms-guide.md) - Lightning Message Service deep dive
|
|
- [jest-testing.md](jest-testing.md) - Advanced testing patterns
|
|
- [accessibility-guide.md](accessibility-guide.md) - WCAG compliance
|
|
- [performance-guide.md](performance-guide.md) - Optimization techniques
|
|
|
|
---
|
|
|
|
## External References
|
|
|
|
- [PICKLES Framework](https://www.salesforceben.com/the-ideal-framework-for-architecting-salesforce-lightning-web-components/) — David Picksley, Third Eye Consulting
|
|
- [LWC Recipes (GitHub)](https://github.com/trailheadapps/lwc-recipes)
|
|
- [James Simone - Composable Modal](https://www.jamessimone.net/blog/joys-of-apex/lwc-composable-modal/)
|