I have written my first post about Picklist supported editable lightning datatable in lightning aura components and since then I have been asked how to add lookup field to it and how to make row selection consistent along with pagination. I was also thinking to convert the old one from aura to lwc and cover the above functionalities. So, here I am posting the lightning datatable which supports lookup and picklist fields in edit mode and row selection consistency along with the pagination.
This datatable has all the features provided by standard lightning-datatable component and few additional features also.
Now, let's go to the main component, the datatable.
We have got all the required components, now let's see an example about how to use the vk-datatable.
PS: Hope this helps! Please share your feedback/suggestions/any exciting posts you come across. Happy Coding!
Coming up: More dynamic way of building datatable in salesforce, which is using 'field sets'.
This datatable has all the features provided by standard lightning-datatable component and few additional features also.
Additional Features:
- Option to select which edited rows should be updated and which can be skipped. Let's say we have edited 4 rows in which one was edited by mistake and don't want to update that row, we can just unselect that row so that only selected rows will get updated.
- It provides the option to select all rows of a table, select all rows of a page and the consistency of row selection while moving between the pages.
- In built simple and easy pagination where we can navigate to any page by just entering the page number.
- Powerful search funtionality where we can search across all columns of the table.
- Image actions, means you can use images also for actions. We just need to pass the source url of the image.
{label:'Compare', type:'image-action', src: `${imageStaticResource}`, actionName:'compareAction', title:'Click to compare'}
Now, let's just dive into the actual part. Please go ahead and create the components in the below mentioned order.
vkDatatableStyles (Static Resource)
Create a .css file with the below code and upload it to a static resource called 'vkDatatableStyles'.
/* © vkambham.blogspot.com */
.vk-data-table .slds-input {
height: inherit;
min-height: inherit;
line-height: inherit;
padding: 0 .5rem;
}
.vk-data-table [role=combobox] input[readonly] {
padding-left: 0.5rem;
}
.vk-data-table .slds-combobox__input {
padding: 0 1.5rem;
padding-left: .5rem;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vk-cell-edit:hover .table-edit-icon{
visibility: visible !important;
}
.vk-pagination-search input{
height: 1.5rem;
min-height: 1.5rem;
padding: .5rem;
}
.vk-pagination-input input{
height: 1.5rem;
min-height: 1.5rem;
width: 3rem;
text-align: center;
padding: .5rem;
}
.slds-datepicker .slds-day:hover {
border-radius: 50%;
margin: auto;
background-color: #ecebea;
}
formElement (LWC)
Create an LWC component called 'formElement' which is created to use as either a form element or a table element. It is a single place holder for all kinds of input elements in view/edit/read-only mode. Here, for datatable we are going to use it as a table element, but in my later posts I will explain how to use it as a form element. Now, go ahead and update this component elements with the below code.formElement.html
This uses a custom lookup component which was posted in one of my blog posts. If you don't have this lookup component in your org already, create it before proceeding further.
<!-- © vkambham.blogspot.com -->
<template>
<dl class={listClass}>
<dt if:false={isTableElement} class="slds-item_label slds-text-color_weak">
<div class="slds-grid">
<div class="slds-truncate" title={_label}>
<template if:true={editMode}>
<abbr if:true={isRequired} class="slds-required vk-ip-required" title="required">*</abbr>
</template>
{_label}
</div>
<div if:true={_helpText} class={helpTextClass}>
<lightning-helptext content={_helpText} class="vk-helptext"></lightning-helptext>
</div>
</div>
</dt>
<dd class={detailClass}>
<div if:false={editMode} class="slds-grid">
<div if:true={isText} class="slds-truncate">{_value}</div>
<div if:true={isPicklist} class="slds-truncate">{_value}</div>
<div if:true={isLookup} class="slds-truncate">
<a href={href} target={target} title={title} class="vk-lookup-link">{linkLabel}</a>
</div>
<div if:true={isCheckbox} class="slds-truncate">
<lightning-input type="checkbox" variant="label-hidden" name="formCheckbox" checked={_value}
disabled></lightning-input>
</div>
<div if:true={isDate} class="slds-truncate">
<lightning-formatted-date-time value={_value} year="numeric" month="2-digit" day="2-digit"
time-zone="UTC"></lightning-formatted-date-time>
</div>
<div if:true={isDateTime} class="slds-truncate">
<lightning-formatted-date-time value={_value} year="numeric" month="2-digit" day="2-digit"
hour="2-digit" minute="2-digit" hour12="true"></lightning-formatted-date-time>
</div>
<div if:true={isNumber} class="slds-truncate">
<lightning-formatted-number value={_value} minimum-fraction-digits={minFractionDigits}
maximum-fraction-digits={maxFractionDigits}></lightning-formatted-number>
</div>
<div if:true={isCurrency} class="slds-truncate">
<lightning-formatted-number value={_value} format-style="currency" currency-code={currencyCode}
minimum-fraction-digits={minFractionDigits} maximum-fraction-digits={maxFractionDigits}>
</lightning-formatted-number>
</div>
<div if:true={isPercent} class="slds-truncate">
<lightning-formatted-number value={_value} format-style="percent-fixed"
minimum-fraction-digits={minFractionDigits} maximum-fraction-digits={maxFractionDigits}>
</lightning-formatted-number>
</div>
<div if:true={isLink} class="slds-truncate">
<a href={href} target={target} title={title}>{linkLabel}</a>
</div>
<div if:true={isLinkAction} class="slds-truncate">
<a name={actionName} title={title} onclick={handleAction}>{linkLabel}</a>
</div>
<div if:true={isImageAction} style="text-align:center;">
<a name={actionName} title={title} onclick={handleAction}>
<img src={imageSource} class="slds-button__icon"></img>
</a>
</div>
<div if:true={isButtonIcon}>
<lightning-button-icon icon-name={iconName} variant={iconVariant} alternative-text={title}
title={title} name={actionName} onclick={handleAction}></lightning-button-icon>
</div>
<div if:false={isReadOnly} class="slds-col_bump-left">
<button class={editIconClass} title="Edit" onclick={editElement}>
<svg class="vk-edit-icon slds-button__icon slds-button__icon_hint" aria-hidden="true">
<use xlink:href="/_slds/icons/utility-sprite/svg/symbols.svg#edit"></use>
</svg>
<div class="slds-assistive-text">Edit</div>
</button>
</div>
</div>
<div if:true={editMode}>
<div if:true={isText}>
<lightning-input class="inpElm" value={_value} variant="label-hidden" required={isRequired}
onchange={sendValue}></lightning-input>
</div>
<div if:true={isCheckbox}>
<lightning-input class="inpElm" type="checkbox" value={_value} checked={_value}
variant="label-hidden" required={isRequired} onchange={sendValue}></lightning-input>
</div>
<div if:true={isPicklist}>
<lightning-combobox class="inpElm" value={_value} options={_options} variant="label-hidden"
onchange={sendValue}></lightning-combobox>
</div>
<div if:true={isLookup}>
<c-lookup class="inpElm" is-table-element={isTableElement} required={isRequired} value={_value}
options={_options} variant="label-hidden" place-holder={placeHolder} icon-name={iconName}
icon-size={iconSize} onchange={handleLookupChange}></c-lookup>
</div>
<div if:true={isDate}>
<lightning-input class="inpElm" type="date" value={_value} variant="label-hidden"
required={isRequired} date-style="short" onchange={sendValue}></lightning-input>
</div>
<div if:true={isDateTime}>
<lightning-input class="inpElm" type="date-time" value={_value} variant="label-hidden"
required={isRequired} date-style="short" onchange={sendValue}></lightning-input>
</div>
<div if:true={isNumber}>
<lightning-input class="inpElm" type="number" value={_value} variant="label-hidden"
required={isRequired} onchange={sendValue}></lightning-input>
</div>
<div if:true={isCurrency}>
<lightning-input class="inpElm" type="number" value={_value} variant="label-hidden"
required={isRequired} formatter="currency" onchange={sendValue}></lightning-input>
</div>
<div if:true={isPercent}>
<lightning-input class="inpElm" type="number" value={_value} variant="label-hidden"
required={isRequired} formatter="percent-fixed" onchange={sendValue}></lightning-input>
</div>
<div if:true={isLink} class="slds-truncate">
<a href={href} target={target} title={title}>{linkLabel}</a>
</div>
<div if:true={isLinkAction} class="slds-truncate">
<a name={actionName} title={title} onclick={handleAction}>{linkLabel}</a>
</div>
<div if:true={isImageAction} class="slds-truncate" style="text-align:center;">
<a name={actionName} title={title} onclick={handleAction}>
<img src={imageSource} class="slds-button__icon"></img>
</a>
</div>
<div if:true={isButtonIcon} class="slds-truncate">
<lightning-button-icon icon-name={iconName} variant={iconVariant} alternative-text={title}
title={title} name={actionName} onclick={handleAction}></lightning-button-icon>
</div>
</div>
</dd>
</dl>
</template>
formElement.js
/* © vkambham.blogspot.com */
import { LightningElement, api } from 'lwc';
const LISTCLASS = 'slds-list slds-border_bottom';
export default class FormElement extends LightningElement {
_mode;
_type;
_label;
_value;
_options;
_size;
_variant;
_helpText;
href;
editMode = false;
isRequired = false;
isReadOnly = false;
isConnected = false;
isText;
isPicklist;
isCheckbox;
isLink;
isLookup;
isDate;
isDateTime;
isNumber;
isCurrency;
isPercent;
isLinkAction;
isImageAction;
isButtonIcon;
listClass = 'slds-list_stacked '+LISTCLASS;
detailClass = 'slds-item_detail vk-form-font';
helpTextClass = '';
editIconClass = 'slds-button slds-button_icon';
isTableElement = false;
@api rowIndex;
@api colIndex;
@api name;
@api title;
@api placeHolder;
@api linkLabel;
@api actionName;
@api numberFormat;
@api currencyCode;
@api maxFractionDigits;
@api minFractionDigits;
@api minValue;
@api maxValue;
@api iconName;
@api iconSize;
@api iconVariant = 'bare';
@api imageSource;
@api set mode(value){
this._mode = value ? value : 'view';
this.editMode = value === 'edit';
this.handleElementClass();
}
get mode(){
return this._mode;
}
@api set type(value){
value = value ? value : 'text';
this._type = value;
this.isText = value.toLowerCase() === 'text' || value.toLowerCase() === 'string';
this.isPicklist = value.toLowerCase() === 'picklist';
this.isCheckbox = value.toLowerCase() === 'checkbox' || value.toLowerCase() === 'boolean';
this.isLookup = value.toLowerCase() === 'lookup' || value.toLowerCase() === 'reference';
this.isDate = value.toLowerCase() === 'date';
this.isDateTime = value.toLowerCase() === 'date-time' || value.toLowerCase() === 'datetime';
this.isNumber = value.toLowerCase() === 'number' || value.toLowerCase() === 'integer';
this.isCurrency = value.toLowerCase() === 'currency';
this.isPercent = value.toLowerCase() === 'percent';
this.isLink = value.toLowerCase() === 'link';
this.isLinkAction = value.toLowerCase() === 'link-action';
this.isImageAction = value.toLowerCase() === 'image-action';
this.isButtonIcon = value.toLowerCase() === 'button-icon';
}
get type(){
return this._type;
}
@api set label(value){
this._label = value ? value : '';
}
get label(){
return this._label;
}
@api set value(value){
this._value = value ? value : '';
if(this.isLookup && this.isConnected) this.populateLookupValue(this._value);
}
get value(){
return this._value;
}
@api set options(value){
this._options = value ? value : [];
}
get options(){
return this._options;
}
@api set required(value){
this.isRequired = value ? value : false;
}
get required(){
return this.isRequired;
}
@api set readOnly(value){
this.isReadOnly = value ? value : false;
}
get readOnly(){
return this.isReadOnly;
}
@api set size(value){
this._size = value ? value : '';
}
get size(){
return this._size;
}
@api set target(value){
this._target = value ? value : '_blank';
}
get target(){
return this._target;
}
@api set variant(value){ //label-inline, label-hidden, and label-stacked
this._variant = value ? value : 'label-stacked';
}
get variant(){
return this._variant;
}
@api set helpText(value){
this._helpText = value ? value : '';
}
get helpText(){
return this._helpText;
}
@api set tableElement(value){
this.isTableElement = value ? value : false;
this.handleElementClass();
}
get tableElement(){
return this.isTableElement;
}
connectedCallback(){
this.isConnected = true;
this.href = this.isLink && this._value ? '/'+this._value : '';
this.readOnly = this.isLink;
if(this.isLookup) this.populateLookupValue(this._value);
}
disconnectedCallback() {
this.isConnected = false;
}
populateLookupValue(value){
if(this._options){
this._options.forEach(opt => {
if(value && value === opt.value){
this.linkLabel = opt.label;
this.href = '/'+value;
}
});
}else{
this.linkLabel = value;
this.href = '/'+value;
}
}
handleElementClass(){
this.listClass = this.editMode ? 'slds-list' : LISTCLASS;
if(!this.isTableElement){
this.listClass += this._variant === 'label-inline' ? ' slds-list_horizontal' : ' slds-list_stacked';
this.helpTextClass = this._variant === 'label-inline' ? 'slds-col_bump-left' : '';
}else{
this.editIconClass += ' table-edit-icon';
this.listClass = 'slds-list_stacked';
this.detailClass = 'slds-item_detail';
}
}
handleEditing(){
if(!this.isReadOnly) this.editElement();
}
@api
editElement(){
this.editMode = true;
let obj = {};
if(this.isTableElement){
obj = {rowIndex: this.rowIndex, colIndex: this.colIndex};
}else{
this.template.querySelector('.slds-list').classList.remove('slds-border_bottom');
}
this.dispatchEvent(new CustomEvent('edit', {detail: obj}));
}
sendValue(event){
let obj = {};
obj.value = this.isCheckbox ? event.target.checked : event.target.value;
if(this.isTableElement){
obj.rowIndex = this.rowIndex;
obj.colIndex = this.colIndex;
obj.name = this.name;
}
this.dispatchEvent(new CustomEvent('valuechange', {detail: obj}));
}
@api
closeEditMode(){
this.editMode = false;
if(!this.isTableElement) this.template.querySelector('.slds-list').classList.add('slds-border_bottom');
}
handleLookupChange(event){
this.linkLabel = event.detail.label;
let obj = {rowIndex: this.rowIndex, colIndex: this.colIndex, name: this.name, value: event.detail.value, label: event.detail.label};
this.dispatchEvent(new CustomEvent('valuechange', {detail: obj}));
}
handleAction(event){
let currentNode = event.target;
if(!currentNode.name) currentNode = currentNode.parentNode;
let actionName = currentNode.name;
let obj ={rowIndex: this.rowIndex, colIndex: this.colIndex, actionName: actionName};
this.dispatchEvent(new CustomEvent('action', {detail: obj}));
}
}
formElement.css
/* © vkambham.blogspot.com */
.vk-edit-icon{
fill: #dddbda;
}
.vk-form-font{
font-size: 0.875rem;
font-weight: 400;
}
.table-edit-icon{
visibility: hidden;
}
.vk-ip-required{
vertical-align: top;
}
.vk-lookup-link {
border-bottom: 1px dotted;
display: inline;
}
.vk-lookup-link:hover {
text-decoration: none;
}
Now, let's go to the main component, the datatable.
vkDatatable (LWC)
Create an LWC component called 'vkDatatable' and update it by adding below code.vkDatatable.html
This uses a custom spinner component which was posted in one of my blog posts. If you don't have this custom spinner component in your org already, create it before proceeding further or replace it with the base spinner component, lightning-spinner.
<!-- © vkambham.blogspot.com -->
<template>
<div if:true={showTable} class="slds-is-relative">
<c-spinner if:true={isLoading} size="small" message={spinnerMsg} alternative-text="Loading"></c-spinner>
<lightning-layout if:true={showPagination} horizontal-align="spread" vertical-align="center" class="slds-border_top">
<lightning-layout-item class="slds-p-vertical_xx-small">
<lightning-layout style={controlPagination} vertical-align="center">
<lightning-layout-item>
<span if:true={showRowNumber} style="padding-left:40px;"></span>
<span if:true={showCheckbox} class="slds-checkbox" title="Select/Deselect All" style="text-align:center;width:40px;">
<input style={controlPagination} type="checkbox" class="select-all-checkbox" id="selectAll" name="" onchange={handleAllRowsSelection}></input>
<label class="slds-checkbox__label" for="selectAll" style="cursor:pointer;">
<span class="slds-checkbox_faux"></span>
</label>
</span>
</lightning-layout-item>
<lightning-layout-item>
<div class="slds-list_inline customSelect">
<label class="slds-text-color_weak slds-p-left_x-small slds-p-right_xx-small" for="recordsPerPage">Show:</label>
<div class="slds-select_container">
<select class="slds-select" id="recordsPerPage" onchange={handleRecordsPerPage}>
<template for:each={pageSizeOptions} for:item="option">
<option key={option} value={option}>{option}</option>
</template>
</select>
</div><label class="slds-text-color_weak slds-p-left_xx-small" for="recordsPerPage">of<b> {records.length} </b>records</label>
</div>
</lightning-layout-item>
</lightning-layout>
</lightning-layout-item>
<lightning-layout-item class="slds-p-vertical_xx-small">
<lightning-layout vertical-align="center">
<lightning-layout-item class="slds-p-right_x-small">
<lightning-input label="" type="search" placeholder={placeHolder} variant="label-hidden" onchange={handleSearch} class="vk-pagination-search"></lightning-input>
</lightning-layout-item>
<lightning-layout-item>
<lightning-layout style={controlPagination} vertical-align="center">
<lightning-layout-item>
<span style={controlPrevious}>
<lightning-button-icon icon-name="utility:chevronleft" variant="" alternative-text="Previous Page" onclick={previousPage} class="vk-btn-icon"></lightning-button-icon>
</span>
</lightning-layout-item>
<lightning-layout-item>
<lightning-layout vertical-align="center">
<lightning-layout-item>
<label class="slds-text-color_weak slds-p-horizontal_xx-small" for="pageNum">Page:</label>
</lightning-layout-item>
<lightning-layout-item>
<lightning-input type="number" value={pageNumber} variant="label-hidden" class="vk-pagination-input" onkeypress={handlePageNumberChange}></lightning-input>
</lightning-layout-item>
<lightning-layout-item>
<label class="slds-text-color_weak slds-p-horizontal_xx-small" for="pageNum">of</label><b id="totalPages">{totalPages}</b></span>
</lightning-layout-item>
</lightning-layout>
</lightning-layout-item>
<lightning-layout-item>
<span style={controlNext}>
<lightning-button-icon icon-name="utility:chevronright" variant="" alternative-text="Next Page" onclick={nextPage} class="vk-btn-icon slds-p-horizontal_x-small"></lightning-button-icon>
</span>
</lightning-layout-item>
</lightning-layout>
</lightning-layout-item>
</lightning-layout>
</lightning-layout-item>
</lightning-layout>
<div class="slds-scrollable_x" onmouseup={stopColumnResizing}>
<table id="custTable" class="vk-data-table" style={tableStyle} role="grid">
<thead>
<tr>
<th if:true={showRowNumber} style="text-align:right;width:40px;padding-right:.4rem"></th>
<th if:true={showCheckbox} style="text-align:center;width:40px;font-weight: normal;">
<span class="slds-checkbox" title="Select/Deselect All Page Rows">
<input type="checkbox" class="page-checkbox" id="pageCheckbox" onchange={handlePageRowsSelection}></input>
<label class="slds-checkbox__label" for="pageCheckbox" style="cursor:pointer;">
<span class="slds-checkbox_faux"></span>
</label>
</span>
</th>
<template for:each={columns} for:item="col">
<th key={col} id={col.sortBy} aria-label={col.label} class={col.thClass} style={col.style}>
<div class="slds-grid">
<div class="slds-col" style="width:100%" onmousemove={setNewWidth}>
<span if:false={col.sortable} class="slds-truncate" title={col.label}>
<abbr if:true={col.required} class="vk-dt-required" title="required">*</abbr>
{col.label}
</span>
<a if:true={col.sortable} class="slds-text-link_reset" role="button" onclick={handleSorting}>
<span class="slds-assistive-text">Sort by: {col.label}</span>
<div class="slds-grid slds-grid_vertical-align-center slds-has-flexi-truncate" title={col.sortByTitle}>
<span class="slds-truncate slds-p-right_xx-small" title={col.label}>
<abbr if:true={col.required} class="vk-dt-required" title="required">*</abbr>
{col.label}
</span>
<span class={col.sortIconClass} style={col.sortIconStyle}>
<svg class="slds-button__icon slds-button__icon_small" aria-hidden="true">
<use xlink:href="/_slds/icons/utility-sprite/svg/symbols.svg#arrowup"></use>
</svg>
</span>
</div>
</a>
</div>
<div if:true={col.resizable} class="slds-col slds-resizable" title="resize">
<div class="vk-resizable__handle" onmousedown={calculateWidth} onmousemove={setNewWidth} onmouseup={stopColumnResizing}>
<div class="vk-resizable__divider" style={resizerStyle}></div>
</div>
</div>
</div>
</th>
</template>
</tr>
</thead>
<tbody onmousemove={stopColumnResizing}>
<template for:each={pageData} for:item="row">
<tr key={row.id} class={row.rowClass}>
<td if:true={showRowNumber} style="text-align:right;width:40px;padding-right:0.4rem;" class="slds-text-color_weak">{row.rowNumber}</td>
<td if:true={showCheckbox} style="text-align:center;width:40px;">
<span class="slds-checkbox" title="Select/Deselect">
<input type="checkbox" name="isSelected" id={row.index} checked={row.isSelected} onchange={handleRowSelection}></input>
<label class="slds-checkbox__label" for={row.index} style="cursor:pointer;">
<span class="slds-checkbox_faux"></span>
</label>
</span>
</td>
<template for:each={row.fields} for:item="field" for:index="colIndex">
<td key={row.index} headers={field.name} class={field.tdClass} role="gridcell">
<c-form-element
table-element
row-index={row.index}
col-index={field.index}
mode={row.mode}
type={field.type}
label={field.label}
value={field.value}
variant={_variant}
required={field.required}
read-only={field.readOnly}
name={field.name}
place-holder={field.placeHolder}
options={field.options}
title={field.title}
link-label={field.linkLabel}
image-source={field.imageSource}
target={field.target}
icon-name={field.iconName}
icon-size={field.iconSize}
action-name={field.actionName}
min-value={field.minValue}
max-value={field.maxValue}
min-fraction-digits={field.minFractionDigits}
max-fraction-digits={field.maxFractionDigits}
onedit={handleCellEdit}
onvaluechange={handleCellChange}
onaction={handleRowAction}>
</c-form-element>
</td>
</template>
</tr>
</template>
</tbody>
</table>
<div if:false={totalRecords} class="slds-var-p-vertical_xx-small slds-var-p-left_small slds-border_bottom">
No records found to display.
</div>
<div if:true={isEdited} class="slds-modal__footer">
<div class="slds-grid slds-grid_align-center">
<lightning-button label="Cancel" onclick={cancelChanges} class="slds-p-right_x-small"></lightning-button>
<lightning-button label="Save" variant="brand" onclick={handleSave}></lightning-button>
</div>
</div>
</div>
</div>
</template>
vkDatatable.js
/* © vkambham.blogspot.com */
import { LightningElement, api } from 'lwc';
import { loadStyle } from 'lightning/platformResourceLoader';
import vkStyles from '@salesforce/resourceUrl/vkDatatableStyles';
const DELAY = 300;
const SHOWIT = 'visibility:visible';
const HIDEIT = 'visibility:hidden';
export default class CustomTable extends LightningElement {
@api columns;
@api
set records(value){
this._records = value ? [...value] : [];
if(this.isConnected) this.setupTable();
}
get records(){
return this._records;
}
@api showCheckbox = false;
@api showRowNumber = false;
@api sortedBy;
@api sortedDirection = 'desc';
//PAGINATION VARIABLES
@api showPagination = false;
@api pageSizeOptions = [5,10,20,30,50,75,100];
@api showSearchBox = false;
@api placeHolder = 'Search Table...';
@api
set tableLayout(value){
this._tableLayout = value ? value : 'fixed';
this.tableStyle = 'table-layout:'+this._tableLayout;
}
get tableLayout(){
return this._tableLayout;
}
//LOCAL VARIABLES
showTable = false;
isLoading = false;
spinnerMsg = 'Loading...';
_records; //Clone of Original records
tableData; //Records modified according to table data structure
tableData2Display; //Table data structured records available to display
pageData; //Records displayed on a page
pageSize = 5;
totalPages;
pageNumber = 1;
searchKey;
controlPagination = SHOWIT;
controlPrevious = HIDEIT;
controlNext = SHOWIT;
isAscending = false;
isEdited = false;
isConnected = false;
_tableLayout = 'fixed';
tableStyle = 'table-layout:'+this._tableLayout;
delayTimeout;
initialLoad = true;
stylesLoaded = false;
selectedTotal = 0;
selectedPerPage = 0;
selRowsMap = new Map();
currentElement;
currentWidth;
mousePosition;
resizerStyle;
recsEdited = new Map();
connectedCallback(){
this.isConnected = true;
this.setupTable();
}
disconnectedCallback() {
this.isConnected = false;
}
renderedCallback(){
if(!this.stylesLoaded){
Promise.all([
loadStyle(this, vkStyles)
]).then(() => {
this.stylesLoaded = true;
}).catch(error => {
console.log('Error loading styles**'+JSON.stringify(error));
this.stylesLoaded = false;
});
}
let table = this.template.querySelector('.vk-data-table');
this.resizerStyle = 'height:'+table.offsetHeight+'px';
let cols = this.template.querySelectorAll('TH');
cols.forEach(col=>{
col.style.width = col.style.width ? col.style.width : col.offsetWidth+'px';
col.style.minWidth = col.style.minWidth ? col.style.minWidth : '50px';
});
}
setupTable(){
this.showTable = false;
this.isEdited = false;
this.totalRecords = this._records.length;
if(this.totalRecords === 0){
this.showPagination = false;
return;
}
if(this.sortedBy) this._records = this.sortData(this._records,this.sortedBy,this.sortedDirection);
this.setupColumns();
this.setupPagination();
this.setupData();
this.showTable = true;
this.initialLoad = false;
}
setupColumns(){
let tempCols = [], i=0;
//set col values
this.columns.forEach(val => {
let col = {...val};
col.index = i++;
col.thClass = 'slds-truncate';
col.thClass += val.resizable ? ' vk-is-resizable' : '';
col.style = col.width ? 'width:'+col.width+'px;' : '';
col.style += col.minWidth ? 'min-width:'+col.minWidth+'px;' : '';
col.style += col.maxWidth ? 'max-width:'+col.maxWidth+'px;' : '';
if(col.sortable === true){
col.thClass += ' vk-is-sortable';
col.sortBy = col.sortBy ? col.sortBy : col.fieldName;
col.sortByTitle = 'Sort By: '+col.label;
let sortIconClass = this.sortedDirection === 'asc' ? 'vk-sort_desc' : 'vk-sort_asc';
col.sortIconStyle = 'visibility:hidden;';
if(col.sortBy === this.sortedBy){
sortIconClass = this.sortedDirection === 'asc' ? 'vk-sort_asc' : 'vk-sort_desc';
col.sortIconStyle = 'visibility:visible;';
}
col.sortIconClass = 'vk-sort-icon-div '+sortIconClass;
}
tempCols.push(col);
});
this.columns = tempCols;
}
setupData(){
let recs = [], i=0;
this._records.forEach(value => {
let row = {}, fields = [], j=0;
this.columns.forEach(col => {
//set data values
let field = {};
field.name = col.fieldName;
field.value = value[col.fieldName];
field.type = col.type;
field.required = col.required;
field.options = col.options;
field.linkLabel = col.type === 'link' || col.type === 'lookup' ? value[col.linkLabel] : col.linkLabel;
field.imageSource = col.type === 'image-action' ? col.src : '#';
field.target = col.target;
field.minValue = col.minValue;
field.maxValue = col.maxValue;
field.minFractionDigits = col.minFractionDigits;
field.maxFractionDigits = col.maxFractionDigits;
field.currencyCode = col.currencyCode;
field.iconName = col.iconName;
field.iconSize = 'xx-small';
field.actionName = col.actionName;
field.iconVariant = col.iconVariant;
field.title = col.title;
field.readOnly = !col.editable;
field.tdClass = col.editable ? 'vk-cell-edit' : '';
field.index = j++;
fields.push(field);
});
row.id = value.Id;
row.index = i++;
row.rowNumber = i;
row.isSelected = false;
row.mode = 'view';
row.fields = fields;
recs.push(row);
});
this.tableData = recs;
this.tableData2Display = JSON.parse(JSON.stringify(recs));
this.setupPages();
}
setupPagination(){
if(!this.showPagination) this.pageSize = this.totalRecords;
else{
this.showSearchBox = true;
this.pageSize = this.pageSizeOptions ? this.pageSizeOptions[0] : 5;
if(this.pageSize >= this.totalRecords){
this.pageSize = this.totalRecords;
this.showPagination = false;
this.showSearchBox = false;
}
this.totalPages = Math.ceil(this.totalRecords/this.pageSize);
}
}
//START: PAGINATION
handleRecordsPerPage(event){
this.pageSize = event.target.value;
this.setupPages();
}
handlePageNumberChange(event){
if(event.keyCode === 13){
this.pageNumber = event.target.value;
this.setupPages();
}
}
previousPage(){
this.pageNumber = this.pageNumber-1;
this.setupPages();
}
nextPage(){
this.pageNumber = this.pageNumber+1;
this.setupPages();
}
setupPages(){
this.totalRecords = this.tableData2Display.length;
this.setPaginationControls();
let pageRecs = [];
for(let i=(this.pageNumber-1)*this.pageSize; i<this.pageNumber*this.pageSize; i++){
if(i === this.totalRecords) break;
pageRecs.push(this.tableData2Display[i]);
}
this.setupPageData(pageRecs);
}
setupPageData(recs){
this.pageData = [];
this.selectedPerPage = 0;
recs.forEach(rec=>{
let row = {...rec};
row.rowClass = row.isSelected ? 'vk-is-selected' : '';
this.selectedPerPage = Number(this.selectedPerPage) + 1;
if(row.isSelected){
this.selRowsMap.set(row.index, this._records[row.index]);
}else{
this.selectedPerPage = Number(this.selectedPerPage) - 1;
if(this.selRowsMap.has(row.index))
this.selRowsMap.delete(row.index);
}
this.selectedTotal = this.selRowsMap.size;
this.pageData.push(row);
});
if(!this.initialLoad) this.manageSelectAllStyle();
}
setPaginationControls(){
if(!this.pageSize) this.pageSize = this.totalRecords;
this.totalPages = Math.ceil(this.totalRecords/this.pageSize);
//Control Pre/Next buttons visibility by Total pages
if(this.totalPages === 1){
this.controlPrevious = HIDEIT;
this.controlNext = HIDEIT;
}else if(this.totalPages > 1){
this.controlPrevious = SHOWIT;
this.controlNext = SHOWIT;
}
//Control Pre/Next buttons visibility by Page number
if(this.pageNumber <= 1){
this.pageNumber = 1;
this.controlPrevious = HIDEIT;
}else if(this.pageNumber >= this.totalPages){
this.pageNumber = this.totalPages;
this.controlNext = HIDEIT;
}
//Control Pre/Next buttons visibility by Pagination visibility
if(this.controlPagination === HIDEIT){
this.controlPrevious = HIDEIT;
this.controlNext = HIDEIT;
}
}
//END: PAGINATION
//START: ROW SELECTION
handleRowSelection(event){
let index = Number(event.target.id.split('-')[0]);
let isSelected = event.target.checked;
this.pageData.forEach(rec => {
if(rec.rowNumber === index+1){
rec.isSelected = isSelected;
this.tableData2Display[index].isSelected = isSelected;
}
});
this.setupPageData(this.pageData);
this.dispatchEvent(new CustomEvent('rowselection', {detail: Array.from(this.selRowsMap.values())}));
}
handlePageRowsSelection(event){
let isSelected = event.target.checked;
this.pageData.forEach(rec => {
rec.isSelected = isSelected;
this.tableData2Display[rec.index].isSelected = isSelected;
});
this.setupPageData(this.pageData);
this.dispatchEvent(new CustomEvent('rowselection', {detail: Array.from(this.selRowsMap.values())}));
}
handleAllRowsSelection(event){
let isSelected = event.target.checked;
this.tableData2Display.forEach(rec=>{
rec.isSelected = isSelected;
if(isSelected)
this.selRowsMap.set(rec.index, this._records[rec.index]);
else if(this.selRowsMap.has(rec.index))
this.selRowsMap.delete(rec.index);
});
this.selectedTotal = this.selRowsMap.size;
this.setupPages();
this.dispatchEvent(new CustomEvent('rowselection', {detail: Array.from(this.selRowsMap.values())}));
}
manageSelectAllStyle(){
//Select Rows per Page
let pageCheckbox = this.template.querySelector('.page-checkbox');
if(!pageCheckbox) return;
if(this.selectedPerPage === 0){
pageCheckbox.checked = false;
pageCheckbox.indeterminate = false;
}else if(this.selectedPerPage === this.pageData.length){
pageCheckbox.checked = true;
pageCheckbox.indeterminate = false;
}else{
pageCheckbox.checked = false;
pageCheckbox.indeterminate = true;
}
//Select All Rows
let allCheckbox = this.template.querySelector('.select-all-checkbox');
if(!allCheckbox) return;
if(this.selectedTotal === 0){
allCheckbox.checked = false;
allCheckbox.indeterminate = false;
}else if(this.selectedTotal === this._records.length){
allCheckbox.checked = true;
allCheckbox.indeterminate = false;
}else{
allCheckbox.checked = false;
allCheckbox.indeterminate = true;
}
}
//END: ROW SELECTION
//START: SORTING
handleSorting(event){
this.isLoading = true;
let childElm = event.target;
let parentElm = childElm.parentNode;
while(parentElm.nodeName != 'TH') parentElm = parentElm.parentNode;
let sortBy = parentElm.id.split('-')[0];
setTimeout(() => {
let sortDirection = this.sortedDirection === 'asc' ? 'desc' : 'asc';
this._records = this.sortData(this._records,sortBy,sortDirection);
this.setupColumns();
this.setupData();
this.isLoading = false;
}, 0);
}
sortData(data, sortBy, sortDirection){
let clonedData = [...data];
clonedData.sort(this.sortDataBy(sortBy, sortDirection === 'asc' ? 1 : -1));
this.sortedDirection = sortDirection;
this.sortedBy = sortBy;
return clonedData;
}
sortDataBy(field, reverse, primer) {
const key = primer
? function(x) { return primer(x[field]) }
: function(x) { return x[field] };
return function (a, b) {
let A, B;
if(isNaN(key(a)) === true)
A = key(a) ? key(a).toLowerCase() : '';
else A = key(a) ? key(a) : -Infinity;
if(isNaN(key(b)) === true)
B = key(b) ? key(b).toLowerCase() : '';
else B = key(b) ? key(b) : -Infinity;
return reverse * ((A > B) - (B > A));
};
}
//END: SORTING
//START: SEARCH
handleSearch(event){
window.clearTimeout(this.delayTimeout);
let searchKey = ''+event.target.value;
this.isLoading = true;
if(searchKey){
this.delayTimeout = setTimeout(() => {
this.searchKey = searchKey.toLowerCase();
let recs = this.tableData.filter(row=>this.searchRow(row,this.searchKey));
this.tableData2Display = recs;
this.setupPages();
this.isLoading = false;
}, DELAY);
}else{
this.tableData2Display = JSON.parse(JSON.stringify(this.tableData));
this.setupPages();
this.isLoading = false;
}
}
searchRow(row,searchKey){
let fields = row.fields.filter(f => {
let fieldVal = f.type === 'link' || f.type === 'lookup' ? ''+f.linkLabel : ''+f.value;
let fieldValue = fieldVal.toLowerCase();
return fieldValue && fieldValue.includes(searchKey) ? true : false;
});
return fields.length > 0;
}
//END: SEARCH
//START: COL RESIZING
calculateWidth(event){
this.currentElement = event.target;
let parentElm = this.currentElement.parentNode;
while(parentElm.tagName != 'TH') parentElm = parentElm.parentNode;
this.currentWidth = parentElm.offsetWidth;
this.mousePosition = event.clientX; //Get current mouse position
//Stop text selection event so mouse move event works perfectlly.
if(event.stopPropagation) event.stopPropagation();
if(event.preventDefault) event.preventDefault();
event.cancelBubble = true;
event.returnValue = false;
}
setNewWidth(event){
if(!this.currentElement) return;
let parentElm = this.currentElement.parentNode;
while(parentElm.tagName != 'TH') parentElm = parentElm.parentNode;
let movedWidth = event.clientX - this.mousePosition;
let newWidth = this.currentWidth + movedWidth;
parentElm.style.width = newWidth+'px';
}
stopColumnResizing(){
this.currentElement = undefined;
}
//END: COL RESIZING
//START: ROW ACTION
handleRowAction(event){
let rowIndex = event.detail.rowIndex;
let actionName = event.detail.actionName;
let obj ={row: this._records[rowIndex], action: {name: actionName}};
this.dispatchEvent(new CustomEvent('rowaction', {detail: obj}));
}
//END: ROW ACTION
//START: CELL EDITING
handleCellEdit(event){
this.isEdited = true;
let resp = event.detail;
this.tableData2Display[resp.rowIndex].mode = 'edit';
this.setupPages();
}
handleCellChange(event){
let resp = event.detail;
let rec = {...this._records[resp.rowIndex]};
if(resp.value !== this._records[resp.rowIndex][resp.name]){
this.tableData2Display[resp.rowIndex].fields[resp.colIndex].tdClass = 'vk-cell-edit vk-cell-edited';
this.tableData2Display[resp.rowIndex].fields[resp.colIndex].value = resp.value;
rec[resp.name] = resp.value;
}else{
this.tableData2Display[resp.rowIndex].fields[resp.colIndex].tdClass = 'vk-cell-edit';
this.tableData2Display[resp.rowIndex].fields[resp.colIndex].value = resp.value;
}
let changedFields = this.tableData2Display[resp.rowIndex].fields.filter(field=>field.tdClass.includes('vk-cell-edited'));
if(changedFields.length > 0){
this.tableData2Display[resp.rowIndex].isSelected = true;
this.recsEdited.set(resp.rowIndex,rec);
}else{
this.tableData2Display[resp.rowIndex].isSelected = false;
if(this.recsEdited.has(resp.rowIndex))
this.recsEdited.delete(resp.rowIndex);
}
this.setupPages();
}
cancelChanges(){
this.isEdited = false;
this.tableData2Display = JSON.parse(JSON.stringify(this.tableData));
this.setupPages();
}
handleSave(){
let recs2Save = [];
for(let [key,value] of this.recsEdited){
if(this.selRowsMap.has(key))
recs2Save.push(value);
}
this.dispatchEvent(new CustomEvent('save', {detail: recs2Save}));
}
//END: CELL EDITING
}
vkDatatable.css
/* © vkambham.blogspot.com */
table thead th{
background-color: #16325c;
color: #ffff;
border-right: 1px solid #e4e9f2;
padding: .25rem .3rem;
}
table tbody td{
border-bottom: 1px solid #dddbda;
padding: .2rem .3rem;
}
table tbody tr:hover>td:not(.slds-has-focus){
box-shadow: #dddbda 0 -1px 0 inset, #dddbda 0 1px 0 inset;
}
table tbody tr:hover>td{
background-color: #f3f2f2;
}
table tbody tr.vk-is-selected>td{
background-color: #dff0fe;
}
table thead th.vk-is-sortable:hover, table thead th.vk-is-sortable:focus{
background-color: #f3f2f2;
color: #16325c;
}
table thead th.vk-is-sortable:hover .vk-sort-icon-div, table thead th.vk-is-sortable:focus .vk-sort-icon-div {
visibility:visible !important;
color: #16325c;
}
.vk-resizable__handle {
width: .25rem;
height: 27px;
position: absolute;
margin-top: -.25rem;
margin-left: .1rem;
cursor: col-resize;
}
.vk-resizable__divider {
position: absolute;
right: 0;
top: 0;
width: 1px;
height: 100vh;
display: block;
cursor: col-resize;
opacity: 0;
}
.vk-is-resizable:hover .vk-resizable__handle, .vk-is-resizable:focus .vk-resizable__handle {
background: #c9c7c5;
}
table th .vk-resizable__handle:hover, table th .vk-resizable__handle:focus{
background: #0070d2;
cursor: col-resize;
}
.vk-resizable__handle:hover .vk-resizable__divider, .vk-resizable__handle:focus .vk-resizable__divider{
opacity: 1;
z-index: 100;
background: #0070d2;
cursor: col-resize;
}
table .vk-cell-edit:hover {
background-color: #ffff !important;
cursor: text;
}
table .vk-cell-edited, table .vk-cell-edited:hover {
background-color: #faffbd !important;
}
.customSelect select{
padding-right: 1.25rem;
min-height: inherit;
line-height: normal;
height: 1.5rem;
}
.customSelect label{
margin-top: .1rem;
}
.customSelect .slds-select_container::before{
border-bottom: 0;
}
.vk-sort_asc{
line-height: 1;
}
.vk-sort_desc{
transform: rotate(180deg);
line-height: .5;
}
.vk-dt-required{
vertical-align: top;
margin-right:.125rem;
}
.slds-checkbox_faux{
bottom: .1rem;
}
We have got all the required components, now let's see an example about how to use the vk-datatable.
vkDatatableUsage (LWC)
Create an LWC component called 'vkDatatableUsage' and update it's elements with the below code.vkDatatableUsage.html
<!-- © vkambham.blogspot.com -->
<template>
<c-spinner if:true={isLoading} variant="brand" message={loadMessage}></c-spinner>
<lightning-card title="Custom Datatable">
<div if:true={error} class="slds-var-p-around_small slds-text-color_error">{error}</div>
<c-vk-datatable if:true={showTable}
columns={columns}
records={opps}
show-row-number
show-checkbox
show-pagination
place-holder="Search..."
page-size-options={pageSizeOptions}
onrowselection={handleRowSelection}
onsave={saveRecords}>
</c-vk-datatable>
</lightning-card>
</template>
vkDatatableUsage.js
/* © vkambham.blogspot.com */
import { LightningElement, wire } from 'lwc';
import getAccounts from '@salesforce/apex/VKDatatableUsageController.getAccounts';
import getOpportunities from '@salesforce/apex/VKDatatableUsageController.getOpportunities';
import updateRecords from '@salesforce/apex/VKDatatableUsageController.saveRecords';
const STAGEOPTIONS = [
{value: 'Prospecting', label: 'Prospecting'},
{value: 'Qualification', label: 'Qualification'},
{value: 'Needs Analysis', label: 'Needs Analysis'},
{value: 'Value Proposition', label: 'Value Proposition'},
{value: 'Id. Decision Makers', label: 'Id. Decision Makers'},
{value: 'Perception Analysis', label: 'Perception Analysis'},
{value: 'Proposal/Price Quote', label: 'Proposal/Price Quote'},
{value: 'Negotiation/Review', label: 'Negotiation/Review'},
{value: 'Closed Lost', label: 'Closed Lost'},
{value: 'Closed Won', label: 'Closed Won'}
];
const TYPEOPTIONS = [
{value: 'Existing Customer - Upgrade', label: 'Existing Customer - Upgrade'},
{value: 'Existing Customer - Replacement', label: 'Existing Customer - Replacement'},
{value: 'Existing Customer - Downgrade', label: 'Existing Customer - Downgrade'},
{value: 'New Customer', label: 'New Customer'}
];
const columns = [
{label: 'Opportunity Name', fieldName: 'Id', type: 'link', linkLabel: 'Name',
sortable: true, sortBy: 'Name', resizable: true, title:'Click to view Opportunity', target:'_blank', editable: true},
{label: 'Account', fieldName: 'AccountId', type: 'lookup', linkLabel: 'accName', iconName:'standard:account',
sortable: true, resizable: true, editable: true, required: true, sortBy: 'accName', title:'Click to view Account',},
{label: 'Type', fieldName: 'Type', type: 'picklist', options: TYPEOPTIONS, sortable: true, resizable: true, required: true, editable: true},
{label: 'Stage', fieldName: 'StageName', type: 'picklist', options: STAGEOPTIONS, sortable: true, resizable: true, editable: true},
{label: 'Amount', fieldName: 'Amount', type: 'currency', sortable: true, resizable: true, editable: true},
{label: 'Close Date', fieldName: 'CloseDate', type: 'date', sortable: true, resizable: true, editable: true},
{label: 'Probability', fieldName: 'Probability', type: 'percent', sortable: true, resizable: true, editable: true},
];
const PAGESIZEOPTIONS = [10,20,40];
export default class VkDatatableUsage extends LightningElement {
error;
columns = columns;
opps; //All opportunities available for data table
showTable = false; //Used to render table after we get the data from apex controller
pageSizeOptions = PAGESIZEOPTIONS;
isLoading = true;
loadMessage = 'Loading...';
@wire(getAccounts)
wAccs({error,data}){
if(data){
let accounts = [];
for(let i=0; i<data.length; i++){
let obj = {value: data[i].Id, label: data[i].Name};
accounts.push(obj);
}
this.columns[1].options = accounts;
}else{
this.error = error;
}
}
connectedCallback(){
this.getOpportunities_();
}
getOpportunities_(){
this.showTable = false;
this.loadMessage = 'Loading...';
this.isLoading = true;
this.error = '';
getOpportunities()
.then(data=>{
this.opps = [];
for(let i=0; i<data.length; i++){
let obj = {...data[i]};
obj.accName = data[i].Account.Name;
this.opps.push(obj);
}
this.showTable = true;
this.isLoading = false;
})
.catch(error=>{
this.error = JSON.stringify(error);
this.showTable = true;
this.isLoading = false;
});
}
handleRowSelection(event){
console.log('Records selected***'+JSON.stringify(event.detail));
}
saveRecords(event){
this.loadMessage = 'Saving...';
this.isLoading = true;
this.error = '';
updateRecords({recsString: JSON.stringify(event.detail)})
.then(response=>{
if(response==='success') this.getOpportunities_();
})
.catch(error=>{
console.log('recs save error***'+error);
this.error = JSON.stringify(error);
this.isLoading = false;
});
}
}
VKDatatableUsageController.apxc
Create an apex class called 'VKDatatableUsageController' and add below code to it.
/* © vkambham.blogspot.com */
public with sharing class VKDatatableUsageController {
@AuraEnabled(cacheable=true)
public static List<Account> getAccounts() {
return [SELECT Id, Name FROM Account];
}
@AuraEnabled
public static List<SObject> getOpportunities() {
return [SELECT Id, Name, Type, StageName, Amount, CloseDate, Probability, AccountId, Account.Name FROM Opportunity];
}
@AuraEnabled
public static String saveRecords(String recsString) {
List<Opportunity> recs2Save = (List<Opportunity>) JSON.deserialize(recsString, List<Opportunity>.class);
update recs2Save;
return 'success';
}
}
That's it. We have a datatable which will now serve most of the purposes. We don't need to build one each time when we get the scenarios where standard lightning-datatable doesn't support.
PS: Hope this helps! Please share your feedback/suggestions/any exciting posts you come across. Happy Coding!
Coming up: More dynamic way of building datatable in salesforce, which is using 'field sets'.
Hi Venky,
ReplyDeleteBrilliantly done, I was able to add it and get it working.
But there seems to be error that is popping with renderedCallBack in vkDatatable.js.
let table = this.template.querySelector('.vk-data-table');
table is being returned as null. Because of this table.offsetHeight is getting exception. Can you please help?
Thanks for bringing it to me, I will take a look.
Deletelooks like this happens when you have an opportunity without account
DeleteI had to change vkDatatableUsage.js line 77
obj.accName = data[i].Account ? data[i].Account.Name : "";
I also run into couple issues in vkDatatable.html
1. lightning-input label attribute was required - so I had to add something for search and page inputs
2. line 81 iterator over columns didn't like the 'col' as a key
had to change
<th key={col}
to <th key={col.fieldName}
3. same for td iterator in line 126
instead of <td key={row.index}
had to use <td key={field.name}
whatsup with a lwc having tags? it doesnt seem supported. also no quotes on the things like class={listClass} should be class="{!listClass}". so having trouble understanding how to copy and paste these items into a workable lightning component?
ReplyDeletemy previous comment had some items stripped out b/c I had tags i them. I copied and pasted these sample in to a lightning component, js, and css of the aura bundle. it doesnt allow me to save. it doesnt understand the template syntax. i thought all aura components needs to start with the aura component syntax? what am i missing here?
DeleteThis is LWC component, not aura component. Please check my first post for aura component.
Deletehello. i followed the instructions but when i launch it by adding the LWC to a lightning tab I get the following error immediately with no results: "Cannot read property 'length' of undefined". its coming from the vkDatatable LWC within the setupTable method. any ideas on why?
ReplyDeleteDo you see any line number of the error coming from? If so, please post that too, I will look into it.
DeletesetupTable(){
Deletethis.showTable = false;
this.isEdited = false;
this.totalRecords = this._records.length;
if(this.totalRecords === 0){
this.showPagination = false;
return;
}
this.totalRecords = this._records === undefined ? 0 : this._records.length;
DeleteHello. I got this working but when I start sorting various columns and clicking through sorting several times (sometimes 5+) i randomly get an error that reads this:
ReplyDelete"Uncaught (in promise) TypeError: Cannot read property 'checked' of undefined"
Any ideas why/how it randomly happens?
That is weird, let me look into it.
DeleteI just checked and I don't see any such issues in my org. Can you see the line number where the error coming from?
DeleteHi Venky you can ignore it was an issue on my side.
ReplyDeleteHowever I recreated from scratch in a dev org from the original and found a legit bug. When you search using the search box and click to edit a row that is past a filtered row (e.g. the filtering gives you row 1-5 and then due to a filter the next row isnt 6 its 10) and you click to edit the row you get an error = "Uncaught TypeError: Cannot set property 'mode' of undefined". Also editing rows prior to the row skip from the filter sometimes when you click cancel the editing mode shows for that row? This is a great project but this bug really hinders the use. can you help?
Venky can you confirm you can recreate this bug? Any ideas on how we can fix it? Seems deeply embedded in the data table JS logic.
DeletePlease verify your data once.
Deletehello. not sure exactly what you mean. if you take your existing sample code and do not make any changes and perform a search on the data table text box which limits the records and then try and edit a row under a filtered row (e.g. you apply a search filter that filters out row 3 and you see row 1, 2, and 5 once you try and make an edit on row 5 you will experience the error).
Deletehi Venky we would love to use it but still stuck on the issue above. What do you mean by "Please verify your data once"?
DeleteI was little busy, not able to check your comments. I will look into the scenario you mentioned. Thanks!
DeleteHello, Venky, When searching records, if the index number is more than 5 then it throws an error. You can check in you code, try to search some records whose index number is more than 5 or 10(I took 28). Can you please address this issue?
DeleteThis comment has been removed by the author.
DeleteHi, this issue appears when you search for example so that you only get 1 result and the rowNumber is no longer let's say "correlated" with the rowIndex.
DeleteFor example if I got 10 rows and I search and only get what was row number 7 then the logic fails because it's trying to update tableData2Display in row 0 instead of looking for tableData in row 7...
I added this changes as a workaround, hope it helps: //START: CELL EDITING
handleCellEdit(event){
this.isEdited = true;
let resp = event.detail;
//bugfix (gba 30-05-2022): when you search for something the rowIndex might no longer match the row number shown on tableData2Display
//1. build "maps"
let lookupData = {};
for (var i = 0, len = this.tableData.length; i < len; i++)
{
lookupData[this.tableData[i].id] = this.tableData[i];
}
let lookupDisplay = {};
for (var i = 0, len = this.tableData2Display.length; i < len; i++)
{
lookupDisplay[this.tableData2Display[i].id] = this.tableData2Display[i];
}
/*
window.alert(resp.rowIndex);
window.alert(this.tableData[resp.rowIndex].id);
window.alert(lookupDisplay[this.tableData[resp.rowIndex].id].rowNumber);
window.alert(this.tableData2Display.indexOf(lookupDisplay[this.tableData[resp.rowIndex].id]));
*/
//2. search the index in tableData that contains the row shown using tableData2Display
//---this.tableData2Display[resp.rowIndex].mode = 'edit';
this.tableData2Display[this.tableData2Display.indexOf(lookupDisplay[this.tableData[resp.rowIndex].id])].mode = ''+'edit';
//END bugfix (gba 30-05-2022)
this.setupPages();
}
handleCellChange(event){
let resp = event.detail;
let rec = {...this._records[resp.rowIndex]};
let lookupData = {};
for (var i = 0, len = this.tableData.length; i < len; i++) {
lookupData[this.tableData[i].id] = this.tableData[i];
}
let lookupDisplay = {};
for (var i = 0, len = this.tableData2Display.length; i < len; i++) {
lookupDisplay[this.tableData2Display[i].id] = this.tableData2Display[i];
}
let rowIndexAUX = this.tableData2Display.indexOf(lookupDisplay[this.tableData[resp.rowIndex].id]);
if(resp.value !== this._records[resp.rowIndex][resp.name]){
this.tableData2Display[rowIndexAUX].fields[resp.colIndex].tdClass = 'cell-edit cell-edited';//resp.rowIndex
this.tableData2Display[rowIndexAUX].fields[resp.colIndex].value = resp.value;//resp.rowIndex
rec[resp.name] = resp.value;
}else{
this.tableData2Display[rowIndexAUX].fields[resp.colIndex].tdClass = 'cell-edit';//resp.rowIndex
this.tableData2Display[rowIndexAUX].fields[resp.colIndex].value = resp.value;//resp.rowIndex
}
let changedFields = this.tableData2Display[rowIndexAUX].fields.filter(field=>field.tdClass.includes('cell-edited'));//resp.rowIndex
if(changedFields.length > 0){
this.tableData2Display[rowIndexAUX].isSelected = true;//resp.rowIndex
this.recsEdited.set(resp.rowIndex,rec);
}else{
this.tableData2Display[rowIndexAUX].isSelected = false;//resp.rowIndex
if(this.recsEdited.has(resp.rowIndex))
this.recsEdited.delete(resp.rowIndex);
}
this.setupPages();
}
Hi,
ReplyDeleteI am using the component but i am getting a lot error on the html - can you help me with the dattable?
how can i connect with you on same - i am working on using this on community page
Uncaught Error during LWC component connect phase: [Cannot read property 'offsetHeight' of null]. I got this error. Can you please help?
ReplyDeleteIf I change the vkDatatable component name to some other, renderedCallback is calling multiple times and not able to load the datatable as expected.
if(!this.stylesLoaded){
Promise.all([
loadStyle(this, vkStyles)
]).then(() => {
this.stylesLoaded = true;
}).catch(error => {
console.log('Error loading styles**'+JSON.stringify(error));
this.stylesLoaded = false;
});
}
I updated code as below
renderedCallback(){
//this.stylesLoaded=false;
console.log('renderedCallback------- ',this.stylesLoaded);
console.log('vkStyles------- ',vkStyles);
if(!this.stylesLoaded){
Promise.all([
loadStyle(this, vkStyles)
]).then(() => {
this.stylesLoaded = true;
this.initializeTableStyles();
console.log('this.stylesLoaded-111-------- ',this.stylesLoaded);
}).catch(error => {
console.log('Error loading styles**'+JSON.stringify(error));
this.stylesLoaded = false;
});
}
}
initializeTableStyles() {
let table = this.template.querySelector('.vk-data-table');
console.log('table--------- ',JSON.stringify(table));
console.log('this.stylesLoaded--------- ',this.stylesLoaded);
this.resizerStyle = 'height:'+table.offsetHeight+'px';
let cols = this.template.querySelectorAll('TH');
cols.forEach(col=>{
col.style.width = col.style.width ? col.style.width : col.offsetWidth+'px';
col.style.minWidth = col.style.minWidth ? col.style.minWidth : '50px';
});
}
Then error is not getting triggered. but styles are not loaded.
Could you please help on this... I have requirement like that, needs to be completed ASAP. I spent 2 days no luck. Thanks in Advance. waiting for your reply.
Do you have columns added in your component? Please check if all TH components are rendered.
DeleteSorry, I was little busy and not able to check the comments. Do you have columns added in your component? Please check if all TH components are rendered.
DeleteHi VK,
ReplyDeleteThanks and great work. The have changed the opportunity to contact for the table. The problem i am facing now is when I save the record got saved, but its not changing on UI, every time I have to reload the whole page.
Is there anything I am missing or any other issue. The only thing I did is chnaged the data and columns JSON and accordingly changed the apex class.
Prot, I don't think there is anything particular to lookup types. But I will check once.
DeleteHi Venky - this is great! I do have a quick question - comboboxes are getting cut off at the bottom of the container. So even though I can scroll, I can never see the bottom choices. I've tried changing the height of the combobox (couldn't find a way that worked), adjusted the z-index (didn't work), etc.
ReplyDeleteIs there a setting or CSS style that can be applied to fix this? Thanks!
If it helps, lookup type combobox is displaying great, only the picklist type combobox is getting cut off (for example, on the "Stage" column in your vkDataTableUsage LWC).
DeleteJ Ted, I was planning to replace combobox with select component. I will update it soon.
DeleteHi, add following styling to vkDatatableStyles static resource and reupload in setup > static resources
Deletec-vk-datatable .slds-scrollable_y,
c-vk-datatable .slds-scrollable--y,
c-vk-datatable .slds-scrollable_x,
c-vk-datatable .slds-scrollable--x {
overflow: visible;
}
Hi Venky,
ReplyDeleteFor me data is loaded in the table. but i am not able to edit..
Thanks
Arun
Arun, do you see any errors in the console?
DeleteI am facing same issue error is coming
DeleteError during LWC component connect phase: [Cannot read properties of undefined (reading 'length')] This error is coming in vkDatatable
How to use "LinkAction"? I tried many combination but it's not working.
ReplyDeleteI need to open a modal pop-up on click of a record and I was wondering if we can use LinkAction.
Sandeep, I will add an example soon.
DeleteAny update on this? I am wondering as well. Thank you.
DeleteThere are several examples in line 26 etc of vkDatatableUsage.js
Delete{label: 'Opportunity Name', fieldName: 'Id', type: 'link', linkLabel: 'Name', sortable: true, sortBy: 'Name', resizable: true, title:'Click to view Opportunity', target:'_blank', editable: true},
Hello Venky,
ReplyDeleteGreat work and thank you for sharing.
I tried working with checkboxes inside the table and noticed this.
- Passing boolean when it is false will be shown as checked and actually the value is false.
formElement.js
@api set value(value){
this._value = value ? value : '';
if(this.isLookup && this.isConnected) this.populateLookupValue(this._value);
}
-So i made a small modification and this fixed my problem.
@api set value(value){
this._value = value !== undefined ? value : "";
if(this.isLookup && this.isConnected) this.populateLookupValue(this._value);
}
Great, thanks for notifying me. I will update it.
DeleteHi Venky,
ReplyDeleteis it possible to use VKDatatable to view, modify and save some edited records and then reuse it to display confirmation view only with already modified data?
What would You use? Dispatch events?
Hi Venky,
ReplyDeleteJust one question, that came up in a scenario I was working on:
In lwc datatable inline editing, is there any way we can track the column under which the editing is done? I have two columns: order and percent, both editable, I would like to track them separately for each row. Finally be able to show their product in the next cell(which was originally blank, but after inline editing turns into order * percent) in the same row.
Hi Juny, The table data is prepared as two dimensional array. Eg: 00 indicates row 1 and column 1; 12 --> Row 2 and Column 3. Loop through your data and access the required cols and rows accordingly.
DeleteHi Venky, could you help me?
ReplyDeleteI'm triying to use your datatable, but i'm not getting to use because of this error:
Error in $A.getCallback() [Unknown error creating component: c:vkDatatableUsage]
createComponentErrorProcessor()@https://nscara-1c-dev-ed.lightning.force.com/libraries/force/componentLibrary.errors.js:9:307
I really dont know what is the but i think the error is in the vkDatatable...
Please make sure you have all the dependent components created in your org.
DeleteHi Venky,
ReplyDeleteEverything is working fine for the LWC, but need your help on the apex test class, especially for the saveRecords() method, trying to cover all the methods and saveRecords() is the only one I'm having issue to cover. If you are available, please help.
Thank you and have a good day!
Hi Venky,
ReplyDeleteI have followed the steps one by one and able to use the data table for a while. After a few trials on editing/updating the records value in the data table, I received this error:
{"status":500,"body":{"exceptionType":"System.LimitException","isUserDefinedException":false,"message":"Too many DML statements: 1","stackTrace":"Class.VKDatatableUsageController.saveRecords: line 17, column 1"},"headers":{}}
is there anywhere I have done wrong that leads to this error?
That's strange, how many records you have in your example?
DeleteHi Venky,
ReplyDeleteThanks for sharing these reusable components.
I want this table to work for more than 5k records.
It will be great if we can add infinite loading support to this table.
I feel pagination is better UI than infinite loading. This component supports pagination, please try using it.
DeleteHi Venky,
ReplyDeleteIs this designed to only update 1 field at a time within a single row? I've followed everything and cannot get it to save all the values. It only changes the last field that I updated in the row.
Thanks
No, it updates multiple fields of row(s). Can you try again and let me know do you see any error in the console.
DeleteHi VK, There are no errors in the console, but line 473 in vkDatatable.js is taking only the last field updated in the row and save only that to database. Can you please guide to make all the fields updated in a row in single save operation?
DeleteThank you.
Hi Venky,
ReplyDeleteDoes this also support mass update? Like in standard lightning data-table, if we select 5 rows, make change in a single row, we get a checkbox for 'Update selected rows' and once checked, all 5 rows will be updated with the same value? Is this supported in your component for picklist?
Thanks
Yes, it support mass update. You can test it by changing multiple rows and see if the selected rows get updated.
DeleteHi VK,
ReplyDeleteNice work. Can u please add in blog
vkDatatable.js after line 473
we have to assign to updated record to this._records
Issue : only last updated cell in edit rows updated.
Hi VK,
DeleteThis post is really awesome.
Only last updated cell in edit rows is getting updated in Database. Can you please help in adding all the cells edited to get saved into Database please?
Had the same issue. The line you need is this:
Deletethis._records[resp.rowIndex] = rec;
Hi, This looks like a great option for me and have it implemented for testing. However, I am trying to dynamically pull in picklist values for a picklist column, that way I dont need to change code when a new option is added. I am having a hard time understanding when/where I should be setting that option if that is the case.
ReplyDeleteHere is my code so far:
recordTypeId;
@wire(getObjectInfo, {objectApiName: CLASS_OBJECT})
classInfo({data, error}){
if(data){
this.recordTypeId = data.defaultRecordTypeId;
}else{
this.error = error;
}
}
@wire(getPicklistValues, { recordTypeId: '$recordTypeId', fieldApiName: STATUS.fieldApiName})
statusPicklistValues({data, error}){
if(data){
for(let i=0; i< data.length; i++){
let obj = {value: data[i].value, label: data[i].value};
this.STATUSOPTIONS.push(obj);
}
this.showTable = true;
console.log(this.STATUSOPTIONS);
}else{
this.error = error;
}
}
And here is where I add it to the columns array
{label: 'Status', fieldName:STATUS.fieldApiName, type:'picklist', options:STATUSOPTIONS, sortable:true, editable:true, require:true, resizeable:true}
Im fairly new to the wire system and the order of operations so any guidance would help. Thank you
Not sure, how you are defining the STATUSOPTIONS variable. It shouldn't be a constant.
DeleteThis comment has been removed by the author.
DeleteSomething like this inside connectedCallback() might do the trick:
DeleteLWC
Remove STATUSOPTIONS and columns from the top of the js, add them after "@api recordId;" for example without the const part
connectedCallback()
{
this.getMembers_();
getPicklistValues({ campaignId: this.recordId})
.then(data=>{
var myJSONString = data,
myObject = JSON.parse(myJSONString);
var myArray = [];
for(var i in myObject)
{
myArray.push(myObject[i]);
}
this.STATUSOPTIONS=myArray;
this.columns = [
{label: 'Campaign Name', fieldName: 'CampaignId', type: 'link', linkLabel: 'campaignName',
sortable: true, sortBy: 'campaignName', resizable: true, title: 'Click to view Campaign', target:'_blank', editable: false},
{label: 'Lead Name', fieldName: 'LeadId', type: 'link', linkLabel: 'leadName',
sortable: true, sortBy: 'leadName', resizable: true, title: this.labels.ColumnLabel4/*Click to view Lead*/, target:'_blank', editable: false},
];
})
.catch(error=>{
this.error = JSON.stringify(error);
console.log('---' + this.error);
});
}
Apex
@AuraEnabled
//----------------------------------------- getPicklistValues() ------------------------------------------------//
public static String getPicklistValues(String campaignId)
{
//vars
String retValue = '[';
List memberStatusList = [SELECT Id,CampaignId,HasResponded,Label
FROM CampaignMemberStatus
WHERE CampaignId = :campaignId];
//status picklist
List picklistValuesList = getPickListValuesList('CampaignMember', 'Status');
//build JSON to be returned
for(Schema.PicklistEntry p : picklistValuesList)
{
retValue += '{"value":"' + p.getValue() + '","label":"' + p.getLabel() + '"},';
}
retValue = retValue.removeEnd(',');
retValue+=']';
System.debug('retValue ' + retValue);
return retValue;
}
//------------------------------------- END getPicklistValues() ------------------------------------------------//
//-------------------------------------- getPickListValuesList()---------------------------------------------//
public static List getPickListValuesList(String objectType, String selectedField)
{
List pickListValuesList = new List();
Schema.SObjectType convertToObj = Schema.getGlobalDescribe().get(objectType);
Schema.DescribeSObjectResult res = convertToObj.getDescribe();
Schema.DescribeFieldResult fieldResult = res.fields.getMap().get(selectedField).getDescribe();
List ple = fieldResult.getPicklistValues();
return ple;
}
//--------------------------------- END getPickListValuesList()---------------------------------------------//
Hello VK,
ReplyDeleteI have used your component but it does not support mass edit and only updates the field last edited. Can you please guide how to do it for all(Mass update).
Thanks,
Got it from the comments. Thanks for sharing!!
DeleteHi VK, thanks for the component. Awesome work!
ReplyDeleteI'm finding a problem with setting some input fields as readonly; once the row is in Edit mode it becomes editable.
I looked at the formElement component and using isReadOnly on the lightning input does not realy work.
You are right, I guess it needs some correction. I will work on it when time permits. Meanwhile, if you fixed it, you can post the changes here, so that it will be helpful for others. Thanks!
DeleteSuper cool solution. Thanks indeed Venky!
ReplyDeleteHello VK:
ReplyDeleteI had an issue before with checkbox values not being saved. the issue was in the formElement component.
The checkbox doesn't have "value" like the other types, but a checked attribute.
So the Fix is : add this line to the sendValue() method:
if(this.type=="checkbox"){
obj.value = event.target.checked;
}
You're right. Thanks for that correction.
DeleteDoes your component support multi picklist and dependent picklist values? If so, please provide an example. Thanks
ReplyDeleteIt is same like other components, all you need is to add that in formElement and implement your multi-picklist or dependent picklist logic there.
DeleteHi vk! awesome job! thanks!
ReplyDeleteany update on the edit issue?, I wasn't able to fix it by my own