Read more at jamessimone.net, or return to the homepage





Lightning Web Components Custom Lead Path

The Path component that comes standard with Salesforce is a wonderful visualization tool for Sales when working on Leads / Opportunities, and it can be utilized to great effect with Cases in Service Cloud, too. The vanilla Path component has seen some customization options since its initial release, and you can wire up dropdown menus attached to it to deal with associated fields. That's a lot more customization than you typically get, out of the box — but what happens when you want more? What happens if you want to group related picklist fields together? What happens if you want to validate required fields without adding additional metadata (in the way of validation rules)? What happens if you want to hide some of the picklist statuses per record type?

In 2017, I had a problem. I was working on a Salesforce migration into Sales Cloud and stakeholders wanted to hide the vanilla "Convert Lead" popup that is surfaced through advancing the Lead path. There were a number of reasons behind the request (all reasonable), but we weren't particularly familiar with Aura — which was fairly new at the time — and our experiences with VisualForce had left us scarred. The stakeholders for Sales were pumped about the Lead path component; the team was dismayed that we might need to hide it to prevent improper lead conversions (which were being routed purely through Apex).

I wanted a fresh challenge and decided to build out a custom Lead path component over the weekend using Aura. I want to heavily preface this statement by saying that this was entirely of my own volition, and while I will always advocate for taking breaks, and the importance particularly of keeping the weekend purely to spend as you please, in this specific instance I was looking to learn something, and I knew my team wouldn't get to the request prior to launching otherwise. At the time, I knew only the basics of HTML, and I was just beginning to learn JavaScript on an old Angular (shudder) codebase. Throwing an entirely new framework into the mix meant for slow going that weekend, and even with the incredible headstart given by the use of the Aura Path component by Appiphony, I barely finished after two 14+ hour days.

The Aura component — markup, JS controller, JS helper, Apex controller, and tests ended up contributing a little over 600 lines of code to our codebase. In looking at the advancements with the Lightning Data Service since I worked on that component, and now having a composable modal to work with (a crucial pre-req), I wanted to see just how slim a Lightning Web Component version of the custom Path component would be.


linkPath Overview

In the end, using LDS to load SObject data, as well as better asynchronous methods in LWC in general, helps to trim the LWC version of the Path to ~400 lines of code. Yes, it does rely on the modal component that we built previously, but the modal is also a reusable (and far more powerful/responsive) component than the one that shipped with the Aura component I built. This Path component:

When using the Lightning Data Service (hereafter referred to as LDS, though this acronym can confusingly also apply to the Lightning Design System), @wire attributes are used to provisionally fetch data without a dedicated Apex controller backend. However, an interesting bullet point on this approach is that most of the examples using LDS to fetch data from the backend rely on at most two sources; this is the case for the getPicklistValues function that we will be exhibiting shortly. getPicklist values shows how a function/property can pass required data by using the $ sign at the beginning of a string to represent a reactive local property; in instances where parameters to LDS are supplied with the dollar sign at the start of their string, the function waits until that data is loaded prior to getting its own data.

