First of all this post isn't really anything to do with Fabrik, but I'm going to expand the blog to talk about various Joomla 1.5 coding problems, solutions and best practices. Please feel free to correct, contradict and eventually even concur with what I'm saying in the comments!

These posts won't be about how to start coding Joomla components, there's lots of tutorials and documentation out there to get you started.

So this first post will address an issue that I have constantly struggled with and until today have never had a solution that I felt was totally acceptable.

Imagine, if you will, that we have a component with a list of items and that you want to create some neat little Ajax code to scroll through that list.

We also want the code to degrade nicely so that if the user doesn't have JavaScript enabled they can still scroll the list. Finally, and here is the part that took me a while to find what I consider an elegant solution we want to ensure that there is no duplication of code used to create the updated list items. In other words there is only one place in which the list's html is generated, regardless of whether we are calling the code via Ajax or not.

The component name that I'm basing this demo on is called 'Fabble' obviously where ever 'fabble' appears you would need to replace that with your own component name. Within the 'Fabble' component we're going to be displaying a list of groups

To ensure the degradation we will build the code in the usual fashion.

The Controller

First of all we'll need to create a controller class in components/com_fabble/controllers/groups.php

defined('_JEXEC') or die();
jimport( 'joomla.application.component.controller' );
class FabbleControllerGroups extends JController{
function display(){
$document =& JFactory::getDocument();
$viewName = JRequest::getVar( 'view', 'home', 'default', 'cmd' );
$modelName = $viewName;
$viewType = $document->getType();
// Set the default view name from the Request
$view = &$this->getView( $viewName, $viewType );
// Push a model into the view
$model = &$this->getModel( $modelName );
if (!JError::isError( $model ) && is_object( $model)) {
$view->setModel( $model, true );
}
// Display the view
$view->assign( 'error', $this->getError() );
return $view->display();
}
} ?>

This is pretty standard code for a controller class, where it sets up the model, creates the view and assigns the model to the view. I've come to the conclusion that its clearer to create a controller per section of your site rather than making on large controller for the entire component.

The view

Next we will create an empty view class in components/com_fabble/views/groups.html.php and get a reference to the document:

// Check to ensure this file is included in Joomla!
defined('_JEXEC') or die();
jimport('joomla.application.component.view');
class fabbleViewGroups extends JView {
function display($tpl = null) {
$document =& JFactory::getDocument();
}
} ?>

Now, inside the display method, we're going to get some variables from the group model:

$nav     =& $this->get( 'nav' );
$groups =& $this->get( 'Data' );

The view's get function maps to the corresponding groups model we assigned in the controller. The model file should be located in

components/com_fabble/models/groups.php and will look like this:

class FabbleModelGroups extends JModel {

/**@var array group objects */
var $groups = null;

/** @var int default num of groups to show per page */
var $_navLimit = 5;

/**
* get a list of groups
* * @return array group objects
*/

function getData()
{
if (!isset( $this->groups )) {
$query = $this->_buildQuery();
$limit = JRequest::getVar( 'limit', $this->_navLimit, '', 'int' );
$limitstart = JRequest::getVar( 'limitstart', 0, '', 'int' );
$this->groups = $this->_getList( $query, $limitstart, $limit );
}
return $this->groups;
}

/**
* build the query to get the list of groups
*
* @return string query
*/

function _buildQuery() {
return "SELECT *, g.id AS id, (SELECT COUNT(id) FROM #__fabble_person_groups\n".
" AS pg where pg.group_id = g.id) AS member_count, p.displayName as created_by_name\n".
" FROM #__fabble_groups AS g LEFT JOIN #__fabble_people AS p ON p.user_id = g.created_by ";
}

/**
* get the navigation for the group list
*
* @return object navigation
*/

function getNav() {
jimport('joomla.html.pagination');
$db =& JFactory::getDBO();
$db->setQuery( "SELECT count(*) FROM #__fabble_groups" );
$limit = JRequest::getVar( 'limit', $this->_navLimit, '', 'int' );
$limitstart = JRequest::getVar( 'limitstart', 0, '', 'int' );
$total = $db->loadResult();
return new JPagination( $total, $limitstart, $limit );
}
}

So, when the view calls $this->get( 'nav' ); it is in fact calling the models getNav() function, likewise the $this->get( 'Data' ) calls the models getData() function.

Now, back in the view, lets assign those variables to the view so they can be used within our template. As they are both objects we need to use the 'assignRef' method:

