Width-Aware Collapsible Table

Hello, and welcome to Another Salesforce Blog!  Here I will be posting solutions to problems that I couldn’t find an answer to in hopes of helping those who find themselves stuck when using the Salesforce platform.

User Story

We want to create a lightning__RecordPage component that displays a table that looks different whether it is in a main region or a sidebar, which means that, by default, the component is responsive and mobile friendly.

For this example, we will be using the relationship between the Account and Contact objects in Salesforce to display a list of related Contacts on the Account object. When in a CLASSIC view, the table will display columns showing a utility:email icon, the Name, Department, and Phone fields of the related Contact records, and a lightning-button-menu that displays options to edit and delete the contacts.

Note: We will not be building out the email functionality or the edit/delete functionality in this tutorial, these are placeholders for the purpose of demonstration.

The GitHub repository for this project is located here.

An Account record in Salesforce with a lefthand region and righthand sidebar displaying the same component in different formats.

Background

There are several ways to do this, but the easiest way is to use CSS grid-template-areas. This will make our code faster, sleeker, and easier to maintain than a solution within the markup.

This tutorial requires knowledge of Lightning Web Components, Apex, and requires Visual Studio Code.

Solution

Step One – Build Out Relevant Apex Classes.

We know that we need to get the Account and its related Contact records, as well as the fields specified previously. Because this component will be on a lightning__RecordPage, we are able to access the recordId of the record page, and are thus able to query for the relevant account. We will start by building a data access with this query.

GetAccountInfo.cls:

public with sharing class GetAccountInfo {

    public static Account getAccount(Id accountId){
        Account accountToReturn = [
            SELECT
                Id,
                Name,
                (SELECT Id, Name, Department, Email, Phone FROM Contacts)
            FROM Account
            WHERE Id = :accountId
            LIMIT 1
        ];

        return accountToReturn;
    }
}

Next, we will build a helper class that allows us to put the information from the queried Account record and its related Contact records into a wrapper class, allowing us to access them as a JavaScript object. We include a constructor for the wrapper class to allow us to initialize instances of the wrapper class, and we must make the constructor @TestVisible so that we are able to access the private constructor of the wrapper class.

AccountInfoWrapper.cls:

public with sharing class AccountInfoWrapper {

    public static AccountWrapper getAccountWrapper(Id accountId) {
        Account queriedAccount = GetAccountInfo.getAccount(accountId);

        AccountWrapper wrapperToReturn = new AccountWrapper(queriedAccount);

        return wrapperToReturn;
    }

    public with sharing class AccountWrapper {
        @AuraEnabled public String accountId;
        @AuraEnabled public String accountName;
        @AuraEnabled public List<ContactWrapper> contactList;

        @TestVisible
        private AccountWrapper(Account accountToWrap) {
            this.accountId = accountToWrap.Id;
            this.accountName = accountToWrap.Name;
            this.contactList = new List<ContactWrapper>();
            if(accountToWrap.Contacts.size() > 0) {
                for(Contact contactToWrap : accountToWrap.Contacts) {
                    ContactWrapper wrappedContact = new ContactWrapper();
                    wrappedContact.contactId = contactToWrap.Id;
                    wrappedContact.contactName = contactToWrap.Name;
                    wrappedContact.contactDepartment = contactToWrap.Department;
                    wrappedContact.contactEmail = contactToWrap.Email;
                    wrappedContact.contactPhone = contactToWrap.Phone;

                    this.contactList.add(wrappedContact);
                }
            }
        }
    }

    public with sharing class ContactWrapper {
        @AuraEnabled public String contactId;
        @AuraEnabled public String contactName;
        @AuraEnabled public String contactDepartment;
        @AuraEnabled public String contactEmail;
        @AuraEnabled public String contactPhone;
    }
}

Lastly, we will create an @AuraEnabled class that interfaces with our helper class and our JavaScript in the Lightning Web Component. It is within this class that we would place any permissions gates that would prevent a user from accessing the relevant records if they did not have the required permissions.

InterfaceClass.cls:

public with sharing class InterfaceClass {
    @AuraEnabled(cacheable=true)
    public static AccountInfoWrapper.AccountWrapper getAccountInfoWrapper(Id accountId){
        try {
            return AccountInfoWrapper.getAccountWrapper(accountId);
        } catch (Exception e) {
            throw new AuraHandledException(e.getMessage());
        }
    }
}

The test classes and .cls-meta.xml files for these classes are included in the GitHub repository for this post.

Step Two – Create Parent Component.