But what happens when you have a component that relies upon two independent streams of wired data? Such is the case when considering rebuilding the Lead Path component — you need the available picklist statuses per record type (for our example, we'll just be using the default record type, but you can easily expand the component that ends up being shown to accomodate specific record types), and in order to show the Path at the current status, you need the current Lead status.

Savvy Path users will note that the vanilla Path component in Salesforce visibly loads and then shows the current status. There's also a video I'll show later of this same issue. That brief flicker is something that we'll try to avoid in our custom component — and in order to do so, we'll have to dive in to the @wire attribute.

linkLightning Data Service Overview

Lightning Data Service, or LDS, differs from fetching data from within your Salesforce instance via an @AuraEnabled Apex controller in a few ways (as enumerated in the documentation):

Record data is loaded progressively (non-blocking). Results are cached client-side automatically. The cache is automatically invalidated when the underlying data changes. Back-and-forths with the server are minimized by sharing the request cache amongst components; components using the same underlying data use only a single request.

The shape of data that's used to update your underlying object representations also differs between LDS and your Apex controller. Consider the Lead object sent to the LDS updateRecord method:

1link{

2link //for `updateRecord`

3link // "apiName" top-level field can be omitted

4link "fields": {

5link "Status": "Closed - Converted",

6link "CustomDate__c": null,

7link "Id": "someLeadId"

8link }

9link}

This is a RecordInput type that can be fed directly to the LDS methods. Alternatively, you can use the generateRecordInputForCreate or generateRecordInputForUpdate helper methods to accomplish the same thing. Either way, your method call to perform the insert/update ends up being pretty simple:

1linkconst recordToUpdate = {

2link fields: {

3link Id: this.recordId,

4link //other fields

5link },

6link};

7linkawait updateRecord(recordToUpdate);

This involves an additional level of nesting from the JSON as compared to a Lead that's being sent to an Apex controller:

1link//this gets deserialized by your Apex controller as a lead

2link{

3link "Status": "Closed - Converted",

4link "CustomDate__c": null,

5link "Id": "someLeadId"

6link}

On the other hand, you have to use a named parameter when sending data to an Apex controller, so it's not as simple as supplying your key/value JSON object to the method:

1linkimport { LightningElement, wire } from "lwc";

2linkimport exampleApexMethod from "@salesforce/apex/SomeApexController.exampleApexMethod";

3link

4linkexport default class ExampleComponent extends LightningElement {

5link lead = {

6link Id: this.recordId,

7link //other values

8link };

9link

10link //the "example" named parameter below

11link //MUST match the name of the parameter on the Apex side

12link @wire(exampleApexMethod, { example: "$lead" }) apexResponse;

13link}

In general, because LDS is available for Aura as well as LWC, it's always a good time to be migrating your components away from using Apex unless the data you're trying to retrieve isn't supported, or an update you're looking to perform would be complicated to perform using the LDS adapters. For one thing, it's surprising to me that the company that invented the word "bulkification" doesn't have bulk-ready LDS adapters; this isn't really applicable in today's example, but for enabling in-line edit in LWC's making use of lightning-datatable, having to iterate through and update the edited rows one-by-one isn't ideal.

There are still legitimate use-cases for Apex controllers with LWC — lots of them, in fact. If your data needs are simple, however, you should definitely be using LDS.

linkLoading Multiple @Wire Methods With Dependent Data Using LDS

There are some obvious examples in the Salesforce documentation that show you how to load data dependent on another @wire method; for our purposes, again, we'll be using getPicklistValues, which typically depends on the getObjectInfo wire (although, embarrassingly, all of the shown examples in the lwc-recipes repo that make use of the getPicklistValues LDS method hard-code the record type).

The basic method signature for using getPicklistValues looks something like this in a LWC:

1linkimport { getPicklistValues } from 'lightning/uiObjectInfoApi';

2linkimport STATUS_FIELD from '@salesforce/schema/Lead.Status';

3link

4linkexport default class CustomPath extends LightningElement {

5link @wire(getPicklistValues, {

6link recordTypeId: '$your wired up record type Id here',

7link fieldApiName: STATUS_FIELD

8link }) //property or function here

9link}

So right away, we have this $ character denoting that we're referring to a reactive property, but there's one crucial line in the docs necessary to bridge between the pre-requisite for using this function without hard-coding, and getting the data you need:

You can use one @wire output as another @wire input. For example, you could use $record.data.fieldName as an input to another wire adapter.

Aha — so we can actually reference the underlying returned data from one @wire method when calling another @wire method:

1linkimport { getObjectInfo } from "lightning/uiObjectInfoApi";

2linkimport { getPicklistValues } from "lightning/uiObjectInfoApi";

3link

4linkimport LEAD_OBJECT from "@salesforce/schema/Lead";

5linkimport STATUS_FIELD from "@salesforce/schema/Lead.Status";

6link

7linkexport default class CustomPath extends LightningElement {

8link @wire(getObjectInfo, { objectApiName: LEAD_OBJECT })

9link objectInfo;

10link

11link //here, we use the results of the `objectInfo` call

12link @wire(getPicklistValues, {

13link recordTypeId: "$objectInfo.data.defaultRecordTypeId",

14link fieldApiName: STATUS_FIELD,

15link })

16link leadStatuses({ data, error }) {

17link const leadStatusCb = (data) => {

18link //logic for handling the data

19link };

20link this._handleWireCallback({ data, error, cb: leadStatusCb });

21link }

22link

23link _handleWireCallback = ({ data, error, cb }) => {

24link //in reality you'd want to gracefully handle/display

25link //the error, but we're prototyping here!

26link //either way, I like to have a method like this to consolidate

27link //error handling when using multiple @wire methods

28link if (error) console.error(error);

29link else if (data) {

30link cb(data);

31link }

32link };

33link}

That takes care of one hurdle — fetching the appropriate Lead Statuses by the default Record Type. But what about the problem I was outlining earlier? When we receive the Lead Statuses, we'll actually need to denote a lot of information about each status:

linkThe Dirty Way To Call LWC @Wire Methods With Dependent Data

There is one way that you can ensure that data is returned (in this case, the current Lead info when making the getPicklistValues call): by adding an extra reactive property to your LDS call that references a variable set by another @wire call:

1link@wire(getPicklistValues, {

2link recordTypeId: '$objectInfo.data.defaultRecordTypeId',

3link fieldApiName: STATUS_FIELD,

4link //this one dirty hack you would never expect ...

5link //in all seriousness, I'm not certain what the "supported"

6link //state is for passing extraneous values to @wire methods

7link status: '$_status'

8link //^^ here _status would be set by another @wire method

9link})

I started experimenting with this method before concluding that despite the niceties of the code about statuses living in one place, it was totally marred by the seemingly friable nature of this approach. While including additional paramaters in @wire methods doesn't throw an error at present, there's no guarantee that the way that the API handles such extra paramaters won't change in the future. Better to do things by the books.

linkThe Proper Way To Deal With Dependent @Wire Data

The proper way to handle such dueling requirements is through the use of the renderedCallback lifecycle method. renderedCallback runs every time that a Lightning Web Component is re-rendered; because of this, logic that pertains only to the first "full" load of the component (when all data has been loaded) is typically gated behind a one-time conditional. On the <template> markup side of the equation, you'll also be using part of the same conditional to only display the full contents of the component once the data has been loaded:

1link<template>

2link <c-modal

3link modal-header="Close Status Required"

4link modal-tagline="Set the specific close status to proceed!"

5link modal-save-handler="{modalSaveHandler}"

6link >

7link <template if:true="{hasData}">

8link <!-- body of the component here >

9link </template>

10link </c-modal>

11link</template>

And in the JavaScript controller:

1linkimport { api, LightningElement, track, wire } from "lwc";

2linkimport { getObjectInfo } from "lightning/uiObjectInfoApi";

3linkimport { getPicklistValues } from "lightning/uiObjectInfoApi";

4linkimport { getRecord, updateRecord } from "lightning/uiRecordApi";

5linkimport { ShowToastEvent } from "lightning/platformShowToastEvent";

6link

7linkimport LEAD_OBJECT from "@salesforce/schema/Lead";

8linkimport CUSTOM_DATE_FIELD from "@salesforce/schema/Lead.CustomDate__c";

9linkimport STATUS_FIELD from "@salesforce/schema/Lead.Status";

10link

11linkconst COMPLETED = "Mark Status as Complete";

12linkconst CLOSED = "Closed";

13linkconst CLOSED_CTA = "Select Closed Status";

14link//more on this in a bit

15linkconst SPECIAL_STATUS = "Closed - Special Date";

16link

17linkexport default class CustomPath extends LightningElement {

18link //I like to keep all @wire/lifecycle

19link //methods at the top of my components

20link @api recordId;

21link @wire(getObjectInfo, { objectApiName: LEAD_OBJECT })

22link objectInfo;

23link

24link @wire(getRecord, {

25link recordId: "$recordId",

26link fields: [CUSTOM_DATE_FIELD, STATUS_FIELD],

27link })

28link lead({ data, error }) {

29link const leadCb = (data) => {

30link this.status = this._getLeadValueOrDefault(

31link data,

32link STATUS_FIELD.fieldApiName

33link );

34link this.storedStatus = this.status;

35link this.dateValue = this._getLeadValueOrDefault(

36link data,

37link CUSTOM_DATE_FIELD.fieldApiName

38link );

39link if (this.status && this.status.includes(CLOSED)) {

40link this.advanceButtonText = CLOSED_CTA;

41link this.currentClosedStatus = this.status;

42link this.customCloseDateSelected =

43link this.currentClosedStatus === SPECIAL_STATUS;

44link }

45link };

46link

47link this._handleWireCallback({ data, error, cb: leadCb });

48link }

49link

50link @wire(getPicklistValues, {

51link recordTypeId: "$objectInfo.data.defaultRecordTypeId",

52link fieldApiName: STATUS_FIELD,

53link })

54link leadStatuses({ data, error }) {

55link const leadStatusCb = (data) => {

56link const statusList = [];

57link data.values.forEach((picklistStatus) => {

58link if (!picklistStatus.label.includes(CLOSED)) {

59link statusList.push(picklistStatus.label);

60link }

61link });

62link //the order matters here and isn't obvious

63link //but we want "Closed" to be the LAST status

64link statusList.push("Closed");

65link this._statuses = statusList;

66link

67link //now build the visible/closed statuses

68link data.values.forEach((status) => {

69link if (status.label.includes(CLOSED)) {

70link //we're using a combobox in markup

71link //which requires both label/value

72link this.closedStatuses.push({

73link label: status.label,

74link value: status.label,

75link });

76link if (!this.currentClosedStatus) {

77link //promote the first closed value to the component

78link //so that the combobox can show a sensible default

79link this.currentClosedStatus = status.label;

80link }

81link } else {

82link this.visibleStatuses.push(this._getPathItemFromStatus(status.label));

83link }

84link });

85link this.visibleStatuses.push(this._getPathItemFromStatus(CLOSED));

86link };

87link this._handleWireCallback({ data, error, cb: leadStatusCb });

88link }

89link

90link renderedCallback() {

91link if (!this._hasRendered && this.hasData) {

92link //prevents the advance button from jumping to the side

93link //as the rest of the component loads

94link this.showAdvanceButton = true;

95link this._hasRendered = true;

96link }

97link if (this.hasData) {

98link //everytime the component re-renders

99link //we need to ensure the correct CSS classes

100link //and accessibility attributes are applied

101link const current = this.visibleStatuses.find((status) =>

102link this.storedStatus.includes(status.label)

103link ) || { label: "Unknown" };

104link current.ariaSelected = true;

105link current.class = "slds-path__item slds-is-current slds-is-active";

106link

107link const currentIndex = this.visibleStatuses.indexOf(current);

108link this.visibleStatuses.forEach((status, index) => {

109link if (index < currentIndex) {

110link status.class = status.class.replace(

111link "slds-is-incomplete",

112link "slds-is-complete"

113link );

114link }

115link });

116link }

117link }

118link

119link /* private fields for tracking */

120link @track advanceButtonText = MARK_COMPLETED;

121link @track closedStatuses = [];

122link @track currentClosedStatus;

123link @track customCloseDateSelected = false;

124link @track dateValue;

125link @track status;

126link @track storedStatus;

127link @track visibleStatuses = [];

128link

129link //truly private fields

130link _hasRendered = false;

131link _statuses;

132link

133link get hasData() {

134link return !!(this.storedStatus && this.visibleStatuses.length > 0);

135link }

136link

137link //truly private methods, only called from within this file

138link _handleWireCallback = ({ data, error, cb }) => {

139link if (error) console.error(error);

140link else if (data) {

141link cb(data);

142link }

143link };

144link

145link _getPathItemFromStatus(status) {

146link const ariaSelected = !!this.storedStatus

147link ? this.storedStatus.includes(status)

148link : false;

149link const isCurrent = !!this.status ? this.status.includes(status) : false;

150link const classList = ["slds-path__item"];

151link if (ariaSelected) {

152link classList.push("slds-is-active");

153link } else {

154link //we'll end up fixing this in rendered callback

155link classList.push("slds-is-incomplete");

156link }

157link if (isCurrent) {

158link classList.push("slds-is-current");

159link }

160link return {

161link //same here

162link ariaSelected: false,

163link class: classList.join(" "),

164link label: status,

165link };

166link }

167link

168link _getLeadValueOrDefault(data, val) {

169link return data ? data.fields[val].displayValue : "";

170link }

171link

172link _updateVisibleStatuses() {

173link //update the shown statuses based on the selection

174link const newStatuses = [];

175link for (let index = 0; index < this.visibleStatuses.length; index++) {

176link const status = this.visibleStatuses[index];

177link const pathItem = this._getPathItemFromStatus(status.label);

178link if (this.status !== this.storedStatus || pathItem.label !== this.status) {

179link pathItem.class = pathItem.class

180link .replace("slds-is-complete", "")

181link .replace(" ", " ");

182link }

183link newStatuses.push(pathItem);

184link }

185link this.visibleStatuses = newStatuses;

186link }

187link}

That's nearly all of the JavaScript required to get the appropriate Path data showing on the page. The only thing that's missing from the controller that's shown are the reactive handlers — for listening to click events, as well as how to handle what goes on in the modal. Before we return to that (much easier) territory, let's conclude the dependent @wire section by saying that though the use of the component's lifecycle methods to completely prepare the appropriate Lead Statuses and properties means that the code is not as terse as it could be, it remains the idiomatic way to massage independent streams of data in your components into the format required by your markup.

linkReturning To The Custom Path

The interesting thing about the markup is that it all gets slotted into the existing modal. If there were additional chances for code re-use in this component, they would likely come from the <li> elements present within the Path. You could definitely work on generalizing the Path itself; because for this example I wanted to show how to group "Closed" statuses together, the logic ends up being fairly coupled to the underlying Path markup. Certainly this could be generalized to accept an SObject type and a picklist field to accomplish the same thing with greater re-use for all picklist fields with "Closed" values — alternatively, stripping out the grouping section would bring you to a fully re-usable Path component that could be used and customized for any picklist field.

I'm also using a CustomDate__c field (included in the linked repository) to show what entering a date required to save a Lead in a certain "Closed" status would look like:

1link<template>

2link <c-modal

3link modal-header="Close Status Required"

4link modal-tagline="Set the specific close status to proceed!"

5link modal-save-handler="{modalSaveHandler}"

6link >

7link <template if:true="{hasData}">

8link <article class="slds-card" slot="body">

9link <div class="slds-card__body slds-card__body_inner">

10link <div class="slds-path">

11link <div class="slds-grid slds-path__track">

12link <div class="slds-grid slds-path__scroller-container">

13link <div

14link class="slds-path__scroller"

15link tabindex="-1"

16link role="application"

17link >

18link <div class="slds-path__scroller_inner">

19link <ul

20link class="slds-path__nav"

21link role="listbox"

22link aria-orientation="horizontal"

23link >

24link <template for:each="{visibleStatuses}" for:item="stage">

25link <li

26link class="{stage.class}"

27link role="presentation"

28link key="{stage.label}"

29link onclick="{handleStatusClick}"

30link >

31link <a

32link class="slds-path__link"

33link tabindex="-1"

34link role="option"

35link title="{stage.label}"

36link aria-selected="{stage.ariaSelected}"

37link >

38link <span class="slds-path__stage">

39link <lightning-icon

40link variant="bare"

41link class="slds-button__icon"

42link icon-name="utility:check"

43link size="x-small"

44link alternative-text="{stage}"

45link ></lightning-icon>

46link </span>

47link <span class="slds-path__title">{stage.label}</span>

48link </a>

49link </li>

50link </template>

51link </ul>

52link </div>

53link </div>

54link <template if:true="{showAdvanceButton}">

55link <div class="slds-grid slds-path__action">

56link <lightning-button

57link class="slds-path__mark-complete slds-no-flex slds-m-horizontal__medium"

58link variant="brand"

59link icon-name="{pathActionIconName}"

60link onclick="{handleAdvanceButtonClick}"

61link title="{advanceButtonText}"

62link label="{advanceButtonText}"

63link >

64link </lightning-button>

65link </div>

66link </template>

67link </div>

68link </div>

69link </div>

70link </div>

71link </article>

72link <div slot="modalContent">

73link <template if:true="{showClosedOptions}">

74link <lightning-combobox

75link class="slds-m-around_small slds-form-element"

76link name="status"

77link label="Status"

78link value="{currentClosedStatus}"

79link placeholder="Select Closed Status"

80link options="{closedStatuses}"

81link onchange="{handleClosedStatusChange}"

82link required

83link message-when-value-missing="Please select a closed status"

84link ></lightning-combobox>

85link <template if:true="{customCloseDateSelected}">

86link <p>

87link The date you use below will cause the lead to reopen in the

88link future. Assignment rules will be rerun at the time; if you are

89link still the owner, you will be notified, otherwise the new owner

90link will be.

91link </p>

92link <lightning-input

93link class="slds-form-element slds-m-around_small"

94link label="Reopen Date"

95link type="date"

96link date-style="short"

97link value="{dateValue}"

98link onchange="{handleDateOnChange}"

99link required

100link >

101link </lightning-input>

102link <p>Once you're done selecting the date, click "Save" to proceed!</p>

103link </template>

104link </template>

105link </div>

106link </template>

107link <template if:false="{hasData}">

108link <lightning-spinner

109link alternative-text="Loading"

110link size="small"

111link ></lightning-spinner>

112link </template>

113link </c-modal>

114link</template>

Of the things in the JavaScript LWC controller that we haven't explored but are referenced in the markup:

1linkmodalSaveHandler = async (event) => {

2link event.stopPropagation();

3link event.preventDefault();

4link

5link //one of the nicer code snippets shown

6link //in the LWC docs - display an error on any

7link //field marked required but improperly filled out

8link const allValid = [

9link ...this.template.querySelectorAll('.slds-form-element')

10link ].reduce((validSoFar, formElement) => {

11link formElement.reportValidity();

12link return validSoFar && formElement.checkValidity();

13link });

14link if (allValid) {

15link this._toggleModal();

16link await this._saveLeadAndToast();

17link }

18link};

19link

20linkhandleStatusClick(event) {

21link event.stopPropagation();

22link //update the stored status, but don't update the record

23link //till the save button is clicked

24link const updatedStatusName = event.target.textContent;

25link this.advanceButtonText =

26link updatedStatusName === this.status ? COMPLETED : 'Mark As Current Status';

27link this.storedStatus = updatedStatusName;

28link

29link if (this.status !== this.storedStatus) {

30link this._updateVisibleStatuses();

31link }

32link

33link if (this.storedStatus === CLOSED) {

34link this._advanceToClosedStatus();

35link }

36link}

37link

38linkhandleClosedStatusChange(event) {

39link const newClosedStatus = event.target.value;

40link this.currentClosedStatus = newClosedStatus;

41link this.storedStatus = newClosedStatus;

42link this.customCloseDateSelected = this.storedStatus === SPECIAL_STATUS;

43link}

44link

45linkhandleDateOnChange(event) {

46link this.dateValue = event.target.value;

47link}

48link

49linkasync handleAdvanceButtonClick(event) {

50link event.stopPropagation();

51link

52link if (

53link this.status === this.storedStatus &&

54link !this.storedStatus.includes(CLOSED)

55link ) {

56link const nextStatusIndex =

57link this.visibleStatuses.findIndex(

58link (visibleStatus) => visibleStatus.label === this.status

59link ) + 1;

60link this.storedStatus = this.visibleStatuses[nextStatusIndex].label;

61link if (nextStatusIndex === this.visibleStatuses.length - 1) {

62link //the last status should always be "Closed"

63link //and the modal should be popped

64link this._advanceToClosedStatus();

65link } else {

66link await this._saveLeadAndToast();

67link }

68link } else if (this.storedStatus.includes(CLOSED)) {

69link //curses! they closed the modal

70link //let's re-open it

71link this._advanceToClosedStatus();

72link } else {

73link await this._saveLeadAndToast();

74link }

75link}

76link

77link//truly private methods, only called from within this file

78link_advanceToClosedStatus() {

79link this.advanceButtonText = CLOSED_CTA;

80link this.storedStatus = this.currentClosedStatus;

81link this.showClosedOptions = true;

82link this._toggleModal();

83link}

84link

85link_toggleModal() {

86link this.template.querySelector('c-modal').toggleModal();

87link}

88link

89linkasync _saveLeadAndToast() {

90link let error;

91link try {

92link this.status = this.storedStatus;

93link const recordToUpdate = {

94link fields: {

95link Id: this.recordId,

96link Status: this.status,

97link CustomDate__c: null

98link }

99link };

100link if (this.dateValue && this.status === SPECIAL_STATUS) {

101link recordToUpdate.fields.CustomDate__c = this.dateValue;

102link }

103link await updateRecord(recordToUpdate);

104link this._updateVisibleStatuses();

105link this.advanceButtonText = MARK_COMPLETED;

106link } catch (err) {

107link error = err;

108link console.error(err);

109link }

110link //not crazy about this ternary

111link //but I'm even less crazy about the 6

112link //extra lines that would be necessary for

113link //a second object

114link this.dispatchEvent(

115link new ShowToastEvent({

116link title: !error ? 'Success!' : 'Record failed to save',

117link variant: !error ? 'success' : 'error',

118link message: !error

119link ? 'Record successfully updated!'

120link : `Record failed to save with message: ${JSON.stringify(error)}`

121link })

122link );

123link //in reality, LDS errors are a lot uglier and should be handled gracefully

124link //I recommend the `reduceErrors` utils function from @tsalb/lwc-utils:

125link //https://github.com/tsalb/lwc-utils/blob/master/force-app/main/default/lwc/utils/utils.js

126link}

So ... ~100 lines (including comments) of code to handle all listeners (and there are a lot of clickable elements in a Path component!), most of which are either simply reflecting event-level data to an underyling, @track'd property, or deal with saving the record / closing the modal. Maybe that seems like a lot. In practice, I consider the use of LDS (when appropriate / possible) beneficial since you're saving on the concomitant lines of code that would be dedicated to your Apex Controller and test class.

linkWhat Does The Custom Path LWC End Up Looking Like?

OK, OK — what does it look like, at the end of the day? First of all, you can see that on render speed alone the custom Path component outperforms the vanilla Path component. There's a noticeable flicker on the vanilla Path prior to the current status being shown.

Here's what the Path looks like in the background with the modal open after having selected a "Closed" status:

Showing the non-expanded modal state

And then with the custom "Special Date" closed status selected (notice I'm using the vanilla component in the background to compare to 😅):

The expanded modal state

Lastly, what it looks like mid-Path:

The Path by itself

linkConclusion

I'm sure that there are still edge-cases to consider when it comes to creating a custom Path component. This exercise, unlike the modal, doesn't cover everything — for example, on the Lead Flexipage, if you wanted to use this custom Path component but didn't have a Lightning Button / Quick Action or other drop-in alternative for lead conversion, the Path as stands still would need work.

If you'd like to see the code for the custom Path component, I've pushed it to a branch here.

Despite this, I hope it's been helpful to see how using building blocks like the composable modal can increase your iteration speed and ability to implement complex features by encapsulating complexity in each Lightning Web Component. Thanks for following along — till next time!

The original version of Lightning Web Components: Custom Path can be read on my blog.

Path OverviewLightning Data Service OverviewLoading Multiple @Wire Methods With Dependent Data Using LDSThe Dirty Way To Call LWC @Wire Methods With Dependent DataThe Proper Way To Deal With Dependent @Wire DataReturning To The Custom PathWhat Does The Custom Path LWC End Up Looking Like?Conclusion

Home Apex Logging Service Apex Object-Oriented Basics Batchable And Queueable Apex Building A Better Singleton Continuous Integration With SFDX Dependency Injection & Factory Pattern Enum Apex Class Gotchas Extendable Apis Future Methods, Callouts & Callbacks Idiomatic Salesforce Apex Introduction & Testing Philosophy Lazy Iterators Lightweight Trigger Handler LWC Composable Modal LWC Composable Pagination LWC Custom Lead Path Mocking DML React Versus Lightning Web Components Refactoring Tips & Tricks Repository Pattern setTimeout & Implementing Delays Sorting And Performance In Apex Test Driven Development Example Testing Custom Permissions Writing Performant Apex Tests



Read more tech articles