It’s been a while since I had some time to write, but here it finally is: how to neatly filter your pagination with ajax.

Now this requires some javascript which you may or may not understand. I tried to keep it simple for implementing, yet flexible for those of you who want to modify, tweak or expand it. All in all it’s gotten a bit more advanced then I planned, but I hope it’s still useful.

Grab the latest JQuery

I’ve used JQuery 1.4.3. This should probably work with older versions too, as well as a bit newer as I don’t use any really special features, but just to be sure you could get the same version. Make sure you put it under the /web/js/ directory and really use it through the /apps/frontend/config/view.yml

javascripts: [jquery.js] # or however the javascrpit file is named

Create a FilterForm

Once again I’d recommend to do it neatly and create an abstract FilterForm to extend for different filters.


// /lib/form/doctrine/filter/FilterForm.class.php
abstract class FilterForm extends BaseForm
{
public function configure()
{
// filter has no label, but can instead use a help
$help_format = "
<div class="filter_help">%help%</div>
";
$row_format = "%help%%error%%field%%hidden_fields%
n";
$this->getWidgetSchema()->getFormFormatter()->setHelpFormat($help_format);
$this->getWidgetSchema()->getFormFormatter()->setRowFormat($row_format);

// filter name format
$this->getWidgetSchema()->setNameFormat('filter[%s]');
}

public function render($attributes = array())
{
$this->renderFilterJs();
return parent::render($attributes);
}

public function renderFilterJs()
{
sfContext::getInstance()->getConfiguration()->loadHelpers('Asset');
use_javascript('/js/jquery.js');
use_javascript('/js/filter.js');

// create an instance of the Filter class
echo '";
}
}

There’s a lot of things already in there. The configure() being the least important – that’s just some guidance on how to change the style on your filters. You could delete it for now or keep it if you want. It basically removes the labels and adds a class for the help.
The render function is already called and only adds the filterJs. The renderFilterJS is important: it adds the needed js. You may notice one oddity though. The “filter” var on the javascript side is never instanced. This I did so we can initiatilize the filter with the proper php variables in the template.

Create any implementation of FilterForm

This is just one possible implementation of a simple admin FilterForm:


