[EXTENSION] XReturnable (was:Ideas for storage of click path / visited pages)

Hi guys,

i’d like to hear your opinions on a idea i had about storing a “click path” (in abscence of a better name). Here’s what it’s about. Sorry, if this get lengthy :)

One fact somehow prevents me from reusing some of my actions: I always need to know either

  a) what view to render after the action or

  B) which URL to redirect to.

If i use static values here, this prevents me for example to reuse a delete or save action in different places.

To discuss a solution i'd like to use a small example. Here some actions with sketches of the according views:

user/list:

Show a list of users with link to edit or delete a user (and a link home).

[Home]


  John Foo  [edit] [delete]


  Jim Bar   [edit] [delete]


admin/userlist:

Also shows a list of users, maybe enhanced with some more admin features.

[Home]


  John Foo  [edit] [delete] [change group]


  Jim Bar   [edit] [delete] [change group]


As we see, both have a link to edit a user.

user/edit:

Shows a form to edit (maybe also create) a user.

  [________]


  Name


  


 [Save] [Cancel] [Delete]


It would be great to use the same action/view for both edit links, so that we could for example have these click paths:

Actions User A:  user/list  ->  user/edit  -> user/save  ->  user/list

Actions User B:  admin/userlist  ->  user/edit  -> user/save  -> admin/userlist

So we somehow need to save, where the user came from, to decide where we have to return to after he clicked save/cancel/delete on the edit page. The only (cross-tab-browsing safe) way to do this, is to add the return information to the URL when we call user/edit.

My idea now is like this:

Just like with CSort, we can add information about the clicked actions/parameters to an URL. This would only happen on demand, of course. The click path could be an array of route/page parameters (or maybe just return URLs). That way we can even create a queue of pages that we want to return to after the current (and maybe even next) action finishes.

How would we add a page to the click path:

We can differ between three types of links/URLs:

1. Links to pages from where we don’t want to return to this page.

This would be the "Home" link for example. It would be created with createUrl() as usual.

2. Links to pages from where we might return to the current page

In user/list above we would have to add the return information to every edit link. The best place for this would be CController::createUrl(). So the [ edit ] link could be constructed with:

<?php


// Note the value 'addReturnUrl' (without key!)


$this->createUrl('user/edit',array('id'=>$model->id, 'addReturnUrl');


When 'addReturnUrl' is present, CController would check, if the current request's URL already supplies clickpath information (maybe we even have to return to another page after user/list) and add the current route/page paremeters to the list of these pages. So it would add maybe a c[] array to URL parameters like this:

..&c[0][q]=user/list&c[0][sort]=name&c[0][otherparam]=...

3. Links to actions that are “end points” of a click path

Links to actions on the user/edit page are special, as they are "end points" of a click path. That means, afterwards we want to return to the last page before the current page. So for the save/cancel/delete URLs, we would need to add the current click path, but not add the current edit page.

<?php


// Note the value 'useReturnUrl' (without key!)


$this->createUrl('user/edit',array('id'=>$model->id, 'useReturnUrl');


This time only the clickpath from the request URL is added again to the created URL. But the current route/parameters are not added.

How would we use the clickpath to find out the return URL?

A ClickPathManager could be accessed by CController::clickPath. It would manage a list of pages, we want to return to. And it could provide a getReturnUrl() method, that delivers the last URL we want to return to.

So if in actionSave() in UserController above we could do something like this:

<?php


public function actionSave() {


    // Do save logic here...





    if ($returnUrl=$this->clickPath->returnUrl)


        $this->redirect($returnUrl);


    else 


        // Redirect to some default URL if no return path is available


}

This is only a rough concept. But does that make sense to anybody else at all? What did i forget? :)

Comments welcome!

Update:

This is an extension now. See http://www.yiiframew…on/xreturnable/

Personally, instead of doing all this complex stuff, I would not have a separate admin controller.  Instead, in the views for the regular pages, I have something like this:

<?php


if (admin) {


?>


render admin buttons, etc


<?php


}

Then I set admin actions to be admin-only though accessRules().  Very simple approach.

Hi Jonah,

well, it's not only about what do render in a view and what not. Maybe i could not make my point very clear. It's more about: How do i know what view do i have to render (or what page do i have to redirect to) after i have performed an action?

This is one of the basic questions for me, if i want to use an action in different controllers. There should be a defined mechanism for an action, to decide what view it has to render. If it's always the same static view this is very inflexible. The view should be very loosely coupled to some action.

Let me get this straight:

You have a edit and save actions.  The pages these actions redirect the user to after being executed depends on where the user came from.

So if the user navigated to the edit action via admin/userList, it should redirect him back to that page after submitting.

and if the user navigated to the edit action via user/list, it should redirect him back to that page after submitting.

If that's true, then what I am saying is if you don't split your user administrative page into a separate action, you don't need to worry about this.

If you have a separate admin/userList and user/list, you might not be DRY coding.  If you put the admin buttons in the user/list action instead as mentioned earlier, you could simply always redirect to the user/list actions.  And it will automatically render the admin stuff only to the admins.

or, in you controller you could do something like:

if (admin)


  redirect to admin/userList


else


  redirect to user/list

Perhaps what you are suggesting is not such a bad idea anyways though.  I can imagine a few places where it could be used.  The login mechanism in CWebUser has a mechanism to redirect to where it came from.

Maybe this would be good as an extensions though, if not part of the core.

I definitely see your point for the example i've provided. I tried to use something simple just to show the concept. Maybe a problem closer to "real world" would be more suitable.

For example i still am working on a timetracker. There's a TimeController, that provides list/edit/save actions. actionEdit shows a form, to edit a time entry from a specific worker. I want to be able to call this action from different places: A report page, that's mainly used by managers and the create page for new entries mainly used by workers to show their logged times for the current day.

For now i need to build my own mechanism to decide, where i should go back, after a time entry was edited and saved: Back to the report page or back to the create page for a time entry? Both contain links to edit a time, but are formated completely different and placed in different problem domains. I can't use your approach here.

Another example would be the current default CRUD operations. As already discussed, i don’t like, that we don’t call actionDelete() from the admin list, but instead need a “helper” method processAdminCommand(). If we could propagate information for return page along with POST to actionDelete (consisting of route + GET parameters), the problem could easily be solved.

What i tried to show above is a mechanism to propagate return routes +  parameters along multiple actions until we reach a "final action" that wants to return to the page where the action initially was triggered. IMO this would really improve reusability of actions - something i don't really see that much now, to be honest.

Yes, I see your point.  It may be better to have it as an extension though.  qiang makes the decision as always though.

You can already retrieve the last referrer url btw, which will work for some less complex cases:

http://www.yiiframew…est#urlReferrer

Yes, already thought about using the referrer. But it can easily be turned off in many browsers - so not reliable. I see the URL as the only place for propagation of this information.

Maybe i’ll try a basic proof-of-concept implementation. :)

