Search-backed and content-driven Megamenus

Okay, that is a mouthful. To rephrase my compound title, we'll be talking about an approach that 

  1. uses search to
  2. update a content item that
  3. follows a structure tailored to a
  4. template that renders a megamenu.

There! Thats the flow in a nutshell. If that already has the gears turning in your head, then you're welcome. Ta-ta! But if you need more, read on.

Note that I'm discussing a design here, resorting to low-level implementations only where needed. Undertanding the moving parts in this design/approach is the real value of this post.

What is a megamenu?

You've seen them. Megamenus are everywhere. A megamenu is basically a beautiful and useful kink in your navigation. Most navigation HTML provides a hierarchical menu reflecting your sitemap. But every so often, a business may want to break away from that mundaneness and give the user a rich navigational experience.

For a quick example, visit amazon.com and hover over any item under Shop by DepartmentEach of those is a megamenu.

Our Simple Course Megamenu

Consider the below megamenu which we will use as a case study for the purposes of this post.

It features 4 tabs. Each tab shows a list of courses arranged alphabetically. Clicking a course takes you to the course content page. All courses are Liferay journal articles of a custom subtype called Course.

The Megamenu Content Structure

More often than not, the information we display in a megamenu has a well-defined structure. For the above example, we can quickly define a content structure for our megamenu that would look something like this:

Tab (Text/Repeats)
   Course Letter (Text/Repeats)
        Course Title (Text)
            Course URL (Text)

Using the above structure, you could manually create all the content necessary for our courses megamenu. Sounds crazy! Right. And we don't want to do anything crazy! So hang in there.

The Megamenu Content Template

Here is the stripped-down megamenu template that uses the structure above to render the megamenu.

<div class="mega-dropdown-nav-container">
  <ul class="nav nav-pills nav-justified block-light" role="tablist">
    #foreach ($studyLevel in $txtQualificationLevel.getSiblings())
      <li role="presentation">
        <a data-target="#$studyLevel.txtIdentifier.getData()" aria-controls="#
           $studyLevel.txtIdentifier.getData()" role="tab" data-toggle="pill"><strong>
              $studyLevel.getData()</strong></a>
      </li>
    #end
  </ul>
</div>

<div class="mega-dropdown-tab-container">
  <div class="tab-content">
    #foreach ($studyLevel in $txtQualificationLevel.getSiblings()) 
      <div role="tabpanel" class="tab-pane $active" id="$studyLevel.txtIdentifier.getData()">
        <div class="mega-dropdown-menu-inner">
          #foreach ($letter in $studyLevel.txtLetter.getSiblings())
            <ul>
              <li class="dropdown-header">$letter.getData()</li>
                #foreach ($course in $letter.txtCourseTitle.getSiblings())
                  <li><a href="/-/$course.txtFriendlyUrl.data">$course.getData()</a></li>
                #end
            </ul>
          #end
        </div>
      </div>
    #end
  </div>
</div>
 

Theme Navigation Trigger

Now, hook up your megamenu content to your theme navigation.
 

navigation.vm

...

#foreach ($nav_item in $nav_items)
#if ($velocityCount == 3) ### drop the COURSES megamenu item here
#parse ("$full_templates_path/megamenu.vm")

#end
...

 

megamenu.vm

#set ($journalContentUtil = $utiLocator.findUtil("com.liferay.portlet.journalcontent.util.JournalContentUtil"))
#set ($content = $journalContentUtil.getContent( $themeDisplay.getSiteGroupId(), $propsUtil.get("megamenu.article.id"), null, $locale.toString(), $themeDisplay ) )
 
<li class="navlink-courses dropdown mega-dropdown visible-md visible-lg visible-xl" id="layout_megamenu" $nav_item_attr_selected role="presentation">
        <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false">Courses <i class="icon icon-chevron-down icon-sm"></i></a>
        <div class="dropdown-menu mega-dropdown-menu" role="tabpanel">
                $content
        </div>
