Table of Contents
In this post, we’ll use Lightning Web Components and User Interface APIs in Salesforce to build a custom related list view that can display complete data of fields in multiple lines without clipping out the text. First, let’s take a look at it’s cool features, then we’ll proceed with the implementation.
Features
1. Reusable LWC with design attributes
This custom component leverages Salesforce User Interface APIs to create a generic related list view component, customizable for any supported object through design attributes.
2. View complete data with “Wrap Text”
If data exceeds more than one line for a particular field/cell, the custom datatable truncates it in a single line by default. But when clicked on “Wrap Text”, it expands the row with a scroll bar on the particular cell, to view the complete content.
3. View Rich Text Area fields in rich-text formatting
The component also renders data in rich-text format for fields that have the datatype as Rich Text Area.
Implementation
To build this custom related list view, we would need to create two LWCs:
- One - that extends the lightning datatable to support rich-text formatted content.
- Two - the container component that embeds the above custom component and implements the datatable functionalities (sorting, refresh, etc.).
Building the custom datatable component
Create a new LWC with the name - customDatatable
customDatatable.html
<template>
</template>
customDatatable.js
import LightningDatatable from "lightning/datatable";
import richTextColumnType from "./richTextColumnType.html";
import { api } from "lwc";
export default class CustomDatatable extends LightningDatatable {
static customTypes = {
// custom type definition
richText: {
template: richTextColumnType,
standardCellLayout: true
}
}
@api
customScrollToTop() {
this.template.querySelector('.slds-scrollable_y').scrollTop = 0;
}
}
customDatatable.js-meta.xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>56.0</apiVersion>
<isExposed>true</isExposed>
<targets>
<target>lightning__RecordPage</target>
<target>lightning__HomePage</target>
</targets>
</LightningComponentBundle>
Using the custom datatable to build a custom related list view
Create another LWC with the name - customRelatedList
customRelatedList.html
<template>
<div class="related-list-style-div"></div>
<div class="slds-card_boundary">
<div class="slds-page-header related-list-header">
<div class="slds-page-header__row">
<div class="slds-page-header__col-title">
<div class="slds-media">
<div class="slds-media__figure">
<lightning-icon
icon-name={iconName}
alternative-text={relatedListTitle}
size="small"
title="large size"
></lightning-icon>
</div>
<div class="slds-media__body">
<div class="slds-page-header__name">
<div class="slds-page-header__name-title">
<h1>
<span class="slds-page-header__title slds-truncate related-list-title" title={relatedListTitleWithCount}>
{relatedListTitleWithCount}
</span>
</h1>
</div>
</div>
</div>
</div>
</div>
<div class="slds-page-header__col-actions">
<div class="slds-page-header__controls">
<div if:true={showClipWrapButton} class="slds-page-header__control">
<button
class="slds-button slds-button_icon slds-button_icon-more"
aria-haspopup="true"
aria-expanded="false"
title="Clip/Wrap Text"
onclick={handleClipWrap}
>
<lightning-icon
if:true={wrapText}
icon-name="utility:right_align"
alternative-text="Wrap Text"
size="xx-small"
></lightning-icon>
<lightning-icon
if:false={wrapText}
icon-name="utility:center_align_text"
alternative-text="Clip Text"
size="xx-small"
></lightning-icon>
<span class="slds-assistive-text">Clip/Wrap Text</span>
</button>
</div>
<div class="slds-page-header__control">
<button
class="slds-button slds-button_icon slds-button_icon-border-filled"
title="Refresh List"
onclick={handleRefreshList}
>
<lightning-icon icon-name="utility:refresh" alternative-text="Refresh List" size="xx-small"></lightning-icon>
<span class="slds-assistive-text">Refresh List</span>
</button>
</div>
<template if:true={showNewButton}>
<div class="slds-page-header__control">
<ul class="slds-button-group-list">
<li>
<lightning-button variant="neutral" label="New" onclick={navigateToNewRecordPage}></lightning-button>
</li>
</ul>
</div>
</template>
</div>
</div>
</div>
<template if:true={showListMeta}>
<div class="slds-page-header__row">
<div class="slds-page-header__col-meta">
<p class="slds-page-header__meta-text related-list-meta">{recordCountMeta} items • Sorted by {sortedByFieldLabel}</p>
</div>
</div>
</template>
</div>
<div class="related-list-body">
<template if:true={showLoading}>
<lightning-spinner alternative-text="Loading"></lightning-spinner>
</template>
<c-custom-datatable
key-field="id"
columns={dataTableColumns}
data={notes}
hide-checkbox-column="true"
sorted-direction={sortDirection}
sorted-by={sortedBy}
onsort={handleSort}
enable-infinite-loading
onloadmore={handleLoadMore}
load-more-offset="10"
show-row-number-column="true"
>
</c-custom-datatable>
<template if:true={showEmptyMessage}>
<div style="height: 10vh; background: #f3f3f3" class="slds-align_absolute-center">
<p>
<template if:false={unsupportedListview}>No items to display</template>
<template if:true={unsupportedListview}>Unsupported listview. Please review the component's design attributes.</template>
</p>
</div>
</template>
</div>
</div>
</template>
customRelatedList.css
.related-list-header {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-bottom: 1px solid rgb(201, 201, 201);
}
.related-list-title {
font-size: 1rem;
position: relative;
top: 2px;
}
.related-list-meta {
font-size: 0.8rem;
}
.related-list-body {
position: relative;
min-height: 10vh;
max-width: 100%;
overflow-x: auto;
}
.setings-down-arrow {
--lwc-squareIconXxSmallContent: 0.6rem;
}
customRelatedList.js
/* eslint-disable @lwc/lwc/no-async-operation */
/* eslint-disable @lwc/lwc/no-inner-html */
import { LightningElement, wire, api } from "lwc";
import { getRecord, getFieldValue } from "lightning/uiRecordApi";
import { getRelatedListInfo, getRelatedListRecords } from "lightning/uiRelatedListApi";
import { NavigationMixin } from "lightning/navigation";
import { encodeDefaultFieldValues } from "lightning/pageReferenceUtils";
import { refreshGraphQL } from "lightning/uiGraphQLApi";
export default class CustomNotesRelatedList extends NavigationMixin(LightningElement) {
@api recordId;
@api objectApiName;
notes;
wiredNotesResult;
relatedListColumns; // API response columns
dataTable;
dataTableColumns; // Datatable columns
dataTableColumnsMap;
relatedListFields;
lookupField;
pageToken;
currentPageToken;
nextPageToken;
@api iconName;
@api pRelatedListTitle;
relatedListTitle;
@api pNotesRelatedList;
notesRelatedList;
@api pNoteObjectApiName;
noteObjectApiName;
@api pNotesFields;
notesFields;
@api pDefaultSortedBy;
sortedBy;
@api pDefaultSortDirection;
sortDirection;
@api pFilterText = '{ CreatedBy: { Name: { ne: "System Batch Job" } } }';
filterText;
@api pPageSize;
pageSize;
@api showNewButton;
showLoading1 = true;
showLoading2 = false;
wrapText = false;
styleAdded = false;
forceRefresh = false;
showClipWrapButton = false;
unsupportedListview = false;
maxCellheight = 150; // in (px)
maxTableHeight = 60; // in (vh)
cellScrollbarWidth = 9; // in (px)
connectedCallback() {
this.relatedListTitle = this.pRelatedListTitle;
this.notesRelatedList = this.pNotesRelatedList;
this.parentObjectApiName = this.pParentObjectApiName;
this.noteObjectApiName = this.pNoteObjectApiName;
this.notesFields = this.pNotesFields.split(",").map((x) => this.pNoteObjectApiName + '.' + x.trim());
this.sortedBy = this.pDefaultSortedBy;
this.sortDirection = this.pDefaultSortDirection;
this.filterText = this.pFilterText;
this.pageSize = this.pPageSize;
}
renderedCallback() {
if (!this.styleAdded) this.loadStyle();
}
loadStyle() {
let e = this.template.querySelector(".related-list-style-div");
if (e) {
let x = "<style>";
// Scrollable cell styles
x += ".related-list-body tbody td span { max-height: " + this.maxCellheight + "px !important; overflow-y: auto !important; white-space: normal !important; word-wrap: break-word !important;}";
// Scrollable table styles
x += ".related-list-body div.slds-scrollable_y { max-height: "+ this.maxTableHeight +"vh !important; }";
// Scroll bar styles (only for cell scroll)
x += ".related-list-body tbody td span::-webkit-scrollbar { width: " + this.cellScrollbarWidth + "px; height: " + this.cellScrollbarWidth + "px; }.related-list-body tbody td span::-webkit-scrollbar-track {border: 1px solid rgb(196, 196, 196);border-radius: 10px;}.related-list-body tbody td span::-webkit-scrollbar-thumb {background: #949494;border-radius: 10px;}";
// For accurate calculation of row numbers
x += ".related-list-body table > tbody > tr.slds-hint-parent { counter-increment: row-number1; } .related-list-body .slds-table .slds-row-number:after { content: counter(row-number1); }";
// To disable row hover
x += ".related-list-body tr:hover > * { background-color: #fff !important; }";
x += "</style>";
e.innerHTML = x;
this.styleAdded = true;
}
// this.template.querySelector(".related-list-body tbody").classList.add("c-custom-counter");
}
@wire(getRecord, { recordId: "$recordId", fields: ["Case.RecordTypeId"] })
caseRecord;
get recordTypeId() {
return getFieldValue(this.caseRecord.data, "Case.RecordTypeId");
}
get showLoading() {
return this.showLoading1 || this.showLoading2;
}
@wire(getRelatedListInfo, {
parentObjectApiName: "$objectApiName",
relatedListId: "$notesRelatedList",
optionalFields: "$notesFields",
restrictColumnsToLayout: false
})
wiredListInfo({ error, data }) {
if (data) {
this.relatedListColumns = data.displayColumns;
this.lookupField = data.fieldApiName;
console.log(JSON.stringify(data, null, 2));
// to sort the columns based on the order of fields in the pNotesFields (when restrictColumnsToLayout is false, the order of fields in the relatedListColumns is not guaranteed to be the same as the order of fields in the pNotesFields. Hence the sorting is necessary)
let columnMap = {};
this.relatedListColumns.forEach((col) => {
columnMap[col.fieldApiName] = col;
});
let columns = [];
let listOfFields = this.pNotesFields.split(",").map((field) => field.trim());
listOfFields.forEach((field) => {
if (columnMap[field]) columns.push(columnMap[field]);
});
// preparing the columns for the datatable
this.dataTableColumns = columns.map((col) => this.prepareDatatableColumn(col));
this.dataTableColumnsMap = {};
this.dataTableColumns.forEach((col) => {
this.dataTableColumnsMap[col.fieldName] = col;
});
this.fieldApiNames = this.relatedListColumns.map((col) => col.fieldApiName);
this.relatedListFields = this.fieldApiNames.map((col) => this.noteObjectApiName + "." + col);
} else if (error) {
console.error(JSON.stringify(error, null, 2));
this.showLoading1 = false;
this.unsupportedListview = true;
}
}
prepareDatatableColumn(col) {
let x = {
label: col.label,
fieldName: col.fieldApiName,
sortable: col.sortable,
type: col.dataType
};
if (col.dataType === "textarea") {
x.type = "richText";
x.wrapText = false;
} else if (col.dataType === "boolean") {
x.initialWidth = 80;
} else if (this.isUrlCol(col)) {
x.type = "url";
x.typeAttributes = {
label: { fieldName: x.fieldName }
};
x.fieldName = col.lookupId + "_URL";
}
return x;
}
isUrlCol(col) {
return col.dataType === "string" && col.lookupId !== null;
}
isNameCol(col) {
return col.dataType === "string" && col.lookupId === "Id";
}
isPicklistCol(col) {
return col.dataType === "picklist";
}
handleSort(event) {
this.showLoading1 = true;
this.moveScrollbarToTop();
this.forceRefresh = true;
this.sortedBy = event.detail.fieldName;
this.sortDirection = event.detail.sortDirection;
this.pageToken = null;
}
get sortBy() {
let x = this.noteObjectApiName + ".";
let z = this.dataTableColumnsMap ? this.dataTableColumnsMap[this.sortedBy] : null;
x = z && z.type === "url" ? x + z.typeAttributes.label.fieldName : x + this.sortedBy;
let y = this.sortDirection === "asc" ? x : "-" + x;
return [y];
}
get sortedByFieldLabel() {
let z = this.dataTableColumnsMap ? this.dataTableColumnsMap[this.sortedBy] : null;
return z ? z.label : "";
}
prepareNote(record) {
let note = {};
function getLookupObjectName(column) {
return column.fieldApiName.split(".")[0];
}
try {
this.relatedListColumns.forEach(
function (col) {
let field = col.fieldApiName;
if (this.isUrlCol(col)) {
note[field] = field in record.fields ? record.fields[field].value : record.fields[getLookupObjectName(col)].displayValue;
note[col.lookupId + "_URL"] = "/" + record.fields[getLookupObjectName(col)].value.id;
} else {
let v = record.fields[field].displayValue ? record.fields[field].displayValue : record.fields[field].value;
note[field] = v;
}
}.bind(this)
);
} catch (error) {
console.error(error);
}
note.Id = record.id;
note.Id_URL = "/" + record.id;
return note;
}
@wire(getRelatedListRecords, {
parentRecordId: "$recordId",
relatedListId: "$notesRelatedList",
fields: "$relatedListFields",
sortBy: "$sortBy",
pageSize: "$pageSize", // max pageSize = 249; default = 50
pageToken: "$pageToken",
where: "$filterText"
})
wiredNotes(result) {
if (!this.relatedListFields) {
return;
}
this.wiredNotesResult = result;
const { error, data } = result;
if (data) {
let x = [];
data.records.forEach((record) => {
x.push(this.prepareNote(record));
});
if (this.currentPageToken && this.currentPageToken === data.previousPageToken) {
let z = JSON.parse(JSON.stringify(this.notes));
Array.prototype.push.apply(z, x);
this.notes = z;
} else {
this.notes = x;
}
this.currentPageToken = data.currentPageToken;
this.nextPageToken = data.nextPageToken;
if (this.forceRefresh) this.refreshData();
if (this.dataTable) this.dataTable.isLoading = false;
this.showLoading1 = false;
} else if (error) {
console.error(JSON.stringify(error));
this.notes = [];
this.showLoading1 = false;
}
}
get recordCount() {
return this.notes ? this.notes.length : 0;
}
get hasNotes() {
return this.recordCount !== 0;
}
get showEmptyMessage() {
return !this.showLoading && !this.hasNotes;
}
get showListMeta() {
return this.recordCount > 1;
}
get recordCountMeta() {
return this.nextPageToken ? this.recordCount + "+" : this.recordCount;
}
get relatedListTitleWithCount() {
return this.relatedListTitle + " (" + this.recordCountMeta + ")";
}
handleClipWrap() {
if (this.dataTableColumns) {
let x = JSON.parse(JSON.stringify(this.dataTableColumns));
this.wrapText = !this.wrapText;
x.forEach((col) => {
col.wrapText = this.wrapText;
});
this.dataTableColumns = x;
}
}
handleRefreshList() {
this.moveScrollbarToTop();
this.showLoading2 = true;
if (this.pageToken) {
this.pageToken = null;
this.currentPageToken = null;
this.forceRefresh = true;
} else {
this.refreshData(this.wiredNotesResult);
}
}
moveScrollbarToTop() {
try {
this.template.querySelector("c-custom-datatable").customScrollToTop();
} catch (error) {
console.error(error);
}
}
async refreshData() {
this.showLoading2 = true;
this.forceRefresh = false;
await refreshGraphQL(this.wiredNotesResult);
this.showLoading2 = false;
}
handleLoadMore(event) {
event.preventDefault();
if (this.nextPageToken) {
this.dataTable = event.target;
event.target.isLoading = true;
this.loadMoreData();
// event.target.isLoading = false;
} else {
event.target.enableInfiniteLoading = false;
}
}
loadMoreData() {
// comment the below line when infinite loading is enabled
// this.showLoading1 = true;
this.pageToken = this.nextPageToken;
}
navigateToNewRecordPage() {
let x = {};
x[this.lookupField] = this.recordId;
const defaultValues = encodeDefaultFieldValues(x);
try {
this[NavigationMixin.Navigate]({
type: "standard__objectPage",
attributes: {
objectApiName: this.noteObjectApiName,
actionName: "new"
},
state: {
nooverride: "1",
navigationLocation: "RELATED_LIST",
defaultFieldValues: defaultValues
}
});
} catch (error) {
console.error(error);
}
}
}
customRelatedList.js-meta.xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>56.0</apiVersion>
<isExposed>true</isExposed>
<masterLabel>Custom Related List</masterLabel>
<description>The component displays a datatable that supports Rich text formatting and scrollable columns for values with multiple lines.</description>
<targets>
<target>lightning__RecordPage</target>
</targets>
<targetConfigs>
<targetConfig targets="lightning__RecordPage">
<property name="iconName" type="String" label="Related list icon name" default="standard:case" description="Refer to SLDS icons." required="true"/>
<property name="pNoteObjectApiName" type="String" label="Child Object API name" default="Case" description="Enter the API name of the child object." required="true"/>
<property name="pRelatedListTitle" type="String" label="Related List Title" default="Related Cases" description="The title of the related list to be displayed." required="true"/>
<property name="pNotesRelatedList" type="String" label="Related List ID" default="Cases" required="true" description="Child Relationship Name (Append '__r' if needed)." />
<property name="pNotesFields" type="String" label="List Columns" default="CaseNumber, CreatedDate, CreatedBy.Alias, IsClosed" description="Provide API names of the fields in the child object (comma separated)." placeholder="Field1, Field2__c, Lookup.Name, etc." required="true" />
<property name="showNewButton" type="Boolean" label="Allow users to create records" description="Enable to show the New button on the related list" default="true" />
<property name="pDefaultSortedBy" type="String" label="Sorted By (Default)" default="CreatedDate" description="Provide the API name of the field in the child object, to sort the list by default." required="true" />
<property name="pDefaultSortDirection" type="String" label="Default sort direction" default="desc" datasource="asc,desc" required="true" />
<property name="pFilterText" type="String" label="Filter Criteria (GraphQL syntax)" description="The filter to apply to the related list records, in GraphQL syntax." />
<property name="pPageSize" type="Integer" label="Page Size" description="Number of records to load in each request." default="50" required="true" max="200" />
</targetConfig>
</targetConfigs>
</LightningComponentBundle>
customRelatedList.svg
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000000" stroke-width="1" stroke-linecap="round" stroke-linejoin="miter"><rect x="3" y="3" width="18" height="18" rx="0" fill="#059cf7" opacity="0.1"></rect><rect x="3" y="3" width="18" height="18" rx="0"></rect><line x1="21" y1="9" x2="3" y2="9"></line><line x1="21" y1="15" x2="3" y2="15"></line></svg>
Add the custom related list component to the record page
Inside the lightning app builder page, search for the “Custom Related List" component
Drag the component into the canvas and modify the values of the design attributes as applicable.
Save the record page. That’s it, we now have a reusable custom related list view component that can display complete data of the long text fields and also, in rich-text format.
Useful Resources
- lightning/uiRelatedListApi | Lightning Web Components Developer Guide
- lightning-datatable - Salesforce Lightning Component Library
- LWC rich text column type for lightning-datatable
I hope this post is helpful! See ya!