About the Author

Kelly Tisdell

Vice President

Published
in Development 10 min read

Searchandising with Broadleaf and Solr (Part 1 - Browsing by Device Type)

One of my favorite things about Broadleaf Commerce as a framework for developing eCommerce applications is that it allows you to easily extend and override the default data and functionality of the framework to suit your specific business requirements. Sure, Broadleaf provides a very rich set of out-of-the-box features, but allows you complete control so that you can provide unique experiences for your customers rather than conforming to an otherwise plain, typical shopping, browsing, and checkout experience. Merchandising and browsing are two extremely important functions of an eCommerce storefront; controlling and customizing this behavior to differentiate yourself from your competition is a must.

One of the out-of-the-box features that Broadleaf provides is the ability to browse and search for products using the excellent Solr search engine. Solr is built on Apache Lucene and provides a very rich, configurable environment for searching, filtering, faceting, and browsing. If you are not familiar with Solr, I recommend a taking a look at their website.

I was recently involved in a sales cycle with a prospective client and one of the use cases that they requested that we demo was the ability to change the default ordering of products depending on the device of the customer. No problem! This functionality is currently not available by default with Broadleaf Commerce, but was a relatively simple custom extension. This tutorial will not only provide a guide on how one might do something like this with Broadleaf, but will also show that the sky is the limit when it comes to customizing Broadleaf to implement creative, innovative ideas to create a better shopping experience for your customers.

In addition to free text searches, Broadleaf uses Solr to browse by categories. In other words, when you select a category, Broadleaf issues a request to Solr to return a paginated list of products for that category in a particular order. The default ordering comes from the “BLC_CATEGORY_PRODUCT_XREF” table. This table has a column called “DISPLAY_ORDER”. We can hijack the default behavior and override it when the device is mobile or a tablet. Here’s how:

Extend the Domain

The first thing we need to do is extend the domain to allow us to administer the different sorting options for each category. We need to create a few new enumerations. The first is to represent the device type:

package com.mycompany.sample.core.type;

import java.io.Serializable;
import java.util.LinkedHashMap;

import org.broadleafcommerce.common.BroadleafEnumerationType;

public class DeviceType implements BroadleafEnumerationType, Serializable {

   private static final long serialVersionUID = 1L;

    private static final LinkedHashMap<String, DeviceType> TYPES = new LinkedHashMap<String, DeviceType>();

    public static final DeviceType MOBILE = new DeviceType("MOBILE", "Mobile");
    public static final DeviceType TABLET = new DeviceType("TABLET", "Tablet");
    public static final DeviceType DESKTOP = new DeviceType("DESKTOP", "Desktop");

    

    public static DeviceType getInstance(final String type) {
        return TYPES.get(type);
    }

    private String type;
    private String friendlyType;



    public DeviceType() {
        //do nothing

    }



    public DeviceType(final String type, final String friendlyType) {
        this.friendlyType = friendlyType;
        setType(type);
    }

    @Override

    public String getType() {
        return type;
    }

    @Override

    public String getFriendlyType() {
        return friendlyType;
    }



    private void setType(final String type) {
        this.type = type;
        if (!TYPES.containsKey(type)) {
            TYPES.put(type, this);
        }
    }

    @Override

    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + ((type == null) ? 0 : type.hashCode());
        return result;
    }

    @Override

    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (!getClass().isAssignableFrom(obj.getClass()))
            return false;
        DeviceType other = (DeviceType) obj;
        if (type == null) {
            if (other.type != null)
                return false;
        } else if (!type.equals(other.type))
            return false;
        return true;
    }


}

The second enumeration will allow us to administer what kind of sorting to use for each Category. For simplicity, the sort options will be: Default, Retail Price Ascending, Retail Price Descending, Product Name Ascending, and Product Name Descending.

package com.mycompany.sample.core.type;

import java.io.Serializable;
import java.util.LinkedHashMap;

import org.broadleafcommerce.common.BroadleafEnumerationType;

public class BrowseSortOrder implements BroadleafEnumerationType, Serializable {

 private static final long serialVersionUID = 1L;

