Skip to main content
Harish K
  1. Posts/
  2. Salesforce/

Custom Related List View using uiRelatedListApi methods

·10 mins·

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.

Untitled

Refer to this doc to view all the List View Supported Objects.

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.

Untitled

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.

Untitled

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>

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>

Inside the lightning app builder page, search for the “Custom Related List" component

Untitled

Drag the component into the canvas and modify the values of the design attributes as applicable.

Untitled

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

I hope this post is helpful! See ya!


Author
Author
Harish K
Eat. Code. Sleep. Repeat.