Friday, 31 January 2014

CakePHP 2.x: Saving paging data in the session

Quite some time ago I wrote a blog post about saving CakePHP 1.x paging data in the session so that they can be available at next page visit. The basic idea was that you could store the page, sort and direction named parameters in the session and restore them back when no paging parameters were available.

When I tried applying the same technique in CakePHP 2.x, I run into a very serious obstacle and that was the fact that the Paginator::numbers() function does not anymore include the page:1 named parameter when creating the link for the first (or previous) page. This created a phenomenon that when someone visited a page without any paging parameters, it was impossible to know whether they were there because of a link to the first page or as a result of a redirect from somewhere else, in which case the session had to be checked and paging data to be restored.

The thing had been puzzling me for some days now. I tried different remedies without any luck or results until I cried for help at stackoverflow.com. It was there that Ilie Pandia shook things a bit and then I managed, thanks to his advice, to create the following component that mimics the original Cake 1.x PaginationRecallComponent by mattc found in the Bakery, from which I borrowed the name and structure.

This version is new (April-2015) and hopefully includes the bug-fixes pointed out by all comments. The tests where made using CakePHP version 2.6.3.

<?php
App::uses('Component', 'Controller');

/**
 * Pagination Recall CakePHP Component
 * CakePHP 2.x version. Thanassis Bakalidis abakalidis.blogspot.com=
 *
 * @author  Thanassis Bakalidis
 * @version  2.2
 * @license  MIT
 * @property SessionComponent $Sesion Session handler to save paging data into
 */
class PaginationRecallComponent extends Component {
    const PREV_DATA_KEY = 'Paginaion-PrevData';

    public $components = ['Session'];
    private $_controller = NULL;
    private $_action = NULL;
    private $_previousUrl;

    public function initialize(\Controller $controller)
    {
        $this->_controller = $controller;
        $this->_action = $controller->params['action'];
    }

    public function startup(Controller $controller)
    {
        if ($this->_controller->name === 'CakeError')
            return;

        $this->_restorePagingParams();

        // save the current controller and action for the next time
        $this->Session->write(
            self::PREV_DATA_KEY,
            [
                'controller' => $this->_controller->name,
                'action' => $this->_action
            ]
        );
    }

    private function _restorePagingParams()
    {
        $sessionKey = "Pagination.{$this->_controller->name}.{$this->_action}";

        // extract paging data from the request parameters
        $pagingParams = $this->_extractPagingParams();

        // if paging data exist write them in the session
        if (!empty($pagingParams)) {
            $this->Session->write( $sessionKey, $pagingParams);
            return;
        }

        // no paging data.
        // construct the previous URL
        $this->_previousUrl = $this->Session->check(self::PREV_DATA_KEY)
            ? $this->Session->read(self::PREV_DATA_KEY)
            : [
                'controller' => '',
                'action' => ''
            ];

        // and check if the current page is the same as the previous
        if ($this->_previousUrl['controller'] === $this->_controller->name &&
            $this->_previousUrl['action'] === $this->_action) {
            // in this case we have a link from our own paging::numbers() function
            // to move to page 1 pf the current page, delete any paging data
            $this->Session->delete($sessionKey);
            return;
        }

        // we are comming from a different page so if we have any session data
        if ($this->Session->check($sessionKey))
            // then restore and use them
            $this->_controller->request->params['named'] = array_merge(
                $this->_controller->request->params['named'],
                $this->Session->read($sessionKey)
            );
    }

    private function _extractPagingParams()
    {
        $pagingParams = $this->_controller->request->params['named'];
        $vars = ['page', 'sort', 'direction'];
        $keys = array_keys($pagingParams);
        $count = count($keys);

        for ($i = 0; $i < $count; $i++)
            if (!in_array($keys[$i], $vars))
                unset($pagingParams[$keys[$i]]);

        return $pagingParams;
    }
}

Note: It turns out that the components shutdown method is not called when the owner controller's action returns a redirect(). Hence, this last update was about getting rid of any shutdown() functionality and performing everything in the components startup() moethod code. This hopefully, fixes the bug of loosing paging data after a call to the delete() method which returns an immediate redirect..

8 comments :

Mark Williams said...

Brilliant. I was up until just now using you previous PagingInfo code, but then stumbled across your SO post when faced with the same "page 1" issue.

Might be worth adding an update and a link to your first post, in case any others land there first?

One question, slightly off-topic. I am using your component with a Filter plugin https://github.com/lecterror/cakephp-filter-plugin