</li>
<li class="navlink-courses hidden-md hidden-lg hidden-xl"><a href="#">Courses</a></li>
 

What we have so far

  • A megamenu content structure
  • A megamenu content template
  • A megamenu content item (journal article) tied to that structure and template
What is still worrisome is the craziness involved in manually updating our megamenu content item. This is where the Liferay Search API comes to the rescue. But first, a quick note on the megamenu XSD.
 

The Megamenu XSD 

The Megamenu Content XML

If you examine the content in the database of your megamenu journal article, you will find something similar to the below.
 
<?xml version="1.0"?>
<root available-locales="en_US" default-locale="en_US">
    <dynamic-element name="txtQualificationLevel" type="text" index-type="keyword" index="0">
        <dynamic-element name="txtIdentifier" index="0" type="text" index-type="keyword">
            <dynamic-content language-id="en_US"><![CDATA[all-courses]]></dynamic-content>
        </dynamic-element>
        <dynamic-element name="txtLetter" index="0" type="text" index-type="keyword">
            <dynamic-element name="txtCourseTitle" index="0" type="text" index-type="keyword">
                <dynamic-element name="txtFriendlyUrl" index="0" type="text" index-type="keyword">
                    <dynamic-content language-id="en_US"><![CDATA[advanced-certificate-in-management]]></dynamic-content>
                </dynamic-element>
                <dynamic-content language-id="en_US"><![CDATA[Advanced Certificate in Management]]></dynamic-content>
            </dynamic-element>
            <dynamic-element name="txtCourseTitle" index="1" type="text" index-type="keyword">
                <dynamic-element name="txtFriendlyUrl" index="1" type="text" index-type="keyword">
                    <dynamic-content language-id="en_US"><![CDATA[advanced-certificate-in-financial-planning]]></dynamic-content>
                </dynamic-element>
                <dynamic-content language-id="en_US"><![CDATA[Advanced Certificate In Financial Planning]]></dynamic-content>
            </dynamic-element>
            <dynamic-content language-id="en_US"><![CDATA[A]]></dynamic-content>
        </dynamic-element>
        <dynamic-element name="txtLetter" index="1" type="text" index-type="keyword">
            <dynamic-element name="txtCourseTitle" index="2" type="text" index-type="keyword">
                <dynamic-element name="txtFriendlyUrl" index="2" type="text" index-type="keyword">
                    <dynamic-content language-id="en_US"><![CDATA[bachelor-of-commerce-bcomm-]]></dynamic-content>
                </dynamic-element>
                <dynamic-content language-id="en_US"><![CDATA[Bachelor of Commerce (BCOM)]]></dynamic-content>
            </dynamic-element>
            <dynamic-content language-id="en_US"><![CDATA[B]]></dynamic-content>
        </dynamic-element>
        <dynamic-content language-id="en_US"><![CDATA[All Courses]]></dynamic-content>
    </dynamic-element>
    <dynamic-element name="txtQualificationLevel" type="text" index-type="keyword" index="1">
        <dynamic-element name="txtIdentifier" index="1" type="text" index-type="keyword">
            <dynamic-content language-id="en_US"><![CDATA[undergraduates]]></dynamic-content>
        </dynamic-element>
        <dynamic-element name="txtLetter" index="2" type="text" index-type="keyword">
            <dynamic-element name="txtCourseTitle" index="3" type="text" index-type="keyword">
                <dynamic-element name="txtFriendlyUrl" index="3" type="text" index-type="keyword">
                    <dynamic-content language-id="en_US"><![CDATA[advanced-certificate-in-management]]></dynamic-content>
                </dynamic-element>
                <dynamic-content language-id="en_US"><![CDATA[Advanced Certificate in Management]]></dynamic-content>
            </dynamic-element>
            <dynamic-content language-id="en_US"><![CDATA[A]]></dynamic-content>
        </dynamic-element>
        <dynamic-content language-id="en_US"><![CDATA[Undergraduates]]></dynamic-content>
    </dynamic-element>
    <dynamic-element name="txtQualificationLevel" type="text" index-type="keyword" index="2">
        <dynamic-element name="txtIdentifier" index="2" type="text" index-type="keyword">
            <dynamic-content language-id="en_US"><![CDATA[postgraduates]]></dynamic-content>
        </dynamic-element>
        <dynamic-element name="txtLetter" index="3" type="text" index-type="keyword">
            <dynamic-element name="txtCourseTitle" index="4" type="text" index-type="keyword">
                <dynamic-element name="txtFriendlyUrl" index="4" type="text" index-type="keyword">
                    <dynamic-content language-id="en_US"><![CDATA[higher-certificate-in-management]]></dynamic-content>
                </dynamic-element>
                <dynamic-content language-id="en_US"><![CDATA[Higher Certificate In Management]]></dynamic-content>
            </dynamic-element>
            <dynamic-content language-id="en_US"><![CDATA[H]]></dynamic-content>
        </dynamic-element>
        <dynamic-element name="txtLetter" index="4" type="text" index-type="keyword">
            <dynamic-element name="txtCourseTitle" index="5" type="text" index-type="keyword">
                <dynamic-element name="txtFriendlyUrl" index="5" type="text" index-type="keyword">
                    <dynamic-content language-id="en_US"><![CDATA[master-of-business-administration-mba-]]></dynamic-content>
                </dynamic-element>
                <dynamic-content language-id="en_US"><![CDATA[Master of Business Administration (MBA)]]></dynamic-content>
            </dynamic-element>
            <dynamic-content language-id="en_US"><![CDATA[M]]></dynamic-content>
        </dynamic-element>
        <dynamic-content language-id="en_US"><![CDATA[Postgraduates]]></dynamic-content>
    </dynamic-element>
    <dynamic-element name="txtQualificationLevel" type="text" index-type="keyword" index="3">
        <dynamic-element name="txtIdentifier" index="3" type="text" index-type="keyword">
            <dynamic-content language-id="en_US"><![CDATA[ce]]></dynamic-content>
        </dynamic-element>
        <dynamic-element name="txtLetter" index="5" type="text" index-type="keyword">
            <dynamic-element name="txtCourseTitle" index="6" type="text" index-type="keyword">
                <dynamic-element name="txtFriendlyUrl" index="6" type="text" index-type="keyword">
                    <dynamic-content language-id="en_US"><![CDATA[postgraduate-diploma-in-banking]]></dynamic-content>
                </dynamic-element>
                <dynamic-content language-id="en_US"><![CDATA[Postgraduate Diploma in Banking]]></dynamic-content>
            </dynamic-element>
            <dynamic-content language-id="en_US"><![CDATA[P]]></dynamic-content>
        </dynamic-element>
        <dynamic-content language-id="en_US"><![CDATA[Continuing education]]></dynamic-content>
    </dynamic-element>
