Advanced Web Content Example with AJAX

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!

Blogs
Thanks for getting this up so quickly. So,... If instead of a Velocity template we were working in XSL could we accomplish the same thing? I'm not so much interested in the generic XSL required, but the way an XSL template interacts with the Liferay environment. For instance the Velocity template above depends on a lot of Java objects, are those all available if you use XSL ? If so, how do you get access to them (and their methods)? Thanks again, it was a good talk.
You can access all the same things from XSL. You just have to know your way around the xalan java language support.
Thanks. I had not found any documentation on this previously, but know that I know Liferay is using Xalan, a number of articles pop up when I do a search on that term. Thanks again.
I added the same template implemented as XSLT for you!

It's completely functionally equivalent.
Hey, cool! Thanks, that'll save me a lot of head scratching!
Note that while you're working with XSL, that there is a small bug in the error processing xsl template which causes your template errors to be swallowed up and never made available.

copy portal-impl.jar!/com/liferay/portlet/journal/dependencies/error.xsl to your WEB-INF/classes folder (with full path) and remove the $companyId variable of the languageUtil:get method.

That will also save you lots of head scratching.
Great article .. i will surely try it out .. thanks ray..
On this line of thinking with WCM...

Like many windows applications having an embedded FileBrowser to pick a file to use in the application, how would you create a custom portlet that uses the existing WCM portlet as a means to create and choose a wcm entry to embed in the custom portlet. Any pointers would be greatly appreciated, since I don't want to recreate the entire functionality of the WCM portlet from scratch.
The RSS portlet does exactly this using a special tag so you can embed a custom header and/or footer to it. The tag is <liferay-ui:journal-article /> (see the code of the RSS portlet, it's pretty simple. There is also the example in the configuration.jsp on how to make a selection of a WCM article and store it in a preference.)
Thanks Ray, you are always so speedy with responses. I'll take a look.
Thanks Again!
One other question, is there anyway to do the same kind of thing with the Document Library or with the Image Library (File Chooser/Viewer) functionality?
As soon s I get a chance I'll give an example of this. We don't have any canned examples that I'm aware of, but we should and so I'll make sure to come up with some shortly.
Hey Ray,

Have you had a chance to develop any examples for as you spoke of above?
....
One other question, is there anyway to do the same kind of thing with the Document Library or with the Image Library (File Chooser/Viewer) functionality?

Thanks,
Richard
I was trying really hard to actually get this into 6.1 as prepackaged components that you could just use. Unfortunately I did not get to do it.

That being said, there is a partial component in 6.1 that could probably be re-purposed as a general solution (which is currently dependent on the control panel). That is the Asset Relation chooser (you can find this in the Related Assets section located in the editors of various assets). It let's you choose from any Asset type to create relations and returns reference information. But if it could be re-worked to specify the types available in particular instance (say limited to doclib references), then it could become very useful.

Now, if you're trying to do this in 6.0 or earlier, the best possible examples are those found in the Web Content article editor. You have fields of type Document Library and Image Gallery with associated chooser. These are tied to a particular implementation for that portlet, but they are the best ootb examples that I can think of.

Good luck Richard and my apologies for the very late reply!
I am developing a war portlet that should allow the user to select a web content and store it in a preference.

I'm taking as a model the RSS portlet as you suggested, but rss/select_journal_article.jspf imports asset_publisher/article_search.jsp that uses ArticleSearch and ArticleDisplayTerms classes wich are in portal-impl.jar.

Is there a way to make that work without including portal-impl.jar in the portlet war?

Thanks!
Oh! never include portal-impl.jar!

Regarding those two classes, I've been meaning to do something like move this into the portal-service.jar.

While not an ideal solution, for the short term I would simply take those two java classes and pull them into your plugin. I don't think they have any dependencies that are not already public, except from one small exception that could be solved by taking one method from JournalIUtil and pulling into the ArticleSearch class directly.

The long term fix is of course to implement the nice asset choosers we talked about.
[...] http://www.liferay.com/web/raymond.auge/blog/-/blogs/advanced-web-content-example-with-ajax... [...] Read More
Great article, I've learned a lot from it.
But instead of the webcontent/name attribute, how I do access the webcontent title, not the structure name attribute?
Thanks!
Not sure what I'm doing wrong! I was so hoping this would be the answer to my issue of displaying multiple web content based on the same Structure. I have followed the instructions and understand most of what is going on, but I never get any content listing, even though I have added two WC using the structure 'WIDGET'. I did change the structureId in the code to what I called mine ('WIDGET') but still get nothing showing up. After refreshing the page the 'No Widgets' does appear briefly but then disappears (yes I did deselect the 'Cacheable' option). I would have thought that if no results are found then this information would continue to display. Is there setting I'm missing? (I have tried in 6.0.10 EE, 6.0.6 CE and 6.1 CE all with same results)
I also tried using the XSLT version as well with the same results. Any suggestions.
Hi Ray! I am using Liferay 6.1. I am trying to make a web content using structure and templates. I have choosed lang xsl for templates. I have one textfield and 1 image in structure. But when i preview web content it shows internal server error. From logs i got this:
08:39:42,949 INFO [PortalImpl:4873] Current URL /c/journal/view_article_content?cmd=preview&groupId=19&articleId=14248&version=1.0 generates exception: com.liferay.portal.kernel.templateparser.TransformException: Unhandled exception

Below is my xsl:
<?xml version="1.0"?>

<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
<xsl:output method="html" omit-xml-declaration="yes"/>
<xsl:template match="/">
<html>
<body>
<xsl:value-of disable-output-escaping="yes" select="root/dynamic-element[@name='textarea']/dynamic-content"/>
<br/>
<img>
<xsl:attribute name="src">
<xsl:value-of disable-output-escaping="yes" select="root/dynamic-element[@name='image']/dynamic-content"/>
</xsl:attribute>
</img>
</body>
</html>
</xsl:template>
</xsl:stylesheet>

I have copied portal-impl.jar!/com/liferay/portlet/journal/dependencies/error.xsl to tomcat/weebapps/Root/WEB-INF/classes folder but have not changed anything because 2 things are not clear to me 1.) what to do with full path 2.) not geeting how to remove $companyId variable of the languageUtil:get method because what i can found is only $companyId variable.
Can you shed some light on this?
Not sure what ime doing wrong about this... my response data is always null.

I tried return it in a json format in the RESOURCE_PHASE doing someting like
{
"jsonArray": $jsonArray
}

with no luck i removed it and tried with

#set ($V = $jsonArray.put($jsonObject))
$jsonFactory.serialize($jsonArray)

but still no luck. here is my call and alert that tells me the responseData is null.

AUI().use(
"aui-base", "aui-io-plugin", "aui-io-request",
function(A) {
A.io.request(
"${request.resource-url}",
{
data: {
},
dataType: "json",
on: {
success: function(event, id, obj) {
alert(this.get('responseData'));

},
failure: function(event, id, obj) {
alert("ajax call failure ");
}
}
}
);
}
);

any thoughts?
Hi Ray, it's amazing topic. But I have a question: I always receive null object "hits" when running this web content. What is the problem?