Quote

Maybe i'll try a basic proof-of-concept implementation. :)

Go for it.  By qiang's lack of comment I don't think he would implant this, and I kinda would agree (yii should stay skim), but this would make a great extension!

The post is long and I haven’t got time to read it yet. ;)

Or maybe that ;)

oops, just made it even longer…

I’ve done a basic implementation now. Would be interested, if i’m really the only one, who finds this useful  ;D. Here’s another, maybe better example what this is for:

<?php


class UserController extends CController


{


    public function init() {


        $this->attachbehavior('returnable','XReturnable');


    }





    public function actionShow() {


        // ...get user by id here ...


        $this->render('show');


    }


}


<?php


class PostController extends CController 


{


    public function init()    {


        $this->attachbehavior('returnable','XReturnable');


    }





    public function actionList() {


        // ... create list of posts...


        $this->render('list');


    }





    public function actionEdit()    {


        if (Yii::app()->request->isPostRequest) {


            // ....save here...


            // Now check if we need to go back


            if (!$this->goBack())


                // Render some default page, if no return URL present after save


                $this->render('done');


        }


        $this->render('edit');


    }





    public function actionDelete() {


        if (Yii::app()->request->isPostRequest) {


            // ... delete here...


            // Now check if we need to go back


            if (!$this->goBack())


                // Render some default page, if no return URL present


                $this->render('done');


        }


    }


}


post/list.php

<h1>List of Posts</h1>





<table>


<tr>


    <th>Post</th>


    <th>Action</th>


</tr>


<tr>


    <td>Demo</td>


    <td>


        <?php echo CHtml::link('edit', $this->getMyReturnParams(array('post/edit'))); ?> |


        <?php echo CHtml::linkButton('delete', array (


            'submit' => $this->createUrl('post/delete',$this->getMyReturnParams()),


            'params' => array('id'=>123),


            'confirm'=>'Are you sure?'


        )) ?>


    </td>


</tr>


</table>





<p>Note where we get redirected after an action</p>


post/edit.php

<h1>Edit view</h1>


<?php echo CHtml::form($this->getReturnParams()) ?>


<p>Here would be a edit form</p>


<?php echo CHtml::submitButton('Save'); ?>


<?php echo CHtml::Button('Delete', array (


    'submit' => $this->createUrl('post/delete',$this->getReturnParams()),


    'params' => array('id'=>123),


    'confirm'=>'Are you sure?'


)) ?>


<?php echo CHtml::link('Cancel', $this->getReturnUrl()); ?>





</form>


user/show.php

<h1>Detail page for a user</h1>


<p>Here are some user details</p>


<?php $this->renderPartial('/post/list') ?>


We have two Controllers: PostController and UserController. Post has list and edit view. On the show action of UserController, i again make use of PostController’s list view to show the latest posts for a user.

On this post list view, there are links to edit/delete a post. With XReturnable i can use this view, wherever i want, and always can go back to where i was, after i finished editing or deleted a post.

API is as follows:

getMyReturnParams($params) can be used for e.g. CHtml::createUrl() to add a stack of URLs to return to to the GET parameters of the link. Includes the return link to the current page.

