Bitwise Breakdown

Lookup Field in Salesforce LWC: Core Functionality

Published on 8 min read
Lookup Field Salesforce LWC

In this blog post series, we will break down the development of a Custom Lookup Field Lightning Web Component (LWC) from scratch. We will cover each step in detail, like debouncing the server call to fetch records, event bubbling to close the dropdown when clicking outside the component. By the end of this series, you’ll have a fully functional lookup field component ready for your Salesforce projects.

You can also find the complete source code for this component on GitHub: Salesforce Custom Components.


Declare Properties and Methods

Before we dive into the making our hands dirt, let’s declare the variables that we are gonna use for this component.

export default class SCLookupField extends LightningElement {
  // non decorative properties
  loading;
  debounceTimeout;
  selectedLabel;
  search = '';
  lookupResults = [];
  clickedOnComponent = false;
  showDropdown = false;
 
  // api properties
  @api name;
  @api valueLabel; // label of the record
  @api label;
  @api lookupName;
  @api filterValues;
}

Explanation:

  • valueLabel needs to be sent from the parent component, which would be displayed as the selected record label in this component.
  • We would be creating a custom metadata for the each lookup type, hence we have declared the lookupName variable as a API property and this needs to be sent from the parent.

Define HTML Template

Lets write the HTML for label, input element, icons and drop down container with the associated properties and event handlers.

<template>
  <div class="lookup-container">
    <!-- input component -->
    <div class="slds-form-element__control slds-input-has-icon slds-input-has-icon_right input-box">
      <label class="slds-form-element__label">
        {label}
        <template if:true={required}>
          <abbr class="slds-required" title="required"> *</abbr>
        </template>
      </label>
      <input class="slds-input" type="text" placeholder={placeholder} value={search}
        onkeyup={handleInputKeyUp} onclick={handleInputClick} />
      <lightning-icon icon-name="utility:search" class="search-icon" alternative-text="open dropdown" size="xx-small"
        onclick={handleCrossIconClick}></lightning-icon>
      <div class="lookup-filled" if:true={selectedLabel}>
        {selectedLabel}
        <lightning-icon icon-name="utility:close" alternative-text="open dropdown" size="xx-small"
          class="dropdown-icon" onclick={handleCrossIconClick}></lightning-icon>
      </div>
    </div>
    <!-- dropdown component -->
    <div class="dropdown-container" onclick={handleDropdownClick} if:true={showDropdown}>
      <div class="lookup-spinner" if:true={loading}>
          <lightning-spinner alternative-text="Loading" size="small"></lightning-spinner>
      </div>
      <ul class="dropdown-container__list" if:false={loading}>
          <template for:each={lookupResults} for:item="lookup">
              <li class="dropdown-container__list-item" 
                key={lookup.value}
                onclick={handleListClick}
                data-value={lookup.value} 
                data-label={lookup.label}
              >
                  {lookup.label}
              </li>    
          </template>
          <template if:true={noResults}>
              <li class="dropdown-container__list-item">
                  No results
              </li>
          </template>
      </ul>
    </div>
  </div>
</template>

Create Custom Metadata

Create one custom metadata object with the following Object Name - SC_Custom_Lookup_Property. This custom metadata records will have these custom fields to store the Object API Name, Field API Name and Condition which we would use in the apex class to query the records.

Custom metadata types

Fetch Lookup Records

Now its time to dive into building methods to fetch records based on the custom metadata which we have created.

Build Apex Method

Lets define a wrapper class for our lookup record.

public class LookupRecordWrapper {
    @AuraEnabled public String value;
    @AuraEnabled public String label;
}

The apex method fetchLookupRecords will accept three arguments

  • lookupName - our custom metadata record’s developer name.
  • searchString - the input whiich end user provide to search the record
  • filterValues - comma separated values for dynamic update of condition in the metadata

and returns List<LookupRecordWrapper>

Note: filterValues will be covered in Part-2 of this series

