LWC Datatable - Lookup and Picklist supported editable lightning datatable

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.

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>&nbsp;{records.length}&nbsp;</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'.

73 Comments

  1. Hi Venky,

    Brilliantly 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?

    ReplyDelete
    Replies
    1. Thanks for bringing it to me, I will take a look.

      Delete
    2. looks like this happens when you have an opportunity without account

      I 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}


      Delete
  2. 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?

    ReplyDelete
    Replies
    1. my 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?

      Delete
    2. This is LWC component, not aura component. Please check my first post for aura component.

      Delete
  3. hello. 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?

    ReplyDelete
    Replies
    1. Do you see any line number of the error coming from? If so, please post that too, I will look into it.

      Delete
    2. setupTable(){
      this.showTable = false;
      this.isEdited = false;
      this.totalRecords = this._records.length;
      if(this.totalRecords === 0){
      this.showPagination = false;
      return;
      }

      Delete
    3. this.totalRecords = this._records === undefined ? 0 : this._records.length;

      Delete
  4. Hello. 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:

    "Uncaught (in promise) TypeError: Cannot read property 'checked' of undefined"

    Any ideas why/how it randomly happens?

    ReplyDelete
    Replies
    1. That is weird, let me look into it.

      Delete
    2. I just checked and I don't see any such issues in my org. Can you see the line number where the error coming from?

      Delete
  5. Hi Venky you can ignore it was an issue on my side.

    However 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?

    ReplyDelete
    Replies
    1. 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.

      Delete
    2. Please verify your data once.

      Delete
    3. hello. 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).

      Delete
    4. hi Venky we would love to use it but still stuck on the issue above. What do you mean by "Please verify your data once"?

      Delete
    5. I was little busy, not able to check your comments. I will look into the scenario you mentioned. Thanks!

      Delete
    6. Hello, 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?

      Delete
    7. This comment has been removed by the author.

      Delete
    8. Hi, 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.

      For 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();
      }

      Delete
  6. Hi,
    I 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

    ReplyDelete
  7. Uncaught Error during LWC component connect phase: [Cannot read property 'offsetHeight' of null]. I got this error. Can you please help?
    If 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.

    ReplyDelete
    Replies
    1. Do you have columns added in your component? Please check if all TH components are rendered.

      Delete
    2. Sorry, 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.

      Delete
  8. Hi VK,
    Thanks 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.

    ReplyDelete
    Replies
    1. Prot, I don't think there is anything particular to lookup types. But I will check once.

      Delete
  9. Hi 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.

    Is there a setting or CSS style that can be applied to fix this? Thanks!

    ReplyDelete
    Replies
    1. 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).

      Delete
    2. J Ted, I was planning to replace combobox with select component. I will update it soon.

      Delete
    3. Hi, add following styling to vkDatatableStyles static resource and reupload in setup > static resources

      c-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;
      }

      Delete
  10. Hi Venky,

    For me data is loaded in the table. but i am not able to edit..

    Thanks
    Arun

    ReplyDelete
    Replies
    1. Arun, do you see any errors in the console?

      Delete
    2. I am facing same issue error is coming
      Error during LWC component connect phase: [Cannot read properties of undefined (reading 'length')] This error is coming in vkDatatable

      Delete
  11. How to use "LinkAction"? I tried many combination but it's not working.
    I need to open a modal pop-up on click of a record and I was wondering if we can use LinkAction.

    ReplyDelete
    Replies
    1. Sandeep, I will add an example soon.

      Delete
    2. Any update on this? I am wondering as well. Thank you.

      Delete
    3. There are several examples in line 26 etc of vkDatatableUsage.js

      {label: 'Opportunity Name', fieldName: 'Id', type: 'link', linkLabel: 'Name', sortable: true, sortBy: 'Name', resizable: true, title:'Click to view Opportunity', target:'_blank', editable: true},

      Delete
  12. Hello Venky,

    Great 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);
    }

    ReplyDelete
    Replies
    1. Great, thanks for notifying me. I will update it.

      Delete
  13. Hi Venky,
    is 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?

    ReplyDelete
  14. Hi Venky,
    Just 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.

    ReplyDelete
    Replies
    1. 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.

      Delete
  15. Hi Venky, could you help me?

    I'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...

    ReplyDelete
    Replies
    1. Please make sure you have all the dependent components created in your org.

      Delete
  16. Hi Venky,

    Everything 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!

    ReplyDelete
  17. Hi Venky,

    I 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?

    ReplyDelete
    Replies
    1. That's strange, how many records you have in your example?

      Delete
  18. Hi Venky,
    Thanks 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.

    ReplyDelete
    Replies
    1. I feel pagination is better UI than infinite loading. This component supports pagination, please try using it.

      Delete
  19. Hi Venky,

    Is 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

    ReplyDelete
    Replies
    1. No, it updates multiple fields of row(s). Can you try again and let me know do you see any error in the console.

      Delete
    2. Hi 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?

      Thank you.

      Delete
  20. Hi Venky,
    Does 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

    ReplyDelete
    Replies
    1. Yes, it support mass update. You can test it by changing multiple rows and see if the selected rows get updated.

      Delete
  21. Hi VK,
    Nice 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.

    ReplyDelete
    Replies
    1. Hi VK,

      This 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?

      Delete
    2. Had the same issue. The line you need is this:
      this._records[resp.rowIndex] = rec;

      Delete
  22. 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.

    Here 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

    ReplyDelete
    Replies
    1. Not sure, how you are defining the STATUSOPTIONS variable. It shouldn't be a constant.

      Delete
    2. This comment has been removed by the author.

      Delete
    3. Something like this inside connectedCallback() might do the trick:

      LWC

      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()---------------------------------------------//

      Delete
  23. Hello VK,

    I 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,

    ReplyDelete
    Replies
    1. Got it from the comments. Thanks for sharing!!

      Delete
  24. Hi VK, thanks for the component. Awesome work!
    I'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.

    ReplyDelete
    Replies
    1. 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!

      Delete
  25. Super cool solution. Thanks indeed Venky!

    ReplyDelete
  26. Hello VK:
    I 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;
    }

    ReplyDelete
    Replies
    1. You're right. Thanks for that correction.

      Delete
  27. Does your component support multi picklist and dependent picklist values? If so, please provide an example. Thanks

    ReplyDelete
    Replies
    1. It is same like other components, all you need is to add that in formElement and implement your multi-picklist or dependent picklist logic there.

      Delete
  28. Hi vk! awesome job! thanks!
    any update on the edit issue?, I wasn't able to fix it by my own

    ReplyDelete
Post a Comment
Previous Post Next Post