Hey folks,
many of you might have run into the problem of having multiple boxes on the same page that need to be paginated. For example you might have a left column with a list of members of your site and a right column that shows for a example a list of forums. Yeah, that's not the best example, but you get the idea.
So the problem with Cake's paginator is, that its link structure does not know which widget is actually paginated. Let's have a look at a typical url after you clicked a pagination link:
/users/view/488070d4-f5c8-41aa-87aa-4a951490b5da/page:2
Okay great. So imagine we have a UsersController with a view() action and our pagination passes in the page we would like to be on with regards to the list of users. Now, we click a pagination link in the communities box (let's call these boxes "widgets" from now on)... Yes you are right, the link will just look the same. So clicking on the pagination links in the community widget will just paginate our user list and the community list will stay at page 1. : /
Problem
How can we tell the CakePHP paginator which model it shall paginate?
The Solution
There are three things we need for this:
- We need to pass in the model that is being paginated to the structure.
- We need to ensure we stay on the same page type when we click on a pagination link.
- We need to extract the proper page variable from the url depending on the given model and put it into our pagination configuration.
1. Pass in the model that is being paginated to the structure
Okay what I would like to recommend to you here is to have a generic paging.ctp element, which could contain the following code:
if (!
isset($paginator->
params['paging'])) {
return;
}
if (!
isset($model) ||
$paginator->
params['paging'][$model]['pageCount'] <
2) {
return;
}
if (!
isset($options)) {
$options =
array();
}
?>
<div class="paging">
<?php
echo $paginator->
prev('<< Previous',
array_merge(array('escape' =>
false,
'class' =>
'prev'),
$options),
null,
array('class' =>
'disabled'));
echo $paginator->
numbers(am
($options,
array('before' =>
false,
'after' =>
false,
'separator' =>
false)));
echo $paginator->
next('Next >>',
array_merge(array('escape' =>
false,
'class' =>
'next'),
$options),
null,
array('class' =>
'disabled'));
?>
</div>
By that you ensure that the pagination texts are consistent throughout your application - something that is desirable in most cases. However, the most important reason for this is that we have all the fancy logic to make this hack work in one place and reusable for all widgets.
Here is how you would call the paging element for your Users index list out of your views:
<?php
echo $this->
element('paging',
array('model' =>
'User'));
?>
Let's go over the element's code now. Firstly, we check if we need to do paging - if a model is supplied and if its pageCount is greater than 1. We then supply a link structure for the paging links, including a previous and a next link and numbers. If you have worked with Cake's pagination before, this should be all very familiar to you.
Now on to making our fancy logic work.
if (!
isset($paginator->
params['paging'])) {
return;
}
if (!
isset($model) ||
$paginator->
params['paging'][$model]['pageCount'] <
2) {
return;
}
if (!
isset($options)) {
$options =
array();
}
$options['model'] =
$model;
$options['url']['model'] =
$model;
$paginator->__defaultModel =
$model;
?>
<div class="paging">
<?php
echo $paginator->
prev('<< Previous',
array_merge(array('escape' =>
false,
'class' =>
'prev'),
$options),
null,
array('class' =>
'disabled'));
echo $paginator->
numbers(am
($options,
array('before' =>
false,
'after' =>
false,
'separator' =>
false)));
echo $paginator->
next('Next >>',
array_merge(array('escape' =>
false,
'class' =>
'next'),
$options),
null,
array('class' =>
'disabled'));
?>
</div>
We are passing the model to the $options array and set a private paginator variable to the $model value. Yeah, that's the ugly part about this hack. However, since we have it only in one place we can live with it. With that code in place, your links render fine and supply the model that needs to be paginated. Our link would now look like:
/users/view/488070d4-f5c8-41aa-87aa-4a951490b5da/page:2/model:User
Keep in mind we have this all configurable when calling the paging element. You can even pass in your own options array to the element if you want to go any further with this.
2. We need to ensure we stay on the same page type when we click on a pagination link
Imagine that you want to display your paginated widgets not only on one page, the users index, but also on /users/view/[ID]. How do we let the paginator know we want to stay on the same user page? Let me rephrase, how do we supply the user ID in a consistent manner to the pagination links, so that if a pagination link in a widget is clicked, it redirects you back to /users/view/[ID], but with the widget being at the proper page.
The code for this is relatively easy, but I am not so happy with the solution yet. What I did is check for a certain variable that would *only* occur on the said pages, like /users/view:
if (isset($community)) {
$options['url'][] =
$community['Community']['id'];
} elseif (isset($user)) {
$options['url'][] =
$user['User']['id'];
}
So, if a $user variable is set, we assume we are on a user specific page. Same for a community... , a forum post, etc.. I feel bad about this part, because that could so easily be buggy. I won't present another approach I had in mind for this, because I would like you guys to discuss the one presented here first. : ]
Here is the pagination element in its entirety:
if (!
isset($paginator->
params['paging'])) {
return;
}
if (!
isset($model) ||
$paginator->
params['paging'][$model]['pageCount'] <
2) {
return;
}
if (!
isset($options)) {
$options =
array();
}
$options['model'] =
$model;
$options['url']['model'] =
$model;
$paginator->__defaultModel =
$model;
if (isset($community)) {
$options['url'][] =
$community['Community']['id'];
} elseif (isset($user)) {
$options['url'][] =
$user['User']['id'];
}
?>
<div class="paging">
<?php
echo $paginator->
prev('<< Previous',
array_merge(array('escape' =>
false,
'class' =>
'prev'),
$options),
null,
array('class' =>
'disabled'));
echo $paginator->
numbers(am
($options,
array('before' =>
false,
'after' =>
false,
'separator' =>
false)));
echo $paginator->
next('Next >>',
array_merge(array('escape' =>
false,
'class' =>
'next'),
$options),
null,
array('class' =>
'disabled'));
?>
</div>
3. Extract the proper page variable from the url
So it seems we got the frontend part done. Let's have a look at some code for the backend to make this work. For the users list, your code to paginate the users might likely look like this:
$this->
paginate['User'] =
array(
'contain' =>
array('Profile'),
'order' =>
array('User.name' =>
'asc'),
'limit' =>
20
);
$users =
$this->
paginate('User');
Straightforward, we need 20 users paginated. Now we need to investigate the url to check if the user model is given in a reusable manner, so that this will work for all paginations of all models:
/**
* undocumented function
*
* @param string $model
* @return void
* @access public
*/
function pageForPagination
($model) {
$page =
1;
$sameModel =
isset($this->
params['named']['model']) &&
$this->
params['named']['model'] ==
$model;
$pageInUrl =
isset($this->
params['named']['page']);
if ($sameModel &&
$pageInUrl) {
$page =
$this->
params['named']['page'];
}
$this->
passedArgs['page'] =
$page;
return $page;
}
The trick is this handy little function that you place in your AppController class. It analyzes the url's given model and extracts the page. If the model is not given, it defaults to page 1. By that we ensure the widget that is being paginated is put on the right page and the other ones stay at page 1.
Here is the rewritten controller code:
$page =
$this->
pageForPagination('User');
$this->
paginate['User'] =
array(
'contain' =>
array('Profile'),
'order' =>
array('Profile.name' =>
'asc'),
'limit' =>
20,
'page' =>
$page
);
$users =
$this->
paginate('User');
Don't forget to supply the calculated page in your pagination config.
Now you are totally free for the other widgets. For example the pagination code for your community widget would look like this now:
$page =
$this->
pageForPagination('Community');
$this->
paginate['Community'] =
array(
'contain' =>
false,
'order' =>
array('Community.title' =>
'asc'),
'limit' =>
5,
'page' =>
$page
);
$communities =
$this->
paginate('Community');
Alrighty, that's it for paginating multiple widgets on the same page with CakePHP. As you see, the hack did not involve a lot of fancy logic, which really is a plus for Cake's paginator. You might wonder why CakePHP does not offer this functionality in the first place. Well, it doesn't yet, but since pagination might be redone in the future chances are good it will support this out of the core at some point.
Also keep in mind we want to promote the use of the controller's $paginate property. In this article I overwrite it for the action used. You might want to move the parameters that aren't changed up to the $params property initialisation.
Have a good one!
-- Tim Koschuetzki aka DarkAngelBGE