// /lib/form/doctrine/filter/FilterAdminMemberForm.class.php
class FilterAdminMemberForm extends FilterForm
{
public function setup()
{
$choices = array(
0=>'All active users',
1=>'All deactivated users',
'all'=>'All users'
);
$this->setWidgets(array(
'deleted' => new sfWidgetFormChoice(array("choices"=>$choices), array("help"=>"Show deactivated users")),
'user_or_email' => new sfWidgetFormInputText(array(), array('help'=>"Filter by User or Email")
));

parent::setup();
}
}

Creating the template

The template will stay simple, because we will only add a generic function at the right place


// /apps/frontend/modules/admin_members/templates/indexSuccess.php
<?php $pager->renderFilter($form, url_for("admin_members_ajax_list")); ?>
<div id="data">// here comes the rest of your content</div>

Note that the $form will be an instance of FilterAdminMemberForm (above) and the url_for() needs to be the url that will accept the filter and responds with the proper html. Also note that the renderFilter form is a function of the myDoctrinePager from our previous post.

Extending myDoctrinePager

I split the additions to myDoctrinePager in two functions. The rendering of the javascript (basically instancing the javascript Filter class with the proper ajax url) and the renderFilter() which calls the renderJS and renders the global paging_filter template with our form.


// /apps/frontend/lib/myDoctrinePager.class.php
public function renderJS($url)
{
?>
<script type="text/javascript">
var filter = new Filter('data', '');
</script>
<?php }

public function renderFilter(sfForm $form, $ajax_url)
{
$this->renderJS($ajax_url);
echo $this->renderPartial('global/paging_filter', array("form"=>$form));
}

Creating the global template ‘paging_filter’

The global template lives in the /apps/frontend/templates directory and has only the basics for any form to fit.


<!-- /apps/frontend/templates/paging_filter.php -->
<?php use_stylesheets_for_form($form) ?>
<?php use_javascripts_for_form($form) ?>
<?php render(); ?>
<table class="form" width="100%">
<thead>
<tr>
<th colspan="2"><?php echo __("Search"); ?></th>
</tr>
</thead>
<tfoot>
<tr>
<td colspan="2">" onclick="filter.doFilter();" />
<?php echo __("reset search"); ?></td>
</tr>
</tfoot>
<tbody><?php echo $form ?></tbody>
</table>

As you can see this is a pretty basic form with only a strange button that, instead of submit, uses an onclick to actually use our javascript filter.

The action

The pager is basically the same as in our previous post. We only added the FilterAdminMemberForm() and the actual filtering.


public function executeIndex(sfWebRequest $request)
{
$this->form = new FilterAdminMemberForm();
$query = Doctrine_Core::getTable('User')

/* Filter by the getParameters() 'deleted' and 'user_or_email' (from the filter fields) */
// filter 'deleted' field
$deleted = $request->getParameter('deleted');
if ($deleted != 'all')
$query->addWhere('deleted = ?', (int)$deleted); // if unset it will become 0 (thus all active users)

// filter 'user_or_email' field
if ($var = $request->getParameter('user_or_email'))
{
$query->addWhere("display_name LIKE ? OR email LIKE ? OR first_name LIKE ? OR last_name LIKE ?",
array("%$var%", "%$var%", "%$var%", "%$var%"));
}

// Finally create the pager with the filtered query
$this->pager = $this->getPager($query);
}

/**
* Returns an initialized myDoctrinePager
* @param Doctrine_Query $query
* @return myDoctrinePager
*/
public function getPager(Doctrine_Query $query)
{
$pager = new myDoctrinePager($query);
$pager->setPage($this->getRequestParameter('page', 1));
$pager->init();
return $pager;
}

The JavaScript!

Finally after all the preperations we come to the actual javascript. You can use it as is with the implementation as written above. Or create your own implementation. I’ve tried to document the code, but if you’re not too familiar with javascript and JQuery you might not understand it…


/*
* A class to handle browser query strings such as test=1&blaat=test
*/
Query = function (q) {
q = q || window.location.search.substring(1); // default query

var query = {};
var arr = q.split("&");

// split current query to an associative array
$(arr).each(function (key, val) {
if (!val)
return;
var obj = val.split("=");
query[obj[0]] = obj[1];
});

this.query = query;
};

// change value of specific key
Query.prototype.set = function (key, val) {
this.query[key] = val;
};

// get value of specific key
Query.prototype.get = function (key) {
return this.query[key];
};

// return the object (associative array) as is
Query.prototype.toObject = function () {
return this.query;
};

// return the (changed) query object as a query string
Query.prototype.toString = function () {
if (jQuery.isEmptyObject(this.query))
return "";

var arr = [];
jQuery.each(this.query, function (key, val) {
arr.push(key+"="+val);
});

return arr.join("&");
};

/*
* Created for filtering pagination through ajax.
*/
Filter = function (container, ajax_url) {
this.ajax_url = ajax_url;
this.filters = {};
this.loader = jQuery('

Loading

');
this.loader.hide();
this.container = container;
this.loaded = false;
this.timeout = [];

_this = this;

// load rest of our script when the document is ready
jQuery(document).ready(function () {_this.load(); });
};
Filter.prototype.load = function () {
this.data = jQuery("#"+this.container);
jQuery(this.data).before(this.loader);

// this part recognizes if there's already an old filter present and parses it.
// we keep our search query for reloads by appending it behind #
if (window.location.href.indexOf("#") > -1)
{
var parts = window.location.href.split("#");
var query = parts[parts.length-1];
if (query.length > 0)
{
this.filters = new Query(query).toObject();
this.reload();

// also show the current values in the form
jQuery.each(this.filters, function (key, val) {
var obj = $('#filter_'+key)[0];
if (obj)
// tags
if (obj.tagName.toLowerCase() == "select")
jQuery.each(obj.options, function (k,elem) {
if (elem.value == val)
{>
obj.selectedIndex = k;
return false;
}
});
// tags
else if (obj.tagName.toLowerCase() == "input")
obj.value = val;
});
}
}

// this is where the onload is called (if it exists).
// in the current implementation the filter.onload is created inside the abstract FormFilter
this.loaded = true;
if (this.onload instanceof Function)
this.onload.call();
};

// add filter value by key
Filter.prototype.setFilter = function (key, val) {
this.filters[key] = val;
};

// get filter value by key
Filter.prototype.getFilter = function (key) {
return this.filters[key];
};

// helper to set the paging value and directly reload
Filter.prototype.page = function (page) {
if (page == this.getFilter('page'))
return;

this.setFilter('page', page);
this.reload();
};

// helper to get the query string of the current filter
Filter.prototype.getFilterQuery = function () {
var query = new Query();

jQuery.each(this.filters,function (key, val) {
query.set(key, escape(val));
});
return query.toString();
};

// set filter (always returns to first page)
Filter.prototype.filter = function (obj) {
this.setFilter('page', 1);
this.setFilter(obj.attr('id').substring(7), obj.val());
};

// reload function does the ajax call
Filter.prototype.reload = function () {
var query = this.getFilterQuery();

// add loading class and empty space to the data div
// .loading { background: #eee url(/images/loading.gif) no-repeat center; }
this.data.addClass('loading');
this.data.html("

");
this.data.show();

// prepare stuff
var url = this.ajax_url+'?'+query;
var data = this.data;

// use the JQuery.load to do the ajax request.
// Also add slideDown effect to displaying the actual data
jQuery(this.data).load(url, {}, function() {
data.hide();
data.removeClass('loading');
data.slideDown('slow');

window.location.href = "#"+query;
});
};

// observe the onChange of the select to correctly start filtering
Filter.prototype.addSelect = function (id) {
var _this = this;
if (!this.loaded)
{
jQuery(document).ready(function () { _this.addSelect(id); })
return;
}

var select = jQuery('#filter_'+id);
select.bind('change', function () { _this.filter(select); } );
};

// alias to reload
Filter.prototype.doFilter = function () {
this.reload();
};

// observe onKeyUp of input to actually start filtering
Filter.prototype.addInput = function (id) {
var _this = this;
if (!this.loaded)
{
jQuery(document).ready(function () { _this.addInput(id); })
return;
}

var input = jQuery('#filter_'+id);
input.bind('keyup', function (evt) {
if (input.val().length > 2 && input.val() != _this.getFilter(id))
{
// trick to have a little delay
window.clearTimeout(_this.timeout[id]);
_this.timeout[id] = window.setTimeout(function () {_this.filter(input);}, 200);
}
else if (_this.getFilter(id) && input.val().length <= 2)
{
_this.timeout[id] = window.setTimeout(function () {
_this.setFilter(id, '');
}, 200);
}
} );
}

Well with all that combined you should be able to get your ajax filter. I hope it isn’t too advanced. If you have questiong feel free to drop them as a comment or e-mail them directly.