Visualforce Autocomplete For Lookup Fields
The standard experience for lookup fields in Salesforce can be clunky. Unless you have recently viewed the record you are trying to look up, you have to type a search phrase (along with wildcards, if needed) in order to search for the record you are looking for. This is how we all know (and probably don’t love) lookup fields:
It’s functional, but it’s quite frustrating with the additional dialog.
Have you ever been to a website where you are typing in your address, for example, and it makes suggestions based on what you type? I’m sure you have. Autocomplete is becoming more and more popular because it creates a great user experience and helps keep your data clean.
If you have not seen this functionality before, here is an example:
As you type, autocomplete gives you suggestions on what you could be searching for. In this example, I am searching for an account owner (User). Based on my input, “Joh”, the autocomplete suggested “John Pipkin”. The same thing would have happened if I had typed “ohn”, “ipki”, or “pip”.
Jitendra Zaa has a fantastic blog post on creating an autocomplete lookup field component for Visualforce pages. The only issue I have with Zaa’s design is that it is not easily configurable. Each object would need a whole new set of Visualforce pages, components, and controllers. So I took his framework and modified it slightly to allow for the same page, component, and controller to be used for any and all objects, even new custom objects you create. Let’s see how it works.
First let’s start with how you would actually implement it in a Visualforce page.
<c:AutoComplete returnValue="{!Account.OwnerId}" for="ownerLookup"
componentLabel="Account Owner" labelStyleClass="col-md-2"
sObject="User" label="Name" value="Id"
details="email"
whereClause="IsActive = true" limitClause="15">
<div class="col-md-10">
<apex:input id="ownerLookup" styleClass="form-control" />
</div>
</c:AutoComplete>
Here are the attributes defined:
- returnValue – the controller property that is used for the ID of the selected record (REQUIRED)
- for – The Id of the input element (REQUIRED)
- componentLabel – The label of the field (REQUIRED)
- labelStyleClass – css class of the input label
- sObject – The api of the SObject of the lookup field (REQUIRED)
- label – the field api of what you want to search inside and also what will be stored in the input field (REQUIRED)
- value – the field api of what will be passed to the page’s controller (REQUIRED)
- details – comma-delimited list of field api names that will show as sub-text in the suggestion box
- whereClause – additional where criteria to the query
- limitClause – limit of the return number of records. Defaults to 10.
This component must have an input text box inside the component with an Id defined (the “for” attribute). You can have as many parent elements as you need or none at all.
For the above example, the resulting query would be “SELECT Id, Name, Email FROM User WHERE Name like ‘%Joh%’ and IsActive = true LIMIT 15”. If there were no whereClause or limitClause attributes defined, the query would be “SELECT Id, Name, Email FROM User where Name like ‘%Joh%’ LIMIT 10”.
Pretty easy, huh? This component is completely dynamic. You can query any field on any object without additional code.
Now, let’s look at what this component is doing.
First, you have a Visualforce page that simply displays a JSON (Java Script Object Notation) list of records returned by the page’s controller query.
<apex:page controller="AC_JSONCtrl" contentType="application/x-JavaScript; charset=utf-8" showHeader="false" standardStylesheets="false" sidebar="false">
{!JSON}
</apex:page>
The data in the Visualforce page is controlled by the page’s controller. The controller gets the variables set in the url to get the data needed to build the query, performs the queries, and returns a JSON representation of the results.
public class AC_JSONCtrl {
public String getJSON(){
List<DataWrapper> wrp = new List<DataWrapper>();
//get params from url
String searchTerm = Apexpages.currentPage().getParameters().get('q');
String objName = Apexpages.currentPage().getParameters().get('obj');
List<String> lstFields = new List<String>();
String label = Apexpages.currentPage().getParameters().get('label');
lstFields.add(label);
String urlDetail = Apexpages.currentPage().getParameters().get('detail');
String urlWhere = ApexPages.currentPage().getParameters().get('wc');
if(urlWhere != null && urlWhere != ''){
urlWhere = EncodingUtil.urlDecode(urlWhere,'utf-8').trim();
if(urlWhere.startsWith('and')){
urlWhere = urlWhere.substring(3);
}
}
String urlLimit = ApexPages.currentPage().getParameters().get('lm');
if(urlLimit == null || urlLimit == ''){
urlLimit = '10';
}
//add comma-separated detail fields to query and store in separate list for retrieve
List<String> lstDetail = new List<String>();
if(urlDetail!= null && urlDetail != ''){
lstDetail = urlDetail.split(',');
lstFields.addAll(lstDetail);
}
String value = Apexpages.currentPage().getParameters().get('value');
lstFields.add(value);
//start building query
String qry = 'Select ';
List<String> fieldQry = new List<String>();
for(String s :lstFields){
fieldQry.add(s);
}
qry += String.join(fieldQry,',');
qry += ' from ' + objName + ' where ' + label + ' like '%' + searchTerm + '%'';
if(urlWhere != null && urlWhere != ''){
qry += ' and ' + urlWhere;
}
qry+= ' Limit ' + urlLimit;
System.debug('====>> qry : ' + qry);
//loop through records to get data
for(SObject o :Database.query(qry)){
String thisdetail ='';
//since multiple details fields can exist, loop through and store data in list
//then convert list to comma-separated string for display
if(urlDetail != null && urlDetail != ''){
if(lstDetail.size() > 1){
List<String> dlist = new List<String>();
for(String d :lstDetail){
dlist.add((String)o.get(d));
}
thisdetail = String.join(dlist,', ');
}
else{
thisdetail = (String) o.get(lstDetail[0]);
}
}
wrp.add(new DataWrapper(((String)o.get(label)), thisdetail, ((String)o.get(value))));
}
return JSON.serialize(wrp);
}
public class DataWrapper{
String label, detail, value;
public DataWrapper(String l, String d, String v){
label = l; //label of suggestion
detail = d; //detail of suggestion
value = v; //the value of what is stored in the input tag. Commonly the Id of the record
}
}
}
There is a lot going on this class that we will cover later. The main part to pay attention to is the DataWrapper inner class. That is what the returned JSON is representing. There are 3 components: Label, Detail, and Value.
The “Label” is the field that is being queried against based on your input. It is also the main part of the autocomplete suggestion. The “Detail” is the field that shows up underneath the label to provide more details into the record that is returned. The “Value” is the field that will be stored in the database. For lookup fields, you will need this to store the “Id” field. In the previous example, the label was “Name,” the detail was “Email,” and the value was “Id.”
Next up, we have the Visualforce component. This is the main part of the functionality and will drive everything else. Here it is:
<apex:component controller="AC_JSONCtrl">
<!-- attributes -->
<apex:attribute name="ComponentLabel" description="Label of Component" type="String" required="true"/>
<apex:attribute name="for" description="Id of input field" type="String" required="true"/>
<apex:attribute name="SObject" description="SObject to query" type="String" required="true"/>
<apex:attribute name="Label" description="Label for autocomplete" type="String" required="true"/>
<apex:attribute name="Value" description="Value for autocomplete" type="String" required="true"/>
<apex:attribute name="ReturnValue" description="Return value for autocomplete" type="Object" required="true"/>
<apex:attribute name="LabelStyleClass" description="Label CSS class" type="String" required="false"/>
<apex:attribute name="Details" description="Details for autocomplete" type="String" required="false"/>
<apex:attribute name="whereClause" description="Additional where clause for query" type="String" required="false"/>
<!-- limit defaults to 10 -->
<apex:attribute name="limitClause" description="Limits the return number of records" type="String" required="false"/>
<!-- CSS -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css"/>
<apex:stylesheet value="{!URLFOR($Resource.AutoComplete, '/css/jquery-ui-1.8.17.custom.css')}"/>
<apex:stylesheet value="{!URLFOR($Resource.AutoComplete, '/css/basic.css')}"/>
<!-- jQuery -->
<apex:includescript value="https://code.jquery.com/jquery-1.10.2.js"/>
<apex:includescript value="https://code.jquery.com/ui/1.11.4/jquery-ui.js"/>
<!-- loading css -->
<style type="text/css">
.ui-autocomplete-loading { background: white url('{!URLFOR($Resource.AutoComplete, '/img/AjaxLoad.gif')}') right center no-repeat; }
</style>
<!-- START COMPONENT BODY -->
<div id="{!for}_hiddenDiv">
<label class="{!LabelStyleClass}">{!ComponentLabel}</label>
<apex:inputHidden value="{!ReturnValue}"/>
</div>
<!-- END COMPONENT BODY -->
<!-- autcomplete function -->
<script. type="text/javascript">
$ac = jQuery.noConflict();
function getLoadingImage()
{
var loadImagURL = "{!URLFOR($Resource.AutoComplete, 'BigLoad.gif')}";
var retStr = ['<img src="', loadImagURL ,'" title="loading..." alt="loading..." class="middleAlign" />'];
return retStr.join("");
}
//the Visualforce page where the data is returned
var sourcePage = '/apex/AC_JSON?core.apexpages.devmode.url=0';
$ac(function() {
var txtVal = $ac('[id$="{!for}"]');
//This method returns the last character of String
function extractLast(term) {
return term.substr(term.length - 1);
}
$ac('[id$="{!for}"]').autocomplete({
source: function( request, response ) {
//Abort Ajax
var $this = $ac(this);
var $element = $ac(this.element);
var jqXHR = $element.data('jqXHR');
if(jqXHR)
jqXHR.abort();
$ac('[id$="{!for}"]').addClass('ui-autocomplete-loading');
$element.data('jqXHR',$ac.ajax({
url: sourcePage+'&q='+txtVal.val()+'&obj={!sObject}&label={!label}&value={!value}&detail={!details}&wc={!URLENCODE(whereClause)}&lm={!limitClause}',
dataType: "json",
data: {
},
success: function( data ) {
response( $ac.map( data , function( item ) {
return {
label: '<a>'+
item.label+"<br />"+
'<span style="font-size:0.8em;font-style:italic">'
+item.detail+
"</span></a>",
value: item.label,
id: item.value
}
}));
},
complete: function() {
//This method is called either request completed or not
$this.removeData('jqXHR');
//remove the class responsible for loading image
$ac('[id$="{!for}"]').removeClass('ui-autocomplete-loading');
}
})
);
},
search: function() {
//If String contains at least 2 characters
if (this.value.length >= 2)
{
$ac('[id$="{!for}"]').autocomplete('option', 'delay', 100);
return true;
}
return false;
},
focus: function() {
// prevent value inserted on focus
return false;
},
select: function(event, ui) {
console.log('select');
var selectedObj = ui.item.label;
$ac('[id$="{!for}_hiddenDiv"] input[type=hidden]').val(ui.item.id);
return true;
}
}).data("ui-autocomplete")._renderItem = autoCompleteRender;
});
function autoCompleteRender(ul, item) {
return $ac("<li></li>").data("item.autocomplete", item).append(item.label).appendTo(ul);
}
</script.>
</apex:component>
There are a lot of things going on here, but I am only going to point out one thing. The jQuery function is making a callout using ajax to the Visualforce page mentioned earlier, getting the JSON data, and then parsing the data into a useful format. For more detail on how that works, check out jQuery’s documentation for autocomplete, https://jqueryui.com/autocomplete/.
When you are using this component, you never actually see the “value” property in the UI. In the component, there is a hidden input field (“myHiddenObjectId”) that is populated with the value defined and stored in the “for” controller variable.
You can see that even though the end-user only sees the Label of the record, your controller will only see the value.
This functionality is available in an unmanaged package. You can download it here. (Side note: as of now, relationship queries are not supported.)
Happy Coding!