</root>
 
This content may look overwhelming at first, but it is sickeningly simplistic, and hence very flexible. This is all you need to know.
  1. The root element has one or more dynamic-element elements.
  2. Each dynamic-element can have one dynamic-content element and one or more dyamic-element elements.
  3. And recursion. That's it.
 

The XML Schema Definition

Use any of numerous tools to create an XML Schema Definition for this XML structure. Once you have an XSD, you can then use any XML Schema binding framework (XML Beans, JAXB, etc) to convert between an object model and an XML document that conforms to said schema. This is powerful now.
 

Putting Search to work

There are many ways to do this, but we chose to implement a simple admin portlet with a single button on it. Clicking the button does the following work.
  1. Fire a search to bring back all content of type course
  2. Sort the courses
  3. Partition the courses into four buckets corresponding to the four tabs on the megamenu. We use a custom field called studyLevel for each course that serves as the discriminator for the tabs
  4. Organize the courses into the object model that is convertible to our Megamenu content XML document.
  5. Convert the object model to XML
  6. Retrieve the megamenu content item
  7. Update it with the XML
  8. Done.

So why even do it this way?

  1. The content that populates the megamenu is web content, so updating the megamenu is as simple as updating a content item. Happens on the fly.
  2. We wanted our megamenu to be extremely performant, so we preferred to just load a content item and thus reuse any Liferay caching that already services existing content
  3. We wanted a way to refresh the content on demand. E.g. fire a search and refresh the content item after adding one or more courses.
  4. We wanted to leverage Liferay's cotnent versioning so we can examine the content evolution of our megamenu and use as needed.

