The Kanban view is a staple for any modern CRM user interface — it’s visual, intuitive, and great for tracking progress. But what if the out-of-the-box Salesforce Kanban isn’t flexible enough for your unique use case? That’s where Custom Kanban View in Lightning Web Components (LWC) comes into play.
In this post, we’ll explore how you can build a powerful, customizable Kanban component using LWC, with features like:
✅ 1. Multiple View Support (User-Created & Switchable)
This Custom Kanban empowers each user to create and manage multiple personal Kanban views — all saved with unique names.
📌 2. Pin Your Favorite View
Tired of reselecting your view every time? With pinning, users can mark a default Kanban view, saved behind the scenes.
🧾 3. Display Multiple Fields per Card
Each Kanban card isn’t just a name — it’s a full snapshot.
🧩 4. Group by Any Field — Not Just Picklists
Why limit grouping to picklists? This Kanban lets you group records by any field.
🔄 5. Drag-and-Drop Updates (with Apex Integration)
Users can drag records from one column to another — and it’s not just for show.
⚙️ 6. Easy Configuration for Admins
No need to deploy code for every change.
🛠 Tech Stack Overview
- LWC Frontend: Responsive, lightweight, and mobile-compatible
- Apex Controller: Dynamic SOQL, drag-and-drop save handlers
- Data Layer: Custom Object
Now, let’s deep dive into the code to see how this Custom Kanban View comes together in LWC.
We’ll walk through the key components step-by-step so you can easily follow along.
Ready? Let’s get coding!
To get started, you’ll first need to create a custom object called Kanban_Setting__c. This object will store all the configuration details for your custom Kanban view — making it flexible and reusable across different use cases. Be sure to add the following fields to the object:
Object_API_Name__c(Text) – API name of the object you want to visualize in the Kanban.Group_By_Field__c(Text) – The field by which records will be grouped into columns.Summary_Field__c(Text) – The field you want to aggregate (e.g., Amount for Opportunities).Fields_To_Show__c(Long Text Area) – Comma-separated list of fields to display on each card.Pinned__c(Checkbox) – Use this to mark a configuration as pinned or default.- User__c (lookup)
Once this object is ready and populated, you’ll be able to fetch its values using the getKanbanConfigById method to dynamically render your Kanban UI.
Below are the key files powering our Custom Kanban solution — including Lightning Web Components and Apex controllers. Let’s explore how each piece fits into the bigger picture:
lwc/kanbanBoardclasses/DynamicKanbanController.clslwc/kanbanConfiguratorclasses/KanbanConfiguratorController.cls
kanbanBoard.html
<template>
<div class="slds-grid slds-m-bottom_medium slds-align_absolute-center slds-card">
<div class="slds-col slds-size_1-of-2">
<div class="slds-grid slds-align_absolute-center slds-m-bottom_small">
<div class="slds-col">
<lightning-combobox name="kanbanSelector" label="Select Kanban View" value={selectedKanbanId}
options={kanbanOptions} onchange={handleKanbanChange}>
</lightning-combobox>
</div>
<div class="slds-col slds-p-left_small slds-align-bottom">
<template lwc:if={isPinned}>
<lightning-button-icon icon-name="utility:bookmark" alternative-text="Pinned"
title="Unpin Kanban" onclick={handlePinClick} data-id={selectedKanbanId}>
</lightning-button-icon>
</template>
<template lwc:else>
<lightning-button-icon icon-name="utility:bookmark_alt" alternative-text="Pin"
title="Pin Kanban" onclick={handlePinClick} data-id={selectedKanbanId}>
</lightning-button-icon>
</template>
</div>
</div>
</div>
<div class="slds-col slds-size_1-of-2 slds-text-align_right">
<lightning-button variant="brand" label="Configure Kanban" onclick={openConfigModal}>
</lightning-button>
</div>
</div>
<!-- KANBAN COLUMNS -->
<div class="kanban-columns-container" role="list">
<template for:each={groupedDataWithClass} for:item="column">
<div key={column.status} class={column.cssClass} data-status={column.status} ondragover={allowDrop}
ondrop={handleDrop} role="listitem">
<h3 class="kanban-column-header">
{column.status} ({column.records.length})
<p>{column.total}</p>
</h3>
<template for:each={column.records} for:item="record">
<div key={record.Id} draggable="true" data-id={record.Id} ondragstart={handleDragStart}
ondragend={handleDragEnd} class="kanban-card slds-box slds-m-bottom_x-small">
<div class="kanban-card-title-container"
style="display: flex; align-items: center; justify-content: space-between;">
<button class="slds-button slds-text-link slds-truncate"
style="flex: 1; padding-right: 0.5rem;" title={record.title} data-id={record.Id}
onclick={navigateToRecord}>
{record.title}
</button>
<div class={record.dropdownClass}>
<button class="slds-button slds-button_icon slds-button_icon-border-filled"
aria-haspopup="true" aria-expanded={record.dropdownOpen} title="Show More"
data-id={record.Id} onclick={toggleDropdown}>
<lightning-icon icon-name="utility:down" size="x-small" alternative-text="Show More"
class="slds-button__icon"></lightning-icon>
<span class="slds-assistive-text">Show More</span>
</button>
<div class="slds-dropdown slds-dropdown_left">
<ul class="slds-dropdown__list" role="menu" aria-label="Show More">
<template if:true={record.permissions.canEdit}>
<li class="slds-dropdown__item" role="presentation">
<a href="javascript:void(0);" role="menuitem" tabindex="0"
data-id={record.Id} onclick={handleEdit}>
<span class="slds-truncate" title="Edit">Edit</span>
</a>
</li>
</template>
<template if:true={record.permissions.canDelete}>
<li class="slds-dropdown__item" role="presentation">
<a href="javascript:void(0);" role="menuitem" tabindex="-1"
data-id={record.Id} onclick={handleDelete}>
<span class="slds-truncate" title="Delete">Delete</span>
</a>
</li>
</template>
<template if:true={record.permissions.canClone}>
<li class="slds-dropdown__item" role="presentation">
<a href="javascript:void(0);" role="menuitem" tabindex="-1"
data-id={record.Id} onclick={handleClone}>
<span class="slds-truncate" title="Clone">Clone</span>
</a>
</li>
</template>
<template if:true={record.permissions.canChangeOwner}>
<li class="slds-dropdown__item" role="presentation">
<a href="javascript:void(0);" role="menuitem" tabindex="-1"
data-id={record.Id} onclick={handleChangeOwner}>
<span class="slds-truncate" title="Change Owner">Change Owner</span>
</a>
</li>
</template>
</ul>
</div>
</div>
</div>
<template for:each={record.fields} for:item="field">
<p key={field.apiName} class="slds-text-body_small">
<template if:true={field.isLink}>
<a href="javascript:void(0);" onclick={navigateToRelatedRecord}
data-id={field.recordId}>
{field.value}
</a>
</template>
<template if:false={field.isLink}>
{field.value}
</template>
</p>
</template>
</div>
</template>
</div>
</template>
</div>
<!-- MODAL -->
<template if:true={showModal}>
<!-- Make sure backdrop is here for centering and focus trap -->
<div class="slds-backdrop slds-backdrop_open"></div>
<section role="dialog" tabindex="-1" class="slds-modal slds-fade-in-open slds-modal_small"
aria-labelledby="modal-heading-01" aria-modal="true">
<div class="slds-modal__container">
<div class="slds-modal__header">
<h2 class="slds-text-heading_medium slds-text-title_bold">Configure Kanban</h2>
<button class="slds-button slds-modal__close" onclick={closeConfigModalWithoutUpdate}>
<lightning-icon icon-name="utility:close" alternative-text="Close"
size="small"></lightning-icon>
</button>
</div>
<div class="slds-modal__content slds-p-around_medium" id="modal-content-id-1">
<slot name="bodycontent">
<c-kanban-configurator selected-kanban-record={selectedKanbanRecord}
onclose={closeConfigModal}></c-kanban-configurator>
</slot>
</div>
<div class="slds-modal__footer">
<slot name="footercontent"></slot>
</div>
</div>
</section>
<div class="slds-backdrop slds-backdrop_open" role="presentation"></div>
</template>
</template>kanbanBoard.css
/* Reset margin and box sizing */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.kanban-columns-container {
display: flex;
flex-direction: row;
gap: 0;
overflow-x: auto;
white-space: nowrap;
padding-bottom: 1rem;
margin-bottom: 1rem;
}
.kanban-column,
[data-status] {
display: flex;
flex-direction: column;
width: 280px;
margin: 0;
padding: 0.5rem;
background-color: #fff;
border: 1px solid #d8dde6;
border-radius: 0.25rem;
flex-shrink: 0;
}
.kanban-column-header {
background-color: #f3f3f3;
padding: 0.75rem;
border-bottom: 1px solid #d8dde6;
border-top-left-radius: 0.25rem;
border-top-right-radius: 0.25rem;
text-align: center;
font-weight: bold;
color: #032d60;
font-size: 0.85rem;
}
.kanban-card {
cursor: grab;
background-color: #fff;
border: 1px solid #d8dde6;
border-radius: 0.25rem;
margin-bottom: 0.5rem;
padding: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
user-select: none;
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.kanban-card:active {
cursor: grabbing;
background-color: #eef4fb;
}
.kanban-card.dragging {
opacity: 0.95;
transform: scale(1.1);
box-shadow:
0 0 8px 3px #0070d2,
0 8px 15px rgba(0, 112, 210, 0.4);
z-index: 2000;
position: relative;
background-color: #ffffff;
transition: box-shadow 0.3s ease, transform 0.3s ease;
}
.kanban-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.kanban-card-title {
flex: 1 1 auto;
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
}
.slds-badge.slds-theme_info {
margin-top: 0.5rem;
display: inline-block;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background-color: #d8e6f9;
color: #032d60;
}
@media (max-width: 600px) {
.kanban-column,
[data-status] {
width: 220px !important;
}
}
.slds-show {
display: block;
}
.slds-hide {
display: none;
}
.kanban-card-title-container p {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.slds-modal__container {
margin-left: auto !important;
margin-right: auto !important;
transform: translateX(0) !important;
}kanbanBoard.js
import { LightningElement, api, track, wire } from 'lwc';
import { NavigationMixin } from 'lightning/navigation';
import { deleteRecord } from 'lightning/uiRecordApi';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import LightningConfirm from 'lightning/confirm';
import { refreshApex } from '@salesforce/apex';
import getRecords from '@salesforce/apex/DynamicKanbanController.getRecords';
import getUserPermissions from '@salesforce/apex/DynamicKanbanController.getUserPermissions';
import updateRecordStatus from '@salesforce/apex/DynamicKanbanController.updateRecordStatus';
import getUserKanbanSettings from '@salesforce/apex/DynamicKanbanController.getUserKanbanSettings';
import getKanbanConfigById from '@salesforce/apex/DynamicKanbanController.getKanbanConfigById';
import updateKanbanSetting from '@salesforce/apex/DynamicKanbanController.updateKanbanSetting';
const BLANK_STATUS_KEY = '__BLANK__';
export default class KanbanBoard extends NavigationMixin(LightningElement) {
@api objectApiName;
@api picklistField;
@api displayFields;
@track records = [];
@track statusOrder = [];
@track selectedStatus = '';
@track userPermissions = {
edit: false,
delete: false,
clone: false,
changeOwner: false
};
// NEW: Kanban settings dropdown
@track kanbanOptions = [];
@track selectedKanbanId = '';
@track selectedKanbanRecord;
showModal = false;
draggedRecordId;
connectedCallback() {
const params = new URLSearchParams(window.location.search);
if (params && params.get('c__objectApiName')) {
this.objectApiName = params.get('c__objectApiName');
}
if (this.objectApiName) {
this.loadKanbanSettings();
this.boundHandleWindowClick = this.handleWindowClick.bind(this);
window.addEventListener('click', this.boundHandleWindowClick);
this.loadUserPermissions();
}
}
disconnectedCallback() {
window.removeEventListener('click', this.boundHandleWindowClick);
}
loadKanbanSettings() {
getUserKanbanSettings({ objectApiName: this.objectApiName })
.then(data => {
this.kanbanOptions = data.map(obj => ({
label: obj.label,
value: obj.value,
pinned: obj.pinned === true || obj.pinned === 'true' // Normalize to boolean
}));
// Try to find a pinned option
const pinnedItem = this.kanbanOptions.find(option => option.pinned);
if (pinnedItem) {
this.selectedKanbanId = pinnedItem.value;
this.isPinned = true;
} else {
this.selectedKanbanId = this.kanbanOptions[0]?.value || '';
this.isPinned = false;
}
if (this.selectedKanbanId) {
//this.isPinned = true;
this.getKanbanConfig(true);
}
console.log('this.kanbanOptions', this.kanbanOptions, data);
this.kanbanOptions = JSON.parse(JSON.stringify(this.kanbanOptions));
console.log('this.selectedKanbanId', this.selectedKanbanId, this.isPinned);
})
.catch(error => {
console.error('Error fetching Kanban settings:', error);
});
}
@track isPinned;
handlePinClick(event) {
this.updateConfig();
}
updateConfig() {
if (!this.selectedKanbanId) return;
updateKanbanSetting({ recordId: this.selectedKanbanId })
.then(result => {
// Optionally refresh your view or show a success toast
console.log('Pin status updated', result);
// Optionally refresh Apex if using @wire
this.isPinned = result;
})
.catch(error => {
console.error('Error updating pin status:', error);
});
}
loadUserPermissions() {
getUserPermissions({ objectApiName: this.objectApiName })
.then(result => {
this.userPermissions = result;
})
.catch(error => {
console.error('Error fetching user permissions:', error);
});
}
get fieldList() {
return this.displayFields?.split(',').map(f => f.trim()) || [];
}
loadRecords() {
getRecords({
objectApiName: this.objectApiName,
picklistField: this.picklistField,
fieldApiNames: this.fieldList
})
.then(data => {
console.log('data', data);
const transformedRecords = data.map(rec => ({
...rec,
dropdownOpen: false,
dropdownClass: 'slds-dropdown-trigger slds-dropdown-trigger_click',
permissions: {
canEdit: this.userPermissions.edit,
canDelete: this.userPermissions.delete,
canClone: this.userPermissions.clone,
canChangeOwner: this.userPermissions.changeOwner
}
}));
this.records = [...transformedRecords];
console.log('this.picklistField ', this.picklistField);
const uniqueStatuses = [...new Set(data.map(r => r[this.picklistField] ?? BLANK_STATUS_KEY))];
console.log('uniqueStatuses ', uniqueStatuses);
this.statusOrder = uniqueStatuses.map((status, i) => ({ status, value: i + 1 }));
if (!this.selectedStatus && this.statusOrder.length > 0) {
this.selectedStatus = this.statusOrder[0].status;
}
console.log('statusOrder', this.statusOrder);
console.log('this.selectedStatus', this.selectedStatus);
console.log('groupedDataWithClass', this.groupedDataWithClass);
})
.catch(error => {
this.wiredRecordsResult = { error };
console.error('Error loading records:', error);
});
}
resolveField(record, fieldPath) {
try {
return fieldPath.split('.').reduce((acc, key) => acc && acc[key], record);
} catch (e) {
return null;
}
}
get groupedData() {
const fields = this.fieldList || [];
const displayFields = [...new Set(fields.filter(f => f !== 'Name'))];
// Get all unique status values using resolveField
const statusSet = new Set(
this.records.map(r => this.resolveField(r, this.picklistField) ?? '__BLANK__')
);
const statusOrder = [...statusSet].map(status => ({
status,
value: status
}));
return statusOrder.map(({ status, value }) => {
const records = this.records
.filter(r => {
const picklistValue = this.resolveField(r, this.picklistField);
return (picklistValue ?? '__BLANK__') === status;
})
.map(r => {
const resolvedFields = displayFields.map(field => {
let value = this.resolveField(r, field);
let recordId, isLink = false;
if (field.includes('.') && field.endsWith('.Name')) {
// If field is like Account.Name, attempt to get related Id
const parentObject = field.split('.')[0];
recordId = r[parentObject]?.Id;
isLink = !!recordId;
}
return {
apiName: field,
value,
isLink,
recordId
};
});
return {
...r,
title: r['Name'],
fields: resolvedFields
};
});
// Calculate summary
const total = records.reduce((sum, rec) => {
const val = Number(rec[this.summaryField]) || 0;
return sum + val;
}, 0);
return {
status,
value,
label: status === '__BLANK__' ? 'Unassigned' : status,
records,
total
};
});
}
/*get groupedData() {
const fields = this.fieldList || [];
const displayFields = [...new Set(fields.filter(f => f !== 'Name'))];
return this.statusOrder.map(({ status, value }) => {
const records = this.records
.filter(r => (r[this.picklistField] ?? BLANK_STATUS_KEY) === status)
.map(r => {
const resolvedFields = displayFields.map(field => {
let value, recordId, isLink = false;
if (field.includes('.')) {
const [parent, child] = field.split('.');
value = r[parent]?.[child] ?? '';
// if Account.Name, grab Account.Id to allow linking
if (child === 'Name' && r[parent]?.Id) {
isLink = true;
recordId = r[parent].Id;
}
} else {
value = r[field];
}
return {
apiName: field,
value,
isLink,
recordId
};
});
return {
...r,
title: r['Name'],
fields: resolvedFields
};
});
// Calculate summary
const total = records.reduce((sum, rec) => {
const val = Number(rec[this.summaryField]) || 0;
return sum + val;
}, 0);
return {
status,
value,
label: status === BLANK_STATUS_KEY ? 'Unassigned' : status,
records,
total
};
});
}*/
get groupedDataWithClass() {
return this.groupedData.map(column => ({
...column,
cssClass:
column.status === this.selectedStatus
? 'kanban-column kanban-column-selected slds-box slds-m-around_small'
: 'kanban-column slds-box slds-m-around_small'
}));
}
navigateToRelatedRecord(event) {
const recordId = event.target.dataset.id;
this[NavigationMixin.Navigate]({
type: 'standard__recordPage',
attributes: {
recordId: recordId,
actionName: 'view'
}
});
}
handleWindowClick() {
this.closeAllDropdowns();
}
toggleDropdown(event) {
event.stopPropagation();
const recordId = event.currentTarget.dataset.id;
this.records = this.records.map(r => {
if (r.Id === recordId) {
const newOpen = !r.dropdownOpen;
return {
...r,
dropdownOpen: newOpen,
dropdownClass: newOpen
? 'slds-dropdown-trigger slds-dropdown-trigger_click slds-is-open'
: 'slds-dropdown-trigger slds-dropdown-trigger_click'
};
}
return {
...r,
dropdownOpen: false,
dropdownClass: 'slds-dropdown-trigger slds-dropdown-trigger_click'
};
});
}
closeAllDropdowns() {
this.records = this.records.map(r => ({
...r,
dropdownOpen: false,
dropdownClass: 'slds-dropdown-trigger slds-dropdown-trigger_click'
}));
}
handleEdit(event) {
event.preventDefault();
event.stopPropagation();
const recordId = event.currentTarget.dataset.id;
this.closeAllDropdowns();
this[NavigationMixin.Navigate]({
type: 'standard__recordPage',
attributes: {
recordId,
objectApiName: this.objectApiName,
actionName: 'edit'
}
});
}
async handleDelete(event) {
event.preventDefault();
event.stopPropagation();
const recordId = event.currentTarget.dataset.id;
this.closeAllDropdowns();
const confirmed = await LightningConfirm.open({
message: 'Are you sure you want to delete this record?',
variant: 'default',
label: 'Confirm Delete',
theme: 'destructive'
});
if (confirmed) {
try {
await deleteRecord(recordId);
this.records = this.records.filter(r => r.Id !== recordId);
this.dispatchEvent(
new ShowToastEvent({
title: 'Deleted',
message: 'Record deleted successfully',
variant: 'success'
})
);
} catch (error) {
this.dispatchEvent(
new ShowToastEvent({
title: 'Error deleting record',
message: error.body?.message || error.message,
variant: 'error'
})
);
}
}
}
handleClone(event) {
event.preventDefault();
event.stopPropagation();
const recordId = event.currentTarget.dataset.id;
this.closeAllDropdowns();
this[NavigationMixin.Navigate]({
type: 'standard__recordPage',
attributes: {
recordId,
objectApiName: this.objectApiName,
actionName: 'clone'
}
});
}
handleChangeOwner(event) {
event.preventDefault();
event.stopPropagation();
const recordId = event.currentTarget.dataset.id;
this.closeAllDropdowns();
this[NavigationMixin.Navigate]({
type: 'standard__quickAction',
attributes: {
apiName: 'ChangeOwner'
},
state: {
recordId,
objectApiName: this.objectApiName
}
});
}
allowDrop(event) {
event.preventDefault();
}
handleDragStart(event) {
this.draggedRecordId = event.target.dataset.id;
event.target.classList.add('dragging');
}
handleDragEnd(event) {
event.target.classList.remove('dragging');
}
async handleDrop(event) {
event.preventDefault();
const newStatus = event.currentTarget.dataset.status;
if (!this.draggedRecordId || newStatus == null) return;
const apiStatus = newStatus === BLANK_STATUS_KEY ? null : newStatus;
try {
await updateRecordStatus({
objectApiName: this.objectApiName,
recordId: this.draggedRecordId,
picklistField: this.picklistField,
newValue: apiStatus
});
this.records = this.records.map(r =>
r.Id === this.draggedRecordId
? { ...r, [this.picklistField]: apiStatus }
: r
);
this.selectedStatus = newStatus;
this.loadRecords();
this.dispatchEvent(
new ShowToastEvent({
title: 'Saved',
message: 'Record update successfully',
variant: 'success'
})
);
} catch (error) {
console.error('Error updating record status:', error);
this.dispatchEvent(
new ShowToastEvent({
title: 'Error saving',
message: error.body ? error.body.message : error.message,
variant: 'error',
mode: 'dismissable'
})
);
}
}
navigateToRecord(event) {
const recordId = event.currentTarget.dataset.id;
this[NavigationMixin.Navigate]({
type: 'standard__recordPage',
attributes: {
recordId,
objectApiName: this.objectApiName,
actionName: 'view'
}
});
}
openConfigModal() {
this.showModal = true;
}
closeConfigModalWithoutUpdate(event) {
this.showModal = false;
}
closeConfigModal(event) {
this.showModal = false;
this.objectApiName = event.detail.objectApiName;
this.picklistField = event.detail.groupField;
this.summaryField = event.detail.summaryField;
this.displayFields = event.detail.selectedFieldApiNames.join(',');
this.selectedKanbanId = event.detail.recordId;
console.log('selectedKanbanId', event.detail.recordId);
this.getKanbanConfig();
if (event.detail.newrecordcreated) {
getUserKanbanSettings({ objectApiName: this.objectApiName })
.then(data => {
this.kanbanOptions = data.map(obj => ({
label: obj.label,
value: obj.value,
pinned: obj.pinned === true || obj.pinned === 'true' // Normalize to boolean
}));
})
.catch(error => {
console.error('Error fetching Kanban settings:', error);
});
}
}
summaryField;
handleKanbanChange(event) {
this.selectedKanbanId = event.detail.value;
//this.isPinned = (this.kanbanOptions.find(e => e.value === this.selectedKanbanId)?.pinned) === true;
console.log('pin change ', event.detail, this.isPinned);
this.getKanbanConfig();
}
getKanbanConfig(isload) {
getKanbanConfigById({ kanbanConfigId: this.selectedKanbanId })
.then(config => {
if (config) {
this.selectedKanbanRecord = config;
this.objectApiName = config.Object_API_Name__c;
this.picklistField = config.Group_By_Field__c;
this.summaryField = config.Summary_Field__c;
this.displayFields = config.Fields_To_Show__c;
this.isPinned = isload ? this.isPinned : config.Pinned__c;
this.loadRecords();
}
console.log('config ', config);
})
.catch(error => {
console.error('Error fetching Kanban config by ID:', error);
});
}
}kanbanBoard.js-meta.xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>62.0</apiVersion>
<isExposed>true</isExposed>
<targets>
<target>lightning__AppPage</target>
<target>lightning__RecordPage</target>
<target>lightning__HomePage</target>
</targets>
<targetConfigs>
<targetConfig targets="lightning__AppPage, lightning__RecordPage, lightning__HomePage">
<property name="objectApiName" type="String" label="Object API Name" required="true" default="Account"/>
<property name="picklistField" type="String" label="Picklist Field (for columns)" required="true" default="Industry"/>
<property name="displayFields" type="String" label="Display Fields (comma-separated)" required="true" default="Name,Phone"/>
</targetConfig>
</targetConfigs>
</LightningComponentBundle>DynamicKanbanController.cls
public with sharing class DynamicKanbanController {
@AuraEnabled
public static List<sObject> getRecords(String objectApiName, String picklistField, List<String> fieldApiNames) {
if (String.isBlank(objectApiName) || String.isBlank(picklistField) || fieldApiNames == null || fieldApiNames.isEmpty()) {
throw new AuraHandledException('Missing required parameters.');
}
// Use a map to deduplicate by lowercase, but keep original casing for SOQL
Map<String, String> fieldMap = new Map<String, String>();
// Add fields from input
for (String field : fieldApiNames) {
if (!String.isBlank(field)) {
fieldMap.put(field.trim().toLowerCase(), field.trim());
}
}
// Ensure 'Id' is present
fieldMap.put('id', 'Id');
// Add picklist fields (supports comma-separated values)
for (String pickField : picklistField.split(',')) {
if (!String.isBlank(pickField)) {
String trimmed = pickField.trim();
fieldMap.put(trimmed.toLowerCase(), trimmed);
}
}
// Prepare final SOQL
List<String> finalFields = new List<String>(fieldMap.values());
String fields = String.join(finalFields, ',');
String query = 'SELECT ' + fields + ' FROM ' + objectApiName + ' ORDER BY CreatedDate DESC LIMIT 200';
return Database.query(query);
}
@AuraEnabled(cacheable=true)
public static Map<String, Boolean> getUserPermissions(String objectApiName) {
if (String.isBlank(objectApiName)) {
throw new AuraHandledException('Object API name is required.');
}
SObject obj;
try {
obj = (SObject) Type.forName(objectApiName).newInstance();
} catch (Exception e) {
throw new AuraHandledException('Invalid Object API Name: ' + objectApiName);
}
Schema.DescribeSObjectResult describe = obj.getSObjectType().getDescribe();
Map<String, Boolean> permissions = new Map<String, Boolean>();
permissions.put('edit', describe.isUpdateable());
permissions.put('delete', describe.isDeletable());
permissions.put('clone', describe.isCreateable());
if (describe.fields.getMap().containsKey('OwnerId')) {
permissions.put('changeOwner', describe.fields.getMap().get('OwnerId').getDescribe().isUpdateable());
} else {
permissions.put('changeOwner', false);
}
return permissions;
}
@AuraEnabled
public static void updateRecordStatus(String objectApiName, Id recordId, String picklistField, String newValue) {
if (recordId == null || String.isBlank(objectApiName) || String.isBlank(picklistField) || String.isBlank(newValue)) {
throw new AuraHandledException('Missing parameters for update.');
}
// Describe the field type
Schema.SObjectType sObjectType = Schema.getGlobalDescribe().get(objectApiName);
if (sObjectType == null) {
throw new AuraHandledException('Invalid object API name: ' + objectApiName);
}
Map<String, Schema.SObjectField> fieldMap = sObjectType.getDescribe().fields.getMap();
if (!fieldMap.containsKey(picklistField)) {
throw new AuraHandledException('Field not found: ' + picklistField);
}
Schema.DisplayType fieldType = fieldMap.get(picklistField).getDescribe().getType();
// Query record
SObject rec = Database.query('SELECT Id, ' + picklistField + ' FROM ' + objectApiName + ' WHERE Id = :recordId LIMIT 1');
Object typedValue;
switch on fieldType {
when Picklist, String, Textarea, Email, Url, Phone {
typedValue = newValue;
}
when Integer {
typedValue = Integer.valueOf(newValue);
}
when Long {
typedValue = Long.valueOf(newValue);
}
when Double {
typedValue = Double.valueOf(newValue);
}
when Currency, Percent {
typedValue = Decimal.valueOf(newValue);
}
when Boolean {
typedValue = (newValue.toLowerCase() == 'true');
}
when Date {
typedValue = Date.valueOf(newValue);
}
when Datetime {
typedValue = Datetime.valueOf(newValue);
}
when else {
throw new AuraHandledException('Unsupported field type for dynamic update: ' + fieldType);
}
}
rec.put(picklistField, typedValue);
update rec;
}
/**
* Get all saved Kanban configurations for the current user
*/
@AuraEnabled
public static List<Map<String, String>> getUserKanbanSettings(String objectApiName) {
List<Map<String, String>> settingsList = new List<Map<String, String>>();
for (Kanban_Setting__c setting : [
SELECT Id, Name, Object_API_Name__c, Pinned__c
FROM Kanban_Setting__c
WHERE User__c = :UserInfo.getUserId()
AND Object_API_Name__c = :objectApiName
ORDER BY CreatedDate DESC
]) {
Map<String, String> row = new Map<String, String>();
row.put('label', setting.Name);
row.put('value', setting.Id);
row.put('pinned', String.valueOf(setting.Pinned__c));
settingsList.add(row);
}
return settingsList;
}
@AuraEnabled
public static Kanban_Setting__c getKanbanConfigById(Id kanbanConfigId) {
return [
SELECT Id, Name, Object_API_Name__c, Group_By_Field__c, Summary_Field__c, Fields_To_Show__c, Pinned__c
FROM Kanban_Setting__c
WHERE Id = :kanbanConfigId
LIMIT 1
];
}
@AuraEnabled
public static Boolean updateKanbanSetting(String recordId) {
if (String.isNotBlank(recordId)) {
// Get the selected setting record to toggle pin
Kanban_Setting__c selectedSetting = [
SELECT Id, Pinned__c, Object_API_Name__c, User__c
FROM Kanban_Setting__c
WHERE Id = :recordId
LIMIT 1
];
// Flip the pinned value
Boolean newPinnedValue = !selectedSetting.Pinned__c;
selectedSetting.Pinned__c = newPinnedValue;
List<Kanban_Setting__c> toUpdate = new List<Kanban_Setting__c>();
toUpdate.add(selectedSetting);
if (newPinnedValue) {
// Unpin all other settings for this user and object
List<Kanban_Setting__c> others = [
SELECT Id, Pinned__c
FROM Kanban_Setting__c
WHERE Object_API_Name__c = :selectedSetting.Object_API_Name__c
AND User__c = :selectedSetting.User__c
AND Id != :selectedSetting.Id
AND Pinned__c = true
];
for (Kanban_Setting__c other : others) {
other.Pinned__c = false;
toUpdate.add(other);
}
}
update toUpdate;
return selectedSetting.Pinned__c;
}
return false;
}
}kanbanConfigurator.html
<template>
<lightning-card title="Kanban Configurator">
<!-- Config name -->
<lightning-input label="kanban View Name" value={kanbanViewName} onchange={handlekanbanNameChange}>
</lightning-input>
<!-- Object Selection -->
<lightning-combobox label="Object" value={selectedObject} options={objectOptions} onchange={handleObjectChange}>
</lightning-combobox>
<!-- Field Selection -->
<template if:true={fieldOptions}>
<lightning-dual-listbox name="fieldsToDisplay" label="Fields to Display" source-label="Available"
selected-label="Selected" options={fieldOptions} value={selectedFields} onchange={handleFieldSelection}>
</lightning-dual-listbox>
<lightning-combobox name="groupBy" label="Group By Field" options={fieldOptions} value={groupByField}
onchange={handleGroupChange}>
</lightning-combobox>
<lightning-combobox name="summaryField" label="Summary Field" options={fieldOptions} value={summaryField}
onchange={handleSummaryChange}>
</lightning-combobox>
<!-- Save Button -->
<div class="slds-align_absolute-center slds-m-top_medium">
<lightning-button label="Update Configuration" variant="brand" onclick={handleUpdateSaveClick}></lightning-button>
<lightning-button label="New Configuration" variant="brand" onclick={handleNewSaveClick}></lightning-button>
</div>
</template>
</lightning-card>
</template>kanbanConfigurator.js
import { LightningElement, track, api } from 'lwc';
import { ShowToastEvent } from 'lightning/platformShowToastEvent'; // <-- Import toast event
import getObjectNames from '@salesforce/apex/KanbanConfiguratorController.getObjectLabelsAndApiNames';
import getFieldLabelsAndApiNames from '@salesforce/apex/KanbanConfiguratorController.getFieldLabelsAndApiNames';
import saveKanbanConfig from '@salesforce/apex/KanbanConfiguratorController.saveKanbanConfig';
import getKanbanConfigByUserAndObject from '@salesforce/apex/KanbanConfiguratorController.getKanbanConfigByUserAndObject';
import { NavigationMixin } from 'lightning/navigation';
export default class KanbanConfigurator extends NavigationMixin(LightningElement) {
@api selectedKanbanRecord;
@track objectOptions = [];
@track fieldOptions = [];
@track selectedObject = '';
@track selectedFields = [];
@track groupByField = '';
@track summaryField = '';
fieldMap = new Map(); // label → API name
reverseFieldMap = new Map(); // API name → label
connectedCallback() {
this.loadObjectList();
this.readObjectFromURL();
}
async readObjectFromURL() {
const params = new URLSearchParams(window.location.search);
//const objectApi = params.get('c__objectApiName');
if (params && params.get('c__objectApiName')) {
this.selectedObject = params.get('c__objectApiName');
console.log('this.fieldOptions:',this.fieldOptions);
await this.loadFields(this.selectedObject);
console.log('this.selectedKanbanRecord:', this.selectedKanbanRecord, this.fieldOptions);
console.log('this.selectedObject:',this.selectedObject);
if (this.selectedKanbanRecord) {
this.kanbanViewName = this.selectedKanbanRecord.Name;
this.groupByField = this.reverseFieldMap.get(this.selectedKanbanRecord.Group_By_Field__c) || this.selectedKanbanRecord.Group_By_Field__c;
this.summaryField = this.reverseFieldMap.get(this.selectedKanbanRecord.Summary_Field__c) || this.selectedKanbanRecord.Summary_Field__c;
const selectedApiFields = this.selectedKanbanRecord.Fields_To_Show__c.split(',');
this.selectedFields = selectedApiFields.map(api => this.reverseFieldMap.get(api) || api);
console.log('this.groupByField:', this.groupByField);
console.log('this.summaryField:', this.summaryField);
console.log('this.selectedFields::',this.selectedFields);
}else{
this.loadExistingConfig(this.selectedObject);
}
}
}
loadObjectList() {
getObjectNames().then(result => {
this.objectOptions = result.map(
obj => ({
label: obj.label,
value: obj.apiName
})
);
}).catch(error => {
console.error('Error loading object names:', error);
});
}
handleObjectChange(event) {
this.selectedObject = event.detail.value;
this.loadFields(this.selectedObject);
this.loadExistingConfig(this.selectedObject);
}
async loadFields(objectName) {
await getFieldLabelsAndApiNames({ objectName }).then(fields => {
console.log('getFieldLabelsAndApiNames ',fields);
this.fieldMap.clear();
this.reverseFieldMap.clear();
this.fieldOptions = fields.map(fld => {
this.fieldMap.set(fld.label, fld.apiName);
this.reverseFieldMap.set(fld.apiName, fld.label);
return { label: fld.label, value: fld.label };
});
});
}
loadExistingConfig(objectApiName) {
getKanbanConfigByUserAndObject({ objectApiName }).then(config => {
if (config) {
this.groupByField = this.reverseFieldMap.get(config.Group_By_Field__c) || config.Group_By_Field__c;
this.summaryField = this.reverseFieldMap.get(config.Summary_Field__c) || config.Summary_Field__c;
const selectedApiFields = config.Fields_To_Show__c.split(',');
this.selectedFields = selectedApiFields.map(api => this.reverseFieldMap.get(api) || api);
} else {
this.groupByField = '';
this.summaryField = '';
this.selectedFields = [];
}
});
}
handleFieldSelection(event) {
this.selectedFields = event.detail.value;
//console.log('this.selectedFields:', this.selectedFields, this.fieldOptions);
}
handleGroupChange(event) {
this.groupByField = event.detail.value;
console.log('this.groupByField:', this.groupByField);
}
handleSummaryChange(event) {
this.summaryField = event.detail.value;
console.log('this.summaryField:', this.summaryField);
}
kanbanViewName;
handlekanbanNameChange(event){
this.kanbanViewName = event.target.value;
}
handleNewSaveClick(){
this.handleSaveClick(true);
}
handleUpdateSaveClick(){
this.handleSaveClick(false);
}
navigateToAppPage() {
this[NavigationMixin.Navigate]({
type: 'standard__navItemPage',
attributes: {
apiName: 'Kanban' // e.g., 'Custom_Kanban_App'
},
state: {
c__objectApiName: this.selectedObject // Pass your objectApiName value here
}
});
}
handleSaveClick(isnew) {
if (!this.selectedObject || this.selectedFields.length === 0 || !this.groupByField || !this.summaryField || !this.kanbanViewName) {
this.dispatchEvent(
new ShowToastEvent({
title: 'Error saving configuration',
message: 'fill all the missing fields',
variant: 'error',
mode: 'dismissable'
})
);
// You can also show an error toast here if you want
return;
}
const fieldApiList = this.selectedFields.map(label => this.fieldMap.get(label));
const groupFieldApi = this.fieldMap.get(this.groupByField);
const summaryFieldApi = this.fieldMap.get(this.summaryField);
console.log('this.selectedKanbanRecord.Id ',this.selectedKanbanRecord.Id)
saveKanbanConfig({
name: this.kanbanViewName,
objectApiName: this.selectedObject,
groupField: groupFieldApi,
summaryField: summaryFieldApi,
selectedFieldApiNames: fieldApiList,
recordId: !isnew ? this.selectedKanbanRecord.Id : ''
}).then(result => {
// Show success toast
this.dispatchEvent(
new ShowToastEvent({
title: 'Success',
message: 'Configuration saved successfully!',
variant: 'success',
mode: 'dismissable'
})
);
if(isnew){
this.navigateToAppPage();
}
// Close the modal by dispatching a custom event 'close'
this.dispatchEvent(new CustomEvent('close', { detail: {
objectApiName: this.selectedObject,
groupField: groupFieldApi,
summaryField: summaryFieldApi,
selectedFieldApiNames: fieldApiList,
recordId: this.selectedKanbanRecord.Id,
name: this.kanbanViewName,
newrecordcreated: isnew
}}
));
}).catch(error => {
this.dispatchEvent(
new ShowToastEvent({
title: 'Error saving configuration',
message: error.body ? error.body.message : error.message,
variant: 'error',
mode: 'dismissable'
})
);
});
}
}kanbanConfigurator.js-meta.xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>62.0</apiVersion>
<isExposed>true</isExposed>
<targets>
<target>lightning__AppPage</target>
<target>lightning__RecordPage</target>
<target>lightning__HomePage</target>
</targets>
</LightningComponentBundle>KanbanConfiguratorController.cls
public with sharing class KanbanConfiguratorController {
/**
* Get all available SObject API names
*/
@AuraEnabled(cacheable=true)
public static List<String> getObjectNames() {
List<String> objectNames = new List<String>();
for (Schema.SObjectType sObjType : Schema.getGlobalDescribe().values()) {
objectNames.add(sObjType.getDescribe().getName()); // Correct casing
}
return objectNames;
}
@AuraEnabled(cacheable=true)
public static List<Map<String, String>> getObjectLabelsAndApiNames() {
List<Map<String, String>> objectList = new List<Map<String, String>>();
Map<String, Schema.SObjectType> globalDescribe = Schema.getGlobalDescribe();
for (Schema.SObjectType sObjType : globalDescribe.values()) {
Schema.DescribeSObjectResult describe = sObjType.getDescribe();
Map<String, String> entry = new Map<String, String>();
entry.put('label', describe.getLabel());
entry.put('apiName', describe.getName()); // Exact casing
objectList.add(entry);
}
return objectList;
}
@AuraEnabled(cacheable=true)
public static List<Map<String, String>> getFieldLabelsAndApiNames(String objectName) {
Map<String, Schema.SObjectField> fields = Schema.getGlobalDescribe().get(objectName).getDescribe().fields.getMap();
List<Map<String, String>> fieldList = new List<Map<String, String>>();
for (Schema.SObjectField field : fields.values()) {
Schema.DescribeFieldResult describe = field.getDescribe();
String label = describe.getLabel();
String apiName = describe.getName();
// Handle lookup fields (references)
if (describe.getType() == Schema.DisplayType.REFERENCE && describe.getRelationshipName() != null) {
List<Schema.SObjectType> referenceToTypes = describe.getReferenceTo();
if (!referenceToTypes.isEmpty()) {
String targetObject = referenceToTypes[0].getDescribe().getName();
Map<String, Schema.SObjectField> relatedFields = Schema.getGlobalDescribe().get(targetObject).getDescribe().fields.getMap();
// Add default 'Name' field from related object, if it exists
if (relatedFields.containsKey('Name')) {
Map<String, String> relatedEntry = new Map<String, String>();
relatedEntry.put('label', label + ' Name');
relatedEntry.put('apiName', describe.getRelationshipName() + '.Name');
fieldList.add(relatedEntry);
}
}
}
// Always add the base field too (e.g., AccountId)
Map<String, String> baseEntry = new Map<String, String>();
baseEntry.put('label', label);
baseEntry.put('apiName', apiName);
fieldList.add(baseEntry);
}
return fieldList;
}
/**
* Save a Kanban configuration
*/
@AuraEnabled
public static Id saveKanbanConfig(String name,String objectApiName, String groupField, String summaryField, List<String> selectedFieldApiNames, String recordId) {
Kanban_Setting__c config = new Kanban_Setting__c();
if(String.isNotBlank(recordId)){
config.Id = recordId;
}
config.Name = name;
config.Object_API_Name__c = objectApiName;
config.Group_By_Field__c = groupField;
config.Summary_Field__c = summaryField;
config.Fields_To_Show__c = String.join(selectedFieldApiNames, ',');
config.User__c = UserInfo.getUserId();
upsert config;
return config.Id;
}
/**
* Get the latest Kanban configuration for the current user and given object
*/
@AuraEnabled(cacheable=true)
public static Kanban_Setting__c getKanbanConfigByUserAndObject(String objectApiName) {
List<Kanban_Setting__c> configs = [
SELECT Id, Object_API_Name__c, Group_By_Field__c, Summary_Field__c, Fields_To_Show__c
FROM Kanban_Setting__c
WHERE User__c = :UserInfo.getUserId()
AND Object_API_Name__c = :objectApiName
ORDER BY CreatedDate DESC
LIMIT 1
];
if (!configs.isEmpty()) {
return configs[0];
} else {
return null;
}
}
}You can install the package in your developer org from here: https://login.salesforce.com/packaging/installPackage.apexp?p0=04tWs000000YKcD
Demo – LWC Kanban
🙌 Wrapping Up
If you enjoyed this post or found it helpful, make sure to check out more tutorials, tips, and insights on Salesforce Diaries — your go-to resource for hands-on LWC development content.
Need help implementing this in your org or want to dive deeper into LWC, Apex, or advanced configurations?
📅 Schedule a 1:1 session with me on Topmate: https://topmate.io/sanket_kumar — I’m happy to guide you! Thanks for reading, and happy coding! 🔥

Very useful
Thank you Aiswharya