    private static final LinkedHashMap<String, BrowseSortOrder> TYPES = new LinkedHashMap<String, BrowseSortOrder>();

    public static final BrowseSortOrder DEFAULT = new BrowseSortOrder("DEFAULT", "Default");
    public static final BrowseSortOrder RETAIL_PRICE_ASC = new BrowseSortOrder("RETAIL_PRICE_ASC", "Retail Price Ascending");
    public static final BrowseSortOrder RETAIL_PRICE_DESC = new BrowseSortOrder("RETAIL_PRICE_DESC", "Retail Price Descending");
    public static final BrowseSortOrder PRODUCT_NAME_ASC = new BrowseSortOrder("PRODUCT_NAME_ASC", "Product Name Ascending");
    public static final BrowseSortOrder PRODUCT_NAME_DESC = new BrowseSortOrder("PRODUCT_NAME_DESC", "Product Name Descending");

    

    public static BrowseSortOrder getInstance(final String type) {
        return TYPES.get(type);
    }

    private String type;
    private String friendlyType;



    public BrowseSortOrder() {
        //do nothing

    }



    public BrowseSortOrder(final String type, final String friendlyType) {
        this.friendlyType = friendlyType;
        setType(type);
    }

    @Override

    public String getType() {
        return type;
    }

    @Override

    public String getFriendlyType() {
        return friendlyType;
    }



    private void setType(final String type) {
        this.type = type;
        if (!TYPES.containsKey(type)) {
            TYPES.put(type, this);
        }
    }

    @Override

    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + ((type == null) ? 0 : type.hashCode());
        return result;
    }

    @Override

    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (!getClass().isAssignableFrom(obj.getClass()))
            return false;
        BrowseSortOrder other = (BrowseSortOrder) obj;
        if (type == null) {
            if (other.type != null)
                return false;
        } else if (!type.equals(other.type))
            return false;
        return true;
    }


}

Next, we need to extend the Product Category to allow us to administer the sorting for each category. First, we create a new interface for our extended Category:

package com.mycompany.sample.core.entity;

import org.broadleafcommerce.core.catalog.domain.Category;

import com.mycompany.sample.core.type.BrowseSortOrder;

public interface MyCategory extends Category {



 public void setDefaultMobileSortOrder(BrowseSortOrder sortOrder);

   

    public BrowseSortOrder getDefaultMobileSortOrder();

 

    public void setDefaultTabletSortOrder(BrowseSortOrder sortOrder);

   

    public BrowseSortOrder getDefaultTabletSortOrder();

 

    public void setDefaultDesktopSortOrder(BrowseSortOrder sortOrder);

  

    public BrowseSortOrder getDefaultDesktopSortOrder();
    
}

Next, we create a concrete implementation of our Category extension:

package com.mycompany.sample.core.entity;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Inheritance;
import javax.persistence.InheritanceType;
import javax.persistence.Table;

import org.broadleafcommerce.common.presentation.AdminPresentation;
import org.broadleafcommerce.common.presentation.client.SupportedFieldType;
import org.broadleafcommerce.core.catalog.domain.CategoryImpl;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;

import com.mycompany.sample.core.type.BrowseSortOrder;

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@Table(name = "MY_CATEGORY")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "blCategories")
public class MyCategoryImpl extends CategoryImpl implements MyCategory {

  private static final long serialVersionUID = 1L;
    
    @Column(name="DESKTOP_SORT_ORDER")
    @AdminPresentation(friendlyName = "Desktop",
      tab = "Default Browse Sorting", tabOrder = 10000000, order = 1000,
        fieldType = SupportedFieldType.BROADLEAF_ENUMERATION, 
      broadleafEnumeration = "com.mycompany.sample.core.type.BrowseSortOrder")
  protected String desktopSortOrder = BrowseSortOrder.DEFAULT.getType();
  
    @Column(name="MOBILE_SORT_ORDER")
 @AdminPresentation(friendlyName = "Mobile",
       tab = "Default Browse Sorting", tabOrder = 10000000, order = 2000,
        fieldType = SupportedFieldType.BROADLEAF_ENUMERATION, 
      broadleafEnumeration = "com.mycompany.sample.core.type.BrowseSortOrder")
  protected String mobileSortOrder = BrowseSortOrder.DEFAULT.getType();
   
