I'd like to point out at the outset that the content of this post may be mundane for the seasoned Liferay developer. What I really want to demonstrate is the ease with which the problem (defined below) can be solved quickly and easily through use of the portal's architectural mechanisms and APIs.
The Problem
Customize the out-of-the-box Search portlet's presentation of results to show each row as a course card; note that our searches are restricted to course content only.
A duly smudged-out page specification is shown below.

Each course card in the above is one search result. When I first came across this requirement, I hopped onto my local portal instance and dropped the Liferay out-of-the-box Search portlet onto a page. Here's what you would see (taken from the Liferay Developer Network).

Two major gaps that needed bridging jumped out at me:
- The search criteria had to be moved to the right-hand-side.
- The list of results was row-wise, but had to be transformed into a course card view, with each course item corresponding to a row.
A quick examination of the portlet configuration did not seem helpful. So I made the foregone conclusion that we would need to write a custom search portlet. We had the HTML from the designers, we had the necessary APIs at our disposal. We even had the codebase of the search portlet as a reference for the faceted search implementation as well as the search criteria presentation. All set to get started.
But our estimates were far less than ideal. We needed this thing implemented fast!
Hooked on a Solution
And then I read up on hooks, in particular the JSP hook. Using a JSP hook, I could alter any code in the actively deployed search portlet, running here: [liferay]/webapps/ROOT/html/portlet/search
So I created the hook and added search.jsp to my META-INF (more on hooks here). After about 30 minutes of studying search.jsp and one simple tweak, the search criteria was on the right-hand-side. Great!
So, the only challenge left now was the resultset presentation.
A Template called Course Card
I had to disengage from the search portlet and focus on the content architecture of the site. An increased familiary with structures and templates ensued. Among these, I had a course structure and a course template.
Pretty soon, I found myself coding up an Application Display Template (ADT) for use with an Asset Publisher portlet instance. The goal was to show courses that matched certain critera. In this template, I iterate through the assets (essentially course content items) and for each asset, I make xpath calls to acquire the various attributes of the course into a bunch of variables. And then, using an HTML fragment I was handed by the designers, I replaced the data placeholders with the corresponding variables. I called the ADT Course Card. It looked something like the below.
The ADT
<div class="row-fluid"><div class="span12"><h2 class="heading-secondary">Courses</h2></div></div>#set ($rowcounter = 0)#if (!$entries.isEmpty())<div class="row-fluid">#foreach ($curEntry in $entries)#set ($rowcounter = $rowcounter + 1)#set( $renderer = $curEntry.getAssetRenderer() )#set( $link = $renderer.getURLViewInContext($renderRequest, $renderResponse, ''))#set( $journalArticle = $renderer.getArticle() )#set ($articlePrimKey = $journalArticle.getResourcePrimKey())#set ($catLocalService = $serviceLocator.findService("com.liferay.portlet.asset.service.AssetCategoryLocalService"))#set ($catPropertyLocalService = $serviceLocator.findService("com.liferay.portlet.asset.service.AssetCategoryPropertyLocalService"))#set ($articleCats = $catLocalService.getCategories("com.liferay.portlet.journal.model.JournalArticle", $articlePrimKey))#set ($qualificationLevelAttribute = $journalArticle.getExpandoBridge().getAttribute("qualificationLevel"))#set ($studyModeAttribute = $journalArticle.getExpandoBridge().getAttribute("studyMode"))#set ($locationAttribute = $journalArticle.getExpandoBridge().getAttribute("location"))#set ($primaryFaculty = $journalArticle.getExpandoBridge().getAttribute("primaryFaculty"))#set( $document = $saxReaderUtil.read($journalArticle.getContent()) )#set( $rootElement = $document.getRootElement() )### get any content fields you need using XPath#set( $courseNameSelector = $saxReaderUtil.createXPath("dynamic-element[@name='txtCourseName']") )#set( $courseName = $courseNameSelector.selectSingleNode($rootElement).getStringValue() )### get any custom fields as below#set ($durationMax = $journalArticle.getExpandoBridge().getAttribute("durationMax"))#set ($durationMin = $journalArticle.getExpandoBridge().getAttribute("durationMin"))####### skipping a bunch of irrelevant code that extracts several other fields and custom fields into various variables. ######################### Display the course data in a card format ###########################<div class="span4 block-vspace"><div class="card-container $cardCss.value"><a href="$link" class="card"><div class="card-content-container"><p class="card-category-title">$faculty</p><div class="card-content"><h4 class="card-title fade in">$courseName</h4><p><strong>$qualificationLevels</strong></p><p class="small">NQF Level $nqfLevel, SAQA No. $saqaNumber</p><p>$curEntry.summary</p><hr><p class="small">Offered as: <strong>$studyModes</strong></p><p class="small">Minimum duration: <strong>$durationMin</strong><br>Maximum duration: <strong>$durationMax</strong></p><p class="small"><strong>$locations</strong></p></div><hr class="card-horizontal-rule"><button class="card-btn-link btn btn-link btn-block"><strong>View course <i class="fa fa-chevron-right fa-sm"></i></strong></button></div></a></div></div>#if ($rowcounter % 3 == 0)</div><div class="row-fluid"> </div><div class="row-fluid">#end#end</div>#end
Hmm. Now, that was what I wanted each of my search results to look like. The difference was I wouldn't be working within the Asset Publisher's query framework, but rather within the search portlet's.
I already had web content items (aka JournalArticles) for the courses. And I already had a Course Detail template (a web content template, not to be confused with ADT) that showed ALL of the course details in one presentation. I could now craft a new Course Card content template that shadowed my Course Card ADT. You can see how this was a bit backwards in that the ADT was more complex to code up. The content template to display the course card was more straightforward because I had easy acces to the fields in the course type structure. This is what my Course Card content template looked like. Notice the similarity barring the XPATH syntax in the ADT to retrieve content fields.
The content template
############### get vocabulary ID's #####################set ($vocabularyLocalService = $serviceLocator.findService("com.liferay.portlet.asset.service.AssetVocabularyLocalService"))#set ($vocabularies = $vocabularyLocalService.getGroupVocabularies($getterUtil.getLong($groupId)))### we are interested in 2 specific vocabularies - Schools and StudyLevels#foreach($voc in $vocabularies)#if ($voc.name == "Faculty")#set ($facultyVocabularyId = $voc.vocabularyId)#end#end#set ($journalArticleLocalService = $serviceLocator.findService("com.liferay.portlet.journal.service.JournalArticleLocalService"))#set ($journalArticle = $journalArticleLocalService.getArticle($getterUtil.getLong($groupId), $reserved-article-id.data))#set ($articlePrimKey = $journalArticle.getResourcePrimKey())#set ($catLocalService = $serviceLocator.findService("com.liferay.portlet.asset.service.AssetCategoryLocalService"))#set ($catPropertyLocalService = $serviceLocator.findService("com.liferay.portlet.asset.service.AssetCategoryPropertyLocalService"))#set ($articleCats = $catLocalService.getCategories("com.liferay.portlet.journal.model.JournalArticle", $articlePrimKey))#set ($qualificationLevelAttribute = $journalArticle.getExpandoBridge().getAttribute("qualificationLevel"))#set ($studyModeAttribute = $journalArticle.getExpandoBridge().getAttribute("studyMode"))#set ($locationAttribute = $journalArticle.getExpandoBridge().getAttribute("location"))#set ($primaryFaculty = $journalArticle.getExpandoBridge().getAttribute("primaryFaculty"))#set ($durationMin = $journalArticle.getExpandoBridge().getAttribute("durationMin"))#set ($durationMax = $journalArticle.getExpandoBridge().getAttribute("durationMax"))####### skipping a bunch of irrelevant code that extracts several other fields and custom fields into various variables. ######################### Display the course card data ###########################<div class="span4 block-vspace"><div class="card-container $cardCss.value"><a href="/-/$reserved-article-url-title.data" class="card"><div class="card-content-container"><p class="card-category-title">$faculty</p><div class="card-content"><h4 class="card-title fade in">$txtCourseName.getData()</h4><p><strong>$qualificationLevels</strong></p><p class="small">NQF Level $txtNqfLevel.getData(), SAQA No. $txtSaqaNumber.getData()</p><p>Designed to offer well-balanced exposure to the knowledge, skills and attitudes required to operate effectively in a general business environment. This qualification offers three majors, namely, Business Management, Marketing and Human Resources.</p><hr><p class="small">Offered as: <strong>$studyModes</strong></p><p class="small">Minimum duration: <strong>$durationMin</strong><br>Maximum duration: <strong>$durationMax</strong></p><p class="small"><strong>$locations</strong></p></div><hr class="card-horizontal-rule"><button class="card-btn-link btn btn-link btn-block" onclick="window.location.href='/-/$reserved-article-url-title.data;'"><strong>View course <i class="icon icon-chevron-right fa-sm"></i></strong></button></div></a></div></div>
With that in place, we were left with the task of using it, i.e retrieving the content (easy) and rendering it using this template from within the search portlet.
Back to the Search JSP hook, and Making it all Work
The only file I needed to touch was main_search.jspf. These were the tweaks.
- Fixed the search container delta to 9 so each page had no more than 9 results, after which default pagination would kick in.
- Added a scriptlet into the row container to load the
JournalArticlefor each course in the iteration. Retrieved the content string after transforming the course
JournalArticleusing the Course Card content template. The API method that does this for you is JournalArticleLocalServiceUtil.getArticleContent.- Write out the content to the response.
<%SearchContainer mainSearchSearchContainer = new SearchContainer(renderRequest, null, null, SearchContainer.DEFAULT_CUR_PARAM, 9, portletURL, null, null);SearchContext searchContext = SearchContextFactory.getInstance(request);mainSearchSearchContainer.setEmptyResultsMessage(LanguageUtil.format(pageContext, "no-results-were-found-that-matched-the-keywords-x", "<strong>" + HtmlUtil.escape(searchContext.getKeywords()) + "</strong>"));.........
<aui:col cssClass="result" first="<%= !showMenu %>" span="10"><%@ include file="/html/portlet/search/main_search_suggest.jspf" %><liferay-ui:search-containersearchContainer="<%= mainSearchSearchContainer %>"total="<%= hits.getLength() %>"><liferay-ui:search-container-resultsresults="<%= hits.toList() %>"/><liferay-ui:search-container-rowclassName="com.liferay.portal.kernel.search.Document"escapedModel="<%= false %>"keyProperty="UID"modelVar="document"stringKey="<%= true %>"></liferay-ui:search-container-row><div class="row-fluid"><c:forEach items="${hitsVar}" var="doc" varStatus="counter"><%int counter = 0;Document docItem = (Document) pageContext.getAttribute("doc");String idString = docItem.getField(com.liferay.portal.kernel.search.Field.ROOT_ENTRY_CLASS_PK).getValues()[0];long articleId = new Long(idString).longValue();com.liferay.portlet.journal.model.JournalArticle article = com.liferay.portlet.journal.service.JournalArticleLocalServiceUtil.getLatestArticle(articleId);String ddmTemplateKey = com.liferay.portal.kernel.util.PropsUtil.get("search.coursecard.templatekey");String content = null;try{content = com.liferay.portlet.journal.service.JournalArticleLocalServiceUtil.getArticleContent(article, ddmTemplateKey, "view", "en_US", themeDisplay);}catch(com.liferay.portlet.journal.NoSuchArticleException e){System.out.println("---> no article found with id " + idString);}%><%= content %><c:if test="${counter.count mod 3 == 0}"></div><div class="row-fluid"> </div><div class="row-fluid"></c:if></c:forEach></div><br style="clear:both"/><liferay-ui:search-iterator type="more" />......</liferay-ui:search-container></aui:col>
That's it!
In Conclusion
The bulk of the work done here was to:
- code a content template for each result (corresponding to a row in the search results)
- modify
main_search.jspf(through a hook) to retrieve the content markup from theJournalArticle, apply the new content template to it and include the result in the response
What you need to know
This is a quick (very quick) solution and should be good for the most part. But you need to know that Liferay 7 will not support JSP hooks. There is talk of (hopefully) a Migration Assistant that will transform the hook into its own OSGI module. However, my impression is that we should plan ahead and design/implement a custom portlet that incorporates the functionality of our hook(s). That way, when an upgrade to Liferay 7 knocks on our door, we'll actually be ready to invite it in.
Hope this was useful.