We are going to create a parent Lightning Web Component that calls the Interface class that we wrote above and passes the returned wrapper class as a JavaScript object to the child Lightning Web Component. This will allow us to easily make our code object agnostic and reusable. We are also going to use the parent component to get the width of the child component so that we can make our component reactive.

contactParentComponent.html:

<template>
    <c-display-contact-table class="displayContactTable" display-contacts={contacts} screen-width={screenWidth}></c-display-contact-table>
</template>

Note that we have included the tag c-display-contact-table, which will be the name of our child component. We are passing two variables, the object array contacts, and the screenWidth. We have also denoted the class for our child component as displayContactTable, which will allow us to query for this tag in a second.

For the JavaScript file, let’s break it down line by line.

First, we import our decorators and our Apex method, getAccountInfoWrapper:

import { LightningElement, api, track, wire } from 'lwc';
import getAccountInfoWrapper from '@salesforce/apex/InterfaceClass.getAccountInfoWrapper';

Next, we define our class and our variables. We make the class record context aware by using @api recordId to create a public recordId property. Additionally, we create tracked arrays for contacts and wiredContacts (more on this in a second!), an empty String for an error message, and variables for screenWidth and componentWidth. We set the default screenWidth to CLASSIC, but we can call this whatever makes sense.

import { LightningElement, api, track, wire } from 'lwc';
import getAccountInfoWrapper from '@salesforce/apex/InterfaceClass.getAccountInfoWrapper';

export default class ContactParentComponent extends LightningElement {
    @api recordId;

    @track contacts = [];
    @track wiredContacts = [];
    error = '';

    screenWidth = 'CLASSIC';
    componentWidth;

After initializing our variables, we write our connectedCallback() function, which sets a listener for when the window is resized. We call this.resizeFunction within our event listener, which we will write in just a moment.

	connectedCallback() {
		window.addEventListener('resize', this.resizeFunction);
	}

In order to call the Apex method that we have imported, we will then create a @wire function, getContacts. We will use a result format so that if we want to use refreshApex(), we are able to do so easily using refreshApex(this.wiredContacts). If the result has data, we will do a deep clone of result.data.contactList into this.contacts to pull the information from our wrapper class into an easily accessible JavaScript object that we will pass to our child component, and we call our resize function to make sure that we’re displaying our table properly based on the width of the component. If there’s an error, we display it in the console, and nothing will display in the table.

    @wire(getAccountInfoWrapper, {accountId : '$recordId'})
    getContacts(result) {
        this.wiredContacts = result;
        if(result.data) {
            this.contacts = JSON.parse(JSON.stringify(result.data.contactList));
            this.error = undefined;
            this.resizeFunction();
        }
        else if (result.error) {
            this.contacts = undefined;
            this.error = result.error.body.message;
            console.error(this.error);
        }
    }

Lastly, and this is the fun part, we are going to do some JavaScript magic to get the width of our table within its specified container on the record page. Not only will this adjust to the width of a region or sidebar, it will automatically display the compact table on a mobile screen, say, a screen of less than 600 pixels.

First, we’re going to define our component as the result of a .querySelector(), which will return the class .displayContactTable that we defined in our markup.

Next, we’re going to set our this.componentWidth by obtaining the .getBoundingClientRect().width of our recently defined component. This returns the width of the DOMRect object, which is a rectangle as shown below:

Visual representation of a DOMRect object via developer.mozilla.org

The width of this rectangle will include the width of any border and padding included, which allows us to get the total width of our child component. Neat!

If the component width is less than 600 pixels, we set our screenWidth to MOBILE, otherwise we set the screenWidth to CLASSIC.

    resizeFunction = () => {
		let component = this.template.querySelector('.displayContactTable');
		this.componentWidth = component.getBoundingClientRect().width;
		if(this.componentWidth < 600) {
			this.screenWidth = 'MOBILE';
		}
		else {
			this.screenWidth = 'CLASSIC';
		}
	}
}

In all, our contactParentComponent.js file will look like this:

import { LightningElement, api, track, wire } from 'lwc';
import getAccountInfoWrapper from '@salesforce/apex/InterfaceClass.getAccountInfoWrapper';

export default class ContactParentComponent extends LightningElement {
    @api recordId;

    @track contacts = [];
    @track wiredContacts = [];
    error = '';

    screenWidth = 'CLASSIC';
    componentWidth;

	connectedCallback() {
		window.addEventListener('resize', this.resizeFunction);
	}