    @Column(name="TABLET_SORT_ORDER")
 @AdminPresentation(friendlyName = "Tablet",
       tab = "Default Browse Sorting", tabOrder = 10000000, order = 3000,
        fieldType = SupportedFieldType.BROADLEAF_ENUMERATION, 
      broadleafEnumeration = "com.mycompany.sample.core.type.BrowseSortOrder")
  protected String tabletSortOrder = BrowseSortOrder.DEFAULT.getType();

   @Override

   public void setDefaultMobileSortOrder(BrowseSortOrder sortOrder) {
      if (sortOrder == null) {
            this.mobileSortOrder = null;
        } else {
            this.mobileSortOrder = sortOrder.getType();
     }
   }

   @Override

   public BrowseSortOrder getDefaultMobileSortOrder() {
        if (mobileSortOrder == null) {
          return BrowseSortOrder.DEFAULT;
     }
       return BrowseSortOrder.getInstance(mobileSortOrder);
    }

   @Override

   public void setDefaultTabletSortOrder(BrowseSortOrder sortOrder) {
      if (sortOrder == null) {
            this.tabletSortOrder = null;
        } else {
            this.tabletSortOrder = sortOrder.getType();
     }
   }

   @Override

   public BrowseSortOrder getDefaultTabletSortOrder() {
        if (tabletSortOrder == null) {
          return BrowseSortOrder.DEFAULT;
     }
       return BrowseSortOrder.getInstance(tabletSortOrder);
    }

   @Override

   public void setDefaultDesktopSortOrder(BrowseSortOrder sortOrder) {
     if (sortOrder == null) {
            this.desktopSortOrder = null;
       } else {
            this.desktopSortOrder = sortOrder.getType();
        }
   }

   @Override

   public BrowseSortOrder getDefaultDesktopSortOrder() {
       if (desktopSortOrder == null) {
         return BrowseSortOrder.DEFAULT;
     }
       return BrowseSortOrder.getInstance(desktopSortOrder);
   }

}

In order to activate this and make Broadleaf use your extended Category, you'll need a few lines of configuration. First, modify the file core/src/main/resources/META-INF/persistence-core.xml and add your new Category to the default persistence unit:

<persistence-unit name="blPU" transaction-type="RESOURCE_LOCAL">
        <non-jta-data-source>jdbc/web</non-jta-data-source>
        <class>com.mycompany.sample.core.entity.MyCategoryImpl</class>
        <exclude-unlisted-classes/>
</persistence-unit>

You will also need to modify the core/src/main/resources/applicationContext-entity.xml and add the following line:

<bean id="org.broadleafcommerce.core.catalog.domain.Category" class="com.mycompany.sample.core.entity.MyCategoryImpl" scope="prototype"/>

If you are using SQL scripts to load catalog data, you will need to ensure that there are database entries for your new MY_CATEGORY table. For example, in the core/src/main/resources/sql/load_catalog_data.sql, you'll want to add a record for each corresponding Category record:

At this point you should have everything wired up to maintain this new data as a first class extension set for Broadleaf Commerce. Let's look at the admin:

Notice that we did not need to write any UI for the Broadleaf Admin Console to allow us to administer these new fields. Although we can administer different sort options on a per category basis at this point, we have not done anything to tell Solr to do this, so let's do that next.

Determine the Device

We now need to determine the Device that is being used. You will likely want to use the excellent Spring Mobile module to detect your device. However, for the sake of simplicity, and to provide a deeper dive into how this works, we'll implement our own custom filter that handles rudimentary device detection based on the User-Agent header. In the Site module, let's add the following Filter class:

package com.mycompany.site.filter;

import java.io.IOException;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.lang.StringUtils;
import org.broadleafcommerce.common.web.BroadleafRequestContext;
import org.springframework.web.filter.OncePerRequestFilter;

import com.mycompany.sample.core.type.DeviceType;

public class MyDeviceFilter extends OncePerRequestFilter {