They play very nicely together, except for in one case. If I filter my results down to a few pages, then navigate to page 2, then filter some more bringing the results to one page, I get a 404 because the paginator has remembered my previous page number (2), but that page contains no data as I have re-filtered. Hence the 404.

My thoughts so far are for the any searches made using the filter plugin to reset the page number in the session, but I have as yet been unable to get this to work. You may not be able to help, but worth an ask, as I was here anyway.

Thanks again for a great bit of code.

Athanassios Bakalidis said...

@Mark Thanks for the comment.
Now, regarding your question about the filter plugin, I have never used it and so I cannot offer any real solution.

The only remedy I can think of is to put filtering controls on the same page as your list. That way when you post a filtering request back to your controller action then the PaginationRecallComponent will determine that your previous URL is the same as your current and thus take you to page 1.

Dvd74 said...

Why not work with delete?

Johannes Braun said...

Thank you! This one helped me a lot. The orignal PaginationRecallComponent from the Bakery didn't work at all for me on a CakePHP 2.4.2 installation. Your version worked out of the box.

Mark Williams said...

Hi,

Well I have been using this component for quite some time now. I resolved the issue I was previously having (first comment posted on this post) by detecting the 404 using a try/catch like so:

try {
$users = $this->Paginator->paginate();
if (empty($users)) {
$this->noResultRedirect();
}
$this->set(compact('users'));
} catch (NotFoundException $e) {
$this->noResultRedirect();
}

And in my AppController, the noResultRedirect() function looks like this:

protected function noResultRedirect() {

$args = $this->passedArgs;
if (isset($args["page"])) {
$pageInArgs = $args["page"];
}
if ($this->Session->read("Pagination." . $this->modelClass . ".options.page")) {
$pageInSession = $this->Session->read("Pagination." . $this->modelClass . ".options.page");
}

if (isset($pageInArgs)) {
$page = $pageInArgs;
// ensure session is sync'd with args
$this->Session->write("Pagination." . $this->modelClass . ".options.page", $page);
} elseif (isset($pageInSession)) {
$page = $pageInSession;
} else {
$page = 1;
}

if ($page > 1) {
// set page to 1 in args
$args["page"] = 1;
// set page to 1 in session
$this->Session->write("Pagination." . $this->modelClass . ".options.page", 1);

// build url and redirect
$args["controller"] = $this->params->controller;
$args["action"] = $this->params->action;
$redirect = Router::url($args);
$this->redirect($redirect);
}
}

This works really well.


I have another question. I need to be able to store the pagination data relative to not just the controller, but also the action.

So my thoughts would be to replace this:
$sessionKey = "Pagination.{$this->_controller->modelClass}.options";

With this:
$sessionKey = "Pagination.{$this->_controller->modelClass}.{$this->_controller->request->params['action']}.options";

This results in the session data being stored in a way that I think is usable, but I am stuck as to what else I need to change in the component code to get this session key to be read.

Any help would be most appreciated,

Thanks again f or a very useful component.

M

John Sutton said...

'querystring'), but
* could easily be extended.
*
* The key problem arose with CakePHP version 2.4. From the migration guide:
*
* PaginatorHelper
* The first page no longer contains /page:1 or ?page=1 in the URL. This helps prevent duplicate
* content issues where you would need to use canonical or noindex otherwise.
*
* So, from 2.4 onwards, the problem is how to discriminate between a request for a new pagination
* state of page:1, as opposed to a request to recall the saved (i.e. last) pagination state.
*
* Had this change at 2.4 (detailed above) _not_ occurred, the following algo would suffice:
*
* if pagination params in request {
* store pagination params in session under key controller.action
* }
* elseif exists stored pagination params for this controller.action {
* restore stored params into request
* }
*
* The original version of this component (by thanassis) used a comparison of consecutive hits to
* discriminate between the two cases, but this fails in a multiwindow or ajax environment. I also
* considered using HTTP_REFERER but this also fails because a redirected request retains the original
* referer.
*
* The solution below defines a special named parameter value, viz. page=0, which means "recall the
* last saved pagination state", and, in the absence of this flag, we take it to be a request for a
* new pagination state which needs to be saved (for later recall) and rendered.
*
* Note that the absence of pagination parameters (page, sort & direction) is itself a request for the
* default pagination. The corollary is that we need to save the pagination state of _all_ actions, in
* every controller, i.e., including all those (probably the vast majority) that do not do any pagination
* at all! By an appropriate choice of session key we ensure that this does not end up clogging up the
* session with irrelevant state data: at the end of a session, we will at maximum have a session record
* for every controller in the system, and additional records keyed off these, one for each action which
* actually uses pagination.
*/
class PaginationRecallComponent extends Component {

public $components = array('Session');
private $_controller = NULL;

public function startup(Controller $controller)
{
$this->_controller = $controller;
$sessionKey = "Pagination.{$this->_controller->name}.{$this->_controller->request->params['action']}";

// extract paging data from the request parameters
$pagingParams = $this->_extractPagingParams();

// If this is a recall request
if (isset($pagingParams['page']) && $pagingParams['page'] === '0') {
// If there is a saved state, restore it (otherwise, nothing to do
// since PaginatorComponent::paginate() will correct page=0 to page=1
// and the default pagination will be rendered).
if (($lastParams = $this->Session->read($sessionKey)) !== null)
$this->_controller->request->params['named'] = array_merge(
$this->_controller->request->params['named'],
$lastParams
);
} else // not a recall, save the params for the next recall
if (count($pagingParams))
$this->Session->write($sessionKey, $pagingParams);
else // which amounts to deleting the last saved state request when the new
// saved state is for a request for the default pagination.
$this->Session->delete($sessionKey);
}

private function _extractPagingParams()
{
$pagingParams = $this->_controller->request->params['named'];
if (!count($pagingParams)) return $pagingParams; // just to save time

$vars = array('page', 'sort', 'direction');
$keys = array_keys($pagingParams);
$count = count($keys);

for ($i = 0; $i < $count; $i++)
if (!in_array($keys[$i], $vars))
unset($pagingParams[$keys[$i]]);

return $pagingParams;
}
}