@AuraEnabled
public static List<LookupRecordWrapper> fetchLookupRecords(
  String lookupName,
  String searchString,
  String filterValues
) {
    String searchLike = '%' + searchString + '%';
    SC_Custom_Lookup_Property__mdt customLookupProperty = SC_Custom_Lookup_Property__mdt.getInstance(lookupName);
    if(customLookupProperty == null) {
        throw new AuraHandledException('Lookup Name is Incorrect (or) Lookup Name has not been added to the metadata');
    }
    String query = String.format('SELECT Id, {0} FROM {1} WHERE {0} LIKE :searchLike AND {0} != NULL ',
        new List<String>{customLookupProperty.SC_Field__c, customLookupProperty.SC_Object__c, searchLike });
    if(String.isNotBlank(customLookupProperty.SC_Condition__c)) {
        query += 'AND (' + customLookupProperty.SC_Condition__c + ') ';
    }
    query += 'LIMIT 10';
    List<sObject> recordList = new List<sObject>();
    try {
        recordList = Database.query(query);
    } catch (Exception e) {
        throw new AuraHandledException('Error Occured while searching. Error : ' + e.getMessage());
    }
    List<LookupRecordWrapper> lookupRecordList = new List<LookupRecordWrapper>();
    for(sObject record: recordList) {
        LookupRecordWrapper lookupRecord = new LookupRecordWrapper();
        lookupRecord.value = (String)record.get('Id');
        // method to get label will be discussed later, 
        // for now it is a placeholder
        lookupRecord.label = 'Placeholder method';
        lookupRecordList.add(lookupRecord);
    }
    return lookupRecordList;
}

Explanation:

Line 7 - 17: Building the query string based on metadata record and the searchString.

Line 24 - 32: Iterating over the fetched records, and adding it to the lookupRecordList.

Line 30: Will be implemented with another apex method.

Helper Apex Method

The apex method getLabel will accept two arguments

  • record - sObject which was queried.
  • fieldName - nested field separated with dots. Eg: Account.Name

and returns String

To start, we’ll split fieldName using ’.’ as the separator and store the resulting values in fieldList.

If fieldList contains more than one element, it indicates that there’s a nested sObject structure. We’ll then iterate over fieldList to navigate through these nested sObjects from the record, finally retrieving and returning the string value.

If fieldList contains only one element, we can simply return the string representation of the value from the record.

public static String getLabel(sObject record, String fieldName) {
    List<String> fieldList = fieldName.split('\\.');
    sObject childObject;
    if(fieldList.size() > 1) {
        childObject = record.getSObject(fieldList[0]);
        for(Integer i = 1; i < fieldList.size() - 1; i++) {
            childObject = record.getSObject(fieldList[i]);
        }
        return (String) childObject.get(fieldList[fieldList.size() - 1]);
    }
    return (String)record.get(fieldList[0]);
}

Now lets, update the placeholder label with the method which we just built.

@AuraEnabled
public static List<LookupRecordWrapper> fetchLookupRecords(
  String lookupName,
  String searchString,
  String filterValues
) {
    //...no change
    for(sObject record: recordList) {
        //...no change
        lookupRecord.value = (String)record.get('Id');
        lookupRecord.label = getLabel(record, customLookupProperty.SC_Field__c);
        //...no change
    }
    //...no change
}

Write Javascript method

Let’s import the apex method, which we built in the lookupField component.

import fetchLookupRecords from '@salesforce/apex/SC_CustomComponents.fetchLookupRecords';

We will imperatively call the fetchLookupRecords, but with debounce. Does it seems like a new word? Lets dig deeper.

Why debounce?

Debouncing helps control how often a function is called when multiple events, like typing, happen quickly in a row. Without debounce, every keystore would trigger a server request, which could overload the server.

fetchLookupRecordsImperative() {
  clearTimeout(this.debounceTimeout);
  this.loading = true;
  this.debounceTimeout = setTimeout(() => {
    fetchLookupRecords({
      lookupName: this.lookupName,
      searchString: this.search,
      filterValues: this.filterValues
    }).then(result => {
      this.lookupResults = result;
    }).catch(error => {
      console.log(error);
    }).finally(() => {
      this.loading = false;
    })
  }, 500);
}

Here’s how we’ve added debounce for the imperative server calls:

  • We use setTimeout to wait 500 milliseconds after the user stops typing before actually calling the server.

  • We use clearTimeout to stop any ongoing timeout, so if the user keeps typing, we don’t send a new request every time.

  • We handle the loading state with this.loading property.

This way, we only make a server call once the user pauses for a moment, improving performance and making the app faster.

Handle Toggle Dropdown