    @wire(getAccountInfoWrapper, {accountId : '$recordId'})
    getContacts(result) {
        this.wiredContacts = result;
        if(result.data) {
            this.contacts = JSON.parse(JSON.stringify(result.data.contactList));
            this.error = undefined;
            this.resizeFunction();
        }
        else if (result.error) {
            this.contacts = undefined;
            this.error = result.error.body.message;
            console.error(this.error);
        }
    }

    resizeFunction = () => {
		let component = this.template.querySelector('.displayContactTable');
		this.componentWidth = component.getBoundingClientRect().width;
		if(this.componentWidth < 600) {
			this.screenWidth = 'MOBILE';
		}
		else {
			this.screenWidth = 'CLASSIC';
		}
	}
}

Once we’ve created our files, we need to make sure that they are accessible by the Lightning App Builder and accessible on a lightning__RecordPage by updating our .js-meta.xml file appropriately. I won’t show this here, but the file will be available in the appropriate GitHub repository.

Step Three – Create Child Component.

Now that we’ve got our contact array pulling from Apex, we need somewhere to display it!

We’ll start with the markup. Note: some of this functionality is not defined, and is a placeholder. I have notated this within the markup.

displayContactTable.html:

<template>
    <table class={tableClass}>
		<colgroup>
			<col span="1" class="small-column">
			<col span="1" class="large-column">
			<col span="1" class="large-column">
			<col span="1" class="large-column">
			<col span="1" class="small-column">
		</colgroup>
		<thead>
			<tr class="slds-line-height_reset">
				<th scope="col">
					<!-- FUNCTIONALITY FOR THIS COLUMN NOT DEFINED -->
					<div class="slds-truncate slds-text-title_caps" title="Email Contact">&nbsp;</div>
				</th>
				<th scope="col">
					<div class="slds-truncate slds-text-title_caps" title="Contact Name">
						Contact Name
					</div>
				</th>
				<th scope="col">
					<div class="slds-truncate slds-text-title_caps" title="Contact Department">
						Contact Department
					</div>
				</th>
				<th scope="col">
					<div class="slds-truncate slds-text-title_caps" title="Contact Phone">
						Contact Phone
					</div>
				</th>
				<th scope="col">
					<!-- FUNCTIONALITY FOR THIS COLUMN NOT DEFINED -->
					<div class="slds-truncate slds-text-title_caps" title="Dropdown">&nbsp;</div>
				</th>
			</tr>
		</thead>
		<tbody>
			<template for:each={displayContacts} for:item="contact"> 
				<tr key={contact.contactId}>
					<td>
						<!-- FUNCTIONALITY FOR THIS COLUMN NOT DEFINED -->
                        <button class="slds-button slds-button_icon" title="Email Contact">
                            <lightning-icon icon-name="utility:email" alternative-text="Email Contact" title="Email"></lightning-icon>
					    </button>
                    </td>
					<td>
						{contact.contactName}
					</td>
					<td>
						{contact.contactDepartment}
					</td>
					<td>
						{contact.contactPhone}
					</td>
					<td>
						<!-- FUNCTIONALITY FOR THIS COLUMN NOT DEFINED -->
						<lightning-button-menu variant="bare" icon-name="utility:chevrondown" menu-alignment="auto">
							<lightning-menu-item value="Edit" label="Edit"></lightning-menu-item>
							<lightning-menu-item value="Delete" label="Delete"></lightning-menu-item>
						</lightning-button-menu>
					</td>
				</tr>
			</template>
		</tbody>
	</table>
</template>

Note that in line 2 (highlighted) that we are setting the class tableClass dynamically. We will be using a getter for this in the JavaScript, which will update the class reactively when the width of the container is changed. We also use a getter and setter for the value of the displayContacts object array.

displayContactTable.js:

import { LightningElement, api, track, wire } from 'lwc';

export default class DisplayAccountInfo extends LightningElement {

    @api screenWidth = 'CLASSIC';

    @track _contacts = [];
    @api displayContacts = [];

    @api
    get contacts() {
        return this._contacts
    }
    set contacts(value) {
        this._contacts = value;
        this.displayContacts = value;
    }

    get tableClass() {
        if (this.screenWidth === 'MOBILE') {
            return 'mobileContainerDiv grid slds-table slds-table_cell-buffer slds-table_bordered full-table'
        }
        return 'classicContainerDiv slds-table slds-table_cell-buffer slds-table_bordered full-table'
    }

}

Note that if the screenWidth is MOBILE, we return the classes mobileContainerDiv and grid. If the screenWidth is anything other than MOBILE, we return the class classicContainerDiv.