  @Override

   protected void doFilterInternal(HttpServletRequest request,
         HttpServletResponse response, FilterChain filterChain)
          throws ServletException, IOException {
      
        BroadleafRequestContext brc = BroadleafRequestContext.getBroadleafRequestContext();
     
        //Mock up some user agent stuff for mobile / tablet...

      String userAgent = request.getHeader("User-Agent");
       DeviceType deviceType = DeviceType.DESKTOP; //Default

       
        if (userAgent != null) {
            userAgent = userAgent.toLowerCase();
            if (userAgent.contains("iphone")) {
               deviceType = DeviceType.MOBILE;
         } else if (userAgent.contains("android 4.1")) {
               deviceType = DeviceType.MOBILE;
         } else if (userAgent.contains("android 4.3")) {
               deviceType = DeviceType.MOBILE;
         } else if (userAgent.contains("ipad")) {
              deviceType = DeviceType.TABLET;
         }
       }
       
        brc.getAdditionalProperties().put("blc_deviceType", deviceType);
      
        filterChain.doFilter(request, response);
    }

}

As you can see above, we simply evaluate the user agent for keywords and set the DeviceType based on that evaluation (note that this is not complete and using Spring Mobile is likely a better approach for a real world solution).

Don't forget to configure your new filter in site/src/main/webapp/WEB-INF/applicationContext-filter.xml:

...
<bean id="myDeviceFilter" class="com.mycompany.site.filter.MyDeviceFilter"/>
...

<bean id="blPostSecurityFilterChain" class="org.springframework.security.web.FilterChainProxy">
        <sec:filter-chain-map request-matcher="ant">
            <sec:filter-chain pattern="/**" filters="

               ...

               blRequestFilter,

               ...

               myDeviceFilter"/>
        </sec:filter-chain-map>
</bean>

Notice that this comes after the blRequestFilter, which is important to establish the BroadleafRequestContext.

Change the Sort Argument based on the Device

Now that we have our Filter in place to detect the customer's device, we need to modify how the results from the query to Solr are sorted based on the device. We do this by overriding the SolrSearchServiceImpl. Here's how:

package com.mycompany.sample.core.service;

import java.io.IOException;
import java.util.List;

import com.mycompany.sample.core.entity.MyCategory;
import com.mycompany.sample.core.type.BrowseSortOrder;
import com.mycompany.sample.core.type.DeviceType;

public class MySolrSearchServiceImpl extends SolrSearchServiceImpl {
    //Constructors omitted...

  
    @Override

    public SearchResult findExplicitSearchResultsByCategory(Category category, SearchCriteria searchCriteria) throws ServiceException {
        if (category instanceof MyCategory) {
           BroadleafRequestContext brc = BroadleafRequestContext.getBroadleafRequestContext();
         if (brc != null) {
              DeviceType deviceType = (DeviceType)brc.getAdditionalProperties().get("blc_deviceType");
              if (deviceType != null) {
                   MyCategory myCategory = (MyCategory)category;
                   String sortOrder = null;
                    if (DeviceType.DESKTOP.equals(deviceType) && myCategory.getDefaultDesktopSortOrder() != null) {
                     sortOrder = getDefaultSortOrder(myCategory.getDefaultDesktopSortOrder());
                   } else if (DeviceType.MOBILE.equals(deviceType) && myCategory.getDefaultMobileSortOrder() != null) {
                        sortOrder = getDefaultSortOrder(myCategory.getDefaultMobileSortOrder());
                    } else if (DeviceType.TABLET.equals(deviceType) && myCategory.getDefaultTabletSortOrder() != null) {
                        sortOrder = getDefaultSortOrder(myCategory.getDefaultTabletSortOrder());
                    }
                   
                    if (sortOrder != null) {
                        List<SearchFacetDTO> facets = getCategoryFacets(category);
                      String query = shs.getExplicitCategoryFieldName() + ":\"" + shs.getCategoryId(category) + "\"";

                     return findSearchResults("*:*", facets, searchCriteria, sortOrder, query); 
                   }
               }
           }
       }
       
        //Default...

        return super.findExplicitSearchResultsByCategory(category, searchCriteria);
     
    }