Unknown said...

Thanks for the component, it helped me a lot and do exactly what I need. The original component had the problem of the first page, but you figured it out how to do it. Thanks a lot.

TheFlyinGeek said...

I've modified the code for compatibility with CakePHP 3.x. I've just started with CakePHP so don't know all the ins and outs yet, if anything could be improved I'm glad to hear of course:


namespace App\Controller\Component;

use Cake\Controller\Component;
use Cake\Event\Event;

/**
* Pagination Recall CakePHP Component
* CakePHP 3.x version. Thanassis Bakalidis abakalidis.blogspot.com=
*
* @author Thanassis Bakalidis, modified by TheFlyinGeek for compatibility with Cake 3.x
* @version 3.0
* @license MIT
* @property SessionComponent $Sesion Session handler to save paging data into
*/
class PaginationRecallComponent extends Component {
const PREV_DATA_KEY = 'Paginaion-PrevData';

private $_session = NULL;
private $_controller = NULL;
private $_action = NULL;
private $_previousUrl;

public function initialize(array $config)
{
$this->_controller = $this->_registry->getController();
$this->_action = $this->_controller->request->params['action'];

$this->_session = $this->request->session();
}

public function startup(Event $event)
{
if ($this->_controller->name === 'CakeError')
return;

$this->_restorePagingParams();

// save the current controller and action for the next time
$this->_session->write(
self::PREV_DATA_KEY,
[
'controller' => $this->_controller->name,
'action' => $this->_action
]
);
}

private function _restorePagingParams()
{
$sessionKey = "Pagination.{$this->_controller->name}.{$this->_action}";

// extract paging data from the request parameters
$pagingParams = $this->_extractPagingParams();

// if paging data exist write them in the session
if (!empty($pagingParams)) {
$this->_session->write( $sessionKey, $pagingParams);
return;
}

// no paging data.
// construct the previous URL
$this->_previousUrl = $this->_session->check(self::PREV_DATA_KEY)
? $this->_session->read(self::PREV_DATA_KEY)
: [
'controller' => '',
'action' => ''
];

// and check if the current page is the same as the previous
if ($this->_previousUrl['controller'] === $this->_controller->name &&
$this->_previousUrl['action'] === $this->_action) {
// in this case we have a link from our own paging::numbers() function
// to move to page 1 pf the current page, delete any paging data
$this->_session->delete($sessionKey);

return;
}

// we are comming from a different page so if we have any session data
if ($this->_session->check($sessionKey))
// then restore and use them
$this->_controller->request->query = array_merge(
$this->_controller->request->query,
$this->_session->read($sessionKey)
);
}

private function _extractPagingParams()
{
$pagingParams = $this->_controller->request->query;

$vars = ['page', 'sort', 'direction'];
$keys = array_keys($pagingParams);
$count = count($keys);

for ($i = 0; $i < $count; $i++)
if (!in_array($keys[$i], $vars))
unset($pagingParams[$keys[$i]]);

return $pagingParams;
}
}