$this->assignRef( 'groups', $groups );
$this->assignRef( 'nav', $nav );

We also need to include the JavaScript classes we want to use:

    JHTML::script('groups.js', 'media/com_fabble/js/');
JHTML::script('ajaxnav.js', 'media/com_fabble/js/');

Then create an instance of our JavaScript class that is going to observe the navigation controls and manage the Ajax call to the server when the user interacts with those controls:

     $opts            = new stdClass();
$opts->nav = $nav->getProperties();
$opts->livesite = JURI::base();
$opts->page = JURI::current() . "?" . $_SERVER['QUERY_STRING'];
$opts = json_encode($opts);
$script = "window.addEvent('domready', function(){".
" new FabbleGroups($opts, {});". "});";
$document->addScriptDeclaration($script);

I'm presuming you have a recent version of PHP5 here that includes the json_encode function. Its best to make your json object in this fashion rather

than creating it by hand as it's less error prone. The last three lines add a script to the document's head which waits for the DOM to be ready before

creating an instance of our FabbleGroup JavaScript class (that's going to be in the file media/com_fabble/js/groups.js)

Finally we want to display our template:

parent::display($tpl);

So, just to recap, here's the full view:

// Check to ensure this file is included in Joomla!
defined('_JEXEC') or die();
jimport('joomla.application.component.view');
class fabbleViewGroups extends JView {
function display($tpl = null) {
$document =& JFactory::getDocument();
$nav =& $this->get( 'nav' );
$groups =& $this->get( 'Data' );
$this->assignRef( 'groups', $groups );
$this->assignRef( 'nav', $nav );
JHTML::script('groups.js', 'media/com_fabble/js/');
JHTML::script('ajaxnav.js', 'media/com_fabble/js/');
$opts = new stdClass();
$opts->nav = $nav->getProperties();
$opts->livesite = JURI::base();
$opts->page = JURI::current() . "?" . $_SERVER['QUERY_STRING'];
$opts = json_encode($opts);
$script = "window.addEvent('domready', function(){".
" new FabbleGroups($opts, {});".
"});";
$document->addScriptDeclaration($script);
parent::display($tpl); }
}
?>

 

The template code

Now lets move on to the template itself. I'm going to create two template files, you will see why I don't merge them into one file later on. The files are

components/com_fabble/views/group/tmpl/default.php
components/com_fabble/views/group/tmpl/default_items.php

Here's default.php's content:

<h1><?php echo JText::_('Groups') ?></h1>
<div id=”fabble-groups”>
<?php
$user =& JFactory::getUser();
if ($this->userid != 0) { ?>
<a class=”fabble-add” href=”index.php?option=com_fabrik&view=form&fabrik=<?php echo $this->groupFormid ?>”>
<?php echo JText::_(’Add group’); ?>
</a>
<?php }
?>
<ul class=”fabble-list”>
<?php echo $this->loadTemplate(’items’) ?>
</ul>
<div class=”fabble-nav”>
<?php print_r( $this->nav->getPagesLinks())?>
</div>
</div>)?>

Pretty straight forward right? The only interesting bit here is where we output the html generated from 'default_items' template with the code:

 <?php echo $this->loadTemplate(’items’) ?>

Now onto the meat of the template found in default_items.php

<?phpforeach($this->groups as $group){
?>
<li>
<h2><a href=”index.php?option=com_fabble&controller=group&id=<?php echo $group->id ?>”><?php echo $group->label ?></a></h2>

<div>
<p><?php echo JText::_(’Owner’) ?>: <?php echo $group->created_by_name ?></p>
<img src=”media/com_fabble/images/group.png” alt=”<?php echo JText::_(’Members’) ?>” />
<?php echo $group->member_count . ” ” . JText::_(’Members’);?>
</div>
</li>
<?php }?>

This will iterate over our group list and output a series of items.

At this point if you went to index.php?option=com_fabble&controller=groups you would see a list of groups which you could navigate through, but there would be no Ajax navigation. As our JavaScript will run on top of this, in a manner which is called progressive enhancement, and thus meets our initial requirement that the page should degrade correctly with JavaScript turned off.

 

A sprinkling of JavaScript magic

Now to add the JavaScript to control the page, we'll start by looking at media/com_fabble/js/groups.js