getReturnParams($params) the same but without the link to the current page.

goBack() performs a redirect if we have a URL stack present in the current URL.

getReturnUrl() returns the URL to return to, if one is present.

For this stuff to work, every page should only use GET parameters to define its state (e.g. ids, sort parameter, etc.). POST should only be used to perform data changing actions.

If there's feedback, i might add this as an extension and of course give more explanation about usage if so desired.

Update:

Attachment removed as this can be downloaded as extension now:

http://www.yiiframew…ion/xreturnable

I think this is exactly what i am looking for. In my application there are many ways that you can get to an update action. The idea of always send the user to the show action is not so attractive, it would be much nicer if you go back to the place where you clicked the update button.

I will give your solution here a try and give some feedback. I thins Yii would greatly benefit from a good solution for this kind of problem.

The script should work pretty fine for this. Just some notes:

  • You can "stack" multiple return pages and return to the page that's on top of the stack (==was last added)

  • Keep in mind, that you will get lengthy (not-friendly) URLs. So this solution might only be apropriate for some admin tools, etc. that don't require nice URLs

  • To make sure, that the page you return to looks the same as you left it, the page's state must only depend on GET parameters. That's because the behavior simply takes all GET variables from a page it should remember.

I still haven't got the time to try it out yet, but i have already realized the issue about storing the return path in a GET variable. This will also limit the number of objects in the return stack sinche there is a limit of the length of the URL (i think, at least).

I thought about store ste stack as a user state with Yii::app()->user->setState. I see that this can't be done in the creation of the link. But i thought about always storing the URL in the constructor of the controller. What do you think about that?

I don't think that there's a limit in URL length - or it is huge. Think of forms with method="GET". They always submit all their form data in URL and i never experienced any problem no matter how big the form data was.

Using setState() wouldn't help much, as it also uses session to store data. So you would get problems with multiple tabs, as every tab would use the same stack in the session.

Normal updates shouldn’t be a problem: The stack is simply re-submitted with the update and doesn’t change. Or i didn’t get your point … :)

Anyway: Just give it a try and play around. Maybe we find an even easier API.

Sorry i edited my last post while you were replying… but i will play around a bit and be back.

BTW, I checked that the max length for an URL is 2083 chars in IE (yes there are ppl out there using such obscure browsers  :)  )

No i have implemented a different approach to the click path /visited pages.

The two things i wanted different than in Mike's nice solution was:

  • Not store the click path in the GET variable.

  • Implementation of it with in existing code with as few changes as possible.

Controller.php

<?php


class Controller extends CController {





    protected $_returnUrl;





    protected $_isReturnable=true;





    function __destruct()


    {


        if( $this->_isReturnable )  $this->saveUrl();


    }





    public function setNotReturnable()


    {


        $this->_isReturnable=false;


    }





    public function saveUrl()


    {


        $rvar=Yii::app()->urlManager->routeVar;





        $get=$_GET;


        $r=isset($get[$rvar]) ? $get[$rvar] : $this->getId().'/'.$this->getAction()->getId();


        unset($get[$rvar]);


        array_unshift($get,$r);


        $stack[]=$get;


        Yii::app()->user->setState('returnUrl',$get);


    }





    public function goBack()


    {


        if ($url=$this->getReturnUrl()) $this->redirect($url);


        return false;


    }





    public function getReturnUrl()


    {


        if ($this->_returnUrl===null)


            $this->_returnUrl=Yii::app()->user->getState('returnUrl');


        return $this->_returnUrl;


    }


}?>

One example of use is in post/update like this:

<?php


    public function actionUpdate()


    {


        $this->setNotReturnable();


        $post=$this->loadPost();


        if(isset($_POST['Post']))


        {


            $post->attributes=$_POST['Post'];


            if($post->save()) $this->goBack();


        }


        $this->render('post',array('post'=>$post));


    }


?>


API:

setNotReturnable() - indicates that this page is not stored in click path.

goBack and getReturnUrl - the same at for Mike’s alternative.

I have not implemented the stack functionality since i don't need it for the moment, but it can be implemented i the same way as for XReturnable. One need to implement some limits to the stack size since this solution always store the returnUrl and the stack can be rather big.

This is a quick rework of mike's idea but what do you think about it? Con's and pros? Any comments would be appreciated…

Hi there,

maybe i'm way off here but how about having a component store the visited urls in session ?

then you would redirect to the previous recorded url in the array stored in session (or even in the app()->user) 

I did something like this for cakephp and it worked quite well

I'm really really new to Yii so I don't know if this would be easy to accomplish

mmm

sorry I did not see page 2 of the post

seems like what I suggested is already there …

my bad :(

@kartom:

I see 2 drawbacks in your solution:

  • You override CController, which means, it's not so easy to integrate

  • As stated before, session (and state is nothing else) isn't optimal, as users might have the same application open in 2 browser tabs. One tab might overwrite returnUrl from the other tab, which gives unexpected results.

I still think that URL is the only way to go for a stable solution. 2 MB of stack size seem rather big to me. Usually you won't have that big $_GET variables.