   @Override

   public SearchResult findSearchResultsByCategory(Category category,
          SearchCriteria searchCriteria) throws ServiceException {
        if (category instanceof MyCategory) {
           BroadleafRequestContext brc = BroadleafRequestContext.getBroadleafRequestContext();
         if (brc != null) {
              DeviceType deviceType = (DeviceType)brc.getAdditionalProperties().get("blc_deviceType");
              if (deviceType != null) {
                   MyCategory myCategory = (MyCategory)category;
                   String sortOrder = null;
                    if (DeviceType.DESKTOP.equals(deviceType) && myCategory.getDefaultDesktopSortOrder() != null) {
                     sortOrder = getDefaultSortOrder(myCategory.getDefaultDesktopSortOrder());
                   } else if (DeviceType.MOBILE.equals(deviceType) && myCategory.getDefaultMobileSortOrder() != null) {
                        sortOrder = getDefaultSortOrder(myCategory.getDefaultMobileSortOrder());
                    } else if (DeviceType.TABLET.equals(deviceType) && myCategory.getDefaultTabletSortOrder() != null) {
                        sortOrder = getDefaultSortOrder(myCategory.getDefaultTabletSortOrder());
                    }
                   
                    if (sortOrder != null) {
                        List<SearchFacetDTO> facets = getCategoryFacets(category);
                      String query = shs.getCategoryFieldName() + ":\"" + shs.getCategoryId(category) + "\"";

                     return findSearchResults("*:*", facets, searchCriteria, sortOrder, query);
                    }
               }
           }
       }
       
        //Default...

        return super.findSearchResultsByCategory(category, searchCriteria);
 }

   

    private String getDefaultSortOrder(BrowseSortOrder browseOrder) {
       String sortOrder = null;
                //Note that name_s is not a default field in Solr for BLC.  You can add it by adding a record to 

                //BLC_FIELD_SEARCH_TYPES.  If the field is marked as translatable in the BLC_FIELD table, 

                //Then you'll have to get the locale off of the BroadleafRequestContext and construct the field name

                //e.g. "en_name_s".  For simplicity, I have assumed the locale is English and hard coded the field name...

        if (BrowseSortOrder.PRODUCT_NAME_ASC.equals(browseOrder)) {
         sortOrder = "en_name_s asc";
      } else if (BrowseSortOrder.PRODUCT_NAME_DESC.equals(browseOrder)) {
         sortOrder = "en_name_s desc";
     } else if (BrowseSortOrder.RETAIL_PRICE_ASC.equals(browseOrder)) {
          sortOrder = "price_p asc";
        } else if (BrowseSortOrder.RETAIL_PRICE_DESC.equals(browseOrder)) {
         sortOrder = "price_p desc";
       }
       
        return sortOrder;
   }

}

Finally, you'll need to tell Broadleaf to use your custom search service. In the site/src/main/webapp/WEB-INF/applicationContext.xml file, modify the definition of the blSearchService bean:

<bean id="blSearchService" class="com.mycompany.sample.core.service.MySolrSearchServiceImpl">
    ...
</bean> 

The Results

You should now be able to control the default sorting of products while browsing categories with different devices. Consider our admin view of the configuration of the "Hot Sauces" category. The desktop will continue to get the default ordering of products, the tablet will get products ordered by price, ascending, and the mobile device will get products ordered by name, ascending (alphabetical).

Here is a screenshot of the default desktop view of the first page of the Hot Sauces category:

Now, consider the screenshot of the first page of Hot Sauces Category from a tablet (notice that I am using Google Chrome's Developer Tools to emulate the iPad). Notice that sort order is different, but the URL is exactly the same:

Finally, consider a screenshot of the same page from the an iPhone 6 (simulated):

Conclusion

That's all there is to it. This example demonstrates a very simple and useful way to extend and customize the search and browsing feature of Broadleaf Commerce to change the sorting of results based on the device of the customer. This example took me less than an hour to implement and is a feature that will likely make the marketing and merchandising folks very happy.