We achieve opening and closing of the dropdown by leveraging event bubbling. There might be other ways too, like by stopping the event propagation, but I prefer this way as it seems easy to understand and maintain.

connectedCallback() {
  document.addEventListener('click', this.handleDocumentClick.bind(this));
}
 
disconnectedCallback() {
  document.removeEventListener('click', this.handleDocumentClick.bind(this));
}
 
handleDocumentClick(event) {
  if (this.clickedOnComponent) {
    this.clickedOnComponent = false;
  } else {
    this.showDropdown = false;
  }
}
 
handleInputClick() {
  if(!this.showDropdown) {
    this.fetchLookupRecordsImperative();
  }
  this.clickedOnComponent = true;
  this.showDropdown = true;
}

Explanation:

  • Event Bubbling is a mechanism where an event propagates up from the innermost element to the outer elements. We listen for a click event on the entire document and decide whether to close the dropdown based on where the click originated. More on event bubbling - Geeksforgeeks (Event Bubbling).
  • If the click happens inside the component, we prevent the dropdown from closing. If it happens outside, we hide the dropdown. This is controlled via the clickedOnComponent flag.

Filter, Select and Deselect Values

Let’s write event handlers to handle user interactions.

  • When user types in the input field, KeyUp event is fired and we are handling it with handleInputKeyUp() method, in which we are updating the search property and calling the fetchLookupRecordsImperative() method.
  • When user click on the record from the dropdown, handleListClick method will be triggered, in which we would be dispatching the change event to the parent with the selected record value with the input name using selectLookupRecord() method.
  • When user click on the cross icon, handleCrossIconClick() method will be triggered, in which we are dispatching a change event to parent with empty value using selectLookupRecord() method.
// constants
const CHANGE_EVENT = 'change'; // Outside of the LightningElement class
 
handleInputKeyUp(event) {
  if(this.search !== event.target.value) {
    this.search = event.target.value;
    this.fetchLookupRecordsImperative();
  }
}
 
handleListClick(event) {
  const {label, value} = event.currentTarget.dataset;
  this.selectLookupRecord(label, value);
}
 
handleCrossIconClick() {
  this.selectLookupRecord('', '');
}
 
selectLookupRecord(label, value) {
  this.dispatchEvent(new CustomEvent(CHANGE_EVENT, {
    detail: {
      name: this.name,
      value: value
    }
  }));
  this.selectedLabel = label;
  this.search = '';
  this.showDropdown = false;
}

Style the Component

We want the input element, dropdown, icons and loading screen to neetly align with scrollable options for dropdown.

Here’s the CSS file that we need:

.lookup-container {
  position: relative;
}
 
.input-box {
  position: relative;
}
 
.search-icon {
  position: absolute;
  bottom: 0;
  transform: translateY(-50%);
  right: 5px;
}
 
.lookup-filled {
  position: absolute;
  z-index: 10;
  height: calc(100% - 22px);
  width: 100%;
  bottom: 0;
  left: 0;
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0 8px 0 12px;
  border: 1px solid #777;
  background-color: white;
  border-radius: 4px;
  cursor: pointer;
}
 
.dropdown-container {
  position: absolute;
  width: 100%;
  left: 0;
  background-color: white;
  z-index: 10;
  border: 1px solid #333;
  border-radius: 4px;
  overflow: hidden;
}
 
.dropdown-container__list {
  padding: 0;
  list-style: none;
  max-height: 150px;
  overflow-y: scroll;
}
 
.dropdown-container__list-item {
  padding: 5px 20px;
  transition: all .3s;
  cursor: pointer;
}
 
.dropdown-container__list-item:hover,
.dropdown-container__list-item:focus {
  background-color: #33333322;
}
 
.lookup-spinner {
  height: 150px;
}

Explanation:

  • We give the dropdown container a subtle shadow for better visual separation from the page.
  • The max-height property ensures that if there are many options, the list doesn’t extend too far down the page. Instead, it scrolls within a defined height.
  • We also add hover effects on list items for visual feedback.

Conclusion

In this post, we walked through the structure and logic of building a custom lookup field in Salesforce LWC. We covered how to fetch records, debounce the server call based on user input, manage selected record value, and control dropdown visibility using event bubbling.

To see the full code and more, visit the GitHub repository: Salesforce Custom Components.

In the next post, we will go deeper into advanced features like filter criteria, and accessibility improvements. Stay tuned!