This example demonstrates several advanced features of Liferay's Web Content Management provided when taking full advantage of Liferay's Web Content Template processing engine. It'll demonstrate implementing PHASES of the portlet lifecycle, performing AJAX calls, using Alloy Javascript Library, using Liferay's SearchEngine, converting java objects to JSON for passing back as AJAX response body all from within our templates.
It starts with a structured piece of content we'll call a Widget, defined by the following Web Content Structure:
<root> <dynamic-element name='name' type='text' index-type='text' repeatable='false'> <meta-data> <entry name="displayAsTooltip"><![CDATA[true]]></entry> <entry name="required"><![CDATA[false]]></entry> <entry name="instructions"><![CDATA[Enter plain text only.]]></entry> <entry name="label"><![CDATA[Widget Name]]></entry> <entry name="predefinedValue"><![CDATA[]]></entry> </meta-data> </dynamic-element> <dynamic-element name='description' type='text_box' index-type='text' repeatable='false'> <meta-data> <entry name="displayAsTooltip"><![CDATA[true]]></entry> <entry name="required"><![CDATA[false]]></entry> <entry name="instructions"><![CDATA[Prefer to use plain text here, at worst use only simple HTML tags like strong, em, etc.]]></entry> <entry name="label"><![CDATA[Description]]></entry> <entry name="predefinedValue"><![CDATA[]]></entry> </meta-data> </dynamic-element> <dynamic-element name='image' type='document_library' index-type='keyword' repeatable='false'> <meta-data> <entry name="displayAsTooltip"><![CDATA[true]]></entry> <entry name="required"><![CDATA[false]]></entry> <entry name="instructions"><![CDATA[]]></entry> <entry name="label"><![CDATA[Widget Image]]></entry> <entry name="predefinedValue"><![CDATA[]]></entry> </meta-data> </dynamic-element> <dynamic-element name='document' type='document_library' index-type='keyword' repeatable='false'> <meta-data> <entry name="displayAsTooltip"><![CDATA[true]]></entry> <entry name="required"><![CDATA[false]]></entry> <entry name="instructions"><![CDATA[Link a PDF document preferably.]]></entry> <entry name="label"><![CDATA[Widget Document]]></entry> <entry name="predefinedValue"><![CDATA[]]></entry> </meta-data> </dynamic-element> </root>
Make sure that each field of the structure is marked as "Searchable" as either text or token otherwise later on we won't be able to individually access those fields. The template for this structure might look like this (but it's not overly important for this example):
<h3>$name.data</h3> <p><img style="float: left;" src="$image.data" alt="$name.data"/> $description.data</p> <a href="$document.data">Spec Sheet</a>
Now given that we have a few pieces of content using this structure, our goal is to create an enhanced view that leverage AJAX in order to be cool... er fullfill our business requirements!
So, we're going to create a structure to act as the settings schema for a simple Web Content app:
<root> <dynamic-element name='number-of-items' type='text' index-type='' repeatable='false'> <meta-data> <entry name="displayAsTooltip"><![CDATA[true]]></entry> <entry name="required"><![CDATA[false]]></entry> <entry name="instructions"><![CDATA[How many items to retrieve with ajax.]]></entry> <entry name="label"><![CDATA[Number of items]]></entry> <entry name="predefinedValue"><![CDATA[]]></entry> </meta-data> </dynamic-element> </root>
Finally, we get to the good part which is the template of the Web Content app. I've implemented this example using Velocity just because it's the one I'm most comfortable with. The template implements both the front end logic as well as the backend that will handle our AJAX request. Remember when writing templates that implement request handling to unckeck "Cacheable".
#set ($ns = $request.portlet-namespace) #set ($companyId = $getterUtil.getLong($request.theme-display.company-id)) #set ($scopeGroupId = $getterUtil.getLong($request.theme-display.scope-group-id)) #set ($numberOfItems = $getterUtil.getInteger($number-of-items.data)) #if ($request.lifecycle == 'RESOURCE_PHASE') ## This phase will handle the ajax request. #else ## This phase (default is 'RENDER_PHASE') will handle the view. #end
Let's look at the view logic and the AJAX call that drives it! It uses Liferay's very own Alloy Javascript Library to perform the AJAx call.
...
#else
<table class="taglib-search-iterator">
<thead>
<tr class="portlet-section-header results-header">
<th>
Widgets
</th>
</tr>
</thead>
<tbody class="${ns}results-container">
<tr class="portlet-section-body results-row last">
<td>
No Widgets
</td>
</tr>
</tbody>
</table>
<script type="text/javascript">
AUI().use(
'aui-base', 'aui-io',
function(A) {
var search = function(eventType) {
A.io.request(
'${request.resource-url}',
{
dataType: 'json',
on: {
success: function(event, id, obj) {
var instance = this;
var hits = instance.get('responseData');
var resultsContainer = A.one('.${ns}results-container');
if (!hits && !hits.docs) {
return;
}
resultsContainer.empty();
for (var i = 0; i < hits.docs.length; i++) {
var doc = hits.docs[i];
console.log(doc);
var title = doc.fields.map['web_content/name'].value || doc.fields.map.uid.value;
var description = doc.fields.map['web_content/description'].value;
var image = doc.fields.map['web_content/image'].value;
var document = doc.fields.map['web_content/document'].value;
var position = ' portlet-section-body';
if (i % 2 == 1) {
position = ' portlet-section-alternate alt';
}
if (i == 0) {
position += ' first';
}
else if (i == hits.length - 1) {
position += ' last';
}
resultsContainer.append(
[
'<tr class="results-row' + position + '">',
'<td>',
'<h3>',
title,
'</h3>',
'<p>',
'<img style="float: left;" src="',
image,
'" alt="',
name,
'"/>',
//description,
'</p>',
'<a href="',
document,
'">Spec Sheet</a>',
'</td>',
'</tr>'
].join('')
);
}
}
}
}
);
}
search();
}
);
</script>
#end
You'll notice that the bulk of the code lies in javascript processing the results into the table. I'm not the greatest of javascript developers so take my code with a grain of salt.
Also notice that the target of the AJAX call is a url generated from the request and is in fact one which invokes the current portlet in the "RESOURCE_PHASE". If you aren't familiar with portlets, the "RESOURCE_PHASE" is one which allows portlets to return output without the wrappings and trappings of the surrounding portal. Effectively the OutputStream or PrintWriter used by the portlet is not touched or altered in any way by the portal. This allows the portlet to do things like handle AJAX requests, or generally server any type of "static" resource, like images.
Finally, see how the value of each individual structure field is retrieved from the JSON object: doc.fields.map['web_content/name'].value. Each field that is marked as "Searchable" when creating the structure can be retrieved from the SearchEngine result prefixed by web_content/. The prefix exists so that dynamically created fields don't collide with fields of the actual Web Content Article object when indexed.
The final step is getting our content! To do that we'll invoke and query Liferay's SearchEngine from within the template and convert the results into JSON format which we will return as the response body of the request.
...
#if ($request.lifecycle == 'RESOURCE_PHASE')
#set ($portalBeanLocator = $portal.getClass().forName('com.liferay.portal.kernel.bean.PortalBeanLocatorUtil'))
#set ($searchEngine = $portalBeanLocator.locate('com.liferay.portal.kernel.search.SearchEngineUtil'))
#set ($queryFactory = $portalBeanLocator.locate('com.liferay.portal.kernel.search.BooleanQueryFactoryUtil'))
#set ($sortFactory = $portalBeanLocator.locate('com.liferay.portal.kernel.search.SortFactoryUtil'))
#set ($jsonFactory = $portalBeanLocator.locate('com.liferay.portal.kernel.json.JSONFactoryUtil'))
#set ($fullQuery = $queryFactory.create())
#set ($contextQuery = $queryFactory.create())
#set ($V = $contextQuery.addRequiredTerm('companyId', $companyId))
#set ($V = $contextQuery.addExactTerm('entryClassName', 'com.liferay.portlet.journal.model.JournalArticle'))
#set ($V = $contextQuery.addRequiredTerm('groupId', $scopeGroupId))
#set ($V = $contextQuery.addRequiredTerm('structureId', '10628'))
#set ($V = $fullQuery.add($contextQuery, 'MUST'))
#set ($sorts = $sortFactory.getDefaultSorts())
#set ($hits = $searchEngine.search($companyId, $fullQuery, $sorts, 0, ))
$jsonFactory.serialize($hits)
#else
...
What I've done here is create a SearchEngine query which limits the results to a specific structureId. This limitation is only imposed because I chose to limit the logic of the example to only support this one structure. It is entirely possible to query for arbitrary content types (even beyond just Web Content) as long as you are willing to implement the view logic to handle those.
Here's an image of what it might look like.
The complete template follows:
#set ($ns = $request.portlet-namespace)
#set ($companyId = $getterUtil.getLong($request.theme-display.company-id))
#set ($scopeGroupId = $getterUtil.getLong($request.theme-display.scope-group-id))
#set ($numberOfItems = $getterUtil.getInteger($number-of-items.data))
#if ($request.lifecycle == 'RESOURCE_PHASE')
#set ($portalBeanLocator = $portal.getClass().forName('com.liferay.portal.kernel.bean.PortalBeanLocatorUtil'))
#set ($searchEngine = $portalBeanLocator.locate('com.liferay.portal.kernel.search.SearchEngineUtil'))
#set ($queryFactory = $portalBeanLocator.locate('com.liferay.portal.kernel.search.BooleanQueryFactoryUtil'))
#set ($sortFactory = $portalBeanLocator.locate('com.liferay.portal.kernel.search.SortFactoryUtil'))
#set ($jsonFactory = $portalBeanLocator.locate('com.liferay.portal.kernel.json.JSONFactoryUtil'))
#set ($fullQuery = $queryFactory.create())
#set ($contextQuery = $queryFactory.create())
#set ($V = $contextQuery.addRequiredTerm('companyId', $companyId))
#set ($V = $contextQuery.addExactTerm('entryClassName', 'com.liferay.portlet.journal.model.JournalArticle'))
#set ($V = $contextQuery.addRequiredTerm('groupId', $scopeGroupId))
#set ($V = $contextQuery.addRequiredTerm('structureId', '10628'))
#set ($V = $fullQuery.add($contextQuery, 'MUST'))
#set ($sorts = $sortFactory.getDefaultSorts())
#set ($hits = $searchEngine.search($companyId, $fullQuery, $sorts, 0, $numberOfItems))
$jsonFactory.serialize($hits)
#else
<table class="taglib-search-iterator">
<thead>
<tr class="portlet-section-header results-header">
<th>
Widgets
</th>
</tr>
</thead>
<tbody class="${ns}results-container">
<tr class="portlet-section-body results-row last">
<td>
No Widgets
</td>
</tr>
</tbody>
</table>
<script type="text/javascript">
AUI().use(
'aui-base', 'aui-io',
function(A) {
var search = function(eventType) {
A.io.request(
'${request.resource-url}',
{
dataType: 'json',
on: {
success: function(event, id, obj) {
var instance = this;
var hits = instance.get('responseData');
var resultsContainer = A.one('.${ns}results-container');
if (!hits && !hits.docs) {
return;
}
resultsContainer.empty();
for (var i = 0; i < hits.docs.length; i++) {
var doc = hits.docs[i];
console.log(doc);
var title = doc.fields.map['web_content/name'].value || doc.fields.map.uid.value;
var description = doc.fields.map['web_content/description'].value;
var image = doc.fields.map['web_content/image'].value;
var document = doc.fields.map['web_content/document'].value;
var position = ' portlet-section-body';
if (i % 2 == 1) {
position = ' portlet-section-alternate alt';
}
if (i == 0) {
position += ' first';
}
else if (i == hits.length - 1) {
position += ' last';
}
resultsContainer.append(
[
'<tr class="results-row' + position + '">',
'<td>',
'<h3>',
title,
'</h3>',
'<p>',
'<img style="float: left;" src="',
image,
'" alt="',
name,
'"/>',
//description,
'</p>',
'<a href="',
document,
'">Spec Sheet</a>',
'</td>',
'</tr>'
].join('')
);
}
}
}
}
);
}
search();
}
);
</script>
#end
Here it is again as XSLT!
<?xml version="1.0"?>
<xsl:stylesheet version="1.0"
xmlns:BooleanQueryFactoryUtil="xalan://com.liferay.portal.kernel.search.BooleanQueryFactoryUtil"
xmlns:JSONFactoryUtil="xalan://com.liferay.portal.kernel.json.JSONFactoryUtil"
xmlns:BooleanQuery="xalan://com.liferay.portal.kernel.search.BooleanQuery"
xmlns:SearchEngineUtil="xalan://com.liferay.portal.kernel.search.SearchEngineUtil"
xmlns:SortFactoryUtil="xalan://com.liferay.portal.kernel.search.SortFactoryUtil"
xmlns:str="http://exslt.org/strings"
xmlns:xalan="http://xml.apache.org/xalan"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
exclude-result-prefixes="xalan"
extension-element-prefixes="BooleanQueryFactoryUtil JSONFactoryUtil BooleanQuery SearchEngineUtil SortFactoryUtil str xalan">
<xsl:output method="text" omit-xml-declaration="yes" />
<xsl:param name="groupId" />
<xsl:variable name="request" select="/root/request" />
<xsl:variable name="ns" select="$request/portlet-namespace" />
<xsl:variable name="companyId" select="$request/theme-display/company-id" />
<xsl:variable name="scopeGroupId" select="$request/theme-display/scope-group-id" />
<xsl:variable name="numberOfItems" select="/root/dynamic-element[@name='number-of-items']/dynamic-content" />
<xsl:template name="out" match="@*|node()">
<xsl:value-of select="local-name()"/><xsl:text> = </xsl:text>
<xsl:copy>
<xsl:apply-templates select="@*|node()"/>
</xsl:copy>
</xsl:template>
<xsl:template match="/">
<xsl:choose>
<xsl:when test="$request/lifecycle = 'RESOURCE_PHASE'">
<xsl:variable name="fullQuery" select="BooleanQueryFactoryUtil:create()" />
<xsl:variable name="contextQuery" select="BooleanQueryFactoryUtil:create()" />
<xsl:variable name="void1" select="BooleanQuery:addRequiredTerm($contextQuery, 'companyId', $companyId)" />
<xsl:variable name="void2" select="BooleanQuery:addExactTerm($contextQuery, 'entryClassName', 'com.liferay.portlet.journal.model.JournalArticle')" />
<xsl:variable name="void3" select="BooleanQuery:addRequiredTerm($contextQuery, 'groupId', $scopeGroupId)" />
<xsl:variable name="void4" select="BooleanQuery:addRequiredTerm($contextQuery, 'structureId', '10628')" />
<xsl:variable name="void5" select="BooleanQuery:add($fullQuery, $contextQuery, 'MUST')" />
<xsl:variable name="sorts" select="SortFactoryUtil:getDefaultSorts()" />
<xsl:message>
<xsl:value-of select="$numberOfItems" />
</xsl:message>
<xsl:variable name="hits" select="SearchEngineUtil:search(number($companyId), $fullQuery, $sorts, number(0), number($numberOfItems))" />
<xsl:value-of select="JSONFactoryUtil:serialize($hits)" />
</xsl:when>
<xsl:otherwise>
<xsl:text disable-output-escaping="yes"><![CDATA[
<table class="taglib-search-iterator">
<thead>
<tr class="portlet-section-header results-header">
<th>
Widgets
</th>
</tr>
</thead>
<tbody class="]]></xsl:text><xsl:value-of select="$ns" /><xsl:text disable-output-escaping="yes"><![CDATA[results-container">
<tr class="portlet-section-body results-row last">
<td>
No Widgets
</td>
</tr>
</tbody>
</table>]]></xsl:text><xsl:text disable-output-escaping="yes"><![CDATA[
<script type="text/javascript">
AUI().use(
'aui-base', 'aui-io',
function(A) {
var search = function(eventType) {
A.io.request(
']]></xsl:text><xsl:value-of disable-output-escaping="yes" select="$request/resource-url" /><xsl:text disable-output-escaping="yes"><![CDATA[',
{
dataType: 'json',
on: {
success: function(event, id, obj) {
var instance = this;
var hits = instance.get('responseData');
var resultsContainer = A.one('.]]></xsl:text><xsl:value-of select="$ns" /><xsl:text disable-output-escaping="yes"><![CDATA[results-container');
resultsContainer.empty();
if (!hits && !hits.docs) {
return;
}
for (var i = 0; i < hits.docs.length; i++) {
var doc = hits.docs[i];
var title = doc.fields.map['web_content/name'].value || doc.fields.map.uid.value;
var description = doc.fields.map['web_content/description'].value;
var image = doc.fields.map['web_content/image'].value;
var document = doc.fields.map['web_content/document'].value;
var position = ' portlet-section-body';
if (i % 2 == 1) {
position = ' portlet-section-alternate alt';
}
if (i == 0) {
position += ' first';
}
else if (i == hits.length - 1) {
position += ' last';
}
resultsContainer.append(
[
'<tr class="results-row' + position + '">',
'<td>',
'<h3>',
title,
'</h3>',
'<p>',
'<img style="float: left;" src="',
image,
'" alt="',
name,
'"/>',
//description,
'</p>',
'<a href="',
document,
'">Spec Sheet</a>',
'</td>',
'</tr>'
].join('')
);
}
}
}
}
);
}
search();
}
);
</script>]]></xsl:text>
</xsl:otherwise>
</xsl:choose>
</xsl:template>
</xsl:stylesheet>
Here's hoping someone finds this useful!