var FabbleGroups = new Class({
getOptions: function(){
return {
'nav':{},
'livesite':'',
'page':''
};
},
initialize: function(options){
this.setOptions(this.getOptions(), options);
this.showGroups = new Ajax('index.php?option=com_fabble&controller=groups&format=raw',
{
'data':{
},
'onComplete':function(d){
$('fabble-groups').getElement('ul').setHTML(d);
this.nav.updateNav();
}.bind(this)
});
this.nav = new AjaxNav('fabble-groups', {
'ajax':this.showGroups,
'nav':this.options.nav,
'page':this.options.page,
'livesite':this.options.livesite
});
} });
FabbleGroups.implement(new Events);
FabbleGroups.implement(new Options);

The initialize() function is called automatically when an instance of the class is created, which we have already done from within the view. The setOptions call will assign the json object we passed in from the view to the class instance's 'this.options' variable.

Next we create our Ajax object and assign it the this.showGroups variable. When triggered it will call the url 'index.php?option=com_fabble&controller=groups&format=raw' and once the Ajax request has completed it will run the 'onComplete' function.

Finally we create an instance of the navigation class. This is separate from the FabbleGroup class so that you can reuse it in other places within your component.

So, with no further ado, lets take a look at how it looks (it should be placed in /media/com_fabble/js/ajaxnav.js

/** *    observes the standard Joomla pagenavigation
and replaces its events with ajax loading events */
var AjaxNav = new Class({
getOptions: function(){
return {
'livesite':'', //live site url
'nav':{}, // the ajax function to run when the navigation is run
'total':0, //total number of records for the nav
'page':''
};
},

initialize: function(element, options){
this.setOptions(this.getOptions(), options);
this.element = $(element);
this._addSpinner();
this.watchNav();
},

_addSpinner: function() {
if(!this._getNav()){
return false;
}
if($type(this.navspinner) !== false){
return;
}
this.navspinner = new Element('img', {'styles':{'display':'none'}, 'src':this.options.livesite+'/media/com_fabble/images/ajax-loader.gif'});
new Element('li').adopt(this.navspinner).injectInside(this._getNav());
},

_getNav: function() {
return this.element.getElement('.pagination');
},

watchNav: function() {
var nav = this._getNav();
var jnav = this.options.nav;
if(!this._getNav()){
return;
}
var total = nav.getElements('li').length;
nav.getElements('li').each(function(li, x){
li.addEvent('click', function(e){
new Event(e).stop();
this._addSpinner();
this.navspinner.setStyle('display','inline');
if(li.getElement('a')){
switch(x){
case 1: //first ??
this.options.jnav.limitstart = 0
break;
case 2: //second??
jnav.limitstart = jnav.limitstart - jnav.limit;
break;
case total -4: //next
jnav.limitstart = jnav.limitstart + jnav.limit;
break;
case total - 3: //end
jnav.limitstart = jnav.total - jnav.total % jnav.limit;
break;
case total - 1: //spinner
return;
break;
default:
jnav.limitstart = ((x - 3) * jnav.limit);
break;
}
this.options.ajax.options.data.limitstart = jnav.limitstart;
this.options.ajax.request();
}
}.bind(this))
}.bind(this));
},
updateNav: function(){
var nav = this._getNav();
var jnav = this.options.nav;
this.navspinner.setStyle('display','none');
var total = nav.getElements('li').length;
nav.getElements('li').each(function(li, x){
switch(x){
case 0:
case total -1:
case total -2:
//ignore
break;
case 1:
case 2:
if(jnav.limitstart != 0){
if(!li.getElement('a')){
var span = li.getElement('span');
var title = span.getText();
li.empty();
if(x == 1){ s = 0;}else{ s = jnav.limitstart - jnav.limit;}
new Element('strong').adopt(
new Element('a', {'title':title, 'href':this.options.page + '&start='+s}).setText(title)).injectInside(li);
}
}else{

if(li.getElement('a')){
var title = li.getElement('a').getText();
li.empty();
new Element('span').setText(title).injectInside(li);
}

}
break;
case total -4:
case total -3:
if(jnav.limitstart != jnav.total - jnav.total % jnav.limit){
if(!li.getElement('a')){
var span = li.getElement('span');
var title = span.getText();
li.empty();
if(x == 1){ s = 0;}else{ s = jnav.limitstart - jnav.limit;}
new Element('strong').adopt(
new Element('a', {'title':title, 'href':this.options.page + '&start='+s}).setText(title)).injectInside(li);
} }else{
if(li.getElement('a')){
var title = li.getElement('a').getText();
li.empty();
new Element('span').setText(title).injectInside(li);
}
}
break;
default:
if(jnav.limitstart == (x-3) * jnav.limit){
if(li.getElement('a')){
var title = li.getElement('a').getText();
li.empty();
new Element('span').setText(title).injectInside(li);
}
}else{
if(!li.getElement('a')){
var span = li.getElement('span');
var title = span.getText();
li.empty();
if(x == 1){ s = 0;}else{ s = jnav.limitstart - jnav.limit;}
new Element('strong').adopt(
new Element('a', {'title':title, 'href':this.options.page + '&start='+s}).setText(title)).injectInside(li);
}
}
break;
}
}.bind(this))
} });
AjaxNav.implement(new Events);
AjaxNav.implement(new Options);

Wow, slighlty longer this class isn't it?

Lets discet the watchNav function, whose purpose is to override the standard navigation events with out own:

var nav = this._getNav();

Will get a refernence to the standard Joomla navigation list controls we added in the template.

 nav.getElements(’li’).each(function(li, x){ …. }

will get every <li> inside the navigation and iterate over each of those li's

 

li.addEvent('click', function(e){

Assigns a new event to each of the li's.

new Event(e).stop();

this will stop the event from bubbling, which in turn means that the navigation's standard code will not be run.
The following switch statement, whilst long winded, simply works out which button has been pressed and updates the jnav objects limistart value

We then update the ajax object's limitstart value we passed in from the FabbleGroup class to the new limitstart value

this.options.ajax.options.data.limitstart = jnav.limitstart;

and finally fire off the request

this.options.ajax.request();

A raw view

Remember that in the FabbleGroup Javascript class we had set the Ajax's object's url to:
'index.php?option=com_fabble&controller=groups&format=raw'

This will tell Joomla to render a raw view of the page we are currently on.
So we need to create a new file: components/com_fabble/views/groups/view.raw.php - note the 'raw' in the file name, this is the view that Joomla looks for as we passed in '&format=raw' in the Ajax request's querystring.

Here's the content of the file:

/**  * @package Joomla
* @subpackage Fabble
* @copyright Copyright (C) 2005 Rob Clayburn. All rights reserved.
* @license http://www.gnu.org/copyleft/gpl.html GNU/GPL, see LICENSE.php */
// Check to ensure this file is included in Joomla!
defined('_JEXEC') or die();
jimport('joomla.application.component.view');
class fabbleViewGroups extends JView {
function display($tpl = null) {
// Initialize variables
$this->assignRef( 'groups', $this->get( 'Data' ) );

parent::display(’items’);
}
} ?>

So again, we are getting the list of groups. As we have updated the limistart varaible when sending the Ajax request the data returned from this function will reflect the current page you have navigated to.

Then, instead of calling the main template ('default.php') we call 'default_items.php' with the function:

parent::display('items');

This has the effect of returning to the Ajax object's onComplete function only the updated <li>'s, precisely the content we are after.

The last step in updating the list is in the FabbleGroup Javascript class's this.shoWGroups onComplete function:

 

'onComplete':function(d){
$('fabble-groups').getElement('ul').setHTML(d);
this.nav.updateNav();
}.bind(this)

So we update the <li>'s html with that returned from the raw view and then tell the navigation object to update itself, basically this invloves iterating over all of the navigation lists items and setting or removing their links.

 

Other methods I considered

Sending JSON

I have to say that I didn't start out doing things in this manner. My first idea was to make the ajax call get a json object directly from the group controller, once the json data received the FabbleGroup JavaScript class would build the required elements and insert them into the list.

I liked this to start off with because the data passed back to the page was smaller in size. However, as soon as the content inside the list becomes complex its was quiet hard to write the javascript to create it, and if the template changed then I would need to update the javascript code to reflect those changes, so all in all a very brittle solution.

Javascript Agogo

So I knew that I had to have the html generated in one place, my first thought was that I could do it all within the javascript classes. This would involve passing pure json to the scripts which would then build the pages. Somewhere my love of JavaScript made me really consider this for a moment, then I realised that it would mean my users would have to have Javascript enabled and it would make adjusting the templates harder.

So to summarise I'm pretty darned happy with the soluton I have now. It degrades beautifully whilst still being flexible and robust.

{rokcomments}