Some useful code snippets follow. I hope someone finds this approach helpful.

Useful Code Snippets

Faceted Search Configuration

{"facets": [
    {
        "displayStyle": "asset_entries",
        "static": true,
        "weight": 0.25,
        "order": "OrderHitsDesc",
        "data": {
            "values": ["com.liferay.portlet.journal.model.JournalArticle"],
            "frequencyThreshold": 0
        },
        "className": "com.liferay.portal.kernel.search.facet.AssetEntriesFacet",
        "label": "asset-type",
        "fieldName": "entryClassName"
    },
    {
        "displayStyle": "asset_tags",
        "weight": 0.25,
        "static": true,
        "order": "OrderHitsDesc",
        "data": {
            "values": ["course"],
            "frequencyThreshold": 0
        },
        "label": "asset-type",
        "className": "com.liferay.portal.kernel.search.facet.MultiValueFacet",
        "fieldName": "type"
    }
]}
 

Calling into the Search API

String searchConfiguration = portletPreferences.getValue("jsonFacetedSearchConfigurationString", StringPool.BLANK);
 
import com.liferay.portal.kernel.search.Document
import com.liferay.portal.kernel.search.SearchContext
import com.liferay.portal.kernel.search.QueryConfig
import com.liferay.portal.kernel.search.FacetedSearcher
import com.liferay.portal.kernel.search.facet.AssetEntriesFacet
import com.liferay.portal.kernel.search.facet.Facet
import com.liferay.portal.kernel.search.facet.ScopeFacet
import com.liferay.portal.kernel.search.facet.config.FacetConfiguration
import com.liferay.portal.kernel.search.facet.config.FacetConfigurationUtil
import com.liferay.portal.kernel.search.facet.util.FacetFactoryUtil
import com.liferay.portal.kernel.search.Field
import com.liferay.portal.kernel.search.Hits
import com.liferay.portal.kernel.search.Indexer
import com.liferay.portal.kernel.search.IndexerRegistryUtil
import com.liferay.portal.kernel.search.QueryConfig
import com.liferay.portal.kernel.search.SearchContext
import com.liferay.portal.kernel.search.SearchContextFactory
import com.liferay.portal.kernel.search.SearchResultUtil
 
List<Document> documents = null;
SearchContext searchContext = SearchContextFactory.getInstance(request);
searchContext.setAttribute("paginationType", "none");
 
QueryConfig queryConfig = new QueryConfig();
searchContext.setQueryConfig(queryConfig);
 
searchContext.setStart(1);
//searchContext.setEnd(); // I am hoping NOT setting this would work as paginationType is set to "none"
 
Facet assetEntriesFacet = new AssetEntriesFacet(searchContext);
assetEntriesFacet.setStatic(true);
searchContext.addFacet(assetEntriesFacet);
Facet scopeFacet = new ScopeFacet(searchContext);
scopeFacet.setStatic(true);
searchContext.addFacet(scopeFacet);
 
List<FacetConfiguration> facetConfigurations = FacetConfigurationUtil.load(undergraduateSearchConfiguration);
for (FacetConfiguration facetConfiguration : facetConfigurations) {
    Facet facet = FacetFactoryUtil.create(searchContext, facetConfiguration);
    searchContext.addFacet(facet);
}
Indexer indexer = FacetedSearcher.getInstance();
Hits hits = indexer.search(searchContext);
documents = hits.toList()
 
Blogs
Javeed, thank you for taking the time to share this. I've tried many times in the past to have a mega menu but was never successful. Your sharing of this code and approach is much appreciated.