Now comes the part we’ve all been waiting for, the CSS that will help us collapse our table neatly. We’ll set some colors, use our .mobileContainerDiv class to hide some column headers when in MOBILE view, and, most importantly, set our cells to display in consecutive rows rather than in columns. We can do this using grid-template-areas.

In order to get this to collapse neatly, we’ll use the following CSS for our tr elements:

/*USE GRID DISPLAY AND GRID TEMPLATE AREAS TO FORMAT COLLAPSED TABLE*/
table.mobileContainerDiv tr {
	display: grid;
	grid-template-rows: min-content auto;
	grid-template-columns: .5fr 2fr .5fr;
	grid-template-areas:
		"a b b b e"
		"a c c c e"
		"a d d d e";
}

This specifies the grid-template-areas a through e (highlighted), and allows us to specify which td elements will occupy which area as seen below:

/*icon column*/
table.mobileContainerDiv td:first-of-type {
	display: grid;
	grid-area: a;
	justify-content: center;
	align-content: center;
	border: bottom;
}

/*contact name column*/
table.mobileContainerDiv td:nth-of-type(2) {
	display: grid;
	grid-area: b;
	border: top;
}

/*contact department column*/
table.mobileContainerDiv td:nth-of-type(3) {
	display: grid;
	grid-area: c;
	color: gray;
	border: none;
}

/*contact phone column*/
table.mobileContainerDiv td:nth-of-type(4) {
    display: grid;
	grid-area: d;
	border: none;
}

/*dropdown column*/
table.mobileContainerDiv td:nth-of-type(5) {
	display: grid;
	grid-area: e;
	justify-content: center;
	align-content: center;
	border: bottom;
}

We align and justify our utility:email icon, as well as our lightning-button-menu icon so that they look pretty and are centered within their respective areas, and we play around with the borders so that they display as appropriate.

Once the CSS is rendered, our MOBILE sized table will look like this:

Compact table view showing an icon in a lefthand column, three rows in the middle displaying the Contact Name, Department, and Phone fields, and a dropdown icon in a righthand column.

In all, our CSS file looks like this:

/*header css*/
table th, tr {
	background-color: #F4F6F9;
}

table td {
	background-color: white;
}

/*HIDE ALL COLUMN HEADERS EXCEPT CONTACT NAME*/
/*email icon column*/
table.mobileContainerDiv th:first-of-type {
	display: none;
}

/*contact department column*/
table.mobileContainerDiv th:nth-of-type(3) {
	display: none;
}

/*contact phone column*/
table.mobileContainerDiv th:nth-of-type(4) {
	display: none;
}

/*dropdown column*/
table.mobileContainerDiv th:nth-of-type(5) {
	display: none;
}

/*USE GRID DISPLAY AND GRID TEMPLATE AREAS TO FORMAT COLLAPSED TABLE*/
table.mobileContainerDiv tr {
	display: grid;
	grid-template-rows: min-content auto;
	grid-template-columns: .5fr 2fr .5fr;
	grid-template-areas:
		"a b b b e"
		"a c c c e"
		"a d d d e";
}

/*icon column*/
table.mobileContainerDiv td:first-of-type {
	display: grid;
	grid-area: a;
	justify-content: center;
	align-content: center;
	border: vertical
}

/*contact name column*/
table.mobileContainerDiv td:nth-of-type(2) {
	display: grid;
	grid-area: b;
	border: top;
}

/*contact department column*/
table.mobileContainerDiv td:nth-of-type(3) {
	display: grid;
	grid-area: c;
	color: gray;
	border: none;
}

/*contact phone column*/
table.mobileContainerDiv td:nth-of-type(4) {
    display: grid;
	grid-area: d;
	border: none;
}

/*dropdown column*/
table.mobileContainerDiv td:nth-of-type(5) {
	display: grid;
	grid-area: e;
	justify-content: center;
	align-content: center;
	border: bottom;
}

Step Four – Add To Record Page and Test!

Now that we’ve got our code written, we need to add it to our Account record page and test it.

Animation of the width aware component being tested.

Thanks for reading, let me know if you have any comments or questions!

-Evelyn, Another Salesforce Blog

One-Time
Monthly
Yearly

Make a one-time donation

Make a monthly donation

Make a yearly donation

Choose an amount

$5.00
$15.00
$50.00
$5.00
$15.00
$50.00
$5.00
$15.00
$50.00

Or enter a custom amount

$

Help keep Another Salesforce Blog on the internet by donating today!

Your contribution is appreciated.

Your contribution is appreciated.

DonateDonate monthlyDonate yearly

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: